From f4ff30d3820fb07dc9af500bfdca96171e46cd7e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 31 Mar 2013 17:02:05 +0200 Subject: [PATCH 001/403] docs: Add Debian package updating to the release procedure --- docs/development.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/development.rst b/docs/development.rst index c1c1d291..59d004fa 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -362,4 +362,6 @@ Creating releases rm MANIFEST # Will be regenerated by setup.py python setup.py sdist upload +#. Update the Debian package. + #. Spread the word. From e56a0376c24d07a7b0c70519616cd846a80b887e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 31 Mar 2013 22:58:29 +0200 Subject: [PATCH 002/403] docs: Simplify Extension class by removing @classmethod --- docs/extensiondev.rst | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index ff831b34..4c33e77c 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -220,14 +220,12 @@ raising :exc:`ImportError` exceptions for missing dependencies, etc. name = 'Mopidy-Soundspot' version = __version__ - @classmethod - def get_default_config(cls): + def get_default_config(self): config_file = os.path.join( os.path.dirname(__file__), 'config.ini') return open(config_file).read() - @classmethod - def validate_config(cls, config): + def validate_config(self, config): # ``config`` is the complete config document for the Mopidy # instance. The extension is free to check any config value it is # interested in, not just its own config values. @@ -239,8 +237,7 @@ raising :exc:`ImportError` exceptions for missing dependencies, etc. if not config.get('soundspot', 'password'): raise ExtensionError('Config soundspot.password not set') - @classmethod - def validate_environment(cls): + def validate_environment(self): # This method can validate anything it wants about the environment # the extension is running in. Examples include checking if all # dependencies are installed. @@ -253,18 +250,15 @@ raising :exc:`ImportError` exceptions for missing dependencies, etc. # You will typically only implement one of the next three methods # in a single extension. - @classmethod - def get_frontend_class(cls): + def get_frontend_class(self): from .frontend import SoundspotFrontend return SoundspotFrontend - @classmethod - def get_backend_class(cls): + def get_backend_class(self): from .backend import SoundspotBackend return SoundspotBackend - @classmethod - def register_gstreamer_elements(cls): + def register_gstreamer_elements(self): from .mixer import SoundspotMixer gobject.type_register(SoundspotMixer) From 1e49021c5b5f5f53e24694e767e0c7e762f8836b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 10:42:09 +0200 Subject: [PATCH 003/403] docs: Refine extensiondev docs --- docs/extensiondev.rst | 78 ++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 4c33e77c..bd5ccd7b 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -60,7 +60,6 @@ extension, Mopidy-Soundspot:: README.rst # Document what it is and how to use it mopidy_soundspot/ # Your code __init__.py - config.ini # Default configuration for the extension ... setup.py # Installation script @@ -189,13 +188,26 @@ Example __init__.py =================== The ``__init__.py`` file should be placed inside the ``mopidy_soundspot`` -Python package. The root of your Python package should have an ``__version__`` -attribute with a :pep:`386` compliant version number, for example "0.1". Next, -it should have a class named ``Extension`` which inherits from Mopidy's -extension base class. This is the class referred to in the ``entry_points`` -part of ``setup.py``. Any imports of other files in your extension should be -kept inside methods. This ensures that this file can be imported without -raising :exc:`ImportError` exceptions for missing dependencies, etc. +Python package. + +The root of your Python package should have an ``__version__`` attribute with a +:pep:`386` compliant version number, for example "0.1". Next, it should have a +class named ``Extension`` which inherits from Mopidy's extension base class. +This is the class referred to in the ``entry_points`` part of ``setup.py``. Any +imports of other files in your extension should be kept inside methods. This +ensures that this file can be imported without raising :exc:`ImportError` +exceptions for missing dependencies, etc. + +The default configuration for the extension is defined by the +``get_default_config()`` method in the ``Extension`` class which returns a +:mod:`ConfigParser` compatible config section. The config section's name should +be the same as the extension's short name, as defined in the ``entry_points`` +part of ``setup.py``. All extensions should include an ``enabled`` config which +should default to ``true``. Provide good defaults for all config values so that +as few users as possible will need to change them. The exception is if the +config value has security implications; in that case you should default to the +most secure configuration. Leave any configurations that doesn't have +meaningful defaults blank, like ``username`` and ``password``. :: @@ -209,7 +221,7 @@ raising :exc:`ImportError` exceptions for missing dependencies, etc. import gobject from mopidy.exceptions import ExtensionError - from mopidy.utils import ext + from mopidy import ext __version__ = '0.1' @@ -221,9 +233,12 @@ raising :exc:`ImportError` exceptions for missing dependencies, etc. version = __version__ def get_default_config(self): - config_file = os.path.join( - os.path.dirname(__file__), 'config.ini') - return open(config_file).read() + return """ + [soundspot] + enabled = true + username = + password = + """ def validate_config(self, config): # ``config`` is the complete config document for the Mopidy @@ -266,27 +281,6 @@ raising :exc:`ImportError` exceptions for missing dependencies, etc. SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) - -Example config.ini -================== - -The default configuration for the extension is located in a ``config.ini`` file -inside the Python package. It contains a single config section, with a name -matching the short name used for the extension in the ``entry_points`` part of -``setup.py``. - -All extensions should include an ``enabled`` config which should default to -``true``. Leave any configurations that doesn't have meaningful defaults blank, -like ``username`` and ``password``. - -.. code-block:: ini - - [soundspot] - enabled = true - username = - password = - - Example frontend ================ @@ -365,9 +359,8 @@ extensions work. information about all extensions available on the system from :mod:`pkg_resources`. -3. Add :class:`Extension` classes for all existing frontends and backends. Make - sure to add default config files and config validation, even though this - will not be used at this implementation stage. +3. Add :class:`Extension` classes for all existing frontends and backends. Skip + any default config and config validation for now. 4. Add entry points for the existing extensions in the ``setup.py`` file. @@ -377,7 +370,9 @@ extensions work. 6. Remove the ``FRONTENDS`` and ``BACKENDS`` settings. -7. Switch to ini file based configuration, using :mod:`ConfigParser`. The +7. Add default config files and config validation to all existing extensions. + +8. Switch to ini file based configuration, using :mod:`ConfigParser`. The default config is the combination of a core config file plus the config from each installed extension. To find the effective config for the system, the following config sources are added together, with the later ones overriding @@ -385,15 +380,15 @@ extensions work. - the default config built from Mopidy core and all installed extensions, - - ``/etc/mopidy.conf``, + - ``/etc/mopidy/mopidy.conf``, - - ``~/.config/mopidy.conf``, + - ``~/.config/mopidy/mopidy.conf``, - any config file provided via command line arguments, and - any config values provided via command line arguments. -8. Add command line options for: +9. Add command line options for: - loading an additional config file for this execution of Mopidy, @@ -401,4 +396,5 @@ extensions work. - printing the effective config and exit, and - - write a config value permanently to ``~/.config/mopidy.conf`` and exit. + - write a config value permanently to ``~/.config/mopidy/mopidy.conf``, or + ``/etc/mopidy/mopidy.conf`` if root, and exit. From b095f140836181a31b3399a1d69d689a61d18abc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 10:43:37 +0200 Subject: [PATCH 004/403] docs: Sort imports --- docs/extensiondev.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index bd5ccd7b..b1928798 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -220,8 +220,8 @@ meaningful defaults blank, like ``username`` and ``password``. import gst import gobject - from mopidy.exceptions import ExtensionError from mopidy import ext + from mopidy.exceptions import ExtensionError __version__ = '0.1' From f89f4151b0f7a037786bdb5d63092dc74dbffac3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 11:02:02 +0200 Subject: [PATCH 005/403] ext: Add Extension base class --- mopidy/ext.py | 27 +++++++++++++++++++++++++++ tests/ext_test.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 mopidy/ext.py create mode 100644 tests/ext_test.py diff --git a/mopidy/ext.py b/mopidy/ext.py new file mode 100644 index 00000000..dc756a3a --- /dev/null +++ b/mopidy/ext.py @@ -0,0 +1,27 @@ +from __future__ import unicode_literals + + +class Extension(object): + + name = None + version = None + + def get_default_config(self): + raise NotImplementedError( + 'Add at least a config section with "enabled = true"') + + def validate_config(self, config): + raise NotImplementedError( + 'You must explicitly pass config validation if not needed') + + def validate_environment(self): + pass + + def get_frontend_class(self): + pass + + def get_backend_class(self): + pass + + def register_gstreamer_elements(self): + pass diff --git a/tests/ext_test.py b/tests/ext_test.py new file mode 100644 index 00000000..98849e21 --- /dev/null +++ b/tests/ext_test.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +from mopidy.ext import Extension + +from tests import unittest + + +class ExtensionTest(unittest.TestCase): + def setUp(self): + self.ext = Extension() + + def test_name_is_none(self): + self.assertIsNone(self.ext.name) + + def test_version_is_none(self): + self.assertIsNone(self.ext.version) + + def test_get_default_config_raises_not_implemented(self): + self.assertRaises(NotImplementedError, self.ext.get_default_config) + + def test_validate_config_raises_not_implemented(self): + self.assertRaises(NotImplementedError, self.ext.validate_config, None) + + def test_validate_environment_does_nothing_by_default(self): + self.assertIsNone(self.ext.validate_environment()) + + def test_get_frontend_class_returns_none_by_default(self): + self.assertIsNone(self.ext.get_frontend_class()) + + def test_get_backend_class_returns_none_by_default(self): + self.assertIsNone(self.ext.get_backend_class()) + + def test_register_gstreamer_elements_does_nothing_by_default(self): + self.assertIsNone(self.ext.register_gstreamer_elements()) From 059147723bb5aa15615be076928d38d0d75a502e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 11:08:05 +0200 Subject: [PATCH 006/403] tests: Test existing exception classes --- tests/exceptions_test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/exceptions_test.py diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py new file mode 100644 index 00000000..5148ebaf --- /dev/null +++ b/tests/exceptions_test.py @@ -0,0 +1,21 @@ +from __future__ import unicode_literals + +from mopidy import exceptions + +from tests import unittest + + +class ExceptionsTest(unittest.TestCase): + def test_exception_can_include_message_string(self): + exc = exceptions.MopidyException('foo') + + self.assertEqual(exc.message, 'foo') + self.assertEqual(str(exc), 'foo') + + def test_settings_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.SettingsError, exceptions.MopidyException)) + + def test_optional_dependency_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.OptionalDependencyError, exceptions.MopidyException)) From 0ec989d2cb9bb18c0529f1f7a6767f815488ee17 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 11:09:16 +0200 Subject: [PATCH 007/403] ext: Add ExtensionError exception class --- mopidy/exceptions.py | 4 ++++ tests/exceptions_test.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index b8d183fb..00c19e9e 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -22,3 +22,7 @@ class SettingsError(MopidyException): class OptionalDependencyError(MopidyException): pass + + +class ExtensionError(MopidyException): + pass diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index 5148ebaf..2bc838d7 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -19,3 +19,7 @@ class ExceptionsTest(unittest.TestCase): def test_optional_dependency_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.OptionalDependencyError, exceptions.MopidyException)) + + def test_extension_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.ExtensionError, exceptions.MopidyException)) From 64b0cc7b98963cbcdd5d704ad075e50547d2715a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 11:30:07 +0200 Subject: [PATCH 008/403] ext: Support multiple frontends/backends in an extension --- docs/extensiondev.rst | 8 ++++---- mopidy/ext.py | 8 ++++---- tests/ext_test.py | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index b1928798..f2c54847 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -265,13 +265,13 @@ meaningful defaults blank, like ``username`` and ``password``. # You will typically only implement one of the next three methods # in a single extension. - def get_frontend_class(self): + def get_frontend_classes(self): from .frontend import SoundspotFrontend - return SoundspotFrontend + return [SoundspotFrontend] - def get_backend_class(self): + def get_backend_classes(self): from .backend import SoundspotBackend - return SoundspotBackend + return [SoundspotBackend] def register_gstreamer_elements(self): from .mixer import SoundspotMixer diff --git a/mopidy/ext.py b/mopidy/ext.py index dc756a3a..6cc35139 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -17,11 +17,11 @@ class Extension(object): def validate_environment(self): pass - def get_frontend_class(self): - pass + def get_frontend_classes(self): + return [] - def get_backend_class(self): - pass + def get_backend_classes(self): + return [] def register_gstreamer_elements(self): pass diff --git a/tests/ext_test.py b/tests/ext_test.py index 98849e21..ac238ca5 100644 --- a/tests/ext_test.py +++ b/tests/ext_test.py @@ -24,11 +24,11 @@ class ExtensionTest(unittest.TestCase): def test_validate_environment_does_nothing_by_default(self): self.assertIsNone(self.ext.validate_environment()) - def test_get_frontend_class_returns_none_by_default(self): - self.assertIsNone(self.ext.get_frontend_class()) + def test_get_frontend_classes_returns_an_empty_list(self): + self.assertListEqual(self.ext.get_frontend_classes(), []) - def test_get_backend_class_returns_none_by_default(self): - self.assertIsNone(self.ext.get_backend_class()) + def test_get_backend_classes_returns_an_empty_list(self): + self.assertListEqual(self.ext.get_backend_classes(), []) def test_register_gstreamer_elements_does_nothing_by_default(self): self.assertIsNone(self.ext.register_gstreamer_elements()) From ba425d8ccb01a4c2dd1c608f5f31dae5a286b2f7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 12:46:05 +0200 Subject: [PATCH 009/403] config: Start adding basic validators + tests for new config values. --- mopidy/utils/config.py | 19 ++++++++++++++ tests/utils/config_test.py | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 mopidy/utils/config.py create mode 100644 tests/utils/config_test.py diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py new file mode 100644 index 00000000..dc813678 --- /dev/null +++ b/mopidy/utils/config.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + + +def validate_choice(value, choices): + """Choice validation, normally called in config value's validate().""" + if choices is not None and value not in choices : + raise ValueError('must be one of %s.' % ', '.join(choices)) + + +def validate_minimum(value, minimum): + """Minimum validation, normally called in config value's validate().""" + if minimum is not None and value < minimum: + raise ValueError('must be larger than %s.' % minimum) + + +def validate_maximum(value, maximum): + """Maximum validation, normally called in config value's validate().""" + if maximum is not None and value > maximum: + raise ValueError('must be smaller than %s.' % maximum) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py new file mode 100644 index 00000000..4c8c28a2 --- /dev/null +++ b/tests/utils/config_test.py @@ -0,0 +1,53 @@ +from __future__ import unicode_literals + +from mopidy.utils import config + +from tests import unittest + + +class ValidateChoiceTest(unittest.TestCase): + def test_no_choices_passes(self): + config.validate_choice('foo', None) + + def test_valid_value_passes(self): + config.validate_choice('foo', ['foo', 'bar', 'baz']) + + def test_empty_choices_fails(self): + with self.assertRaises(ValueError): + config.validate_choice('foo', []) + + def test_invalid_value_fails(self): + with self.assertRaises(ValueError): + config.validate_choice('foobar', ['foo', 'bar', 'baz']) + + +class ValidateMinimumTest(unittest.TestCase): + def test_no_minimum_passes(self): + config.validate_minimum(10, None) + + def test_valid_value_passes(self): + config.validate_minimum(10, 5) + + def test_to_small_value_fails(self): + with self.assertRaises(ValueError): + config.validate_minimum(10, 20) + + def test_to_small_value_fails_with_zero_as_minimum(self): + with self.assertRaises(ValueError): + config.validate_minimum(-1, 0) + + +class ValidateMaximumTest(unittest.TestCase): + def test_no_maximum_passes(self): + config.validate_maximum(5, None) + + def test_valid_value_passes(self): + config.validate_maximum(5, 10) + + def test_to_large_value_fails(self): + with self.assertRaises(ValueError): + config.validate_maximum(10, 5) + + def test_to_large_value_fails_with_zero_as_maximum(self): + with self.assertRaises(ValueError): + config.validate_maximum(5, 0) From c22f0f5f9d3769d73c317ba1be78077f04266ef7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:04:58 +0200 Subject: [PATCH 010/403] config: Add ConfigValue base class and tests. --- mopidy/utils/config.py | 53 ++++++++++++++++++++++++++++++++++++++ tests/utils/config_test.py | 35 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index dc813678..10f1bf78 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -17,3 +17,56 @@ def validate_maximum(value, maximum): """Maximum validation, normally called in config value's validate().""" if maximum is not None and value > maximum: raise ValueError('must be smaller than %s.' % maximum) + + +class ConfigValue(object): + """Represents a config key's value and how to handle it. + + Normally you will only be interacting with sub-classes for config values + that encode either deserialization behavior and/or validation. + + Each config value should be used for the following actions: + + 1. Deserializing from a raw string and validating, raising ValueError on + failure. + 2. Serializing a value back to a string that can be stored in a config. + 3. Formatting a value to a printable form (useful for masking secrets). + + :class:`None` values should not be deserialized, serialized or formatted, + the code interacting with the config should simply skip None config values. + """ + + #: Collection of valid choices for converted value. Must be combined with + #: :function:`validate_choices` in :method:`validate` do any thing. + choices = None + + #: Minimum of converted value. Must be combined with + #: :function:`validate_minimum` in :method:`validate` do any thing. + minimum = None + + #: Maximum of converted value. Must be combined with + #: :function:`validate_maximum` in :method:`validate` do any thing. + maximum = None + + #: Indicate if we should mask the when printing for human consumption. + secret = None + + def __init__(self, choices=None, minimum=None, maximum=None, secret=None): + self.choices = choices + self.minimum = minimum + self.maximum = maximum + self.secret = secret + + def deserialize(self, value): + """Cast raw string to appropriate type.""" + return value + + def serialize(self, value): + """Convert value back to string for saving.""" + return str(value) + + def format(self, value): + """Format value for display.""" + if self.secret: + return '********' + return self.serialize(value) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 4c8c28a2..c1572c78 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -51,3 +51,38 @@ class ValidateMaximumTest(unittest.TestCase): def test_to_large_value_fails_with_zero_as_maximum(self): with self.assertRaises(ValueError): config.validate_maximum(5, 0) + + +class ConfigValueTest(unittest.TestCase): + def test_init(self): + value = config.ConfigValue() + self.assertIsNone(value.choices) + self.assertIsNone(value.minimum) + self.assertIsNone(value.maximum) + self.assertIsNone(value.secret) + + def test_init_with_params(self): + value = config.ConfigValue( + choices=['foo'], minimum=0, maximum=10, secret=True) + self.assertEqual(['foo'], value.choices) + self.assertEqual(0, value.minimum) + self.assertEqual(10, value.maximum) + self.assertEqual(True, value.secret) + + def test_deserialize_passes_through(self): + value = config.ConfigValue() + obj = object() + self.assertEqual(obj, value.deserialize(obj)) + + def test_serialize_converts_to_string(self): + value = config.ConfigValue() + self.assertIsInstance(value.serialize(object()), basestring) + + def test_format_uses_serialize(self): + value = config.ConfigValue() + obj = object() + self.assertEqual(value.serialize(obj), value.format(obj)) + + def test_format_masks_secrets(self): + value = config.ConfigValue(secret=True) + self.assertEqual('********', value.format(object())) From c905134bf4d0bb399cad24352daa39cb5d8827f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 12:49:57 +0200 Subject: [PATCH 011/403] Fix error in MANIFEST.in --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 6a64cb9a..f98952ca 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,7 +7,7 @@ include mopidy/backends/spotify/spotify_appkey.key include pylintrc recursive-include docs * prune docs/_build -recursive-include mopidy/frontends/http/data/ +recursive-include mopidy/frontends/http/data * recursive-include requirements * recursive-include tests *.py recursive-include tests/data * From 1dab53d5c885c54062febcf3c73327d7ba2376d2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 12:50:14 +0200 Subject: [PATCH 012/403] Ignore *.egg-info files generated by setuptools --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9229541f..6ef1ff32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.egg-info *.pyc *.swp .coverage From a8b81eadd04a9fe4ca4ffa00db916770e018bdc7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 12:52:03 +0200 Subject: [PATCH 013/403] Switch from distutils to setuptools --- docs/changes.rst | 9 ++++ requirements/core.txt | 3 ++ setup.py | 112 +++++++++++------------------------------- 3 files changed, 41 insertions(+), 83 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index ee1ea5d7..3ad5b2e1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,6 +4,15 @@ Changes This change log is used to track all major changes to Mopidy. +v0.14.0 (UNRELEASED) +==================== + +**Dependencies** + +- setuptools or distribute is now required. We've introduced this dependency to + use setuptools' entry points functionality to find installed Mopidy + extensions. + v0.13.0 (2013-03-31) ==================== diff --git a/requirements/core.txt b/requirements/core.txt index 9ffac2cf..d8e81e61 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1,2 +1,5 @@ +setuptools +# Available as python-setuptools in Debian/Ubuntu + Pykka >= 1.1 # Available as python-pykka from apt.mopidy.com diff --git a/setup.py b/setup.py index 5840ca53..1c0d10b8 100644 --- a/setup.py +++ b/setup.py @@ -1,101 +1,47 @@ -""" -Most of this file is taken from the Django project, which is BSD licensed. -""" - from __future__ import unicode_literals -from distutils.core import setup -from distutils.command.install_data import install_data -from distutils.command.install import INSTALL_SCHEMES -import os import re -import sys + +from setuptools import setup, find_packages -def get_version(): - init_py = open('mopidy/__init__.py').read() +def get_version(filename): + init_py = open(filename).read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) return metadata['version'] -class osx_install_data(install_data): - # On MacOS, the platform-specific lib dir is - # /System/Library/Framework/Python/.../ which is wrong. Python 2.5 supplied - # with MacOS 10.5 has an Apple-specific fix for this in - # distutils.command.install_data#306. It fixes install_lib but not - # install_data, which is why we roll our own install_data class. - - def finalize_options(self): - # By the time finalize_options is called, install.install_lib is set to - # the fixed directory, so we set the installdir to install_lib. The - # install_data class uses ('install_data', 'install_dir') instead. - self.set_undefined_options('install', ('install_lib', 'install_dir')) - install_data.finalize_options(self) - - -if sys.platform == "darwin": - cmdclasses = {'install_data': osx_install_data} -else: - cmdclasses = {'install_data': install_data} - - -def fullsplit(path, result=None): - """ - Split a pathname into components (the opposite of os.path.join) in a - platform-neutral way. - """ - if result is None: - result = [] - head, tail = os.path.split(path) - if head == '': - return [tail] + result - if head == path: - return result - return fullsplit(head, [tail] + result) - - -# Tell distutils to put the data_files in platform-specific installation -# locations. See here for an explanation: -# http://groups.google.com/group/comp.lang.python/browse_thread/ -# thread/35ec7b2fed36eaec/2105ee4d9e8042cb -for scheme in INSTALL_SCHEMES.values(): - scheme['data'] = scheme['purelib'] - - -# Compile the list of packages available, because distutils doesn't have -# an easy way to do this. -packages, data_files = [], [] -root_dir = os.path.dirname(__file__) -if root_dir != b'': - os.chdir(root_dir) -project_dir = b'mopidy' - -for dirpath, dirnames, filenames in os.walk(project_dir): - # Ignore dirnames that start with '.' - for i, dirname in enumerate(dirnames): - if dirname.startswith(b'.'): - del dirnames[i] - if b'__init__.py' in filenames: - packages.append(b'.'.join(fullsplit(dirpath))) - elif filenames: - data_files.append([ - dirpath, [os.path.join(dirpath, f) for f in filenames]]) - - setup( name='Mopidy', - version=get_version(), - author='Stein Magnus Jodal', - author_email='stein.magnus@jodal.no', - packages=packages, - package_data={b'mopidy': ['backends/spotify/spotify_appkey.key']}, - cmdclass=cmdclasses, - data_files=data_files, - scripts=['bin/mopidy', 'bin/mopidy-scan'], + version=get_version('mopidy/__init__.py'), url='http://www.mopidy.com/', license='Apache License, Version 2.0', + author='Stein Magnus Jodal', + author_email='stein.magnus@jodal.no', description='Music server with MPD and Spotify support', long_description=open('README.rst').read(), + packages=find_packages(exclude=['tests', 'tests.*']), + zip_safe=False, + include_package_data=True, + install_requires=[ + 'setuptools', + 'Pykka >= 1.1', + ], + extras_require={ + b'spotify': ['pyspotify >= 1.9, < 1.11'], + b'lastfm': ['pylast >= 0.5.7'], + b'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], + b'external_mixers': ['pyserial'], + }, + test_suite='nose.collector', + tests_require=[ + 'nose', + 'mock >= 0.7', + 'unittest2', + ], + entry_points={ + b'mopidy.extension': [], + }, classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: No Input/Output (Daemon)', From 1a9fea08a589b3e659f1579b3b1b4537542f71e6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 12:52:24 +0200 Subject: [PATCH 014/403] docs: setuptools always regenerates MANIFEST --- docs/development.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/development.rst b/docs/development.rst index 59d004fa..4374acf2 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -359,7 +359,6 @@ Creating releases #. Build package and upload to PyPI:: - rm MANIFEST # Will be regenerated by setup.py python setup.py sdist upload #. Update the Debian package. From a4f8af048d0c3e79b400d99997df8795dc5661df Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 12:52:53 +0200 Subject: [PATCH 015/403] Let setuptools generate executables --- bin/mopidy | 5 ----- bin/mopidy-scan | 5 ----- setup.py | 4 ++++ 3 files changed, 4 insertions(+), 10 deletions(-) delete mode 100755 bin/mopidy delete mode 100755 bin/mopidy-scan diff --git a/bin/mopidy b/bin/mopidy deleted file mode 100755 index 0472518e..00000000 --- a/bin/mopidy +++ /dev/null @@ -1,5 +0,0 @@ -#! /usr/bin/env python - -if __name__ == '__main__': - from mopidy.__main__ import main - main() diff --git a/bin/mopidy-scan b/bin/mopidy-scan deleted file mode 100755 index 00f51809..00000000 --- a/bin/mopidy-scan +++ /dev/null @@ -1,5 +0,0 @@ -#! /usr/bin/env python - -if __name__ == '__main__': - from mopidy.scanner import main - main() diff --git a/setup.py b/setup.py index 1c0d10b8..eeab24bd 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,10 @@ setup( 'unittest2', ], entry_points={ + b'console_scripts': [ + 'mopidy = mopidy.__main__:main', + 'mopidy-scan = mopidy.scanner:main', + ], b'mopidy.extension': [], }, classifiers=[ From 0f593b66bd832b2ca3fbdd206a7b8a03f113ccde Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 12:53:32 +0200 Subject: [PATCH 016/403] docs: Remove 'platforms' from extension's setup.py example --- docs/extensiondev.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index f2c54847..afebc6a7 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -162,7 +162,6 @@ class that will connect the rest of the dots. #py_modules=['mopidy_soundspot'], zip_safe=False, include_package_data=True, - platforms='any', install_requires=[ 'setuptools', 'Mopidy', From 5fcae977cabaf0fddee2db67ea912fa512fe0083 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 13:07:01 +0200 Subject: [PATCH 017/403] docs: If using pip Pykka will be installed automatically --- docs/installation/index.rst | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 2b9806fd..45f93510 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -135,10 +135,10 @@ Pip. sudo easy_install pip -#. Then get, build, and install the latest releast of pyspotify, pylast, pykka, +#. Then get, build, and install the latest releast of pyspotify, pylast, and Mopidy using Pip:: - sudo pip install -U pyspotify pylast pykka mopidy + sudo pip install -U pyspotify pylast mopidy #. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. @@ -171,15 +171,7 @@ can install Mopidy from PyPI using Pip. sudo yum install -y gcc python-devel python-pip -#. Then you'll need to install all of Mopidy's hard dependencies: - - - Pykka >= 1.0:: - - sudo pip install -U pykka - - On Fedora the binary is called ``pip-python``:: - - sudo pip-python install -U pykka +#. Then you'll need to install all of Mopidy's hard non-Python dependencies: - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most popular Linux distributions. Search for GStreamer in your package manager, @@ -235,7 +227,8 @@ can install Mopidy from PyPI using Pip. sudo pip install -U pyspotify - # Fedora: + On Fedora the binary is called ``pip-python``:: + sudo pip-python install -U pyspotify #. Optional: If you want to scrobble your played tracks to Last.fm, you need @@ -243,7 +236,8 @@ can install Mopidy from PyPI using Pip. sudo pip install -U pylast - # Fedora: + On Fedora the binary is called ``pip-python``:: + sudo pip-python install -U pylast #. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound @@ -259,7 +253,8 @@ can install Mopidy from PyPI using Pip. sudo pip install -U mopidy - # Fedora: + On Fedora the binary is called ``pip-python``:: + sudo pip-python install -U mopidy To upgrade Mopidy to future releases, just rerun this command. From 77f8e228d8615909fc8b746e18b3010e52eb37c9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 13:19:46 +0200 Subject: [PATCH 018/403] docs: Fix typo --- docs/extensiondev.rst | 2 +- docs/installation/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index afebc6a7..04c6aa6b 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -168,7 +168,7 @@ class that will connect the rest of the dots. 'pysoundspot', ], entry_points={ - 'mopidy.extension': [ + b'mopidy.extension': [ 'soundspot = mopidy_soundspot:Extension', ], }, diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 45f93510..ab81b753 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -135,7 +135,7 @@ Pip. sudo easy_install pip -#. Then get, build, and install the latest releast of pyspotify, pylast, +#. Then get, build, and install the latest release of pyspotify, pylast, and Mopidy using Pip:: sudo pip install -U pyspotify pylast mopidy From 119644c186091efc956bb219052a173525cf3346 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:20:33 +0200 Subject: [PATCH 019/403] config: Add String config value and tests. --- mopidy/utils/config.py | 10 ++++++++++ tests/utils/config_test.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 10f1bf78..a34fccb6 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -70,3 +70,13 @@ class ConfigValue(object): if self.secret: return '********' return self.serialize(value) + + +class String(ConfigValue): + def deserialize(self, value): + value = value.strip() + validate_choice(value, self.choices) + return value + + def serialize(self, value): + return value.strip() diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index c1572c78..28436b5c 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -86,3 +86,24 @@ class ConfigValueTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.ConfigValue(secret=True) self.assertEqual('********', value.format(object())) + + +class StringTest(unittest.TestCase): + def test_deserialize_strips_whitespace(self): + value = config.String() + self.assertEqual('foo', value.deserialize(' foo ')) + + def test_deserialize_enforces_choices(self): + value = config.String(choices=['foo', 'bar', 'baz']) + + self.assertEqual('foo', value.deserialize('foo')) + with self.assertRaises(ValueError): + value.deserialize('foobar') + + def test_serialize_strips_whitespace(self): + value = config.String() + self.assertEqual('foo', value.serialize(' foo ')) + + def test_format_masks_secrets(self): + value = config.String(secret=True) + self.assertEqual('********', value.format('s3cret')) From a63bb4eaec979a92dbbc9b76844c61c91ef84052 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 13:24:40 +0200 Subject: [PATCH 020/403] docs: Include https in links where appropriate --- README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 8598b153..b572cdab 100644 --- a/README.rst +++ b/README.rst @@ -18,9 +18,9 @@ platforms, including Windows, Mac OS X, Linux, Android and iOS. To get started with Mopidy, check out `the docs `_. - `Documentation `_ -- `Source code `_ -- `Issue tracker `_ -- `CI server `_ +- `Source code `_ +- `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - Mailing list: `mopidy@googlegroups.com `_ -- `Download development snapshot `_ +- `Download development snapshot `_ From 7cb68a41ac6c639cc4818f21cab801b57ca20253 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:29:32 +0200 Subject: [PATCH 021/403] config: Improve validate error messages and fix handling of non-string choices. --- mopidy/utils/config.py | 7 ++++--- tests/utils/config_test.py | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index a34fccb6..2fcdfd94 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -4,19 +4,20 @@ from __future__ import unicode_literals def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" if choices is not None and value not in choices : - raise ValueError('must be one of %s.' % ', '.join(choices)) + names = ', '.join(repr(c) for c in choices) + raise ValueError('%r must be one of %s.' % (value, names)) def validate_minimum(value, minimum): """Minimum validation, normally called in config value's validate().""" if minimum is not None and value < minimum: - raise ValueError('must be larger than %s.' % minimum) + raise ValueError('%r must be larger than %r.' % (value, minimum)) def validate_maximum(value, maximum): """Maximum validation, normally called in config value's validate().""" if maximum is not None and value > maximum: - raise ValueError('must be smaller than %s.' % maximum) + raise ValueError('%r must be smaller than %r.' % (value, maximum)) class ConfigValue(object): diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 28436b5c..5345e5a4 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -11,6 +11,7 @@ class ValidateChoiceTest(unittest.TestCase): def test_valid_value_passes(self): config.validate_choice('foo', ['foo', 'bar', 'baz']) + config.validate_choice(1, [1, 2, 3]) def test_empty_choices_fails(self): with self.assertRaises(ValueError): @@ -19,6 +20,8 @@ class ValidateChoiceTest(unittest.TestCase): def test_invalid_value_fails(self): with self.assertRaises(ValueError): config.validate_choice('foobar', ['foo', 'bar', 'baz']) + with self.assertRaises(ValueError): + config.validate_choice(5, [1, 2, 3]) class ValidateMinimumTest(unittest.TestCase): @@ -95,7 +98,6 @@ class StringTest(unittest.TestCase): def test_deserialize_enforces_choices(self): value = config.String(choices=['foo', 'bar', 'baz']) - self.assertEqual('foo', value.deserialize('foo')) with self.assertRaises(ValueError): value.deserialize('foobar') From 21d0a938f9d46beb8000366abc423f149a895e9c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:31:15 +0200 Subject: [PATCH 022/403] config: Add Integer ConfigValue and tests. --- mopidy/utils/config.py | 9 +++++++++ tests/utils/config_test.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 2fcdfd94..ab6cb2f7 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -81,3 +81,12 @@ class String(ConfigValue): def serialize(self, value): return value.strip() + + +class Integer(ConfigValue): + def deserialize(self, value): + value = int(value.strip()) + validate_choice(value, self.choices) + validate_minimum(value, self.minimum) + validate_maximum(value, self.maximum) + return value diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 5345e5a4..b973cef1 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -109,3 +109,40 @@ class StringTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.String(secret=True) self.assertEqual('********', value.format('s3cret')) + + +class IntegerTest(unittest.TestCase): + def test_deserialize_converts_to_int(self): + value = config.Integer() + self.assertEqual(123, value.deserialize('123')) + self.assertEqual(0, value.deserialize('0')) + self.assertEqual(-10, value.deserialize('-10')) + + def test_deserialize_fails_on_bad_data(self): + value = config.Integer() + with self.assertRaises(ValueError): + value.deserialize('asd') + with self.assertRaises(ValueError): + value.deserialize('3.14') + + def test_deserialize_enforces_choices(self): + value = config.Integer(choices=[1, 2, 3]) + self.assertEqual(3, value.deserialize('3')) + with self.assertRaises(ValueError): + value.deserialize('5') + + def test_deserialize_enforces_minimum(self): + value = config.Integer(minimum=10) + self.assertEqual(15, value.deserialize('15')) + with self.assertRaises(ValueError): + value.deserialize('5') + + def test_deserialize_enforces_maximum(self): + value = config.Integer(maximum=10) + self.assertEqual(5, value.deserialize('5')) + with self.assertRaises(ValueError): + value.deserialize('15') + + def test_format_masks_secrets(self): + value = config.Integer(secret=True) + self.assertEqual('********', value.format('1337')) From 452cf839c417a3841ae413442addab9daea2bdb9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:38:59 +0200 Subject: [PATCH 023/403] config: Add Boolean ConfigValue and tests. --- mopidy/utils/config.py | 19 +++++++++++++++++++ tests/utils/config_test.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index ab6cb2f7..4009011d 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -90,3 +90,22 @@ class Integer(ConfigValue): validate_minimum(value, self.minimum) validate_maximum(value, self.maximum) return value + + +class Boolean(ConfigValue): + true_values = ('1', 'yes', 'true', 'on') + false_values = ('0', 'no', 'false', 'off') + + def deserialize(self, value): + if value.lower() in self.true_values: + return True + elif value.lower() in self.false_values: + return False + + raise ValueError('invalid value for boolean: %r' % value) + + def serialize(self, value): + if value: + return 'true' + else: + return 'false' diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index b973cef1..1442395f 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -146,3 +146,32 @@ class IntegerTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.Integer(secret=True) self.assertEqual('********', value.format('1337')) + + +class BooleanTest(unittest.TestCase): + def test_deserialize_converts_to_bool(self): + value = config.Boolean() + for true in ('1', 'yes', 'true', 'on'): + self.assertIs(value.deserialize(true), True) + self.assertIs(value.deserialize(true.upper()), True) + self.assertIs(value.deserialize(true.capitalize()), True) + for false in ('0', 'no', 'false', 'off'): + self.assertIs(value.deserialize(false), False) + self.assertIs(value.deserialize(false.upper()), False) + self.assertIs(value.deserialize(false.capitalize()), False) + + def test_deserialize_fails_on_bad_data(self): + value = config.Boolean() + with self.assertRaises(ValueError): + value.deserialize('nope') + with self.assertRaises(ValueError): + value.deserialize('sure') + + def test_serialize_normalises_strings(self): + value = config.Boolean() + self.assertEqual('true', value.serialize(True)) + self.assertEqual('false', value.serialize(False)) + + def test_format_masks_secrets(self): + value = config.Boolean(secret=True) + self.assertEqual('********', value.format('true')) From d46f926f14944e05a3a3a49bb24f05d1bcee003a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:46:13 +0200 Subject: [PATCH 024/403] config: Add List ConfigValue and tests. --- mopidy/utils/config.py | 13 +++++++++++++ tests/utils/config_test.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 4009011d..a1a91b88 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import re + def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" @@ -109,3 +111,14 @@ class Boolean(ConfigValue): return 'true' else: return 'false' + + +class List(ConfigValue): + def deserialize(self, value): + if '\n' in value: + return re.split(r'\s*\n\s*', value.strip()) + else: + return re.split(r',\s*', value.strip()) + + def serialize(self, value): + return '\n '.join(value) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 1442395f..bcc80561 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -175,3 +175,20 @@ class BooleanTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.Boolean(secret=True) self.assertEqual('********', value.format('true')) + + +class ListTest(unittest.TestCase): + def test_deserialize_splits_commas(self): + value = config.List() + self.assertEqual(['foo', 'bar', 'baz'], + value.deserialize('foo, bar,baz')) + + def test_deserialize_splits_newlines(self): + value = config.List() + self.assertEqual(['foo,bar', 'bar', 'baz'], + value.deserialize('foo,bar\nbar\nbaz')) + + def test_serialize_joins_by_newlines(self): + value = config.List() + self.assertRegexpMatches(value.serialize(['foo', 'bar', 'baz']), + r'foo\n\s*bar\n\s*baz') From 6af8b4b0905fea13a80c54cf8a47f8008acca035 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 14:02:28 +0200 Subject: [PATCH 025/403] config: Add LogLevel ConfigValue and tests. --- mopidy/utils/config.py | 17 +++++++++++++++++ tests/utils/config_test.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index a1a91b88..0d6eb928 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import logging import re @@ -122,3 +123,19 @@ class List(ConfigValue): def serialize(self, value): return '\n '.join(value) + + +class LogLevel(ConfigValue): + levels = {'critical' : logging.CRITICAL, + 'error' : logging.ERROR, + 'warning' : logging.WARNING, + 'info' : logging.INFO, + 'debug' : logging.DEBUG} + + def deserialize(self, value): + if value.lower() not in self.levels: + raise ValueError('%r must be one of %s.' % (value, ', '.join(self.levels))) + return self.levels.get(value.lower()) + + def serialize(self, value): + return dict((v, k) for k, v in self.levels.items()).get(value) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index bcc80561..e7583cfb 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import logging + from mopidy.utils import config from tests import unittest @@ -192,3 +194,34 @@ class ListTest(unittest.TestCase): value = config.List() self.assertRegexpMatches(value.serialize(['foo', 'bar', 'baz']), r'foo\n\s*bar\n\s*baz') + + +class BooleanTest(unittest.TestCase): + levels = {'critical' : logging.CRITICAL, + 'error' : logging.ERROR, + 'warning' : logging.WARNING, + 'info' : logging.INFO, + 'debug' : logging.DEBUG} + + def test_deserialize_converts_to_numeric_loglevel(self): + value = config.LogLevel() + for name, level in self.levels.items(): + self.assertEqual(level, value.deserialize(name)) + self.assertEqual(level, value.deserialize(name.upper())) + self.assertEqual(level, value.deserialize(name.capitalize())) + + def test_deserialize_fails_on_bad_data(self): + value = config.LogLevel() + with self.assertRaises(ValueError): + value.deserialize('nope') + with self.assertRaises(ValueError): + value.deserialize('sure') + + def test_serialize_converts_to_string(self): + value = config.LogLevel() + for name, level in self.levels.items(): + self.assertEqual(name, value.serialize(level)) + + def test_serialize_unknown_level(self): + value = config.LogLevel() + self.assertIsNone(value.serialize(1337)) From ab26072dff546ca53cd24c4788ed98c5462592a4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 14:16:02 +0200 Subject: [PATCH 026/403] config: Switch to non context manager version of assertRaises --- tests/utils/config_test.py | 52 +++++++++++++------------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index e7583cfb..289d9df8 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -16,14 +16,12 @@ class ValidateChoiceTest(unittest.TestCase): config.validate_choice(1, [1, 2, 3]) def test_empty_choices_fails(self): - with self.assertRaises(ValueError): - config.validate_choice('foo', []) + self.assertRaises(ValueError, config.validate_choice, 'foo', []) def test_invalid_value_fails(self): - with self.assertRaises(ValueError): - config.validate_choice('foobar', ['foo', 'bar', 'baz']) - with self.assertRaises(ValueError): - config.validate_choice(5, [1, 2, 3]) + words = ['foo', 'bar', 'baz'] + self.assertRaises(ValueError, config.validate_choice, 'foobar', words) + self.assertRaises(ValueError, config.validate_choice, 5, [1, 2, 3]) class ValidateMinimumTest(unittest.TestCase): @@ -34,12 +32,10 @@ class ValidateMinimumTest(unittest.TestCase): config.validate_minimum(10, 5) def test_to_small_value_fails(self): - with self.assertRaises(ValueError): - config.validate_minimum(10, 20) + self.assertRaises(ValueError, config.validate_minimum, 10, 20) def test_to_small_value_fails_with_zero_as_minimum(self): - with self.assertRaises(ValueError): - config.validate_minimum(-1, 0) + self.assertRaises(ValueError, config.validate_minimum, -1, 0) class ValidateMaximumTest(unittest.TestCase): @@ -50,12 +46,10 @@ class ValidateMaximumTest(unittest.TestCase): config.validate_maximum(5, 10) def test_to_large_value_fails(self): - with self.assertRaises(ValueError): - config.validate_maximum(10, 5) + self.assertRaises(ValueError, config.validate_maximum, 10, 5) def test_to_large_value_fails_with_zero_as_maximum(self): - with self.assertRaises(ValueError): - config.validate_maximum(5, 0) + self.assertRaises(ValueError, config.validate_maximum, 5, 0) class ConfigValueTest(unittest.TestCase): @@ -101,8 +95,7 @@ class StringTest(unittest.TestCase): def test_deserialize_enforces_choices(self): value = config.String(choices=['foo', 'bar', 'baz']) self.assertEqual('foo', value.deserialize('foo')) - with self.assertRaises(ValueError): - value.deserialize('foobar') + self.assertRaises(ValueError, value.deserialize, 'foobar') def test_serialize_strips_whitespace(self): value = config.String() @@ -122,28 +115,23 @@ class IntegerTest(unittest.TestCase): def test_deserialize_fails_on_bad_data(self): value = config.Integer() - with self.assertRaises(ValueError): - value.deserialize('asd') - with self.assertRaises(ValueError): - value.deserialize('3.14') + self.assertRaises(ValueError, value.deserialize, 'asd') + self.assertRaises(ValueError, value.deserialize, '3.14') def test_deserialize_enforces_choices(self): value = config.Integer(choices=[1, 2, 3]) self.assertEqual(3, value.deserialize('3')) - with self.assertRaises(ValueError): - value.deserialize('5') + self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_minimum(self): value = config.Integer(minimum=10) self.assertEqual(15, value.deserialize('15')) - with self.assertRaises(ValueError): - value.deserialize('5') + self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_maximum(self): value = config.Integer(maximum=10) self.assertEqual(5, value.deserialize('5')) - with self.assertRaises(ValueError): - value.deserialize('15') + self.assertRaises(ValueError, value.deserialize, '15') def test_format_masks_secrets(self): value = config.Integer(secret=True) @@ -164,10 +152,8 @@ class BooleanTest(unittest.TestCase): def test_deserialize_fails_on_bad_data(self): value = config.Boolean() - with self.assertRaises(ValueError): - value.deserialize('nope') - with self.assertRaises(ValueError): - value.deserialize('sure') + self.assertRaises(ValueError, value.deserialize, 'nope') + self.assertRaises(ValueError, value.deserialize, 'sure') def test_serialize_normalises_strings(self): value = config.Boolean() @@ -212,10 +198,8 @@ class BooleanTest(unittest.TestCase): def test_deserialize_fails_on_bad_data(self): value = config.LogLevel() - with self.assertRaises(ValueError): - value.deserialize('nope') - with self.assertRaises(ValueError): - value.deserialize('sure') + self.assertRaises(ValueError, value.deserialize, 'nope') + self.assertRaises(ValueError, value.deserialize, 'sure') def test_serialize_converts_to_string(self): value = config.LogLevel() From 66c067aa96ece4ac83ad651e514b90ee0d2e7078 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 14:21:44 +0200 Subject: [PATCH 027/403] config: Add Hostname and Port ConfigValues and tests. --- mopidy/utils/config.py | 17 +++++++++++++++++ tests/utils/config_test.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 0d6eb928..efc07d10 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging import re +import socket def validate_choice(value, choices): @@ -139,3 +140,19 @@ class LogLevel(ConfigValue): def serialize(self, value): return dict((v, k) for k, v in self.levels.items()).get(value) + + +class Hostname(ConfigValue): + def deserialize(self, value): + try: + socket.getaddrinfo(value, None) + except socket.error: + raise ValueError('must be a resolveable hostname or valid IP.') + return value + + +class Port(Integer): + def __init__(self, **kwargs): + super(Port, self).__init__(**kwargs) + self.minimum = 1 + self.maximum = 2**16 - 1 diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 289d9df8..b0ccfe78 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals import logging +import mock +import socket from mopidy.utils import config @@ -209,3 +211,33 @@ class BooleanTest(unittest.TestCase): def test_serialize_unknown_level(self): value = config.LogLevel() self.assertIsNone(value.serialize(1337)) + + +class HostnameTest(unittest.TestCase): + @mock.patch('socket.getaddrinfo') + def test_deserialize_checks_addrinfo(self, getaddrinfo_mock): + value = config.Hostname() + value.deserialize('example.com') + getaddrinfo_mock.assert_called_once_with('example.com', None) + + @mock.patch('socket.getaddrinfo') + def test_deserialize_handles_failures(self, getaddrinfo_mock): + value = config.Hostname() + getaddrinfo_mock.side_effect = socket.error + self.assertRaises(ValueError, value.deserialize, 'example.com') + + +class PortTest(unittest.TestCase): + def test_valid_ports(self): + value = config.Port() + self.assertEqual(1, value.deserialize('1')) + self.assertEqual(80, value.deserialize('80')) + self.assertEqual(6600, value.deserialize('6600')) + self.assertEqual(65535, value.deserialize('65535')) + + def test_invalid_ports(self): + value = config.Port() + self.assertRaises(ValueError, value.deserialize, '65536') + self.assertRaises(ValueError, value.deserialize, '100000') + self.assertRaises(ValueError, value.deserialize, '0') + self.assertRaises(ValueError, value.deserialize, '-1') From 7060a75072089e35a908cbbe5224d3dce57e0826 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 13:38:51 +0200 Subject: [PATCH 028/403] spotify: Define extension --- mopidy/backends/spotify/__init__.py | 76 ++++++++++++++++++++++++++--- setup.py | 4 +- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 507511f4..8c65817a 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,4 +1,39 @@ -"""A backend for playing music from Spotify +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError +from mopidy.utils.formatting import indent + + +config = """ +[spotify] + +# If the Spotify extension should be enabled or not +enabled = true + +# Your Spotify Premium username +username = + +# Your Spotify Premium password +password = + +# The preferred audio bitrate. Valid values are 96, 160, 320 +bitrate = 160 + +# Max number of seconds to wait for Spotify operations to complete +timeout = 10 + +# Path to the Spotify data cache. Cannot be shared with other Spotify apps +cache_path = $XDG_CACHE_DIR/mopidy/spotify + +# Connect to Spotify through a proxy +proxy_host = +proxy_username = +proxy_password = +""" + +__doc__ = """A backend for playing music from Spotify `Spotify `_ is a music streaming service. The backend uses the official `libspotify @@ -22,14 +57,39 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend .. literalinclude:: ../../../requirements/spotify.txt -**Settings:** +**Default config:** -- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` -- :attr:`mopidy.settings.SPOTIFY_USERNAME` -- :attr:`mopidy.settings.SPOTIFY_PASSWORD` -""" +.. code-block:: ini -from __future__ import unicode_literals +%(config)s +""" % {'config': indent(config)} -# flake8: noqa + +# TODO Move import into method when BACKENDS setting is removed from .actor import SpotifyBackend + + +class Extension(ext.Extension): + + name = 'Mopidy-Spotify' + version = mopidy.__version__ + + def get_default_config(self): + return config + + def validate_config(self, config): + if not config.getboolean('spotify', 'enabled'): + return + if not config.get('spotify', 'username'): + raise ExtensionError('Config spotify.username not set') + if not config.get('spotify', 'password'): + raise ExtensionError('Config spotify.password not set') + + def validate_environment(self): + try: + import spotify # noqa + except ImportError as e: + raise ExtensionError('pyspotify library not found', e) + + def get_backend_classes(self): + return [SpotifyBackend] diff --git a/setup.py b/setup.py index eeab24bd..ec84abd9 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,9 @@ setup( 'mopidy = mopidy.__main__:main', 'mopidy-scan = mopidy.scanner:main', ], - b'mopidy.extension': [], + b'mopidy.extension': [ + 'spotify = mopidy.backends.spotify:Extension', + ], }, classifiers=[ 'Development Status :: 4 - Beta', From d8c7b876bed988d706daf33ffb6b3c0da5c28750 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 14:50:22 +0200 Subject: [PATCH 029/403] local: Define extension --- mopidy/backends/local/__init__.py | 29 ++++++++++++++++++++++++++--- setup.py | 1 + 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 8ee58d3b..8f7f7be0 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,4 +1,10 @@ -"""A backend for playing music from a local music archive. +from __future__ import unicode_literals + +import mopidy +from mopidy import ext + + +__doc__ = """A backend for playing music from a local music archive. This backend handles URIs starting with ``file:``. @@ -20,7 +26,24 @@ https://github.com/mopidy/mopidy/issues?labels=Local+backend - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` """ -from __future__ import unicode_literals -# flake8: noqa +# TODO Move import into method when BACKENDS setting is removed from .actor import LocalBackend + + +class Extension(ext.Extension): + + name = 'Mopidy-Local' + version = mopidy.__version__ + + def get_default_config(self): + return '[local]' + + def validate_config(self, config): + pass + + def validate_environment(self): + pass + + def get_backend_classes(self): + return [LocalBackend] diff --git a/setup.py b/setup.py index ec84abd9..132e00a9 100644 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ setup( 'mopidy-scan = mopidy.scanner:main', ], b'mopidy.extension': [ + 'local = mopidy.backends.local:Extension', 'spotify = mopidy.backends.spotify:Extension', ], }, From d65a986a56c4b72ab0aa373ba51f4fa57203d643 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 14:56:20 +0200 Subject: [PATCH 030/403] stream: Define extension --- mopidy/backends/stream/__init__.py | 29 ++++++++++++++++++++++++++--- setup.py | 1 + 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 82755540..8c856c10 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -1,4 +1,10 @@ -"""A backend for playing music for streaming music. +from __future__ import unicode_literals + +import mopidy +from mopidy import ext + + +__doc__ = """A backend for playing music for streaming music. This backend will handle streaming of URIs in :attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are @@ -17,7 +23,24 @@ https://github.com/mopidy/mopidy/issues?labels=Stream+backend - :attr:`mopidy.settings.STREAM_PROTOCOLS` """ -from __future__ import unicode_literals -# flake8: noqa +# TODO Move import into method when BACKENDS setting is removed from .actor import StreamBackend + + +class Extension(ext.Extension): + + name = 'Mopidy-Stream' + version = mopidy.__version__ + + def get_default_config(self): + return '[stream]' + + def validate_config(self, config): + pass + + def validate_environment(self): + pass + + def get_backend_classes(self): + return [StreamBackend] diff --git a/setup.py b/setup.py index 132e00a9..340d5058 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup( b'mopidy.extension': [ 'local = mopidy.backends.local:Extension', 'spotify = mopidy.backends.spotify:Extension', + 'stream = mopidy.backends.stream:Extension', ], }, classifiers=[ From a5f3bfc9c43481850da76275300bb83b510d3ea5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 15:01:28 +0200 Subject: [PATCH 031/403] mpd: Define extension --- mopidy/frontends/mpd/__init__.py | 29 ++++++++++++++++++++++++++--- setup.py | 1 + 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 6b4eacc8..e095da14 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,4 +1,10 @@ -"""The MPD server frontend. +from __future__ import unicode_literals + +import mopidy +from mopidy import ext + + +__doc__ = """The MPD server frontend. MPD stands for Music Player Daemon. MPD is an independent project and server. Mopidy implements the MPD protocol, and is thus compatible with clients for the @@ -44,7 +50,24 @@ near future: - Live update of the music database is not supported """ -from __future__ import unicode_literals -# flake8: noqa +# TODO Move import into method when FRONTENDS setting is removed from .actor import MpdFrontend + + +class Extension(ext.Extension): + + name = 'Mopidy-MPD' + version = mopidy.__version__ + + def get_default_config(self): + return '[mpd]' + + def validate_config(self, config): + pass + + def validate_environment(self): + pass + + def get_frontend_classes(self): + return [MpdFrontend] diff --git a/setup.py b/setup.py index 340d5058..eb46fe87 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ setup( ], b'mopidy.extension': [ 'local = mopidy.backends.local:Extension', + 'mpd = mopidy.frontends.mpd:Extension', 'spotify = mopidy.backends.spotify:Extension', 'stream = mopidy.backends.stream:Extension', ], From 5b6fc25cf843f29f99921818932a9fb380c9ea7b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 15:04:25 +0200 Subject: [PATCH 032/403] mpris: Define extension --- mopidy/frontends/mpris/__init__.py | 33 +++++++++++++++++++++++++++--- setup.py | 1 + 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 2be6efea..acaff7ff 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,4 +1,11 @@ -""" +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError + + +__doc__ = """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus interface. @@ -50,7 +57,27 @@ Now you can control Mopidy through the player object. Examples: player.Quit(dbus_interface='org.mpris.MediaPlayer2') """ -from __future__ import unicode_literals -# flake8: noqa +# TODO Move import into method when FRONTENDS setting is removed from .actor import MprisFrontend + + +class Extension(ext.Extension): + + name = 'Mopidy-MPRIS' + version = mopidy.__version__ + + def get_default_config(self): + return '[mpris]' + + def validate_config(self, config): + pass + + def validate_environment(self): + try: + import dbus # noqa + except ImportError as e: + raise ExtensionError('Library dbus not found', e) + + def get_frontend_classes(self): + return [MprisFrontend] diff --git a/setup.py b/setup.py index eb46fe87..3672e2f2 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup( b'mopidy.extension': [ 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', + 'mpris = mopidy.frontends.mpris:Extension', 'spotify = mopidy.backends.spotify:Extension', 'stream = mopidy.backends.stream:Extension', ], From 0f1c4c1dff1cd364285c6fcff6fec478e308418a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 15:06:14 +0200 Subject: [PATCH 033/403] http: Define extension --- mopidy/frontends/http/__init__.py | 38 +++++++++++++++++++++++++++++-- setup.py | 1 + 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index e81ddf3f..fb354c09 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,4 +1,11 @@ -""" +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError + + +__doc__ = """ The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g. from a web based client. @@ -477,5 +484,32 @@ Example to get started with and all events that are emitted. """ -# flake8: noqa + +# TODO Move import into method when FRONTENDS setting is removed from .actor import HttpFrontend + + +class Extension(ext.Extension): + + name = 'Mopidy-HTTP' + version = mopidy.__version__ + + def get_default_config(self): + return '[http]' + + def validate_config(self, config): + pass + + def validate_environment(self): + try: + import cherrypy # noqa + except ImportError as e: + raise ExtensionError('Library cherrypy not found', e) + + try: + import ws4py # noqa + except ImportError as e: + raise ExtensionError('Library ws4py not found', e) + + def get_frontend_classes(self): + return [HttpFrontend] diff --git a/setup.py b/setup.py index 3672e2f2..db2b1932 100644 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ setup( 'mopidy-scan = mopidy.scanner:main', ], b'mopidy.extension': [ + 'http = mopidy.frontends.http:Extension', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'mpris = mopidy.frontends.mpris:Extension', From 2176ab3d98b2f004158df07649be15cbd0f83bb7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 15:11:35 +0200 Subject: [PATCH 034/403] lastfm: Define extension --- mopidy/frontends/lastfm/__init__.py | 55 +++++++++++++++++++ .../frontends/{lastfm.py => lastfm/actor.py} | 24 -------- setup.py | 1 + 3 files changed, 56 insertions(+), 24 deletions(-) create mode 100644 mopidy/frontends/lastfm/__init__.py rename mopidy/frontends/{lastfm.py => lastfm/actor.py} (87%) diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py new file mode 100644 index 00000000..aac27848 --- /dev/null +++ b/mopidy/frontends/lastfm/__init__.py @@ -0,0 +1,55 @@ +from __future__ import unicode_literals + +import mopidy +from mopidy import ext +from mopidy.exceptions import ExtensionError + + +__doc__ = """ +Frontend which scrobbles the music you play to your `Last.fm +`_ profile. + +.. note:: + + This frontend requires a free user account at Last.fm. + +**Dependencies:** + +.. literalinclude:: ../../../requirements/lastfm.txt + +**Settings:** + +- :attr:`mopidy.settings.LASTFM_USERNAME` +- :attr:`mopidy.settings.LASTFM_PASSWORD` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.lastfm.LastfmFrontend``. By default, the setting includes +the Last.fm frontend. +""" + + +# TODO Move import into method when FRONTENDS setting is removed +from .actor import LastfmFrontend + + +class Extension(ext.Extension): + + name = 'Mopidy-Lastfm' + version = mopidy.__version__ + + def get_default_config(self): + return '[lastfm]' + + def validate_config(self, config): + pass + + def validate_environment(self): + try: + import pylast # noqa + except ImportError as e: + raise ExtensionError('pylast library not found', e) + + def get_frontend_classes(self): + return [LastfmFrontend] diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm/actor.py similarity index 87% rename from mopidy/frontends/lastfm.py rename to mopidy/frontends/lastfm/actor.py index 61dc306c..60a909e0 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm/actor.py @@ -1,27 +1,3 @@ -""" -Frontend which scrobbles the music you play to your `Last.fm -`_ profile. - -.. note:: - - This frontend requires a free user account at Last.fm. - -**Dependencies:** - -.. literalinclude:: ../../../requirements/lastfm.txt - -**Settings:** - -- :attr:`mopidy.settings.LASTFM_USERNAME` -- :attr:`mopidy.settings.LASTFM_PASSWORD` - -**Usage:** - -Make sure :attr:`mopidy.settings.FRONTENDS` includes -``mopidy.frontends.lastfm.LastfmFrontend``. By default, the setting includes -the Last.fm frontend. -""" - from __future__ import unicode_literals import logging diff --git a/setup.py b/setup.py index db2b1932..cff6ce23 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ setup( ], b'mopidy.extension': [ 'http = mopidy.frontends.http:Extension', + 'lastfm = mopidy.frontends.lastfm:Extension', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'mpris = mopidy.frontends.mpris:Extension', From a52ae2091b275e9cd41acbd2e28f8f468be10240 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 16:06:23 +0200 Subject: [PATCH 035/403] Update Pykka version check --- mopidy/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index b091ef25..416f4fbf 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -15,9 +15,9 @@ if not (2, 6) <= sys.version_info < (3,): '.'.join(map(str, sys.version_info[:3]))) if (isinstance(pykka.__version__, basestring) - and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')): + and not SV('1.1') <= SV(pykka.__version__) < SV('2.0')): sys.exit( - 'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) + 'Mopidy requires Pykka >= 1.1, < 2, but found %s' % pykka.__version__) warnings.filterwarnings('ignore', 'could not open display') From ec7c172c2ec1dee35acad25c6b02692485910f6c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 16:14:34 +0200 Subject: [PATCH 036/403] Add ext prefix to config sections --- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/__init__.py | 2 +- mopidy/backends/stream/__init__.py | 2 +- mopidy/frontends/http/__init__.py | 2 +- mopidy/frontends/lastfm/__init__.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 8f7f7be0..99c50e1f 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -37,7 +37,7 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[local]' + return '[ext.local]' def validate_config(self, config): pass diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 8c65817a..efa5338b 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -7,7 +7,7 @@ from mopidy.utils.formatting import indent config = """ -[spotify] +[ext.spotify] # If the Spotify extension should be enabled or not enabled = true diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 8c856c10..dbf3e6d5 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -34,7 +34,7 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[stream]' + return '[ext.stream]' def validate_config(self, config): pass diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index fb354c09..25fe788f 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -495,7 +495,7 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[http]' + return '[ext.http]' def validate_config(self, config): pass diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index aac27848..e3f1f5c6 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -40,7 +40,7 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[lastfm]' + return '[ext.lastfm]' def validate_config(self, config): pass diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e095da14..8d9d13e0 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -61,7 +61,7 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[mpd]' + return '[ext.mpd]' def validate_config(self, config): pass diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index acaff7ff..b21dafff 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -68,7 +68,7 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[mpris]' + return '[ext.mpris]' def validate_config(self, config): pass From 211b20c49660d2e04783db346b3ff3a13f40fd56 Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Mon, 1 Apr 2013 17:56:05 +0200 Subject: [PATCH 037/403] Adds 'Starred' playlist to the playlist catalog Related to issue #326 --- mopidy/backends/spotify/session_manager.py | 1 + mopidy/backends/spotify/translator.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 6f386aae..cedff8d1 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -169,6 +169,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): return playlists = map( translator.to_mopidy_playlist, self.session.playlist_container()) + playlists.append(translator.to_mopidy_playlist(self.session.starred())) playlists = filter(None, playlists) self.backend.playlists.playlists = playlists logger.info('Loaded %d Spotify playlist(s)', len(playlists)) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index ba5f85da..cec15200 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -72,9 +72,7 @@ def to_mopidy_playlist(spotify_playlist): return Playlist(uri=uri, name='[loading...]') name = spotify_playlist.name() if not name: - # Other user's "starred" playlists isn't handled properly by pyspotify - # See https://github.com/mopidy/pyspotify/issues/81 - return + name = "Starred" if spotify_playlist.owner().canonical_name() != settings.SPOTIFY_USERNAME: name += ' by ' + spotify_playlist.owner().canonical_name() return Playlist( From 5982d1059c0a73b26101872cf252c6369ad40ba5 Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Mon, 1 Apr 2013 18:23:06 +0200 Subject: [PATCH 038/403] Reverse the list of tracks in the Starred playlist. As it is in reverse order from the "official" spotify client. --- mopidy/backends/spotify/translator.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index cec15200..cffb4022 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -71,14 +71,19 @@ def to_mopidy_playlist(spotify_playlist): if not spotify_playlist.is_loaded(): return Playlist(uri=uri, name='[loading...]') name = spotify_playlist.name() + tracks=[ + to_mopidy_track(spotify_track) + for spotify_track in spotify_playlist + if not spotify_track.is_local() + ] if not name: name = "Starred" + # Tracks in the Starred playlist are in reverse order from the official + # client. + tracks.reverse() if spotify_playlist.owner().canonical_name() != settings.SPOTIFY_USERNAME: name += ' by ' + spotify_playlist.owner().canonical_name() return Playlist( uri=uri, name=name, - tracks=[ - to_mopidy_track(spotify_track) - for spotify_track in spotify_playlist - if not spotify_track.is_local()]) + tracks=tracks) From 111d315d1ede7259cb2f2805d932d89b54b09f0d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 19:34:39 +0200 Subject: [PATCH 039/403] spotify: Fix style --- mopidy/backends/spotify/translator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index cffb4022..dfd9d99a 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -71,13 +71,13 @@ def to_mopidy_playlist(spotify_playlist): if not spotify_playlist.is_loaded(): return Playlist(uri=uri, name='[loading...]') name = spotify_playlist.name() - tracks=[ + tracks = [ to_mopidy_track(spotify_track) for spotify_track in spotify_playlist if not spotify_track.is_local() ] if not name: - name = "Starred" + name = 'Starred' # Tracks in the Starred playlist are in reverse order from the official # client. tracks.reverse() From a7a3803446b4ec6022a3637411eaf35be33db304 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 19:35:38 +0200 Subject: [PATCH 040/403] docs: Update changelog and authors --- AUTHORS | 1 + docs/changes.rst | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/AUTHORS b/AUTHORS index d6ede848..87925152 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,3 +18,4 @@ - herrernst - Nick Steel - Zan Dobersek +- Thomas Refis diff --git a/docs/changes.rst b/docs/changes.rst index 3ad5b2e1..0c14db72 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -13,6 +13,11 @@ v0.14.0 (UNRELEASED) use setuptools' entry points functionality to find installed Mopidy extensions. +**Spotify backend** + +- Add support for starred playlists, both your own and those owned by other + users. (Fixes: :issue:`326`) + v0.13.0 (2013-03-31) ==================== From 7d90dd42b54d36bf077121179f44c599ead4f058 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 18:11:52 +0200 Subject: [PATCH 041/403] main: Load frontends/backends from extensions --- mopidy/__main__.py | 57 +++++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a7e914a9..cf67b68f 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -9,6 +9,7 @@ import sys import gobject gobject.threads_init() +import pkg_resources import pykka.debug @@ -54,10 +55,11 @@ def main(): log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) + extensions = load_extensions() audio = setup_audio() - backends = setup_backends(audio) + backends = setup_backends(extensions, audio) core = setup_core(audio, backends) - setup_frontends(core) + setup_frontends(extensions, core) loop.run() except exceptions.SettingsError as ex: logger.error(ex.message) @@ -67,9 +69,9 @@ def main(): logger.exception(ex) finally: loop.quit() - stop_frontends() + stop_frontends(extensions) stop_core() - stop_backends() + stop_backends(extensions) stop_audio() process.stop_remaining_actors() @@ -138,6 +140,17 @@ def setup_settings(interactive): sys.exit(1) +def load_extensions(): + extensions = [] + for entry_point in pkg_resources.iter_entry_points('mopidy.extension'): + extension_class = entry_point.load() + extension = extension_class() + logger.info( + 'Loading extension: %s %s', extension.name, extension.version) + extensions.append(extension) + return extensions + + def setup_audio(): return Audio.start().proxy() @@ -146,18 +159,19 @@ def stop_audio(): process.stop_actors_by_class(Audio) -def setup_backends(audio): +def setup_backends(extensions, audio): backends = [] - for backend_class_name in settings.BACKENDS: - backend_class = importing.get_class(backend_class_name) - backend = backend_class.start(audio=audio).proxy() - backends.append(backend) + for extension in extensions: + for backend_class in extension.get_backend_classes(): + backend = backend_class.start(audio=audio).proxy() + backends.append(backend) return backends -def stop_backends(): - for backend_class_name in settings.BACKENDS: - process.stop_actors_by_class(importing.get_class(backend_class_name)) +def stop_backends(extensions): + for extension in extensions: + for backend_class in extension.get_backend_classes(): + process.stop_actors_by_class(backend_class) def setup_core(audio, backends): @@ -168,21 +182,16 @@ def stop_core(): process.stop_actors_by_class(Core) -def setup_frontends(core): - for frontend_class_name in settings.FRONTENDS: - try: - importing.get_class(frontend_class_name).start(core=core) - except exceptions.OptionalDependencyError as ex: - logger.info('Disabled: %s (%s)', frontend_class_name, ex) +def setup_frontends(extensions, core): + for extension in extensions: + for frontend_class in extension.get_frontend_classes(): + frontend_class.start(core=core) -def stop_frontends(): - for frontend_class_name in settings.FRONTENDS: - try: - frontend_class = importing.get_class(frontend_class_name) +def stop_frontends(extensions): + for extension in extensions: + for frontend_class in extension.get_frontend_classes(): process.stop_actors_by_class(frontend_class) - except exceptions.OptionalDependencyError: - pass if __name__ == '__main__': From 6345022cfcd02772bccd2b52abb2c8a1a0cd62e8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 18:21:17 +0200 Subject: [PATCH 042/403] Move frontend/backend import into methods --- mopidy/backends/local/__init__.py | 5 +---- mopidy/backends/spotify/__init__.py | 5 +---- mopidy/backends/stream/__init__.py | 5 +---- mopidy/frontends/http/__init__.py | 5 +---- mopidy/frontends/lastfm/__init__.py | 5 +---- mopidy/frontends/mpd/__init__.py | 5 +---- mopidy/frontends/mpris/__init__.py | 5 +---- tests/backends/local/events_test.py | 4 ++-- tests/backends/local/library_test.py | 4 ++-- tests/backends/local/playback_test.py | 4 ++-- tests/backends/local/playlists_test.py | 4 ++-- tests/backends/local/tracklist_test.py | 4 ++-- tests/frontends/http/events_test.py | 4 ++-- tests/frontends/mpris/events_test.py | 4 ++-- 14 files changed, 21 insertions(+), 42 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 99c50e1f..c2001da5 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -27,10 +27,6 @@ https://github.com/mopidy/mopidy/issues?labels=Local+backend """ -# TODO Move import into method when BACKENDS setting is removed -from .actor import LocalBackend - - class Extension(ext.Extension): name = 'Mopidy-Local' @@ -46,4 +42,5 @@ class Extension(ext.Extension): pass def get_backend_classes(self): + from .actor import LocalBackend return [LocalBackend] diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index efa5338b..503d9eb6 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -65,10 +65,6 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend """ % {'config': indent(config)} -# TODO Move import into method when BACKENDS setting is removed -from .actor import SpotifyBackend - - class Extension(ext.Extension): name = 'Mopidy-Spotify' @@ -92,4 +88,5 @@ class Extension(ext.Extension): raise ExtensionError('pyspotify library not found', e) def get_backend_classes(self): + from .actor import SpotifyBackend return [SpotifyBackend] diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index dbf3e6d5..4096476e 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -24,10 +24,6 @@ https://github.com/mopidy/mopidy/issues?labels=Stream+backend """ -# TODO Move import into method when BACKENDS setting is removed -from .actor import StreamBackend - - class Extension(ext.Extension): name = 'Mopidy-Stream' @@ -43,4 +39,5 @@ class Extension(ext.Extension): pass def get_backend_classes(self): + from .actor import StreamBackend return [StreamBackend] diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 25fe788f..0107e357 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -485,10 +485,6 @@ Example to get started with """ -# TODO Move import into method when FRONTENDS setting is removed -from .actor import HttpFrontend - - class Extension(ext.Extension): name = 'Mopidy-HTTP' @@ -512,4 +508,5 @@ class Extension(ext.Extension): raise ExtensionError('Library ws4py not found', e) def get_frontend_classes(self): + from .actor import HttpFrontend return [HttpFrontend] diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index e3f1f5c6..df41d130 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -30,10 +30,6 @@ the Last.fm frontend. """ -# TODO Move import into method when FRONTENDS setting is removed -from .actor import LastfmFrontend - - class Extension(ext.Extension): name = 'Mopidy-Lastfm' @@ -52,4 +48,5 @@ class Extension(ext.Extension): raise ExtensionError('pylast library not found', e) def get_frontend_classes(self): + from .actor import LastfmFrontend return [LastfmFrontend] diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 8d9d13e0..10334bcf 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -51,10 +51,6 @@ near future: """ -# TODO Move import into method when FRONTENDS setting is removed -from .actor import MpdFrontend - - class Extension(ext.Extension): name = 'Mopidy-MPD' @@ -70,4 +66,5 @@ class Extension(ext.Extension): pass def get_frontend_classes(self): + from .actor import MpdFrontend return [MpdFrontend] diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index b21dafff..268a9bc2 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -58,10 +58,6 @@ Now you can control Mopidy through the player object. Examples: """ -# TODO Move import into method when FRONTENDS setting is removed -from .actor import MprisFrontend - - class Extension(ext.Extension): name = 'Mopidy-MPRIS' @@ -80,4 +76,5 @@ class Extension(ext.Extension): raise ExtensionError('Library dbus not found', e) def get_frontend_classes(self): + from .actor import MprisFrontend return [MprisFrontend] diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 79d2780b..b35fad1a 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -1,12 +1,12 @@ from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from tests import unittest, path_to_data_dir from tests.backends.base import events class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 7324d85f..7bf8d565 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from tests import unittest, path_to_data_dir from tests.backends.base.library import LibraryControllerTest @@ -9,7 +9,7 @@ from tests.backends.base.library import LibraryControllerTest class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 9a306ee0..834ce8e0 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -12,7 +12,7 @@ from tests.backends.local import generate_song class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 70ed27d6..f3794cee 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import os from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -16,7 +16,7 @@ from tests.backends.local import generate_song class LocalPlaylistsControllerTest( PlaylistsControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 735043d6..ec09ac83 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import settings -from mopidy.backends.local import LocalBackend +from mopidy.backends.local import actor from mopidy.models import Track from tests import unittest, path_to_data_dir @@ -10,7 +10,7 @@ from tests.backends.local import generate_song class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): - backend_class = LocalBackend + backend_class = actor.LocalBackend tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index 5c064e93..77438fd4 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -12,7 +12,7 @@ import mock from mopidy.exceptions import OptionalDependencyError try: - from mopidy.frontends.http import HttpFrontend + from mopidy.frontends.http import actor except OptionalDependencyError: pass @@ -24,7 +24,7 @@ from tests import unittest @mock.patch('cherrypy.engine.publish') class HttpEventsTest(unittest.TestCase): def setUp(self): - self.http = HttpFrontend(core=mock.Mock()) + self.http = actor.HttpFrontend(core=mock.Mock()) def test_track_playback_paused_is_broadcasted(self, publish): publish.reset_mock() diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index f1add1b3..78e40071 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -8,7 +8,7 @@ from mopidy.exceptions import OptionalDependencyError from mopidy.models import Playlist, TlTrack try: - from mopidy.frontends.mpris import MprisFrontend, objects + from mopidy.frontends.mpris import actor, objects except OptionalDependencyError: pass @@ -19,7 +19,7 @@ from tests import unittest class BackendEventsTest(unittest.TestCase): def setUp(self): # As a plain class, not an actor: - self.mpris_frontend = MprisFrontend(core=None) + self.mpris_frontend = actor.MprisFrontend(core=None) self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object From 8ff7d792d51205192ef2a15a24c4f6bcd4e7422b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 18:47:31 +0200 Subject: [PATCH 043/403] main: Validate extension environment --- mopidy/__main__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index cf67b68f..cd3042c7 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -143,10 +143,22 @@ def setup_settings(interactive): def load_extensions(): extensions = [] for entry_point in pkg_resources.iter_entry_points('mopidy.extension'): + logger.debug('Loading extension %s', entry_point.name) extension_class = entry_point.load() extension = extension_class() + + # TODO Validate configuration, filter out disabled extensions + + try: + extension.validate_environment() + except exceptions.ExtensionError as ex: + logger.info( + 'Disabled extension: %s (%s)', extension.name, ex.message) + continue + logger.info( - 'Loading extension: %s %s', extension.name, extension.version) + 'Loaded extension %s: %s %s', + entry_point.name, extension.name, extension.version) extensions.append(extension) return extensions From e1d75eec30a74ed9890965682f64aa94207425d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 18:48:13 +0200 Subject: [PATCH 044/403] main: Validate extension's distribution requirements --- mopidy/__main__.py | 10 +++++++++- setup.py | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index cd3042c7..faee896c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -144,7 +144,15 @@ def load_extensions(): extensions = [] for entry_point in pkg_resources.iter_entry_points('mopidy.extension'): logger.debug('Loading extension %s', entry_point.name) - extension_class = entry_point.load() + + try: + extension_class = entry_point.load() + except pkg_resources.DistributionNotFound as ex: + logger.info( + 'Disabled extension %s: Dependency %s not found', + entry_point.name, ex) + continue + extension = extension_class() # TODO Validate configuration, filter out disabled extensions diff --git a/setup.py b/setup.py index cff6ce23..8d3d6d5a 100644 --- a/setup.py +++ b/setup.py @@ -45,12 +45,12 @@ setup( 'mopidy-scan = mopidy.scanner:main', ], b'mopidy.extension': [ - 'http = mopidy.frontends.http:Extension', - 'lastfm = mopidy.frontends.lastfm:Extension', + 'http = mopidy.frontends.http:Extension [http]', + 'lastfm = mopidy.frontends.lastfm:Extension [lastfm]', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'mpris = mopidy.frontends.mpris:Extension', - 'spotify = mopidy.backends.spotify:Extension', + 'spotify = mopidy.backends.spotify:Extension [spotify]', 'stream = mopidy.backends.stream:Extension', ], }, From 396fec5ba87800f4d7a58847f641af7be268d5c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 18:48:37 +0200 Subject: [PATCH 045/403] main: Log a bit more on startup and shutdown --- mopidy/__main__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index faee896c..f0ee6085 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -172,14 +172,17 @@ def load_extensions(): def setup_audio(): + logger.info('Starting Mopidy audio') return Audio.start().proxy() def stop_audio(): + logger.info('Stopping Mopidy audio') process.stop_actors_by_class(Audio) def setup_backends(extensions, audio): + logger.info('Starting Mopidy backends') backends = [] for extension in extensions: for backend_class in extension.get_backend_classes(): @@ -189,26 +192,31 @@ def setup_backends(extensions, audio): def stop_backends(extensions): + logger.info('Stopping Mopidy backends') for extension in extensions: for backend_class in extension.get_backend_classes(): process.stop_actors_by_class(backend_class) def setup_core(audio, backends): + logger.info('Starting Mopidy core') return Core.start(audio=audio, backends=backends).proxy() def stop_core(): + logger.info('Stopping Mopidy core') process.stop_actors_by_class(Core) def setup_frontends(extensions, core): + logger.info('Starting Mopidy frontends') for extension in extensions: for frontend_class in extension.get_frontend_classes(): frontend_class.start(core=core) def stop_frontends(extensions): + logger.info('Stopping Mopidy frontends') for extension in extensions: for frontend_class in extension.get_frontend_classes(): process.stop_actors_by_class(frontend_class) From eef148d3af7e77eb5246ca5a21461aad28437c6b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 20:01:36 +0200 Subject: [PATCH 046/403] main: Remove unused import --- mopidy/__main__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index f0ee6085..f3a95bfb 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -37,8 +37,7 @@ from mopidy import exceptions, settings from mopidy.audio import Audio from mopidy.core import Core from mopidy.utils import ( - deps, importing, log, path, process, settings as settings_utils, - versioning) + deps, log, path, process, settings as settings_utils, versioning) logger = logging.getLogger('mopidy.main') From f446c323bd605c84faaacd70b4ac778c84f5174b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 20:03:26 +0200 Subject: [PATCH 047/403] main: Move TODO --- mopidy/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index f3a95bfb..3831bd7e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -144,6 +144,8 @@ def load_extensions(): for entry_point in pkg_resources.iter_entry_points('mopidy.extension'): logger.debug('Loading extension %s', entry_point.name) + # TODO Filter out disabled extensions + try: extension_class = entry_point.load() except pkg_resources.DistributionNotFound as ex: @@ -154,7 +156,7 @@ def load_extensions(): extension = extension_class() - # TODO Validate configuration, filter out disabled extensions + # TODO Validate configuration try: extension.validate_environment() From 05350841622c5bda67dbc0c10a4f8b531ec79c34 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 20:14:04 +0200 Subject: [PATCH 048/403] config: Add config schema and tests. Config schemas are used to group config values and check that each of them is deserialized corretly, that none are missing and that there are no unkown keys present. --- mopidy/exceptions.py | 18 ++++++++ mopidy/utils/config.py | 63 ++++++++++++++++++++++++--- tests/utils/config_test.py | 87 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 159 insertions(+), 9 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index b8d183fb..f8d9d61a 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -20,5 +20,23 @@ class SettingsError(MopidyException): pass +class ConfigError(MopidyException): + def __init__(self, errors): + self._errors = errors + + def __getitem__(self, key): + return self._errors[key] + + def __iter__(self): + return self._errors.iterkeys() + + @property + def message(self): + lines = [] + for key, msg in self._errors.items(): + lines.append('%s: %s' % (key, msg)) + return '\n'.join(lines) + + class OptionalDependencyError(MopidyException): pass diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index efc07d10..e96d3d29 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -4,12 +4,14 @@ import logging import re import socket +from mopidy import exceptions + def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" if choices is not None and value not in choices : names = ', '.join(repr(c) for c in choices) - raise ValueError('%r must be one of %s.' % (value, names)) + raise ValueError('must be one of %s, not %s.' % (names, value)) def validate_minimum(value, minimum): @@ -68,11 +70,13 @@ class ConfigValue(object): def serialize(self, value): """Convert value back to string for saving.""" + if value is None: + return '' return str(value) def format(self, value): """Format value for display.""" - if self.secret: + if self.secret and value is not None: return '********' return self.serialize(value) @@ -83,9 +87,6 @@ class String(ConfigValue): validate_choice(value, self.choices) return value - def serialize(self, value): - return value.strip() - class Integer(ConfigValue): def deserialize(self, value): @@ -156,3 +157,55 @@ class Port(Integer): super(Port, self).__init__(**kwargs) self.minimum = 1 self.maximum = 2**16 - 1 + + +class ConfigSchema(object): + """Logical group of config values that corespond to a config section. + + Schemas are setup by assigning config keys with config values to instances. + Once setup `convert` can be called with a list of `(key, value)` tuples to + process. For convienience we also support a `format` method that can used + for printing out the converted values. + """ + def __init__(self): + self._schema = {} + self._order = [] + + def __setitem__(self, key, value): + if key not in self._schema: + self._order.append(key) + self._schema[key] = value + + def __getitem__(self, key): + return self._schema[key] + + def format(self, name, values): + lines = ['[%s]' % name] + for key in self._order: + value = values.get(key) + if value is not None: + lines.append('%s = %s' % (key, self._schema[key].format(value))) + return '\n'.join(lines) + + def convert(self, items): + errors = {} + values = {} + + for key, value in items: + try: + if value.strip(): + values[key] = self._schema[key].deserialize(value) + else: # treat blank entries as none + values[key] = None + except KeyError: # not in our schema + errors[key] = 'unknown config key.' + except ValueError as e: # deserialization failed + errors[key] = str(e) + + for key in self._schema: + if key not in values and key not in errors: + errors[key] = 'config key not found.' + + if errors: + raise exceptions.ConfigError(errors) + return values diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index b0ccfe78..b5052bae 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -4,6 +4,7 @@ import logging import mock import socket +from mopidy import exceptions from mopidy.utils import config from tests import unittest @@ -99,10 +100,6 @@ class StringTest(unittest.TestCase): self.assertEqual('foo', value.deserialize('foo')) self.assertRaises(ValueError, value.deserialize, 'foobar') - def test_serialize_strips_whitespace(self): - value = config.String() - self.assertEqual('foo', value.serialize(' foo ')) - def test_format_masks_secrets(self): value = config.String(secret=True) self.assertEqual('********', value.format('s3cret')) @@ -241,3 +238,85 @@ class PortTest(unittest.TestCase): self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '0') self.assertRaises(ValueError, value.deserialize, '-1') + + +class ConfigSchemaTest(unittest.TestCase): + def setUp(self): + self.schema = config.ConfigSchema() + self.schema['foo'] = mock.Mock() + self.schema['bar'] = mock.Mock() + self.schema['baz'] = mock.Mock() + self.values = {'bar': '123', 'foo': '456', 'baz': '678'} + + def test_format(self): + self.schema['foo'].format.return_value = 'qwe' + self.schema['bar'].format.return_value = 'asd' + self.schema['baz'].format.return_value = 'zxc' + + expected = ['[qwerty]', 'foo = qwe', 'bar = asd', 'baz = zxc'] + result = self.schema.format('qwerty', self.values) + self.assertEqual('\n'.join(expected), result) + + def test_format_unkwown_value(self): + self.schema['foo'].format.return_value = 'qwe' + self.schema['bar'].format.return_value = 'asd' + self.schema['baz'].format.return_value = 'zxc' + self.values['unknown'] = 'rty' + + result = self.schema.format('qwerty', self.values) + self.assertNotIn('unknown = rty', result) + + def test_convert(self): + self.schema.convert(self.values.items()) + + def test_convert_with_missing_value(self): + del self.values['foo'] + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('not found', cm.exception['foo']) + + def test_convert_with_extra_value(self): + self.values['extra'] = '123' + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('unknown', cm.exception['extra']) + + def test_convert_with_blank_value(self): + self.values['foo'] = '' + result = self.schema.convert(self.values.items()) + self.assertIsNone(result['foo']) + + def test_convert_with_deserialization_error(self): + self.schema['foo'].deserialize.side_effect = ValueError('failure') + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('failure', cm.exception['foo']) + + def test_convert_with_multiple_deserialization_errors(self): + self.schema['foo'].deserialize.side_effect = ValueError('failure') + self.schema['bar'].deserialize.side_effect = ValueError('other') + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('failure', cm.exception['foo']) + self.assertIn('other', cm.exception['bar']) + + def test_convert_deserialization_unknown_and_missing_errors(self): + self.values['extra'] = '123' + self.schema['bar'].deserialize.side_effect = ValueError('failure') + del self.values['baz'] + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('unknown', cm.exception['extra']) + self.assertNotIn('foo', cm.exception) + self.assertIn('failure', cm.exception['bar']) + self.assertIn('not found', cm.exception['baz']) From 74788a2ccb6bade0d22ad5b235f924a3488f58fa Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 20:14:20 +0200 Subject: [PATCH 049/403] Remove the BACKENDS setting --- docs/development.rst | 8 +------- mopidy/settings.py | 19 ------------------- mopidy/utils/settings.py | 2 -- tests/backends/local/playback_test.py | 1 - tests/backends/local/tracklist_test.py | 1 - tests/utils/settings_test.py | 7 ------- 6 files changed, 1 insertion(+), 37 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 4374acf2..776b004d 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -281,12 +281,6 @@ settings file in the following way:: import os profile = os.environ.get('PROFILE', '').split(',') - if 'spotify' in profile: - BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) - elif 'local' in profile: - BACKENDS = (u'mopidy.backends.local.LocalBackend',) - LOCAL_MUSIC_PATH = u'~/music' - if 'shoutcast' in profile: OUTPUT = u'lame ! shout2send mount="/stream"' elif 'silent' in profile: @@ -296,7 +290,7 @@ settings file in the following way:: SPOTIFY_USERNAME = u'xxxxx' SPOTIFY_PASSWORD = u'xxxxx' -Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +Using this setup you can now run Mopidy with ``PROFILE=silent mopidy`` if you for instance want to test Spotify without any actual audio output. diff --git a/mopidy/settings.py b/mopidy/settings.py index d0d279c2..7df43ae5 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -9,25 +9,6 @@ All available settings and their default values. from __future__ import unicode_literals -#: List of playback backends to use. See :ref:`backend-implementations` for all -#: available backends. -#: -#: When results from multiple backends are combined, they are combined in the -#: order the backends are listed here. -#: -#: Default:: -#: -#: BACKENDS = ( -#: u'mopidy.backends.local.LocalBackend', -#: u'mopidy.backends.spotify.SpotifyBackend', -#: u'mopidy.backends.stream.StreamBackend', -#: ) -BACKENDS = ( - 'mopidy.backends.local.LocalBackend', - 'mopidy.backends.spotify.SpotifyBackend', - 'mopidy.backends.stream.StreamBackend', -) - #: The log format used for informational logging. #: #: See http://docs.python.org/2/library/logging.html#formatter-objects for diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 8ae61e5b..011b723a 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -143,13 +143,11 @@ def validate_settings(defaults, settings): } must_be_iterable = [ - 'BACKENDS', 'FRONTENDS', 'STREAM_PROTOCOLS', ] must_have_value_set = [ - 'BACKENDS', 'FRONTENDS', ] diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 834ce8e0..8d997d2e 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -17,7 +17,6 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') super(LocalPlaybackControllerTest, self).setUp() diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index ec09ac83..0c47a5db 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -15,7 +15,6 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): - settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') super(LocalTracklistControllerTest, self).setUp() diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 51f0d89c..4e7d9d77 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -11,7 +11,6 @@ from tests import unittest class ValidateSettingsTest(unittest.TestCase): def setUp(self): self.defaults = { - 'BACKENDS': ['a'], 'FRONTENDS': ['a'], 'MPD_SERVER_HOSTNAME': '::', 'MPD_SERVER_PORT': 6600, @@ -81,12 +80,6 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual( result['FRONTENDS'], 'Must be set.') - def test_empty_backends_list_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'BACKENDS': []}) - self.assertEqual( - result['BACKENDS'], 'Must be set.') - def test_noniterable_multivalue_setting_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'FRONTENDS': ('this is not a tuple')}) From aa1f8199c2adc0cbdc912e70792a01e39f909712 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 20:22:29 +0200 Subject: [PATCH 050/403] Remove the FRONTENDS setting --- docs/api/frontends.rst | 4 ++-- docs/clients/mpris.rst | 11 +++++------ docs/extensiondev.rst | 2 ++ mopidy/frontends/http/__init__.py | 17 +++++++++-------- mopidy/frontends/lastfm/__init__.py | 4 +--- mopidy/frontends/mpd/__init__.py | 4 +--- mopidy/frontends/mpris/__init__.py | 4 +--- mopidy/settings.py | 16 ---------------- mopidy/utils/settings.py | 9 --------- tests/utils/settings_test.py | 15 --------------- 10 files changed, 21 insertions(+), 65 deletions(-) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 8488b408..a6e6f500 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -20,8 +20,8 @@ The following requirements applies to any frontend implementation: - It MAY use additional actors to implement whatever it does, and using actors in frontend implementations is encouraged. -- The frontend is activated by including its main actor in the - :attr:`mopidy.settings.FRONTENDS` setting. +- The frontend is enabled if the extension it is part of is enabled. See + :ref:`extensiondev` for more information. - The main actor MUST be able to start and stop the frontend when the main actor is started and stopped. diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index c782fa26..28da63ed 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -36,12 +36,11 @@ Mopidy executable. If this isn't in place, the sound menu will not detect that Mopidy is running. Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to -control Mopidy. The frontend is activated by default, so unless you've changed -the :attr:`mopidy.settings.FRONTENDS` setting, you should be good to go. Keep -an eye out for warnings or errors from the MPRIS frontend when you start -Mopidy, since it may fail because of missing dependencies or because Mopidy is -started outside of X; the frontend won't work if ``$DISPLAY`` isn't set when -Mopidy is started. +control Mopidy. The frontend is enabled by default, so as long as you have all +its dependencies available, you should be good to go. Keep an eye out for +warnings or errors from the MPRIS frontend when you start Mopidy, since it may +fail because of missing dependencies or because Mopidy is started outside of X; +the frontend won't work if ``$DISPLAY`` isn't set when Mopidy is started. Under normal use, if Mopidy isn't running and you open the menu and click on "Mopidy Music Server", a terminal window will open and automatically start diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 04c6aa6b..976f8f84 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -1,3 +1,5 @@ +.. _extensiondev: + ********************* Extension development ********************* diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 0107e357..7b99efd0 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -25,8 +25,10 @@ from a web based client. Setup ===== -When this frontend is included in :attr:`mopidy.settings.FRONTENDS`, it starts -a web server at the port specified by :attr:`mopidy.settings.HTTP_SERVER_PORT`. +The frontend is enabled by default if all dependencies are available. + +When it is enabled it starts a web server at the port specified by +:attr:`mopidy.settings.HTTP_SERVER_PORT`. .. warning:: Security @@ -364,15 +366,14 @@ event listeners, and delete the object like this: Example to get started with --------------------------- -1. Create an empty directory for your web client. +1. Make sure that you've installed all dependencies required by the HTTP + frontend. -2. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point +2. Create an empty directory for your web client. + +3. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point to your new directory. -3. Make sure that you've included - ``mopidy.frontends.http.HttpFrontend`` in - :attr:`mopidy.settings.FRONTENDS`. - 4. Start/restart Mopidy. 5. Create a file in the directory named ``index.html`` containing e.g. "Hello, diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index df41d130..439ada50 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -24,9 +24,7 @@ Frontend which scrobbles the music you play to your `Last.fm **Usage:** -Make sure :attr:`mopidy.settings.FRONTENDS` includes -``mopidy.frontends.lastfm.LastfmFrontend``. By default, the setting includes -the Last.fm frontend. +The frontend is enabled by default if all dependencies are available. """ diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 10334bcf..5cb8b8c0 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -22,9 +22,7 @@ original MPD server. **Usage:** -Make sure :attr:`mopidy.settings.FRONTENDS` includes -``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD -frontend. +The frontend is enabled by default. **Limitations:** diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 268a9bc2..940c4210 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -32,9 +32,7 @@ An example of an MPRIS client is the `Ubuntu Sound Menu **Usage:** -Make sure :attr:`mopidy.settings.FRONTENDS` includes -``mopidy.frontends.mpris.MprisFrontend``. By default, the setting includes the -MPRIS frontend. +The frontend is enabled by default if all dependencies are available. **Testing the frontend** diff --git a/mopidy/settings.py b/mopidy/settings.py index 7df43ae5..cde6430a 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -39,22 +39,6 @@ DEBUG_LOG_FILENAME = 'mopidy.log' #: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' DESKTOP_FILE = '/usr/share/applications/mopidy.desktop' -#: List of server frontends to use. See :ref:`frontend-implementations` for -#: available frontends. -#: -#: Default:: -#: -#: FRONTENDS = ( -#: u'mopidy.frontends.mpd.MpdFrontend', -#: u'mopidy.frontends.lastfm.LastfmFrontend', -#: u'mopidy.frontends.mpris.MprisFrontend', -#: ) -FRONTENDS = ( - 'mopidy.frontends.mpd.MpdFrontend', - 'mopidy.frontends.lastfm.LastfmFrontend', - 'mopidy.frontends.mpris.MprisFrontend', -) - #: Which address Mopidy's HTTP server should bind to. #: #: Used by :mod:`mopidy.frontends.http`. diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 011b723a..051f0f1c 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -123,7 +123,6 @@ def validate_settings(defaults, settings): changed = { 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', - 'FRONTEND': 'FRONTENDS', 'GSTREAMER_AUDIO_SINK': 'OUTPUT', 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', 'LOCAL_OUTPUT_OVERRIDE': 'OUTPUT', @@ -143,14 +142,9 @@ def validate_settings(defaults, settings): } must_be_iterable = [ - 'FRONTENDS', 'STREAM_PROTOCOLS', ] - must_have_value_set = [ - 'FRONTENDS', - ] - for setting, value in settings.iteritems(): if setting in changed: if changed[setting] is None: @@ -180,9 +174,6 @@ def validate_settings(defaults, settings): 'Must be a tuple. ' "Remember the comma after single values: (u'value',)") - elif setting in must_have_value_set and not value: - errors[setting] = 'Must be set.' - elif setting not in defaults and not setting.startswith('CUSTOM_'): errors[setting] = 'Unknown setting.' suggestion = did_you_mean(setting, defaults) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 4e7d9d77..2c13066c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -11,7 +11,6 @@ from tests import unittest class ValidateSettingsTest(unittest.TestCase): def setUp(self): self.defaults = { - 'FRONTENDS': ['a'], 'MPD_SERVER_HOSTNAME': '::', 'MPD_SERVER_PORT': 6600, 'SPOTIFY_BITRATE': 160, @@ -74,20 +73,6 @@ class ValidateSettingsTest(unittest.TestCase): 'SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) - def test_empty_frontends_list_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'FRONTENDS': []}) - self.assertEqual( - result['FRONTENDS'], 'Must be set.') - - def test_noniterable_multivalue_setting_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'FRONTENDS': ('this is not a tuple')}) - self.assertEqual( - result['FRONTENDS'], - 'Must be a tuple. ' - "Remember the comma after single values: (u'value',)") - class SettingsProxyTest(unittest.TestCase): def setUp(self): From 980792e52745b50a485b2e070f666fd53ed4b9e3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 20:23:34 +0200 Subject: [PATCH 051/403] config: Add ExtensionConfigSchema. --- mopidy/utils/config.py | 14 ++++++++++++++ tests/utils/config_test.py | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index e96d3d29..d7eed6bf 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -209,3 +209,17 @@ class ConfigSchema(object): if errors: raise exceptions.ConfigError(errors) return values + + +class ExtensionConfigSchema(ConfigSchema): + """Sub-classed ConfigSchema for use in extensions. + + Ensures that `enabled` config value is present and that section name is + prefixed with ext. + """ + def __init__(self): + super(ExtensionConfigSchema, self).__init__() + self['enabled'] = Boolean() + + def format(self, name, values): + return super(ExtensionConfigSchema, self).format('ext.%s' % name, values) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index b5052bae..ae4f4a02 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -320,3 +320,13 @@ class ConfigSchemaTest(unittest.TestCase): self.assertNotIn('foo', cm.exception) self.assertIn('failure', cm.exception['bar']) self.assertIn('not found', cm.exception['baz']) + + +class ExtensionConfigSchemaTest(unittest.TestCase): + def test_schema_includes_enabled(self): + schema = config.ExtensionConfigSchema() + self.assertIsInstance(schema['enabled'], config.Boolean) + + def test_section_name_is_prefixed(self): + schema = config.ExtensionConfigSchema() + self.assertEqual('[ext.foo]', schema.format('foo', {})) From b4c553e201406ad23dc58928a4ba5561c802b4fb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 20:33:22 +0200 Subject: [PATCH 052/403] config: Add LogLevelConfigSchema. --- mopidy/utils/config.py | 33 +++++++++++++++++++++++++++++++++ tests/utils/config_test.py | 16 ++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index d7eed6bf..a121b277 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -223,3 +223,36 @@ class ExtensionConfigSchema(ConfigSchema): def format(self, name, values): return super(ExtensionConfigSchema, self).format('ext.%s' % name, values) + + +class LogLevelConfigSchema(object): + """Special cased schema for handling a config section with loglevels. + + Expects the config keys to be logger names and the values to be log levels + as understood by the LogLevel config value. Does not sub-class ConfigSchema, + but implements the same interface. + """ + def __init__(self): + self._configvalue = LogLevel() + + def format(self, name, values): + lines = ['[%s]' % name] + for key, value in sorted(values.items()): + if value is not None: + lines.append('%s = %s' % (key, self._configvalue.format(value))) + return '\n'.join(lines) + + def convert(self, items): + errors = {} + values = {} + + for key, value in items: + try: + if value.strip(): + values[key] = self._configvalue.deserialize(value) + except ValueError as e: # deserialization failed + errors[key] = str(e) + + if errors: + raise exceptions.ConfigError(errors) + return values diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index ae4f4a02..fae47111 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -330,3 +330,19 @@ class ExtensionConfigSchemaTest(unittest.TestCase): def test_section_name_is_prefixed(self): schema = config.ExtensionConfigSchema() self.assertEqual('[ext.foo]', schema.format('foo', {})) + + +class LogLevelConfigSchemaTest(unittest.TestCase): + def test_conversion(self): + schema = config.LogLevelConfigSchema() + result = schema.convert([('foo.bar', 'DEBUG'), ('baz', 'INFO')]) + + self.assertEqual(logging.DEBUG, result['foo.bar']) + self.assertEqual(logging.INFO, result['baz']) + + def test_format(self): + schema = config.LogLevelConfigSchema() + expected = ['[levels]', 'baz = info', 'foo.bar = debug'] + result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) + self.assertEqual('\n'.join(expected), result) + From bddb90e49012ec0f839fce45bd7e59003b39ec1f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 20:44:23 +0200 Subject: [PATCH 053/403] docs: Add 'ext.' prefix to config sections --- docs/extensiondev.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 976f8f84..0505cec8 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -203,7 +203,8 @@ The default configuration for the extension is defined by the ``get_default_config()`` method in the ``Extension`` class which returns a :mod:`ConfigParser` compatible config section. The config section's name should be the same as the extension's short name, as defined in the ``entry_points`` -part of ``setup.py``. All extensions should include an ``enabled`` config which +part of ``setup.py``, but prefixed with ``ext.``, for example +``ext.soundspot``. All extensions should include an ``enabled`` config which should default to ``true``. Provide good defaults for all config values so that as few users as possible will need to change them. The exception is if the config value has security implications; in that case you should default to the @@ -235,7 +236,7 @@ meaningful defaults blank, like ``username`` and ``password``. def get_default_config(self): return """ - [soundspot] + [ext.soundspot] enabled = true username = password = From e00b7a63f00f7a0b7c3e75598bd7652a55c4385a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:03:16 +0200 Subject: [PATCH 054/403] config: Add mopidy.config to hold config schemas and eventually settings access and loading. --- mopidy/config.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 mopidy/config.py diff --git a/mopidy/config.py b/mopidy/config.py new file mode 100644 index 00000000..2c713a52 --- /dev/null +++ b/mopidy/config.py @@ -0,0 +1,25 @@ +from mopidy.utils import config + +schemas = {} # TODO: use ordered dict? +schemas['logging'] = config.ConfigSchema() +schemas['logging']['config_file'] = config.String() +schemas['logging']['console_format'] = config.String() +schemas['logging']['debug_format'] = config.String() +schemas['logging']['debug_file'] = config.String() +schemas['logging']['debug_thread'] = config.Boolean() + +schemas['logging.levels'] = config.LogLevelConfigSchema() + +schemas['audio'] = config.ConfigSchema() +schemas['audio']['mixer'] = config.String() +schemas['audio']['mixer_track'] = config.String() +schemas['audio']['output'] = config.String() + +# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema +#schemas['audio.outputs'] = config.AudioOutputConfigSchema() + + +def register_schema(name, schema): + if name in schemas: + raise Exception + schemas[name] = schema From 3509ec4b37c8f32beebaecd8a71470c6fbc8c581 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:19:38 +0200 Subject: [PATCH 055/403] config: Address review comments. --- mopidy/exceptions.py | 3 --- mopidy/utils/config.py | 30 +++++++++++++++++------------- tests/utils/config_test.py | 10 +++++----- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index f8d9d61a..14d374a0 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -27,9 +27,6 @@ class ConfigError(MopidyException): def __getitem__(self, key): return self._errors[key] - def __iter__(self): - return self._errors.iterkeys() - @property def message(self): lines = [] diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index a121b277..fad641f1 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -87,6 +87,9 @@ class String(ConfigValue): validate_choice(value, self.choices) return value + def serialize(self, value): + return value.encode('utf-8') + class Integer(ConfigValue): def deserialize(self, value): @@ -121,10 +124,10 @@ class List(ConfigValue): if '\n' in value: return re.split(r'\s*\n\s*', value.strip()) else: - return re.split(r',\s*', value.strip()) + return re.split(r'\s*,\s*', value.strip()) def serialize(self, value): - return '\n '.join(value) + return '\n '.join(v.encode('utf-8') for v in value) class LogLevel(ConfigValue): @@ -148,7 +151,7 @@ class Hostname(ConfigValue): try: socket.getaddrinfo(value, None) except socket.error: - raise ValueError('must be a resolveable hostname or valid IP.') + raise ValueError('must be a resolveable hostname or valid IP') return value @@ -160,13 +163,14 @@ class Port(Integer): class ConfigSchema(object): - """Logical group of config values that corespond to a config section. + """Logical group of config values that correspond to a config section. - Schemas are setup by assigning config keys with config values to instances. - Once setup `convert` can be called with a list of `(key, value)` tuples to - process. For convienience we also support a `format` method that can used + Schemas are set up by assigning config keys with config values to instances. + Once setup :meth:`convert` can be called with a list of `(key, value)` tuples to + process. For convienience we also support :meth:`format` method that can used for printing out the converted values. """ + # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) def __init__(self): self._schema = {} self._order = [] @@ -212,7 +216,7 @@ class ConfigSchema(object): class ExtensionConfigSchema(ConfigSchema): - """Sub-classed ConfigSchema for use in extensions. + """Sub-classed :class:`ConfigSchema` for use in extensions. Ensures that `enabled` config value is present and that section name is prefixed with ext. @@ -229,17 +233,17 @@ class LogLevelConfigSchema(object): """Special cased schema for handling a config section with loglevels. Expects the config keys to be logger names and the values to be log levels - as understood by the LogLevel config value. Does not sub-class ConfigSchema, - but implements the same interface. + as understood by the :class:`LogLevel` config value. Does not sub-class + :class:`ConfigSchema`, but implements the same interface. """ def __init__(self): - self._configvalue = LogLevel() + self._config_value = LogLevel() def format(self, name, values): lines = ['[%s]' % name] for key, value in sorted(values.items()): if value is not None: - lines.append('%s = %s' % (key, self._configvalue.format(value))) + lines.append('%s = %s' % (key, self._config_value.format(value))) return '\n'.join(lines) def convert(self, items): @@ -249,7 +253,7 @@ class LogLevelConfigSchema(object): for key, value in items: try: if value.strip(): - values[key] = self._configvalue.deserialize(value) + values[key] = self._config_value.deserialize(value) except ValueError as e: # deserialization failed errors[key] = str(e) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index fae47111..a98c37b5 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -182,11 +182,11 @@ class ListTest(unittest.TestCase): class BooleanTest(unittest.TestCase): - levels = {'critical' : logging.CRITICAL, - 'error' : logging.ERROR, - 'warning' : logging.WARNING, - 'info' : logging.INFO, - 'debug' : logging.DEBUG} + levels = {'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG} def test_deserialize_converts_to_numeric_loglevel(self): value = config.LogLevel() From 5e608c18dcf2f36b18898b4256b511c94228ad4c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:26:48 +0200 Subject: [PATCH 056/403] config: re-add ConfigError.__iter__ --- mopidy/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 0c370c8a..23aa3fb8 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -27,6 +27,9 @@ class ConfigError(MopidyException): def __getitem__(self, key): return self._errors[key] + def __iter__(self): + return self._errors.iterkeys() + @property def message(self): lines = [] From 5283d1e2b2c462ef2569967d13699b1fbae34863 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:35:01 +0200 Subject: [PATCH 057/403] main: Add ConfigError test. --- tests/exceptions_test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index 2bc838d7..12a18338 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -23,3 +23,13 @@ class ExceptionsTest(unittest.TestCase): def test_extension_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ExtensionError, exceptions.MopidyException)) + + def test_config_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.ConfigError, exceptions.MopidyException)) + + def test_config_error_provides_getitem(self): + exception = exceptions.ConfigError({'field1': 'msg1', 'field2': 'msg2'}) + self.assertEqual('msg1', exception['field1']) + self.assertEqual('msg2', exception['field2']) + self.assertItemsEqual(['field1', 'field2'], exception) From c416893fb3b6338ebb73969e005b762663c6265f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:57:53 +0200 Subject: [PATCH 058/403] ext: Update suggestion for config validation mechanics. --- docs/extensiondev.rst | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 0505cec8..7b4bc374 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -222,8 +222,9 @@ meaningful defaults blank, like ``username`` and ``password``. import gst import gobject + from mopidy import exceptions from mopidy import ext - from mopidy.exceptions import ExtensionError + from mopidy.utils import config __version__ = '0.1' @@ -242,17 +243,11 @@ meaningful defaults blank, like ``username`` and ``password``. password = """ - def validate_config(self, config): - # ``config`` is the complete config document for the Mopidy - # instance. The extension is free to check any config value it is - # interested in, not just its own config values. - - if not config.getboolean('soundspot', 'enabled'): - return - if not config.get('soundspot', 'username'): - raise ExtensionError('Config soundspot.username not set') - if not config.get('soundspot', 'password'): - raise ExtensionError('Config soundspot.password not set') + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['username'] = config.String(required=True) + schema['password'] = config.String(required=True, secret=True) + return schema def validate_environment(self): # This method can validate anything it wants about the environment @@ -262,7 +257,7 @@ meaningful defaults blank, like ``username`` and ``password``. try: import pysoundspot except ImportError as e: - raise ExtensionError('pysoundspot library not found', e) + raise exceptions.ExtensionError('pysoundspot library not found', e) # You will typically only implement one of the next three methods # in a single extension. From f3004ed4d27d5c13f79d762f7c8b48fa719b4925 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:05:10 +0200 Subject: [PATCH 059/403] config: Fix flake8 warnings --- mopidy/utils/config.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index fad641f1..b26c4f0a 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -9,7 +9,7 @@ from mopidy import exceptions def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" - if choices is not None and value not in choices : + if choices is not None and value not in choices: names = ', '.join(repr(c) for c in choices) raise ValueError('must be one of %s, not %s.' % (names, value)) @@ -131,15 +131,18 @@ class List(ConfigValue): class LogLevel(ConfigValue): - levels = {'critical' : logging.CRITICAL, - 'error' : logging.ERROR, - 'warning' : logging.WARNING, - 'info' : logging.INFO, - 'debug' : logging.DEBUG} + levels = { + 'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + } def deserialize(self, value): if value.lower() not in self.levels: - raise ValueError('%r must be one of %s.' % (value, ', '.join(self.levels))) + raise ValueError('%r must be one of %s.' % ( + value, ', '.join(self.levels))) return self.levels.get(value.lower()) def serialize(self, value): @@ -159,16 +162,16 @@ class Port(Integer): def __init__(self, **kwargs): super(Port, self).__init__(**kwargs) self.minimum = 1 - self.maximum = 2**16 - 1 + self.maximum = 2 ** 16 - 1 class ConfigSchema(object): """Logical group of config values that correspond to a config section. - Schemas are set up by assigning config keys with config values to instances. - Once setup :meth:`convert` can be called with a list of `(key, value)` tuples to - process. For convienience we also support :meth:`format` method that can used - for printing out the converted values. + Schemas are set up by assigning config keys with config values to + instances. Once setup :meth:`convert` can be called with a list of `(key, + value)` tuples to process. For convienience we also support :meth:`format` + method that can used for printing out the converted values. """ # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) def __init__(self): @@ -188,7 +191,8 @@ class ConfigSchema(object): for key in self._order: value = values.get(key) if value is not None: - lines.append('%s = %s' % (key, self._schema[key].format(value))) + lines.append('%s = %s' % ( + key, self._schema[key].format(value))) return '\n'.join(lines) def convert(self, items): @@ -226,7 +230,8 @@ class ExtensionConfigSchema(ConfigSchema): self['enabled'] = Boolean() def format(self, name, values): - return super(ExtensionConfigSchema, self).format('ext.%s' % name, values) + return super(ExtensionConfigSchema, self).format( + 'ext.%s' % name, values) class LogLevelConfigSchema(object): @@ -243,7 +248,8 @@ class LogLevelConfigSchema(object): lines = ['[%s]' % name] for key, value in sorted(values.items()): if value is not None: - lines.append('%s = %s' % (key, self._config_value.format(value))) + lines.append('%s = %s' % ( + key, self._config_value.format(value))) return '\n'.join(lines) def convert(self, items): From 4a8b7c763e377cca5ad908f19d70c99b1b13ee74 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:10:18 +0200 Subject: [PATCH 060/403] docs: Remove py_modules comment from example setup.py --- docs/extensiondev.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 7b4bc374..43b0b723 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -159,9 +159,6 @@ class that will connect the rest of the dots. description='Very short description', long_description=open('README.rst').read(), packages=['mopidy_soundspot'], - # If you ship package instead of a single module instead, use - # 'py_modules' instead of 'packages': - #py_modules=['mopidy_soundspot'], zip_safe=False, include_package_data=True, install_requires=[ From 98269f4ed1093afa66986d4a80f82649e5c6cb8d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 23:21:56 +0200 Subject: [PATCH 061/403] config: Add optional setting to config values and improve tests. --- mopidy/utils/config.py | 48 ++++++++++------ tests/utils/config_test.py | 113 ++++++++++++++++++++++++++----------- 2 files changed, 112 insertions(+), 49 deletions(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index fad641f1..e20afd26 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -7,9 +7,16 @@ import socket from mopidy import exceptions +def validate_required(value, required): + """Required validation, normally called in config value's validate() on the + raw string, _not_ the converted value.""" + if required and not value.strip(): + raise ValueError('must be set.') + + def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" - if choices is not None and value not in choices : + if choices is not None and value not in choices: names = ', '.join(repr(c) for c in choices) raise ValueError('must be one of %s, not %s.' % (names, value)) @@ -55,14 +62,18 @@ class ConfigValue(object): #: :function:`validate_maximum` in :method:`validate` do any thing. maximum = None + #: Indicate if this field is required. + opitional = None + #: Indicate if we should mask the when printing for human consumption. secret = None - def __init__(self, choices=None, minimum=None, maximum=None, secret=None): - self.choices = choices - self.minimum = minimum - self.maximum = maximum - self.secret = secret + def __init__(self, **kwargs): + self.choices = kwargs.get('choices') + self.minimum = kwargs.get('minimum') + self.maximum = kwargs.get('maximum') + self.optional = kwargs.get('optional') + self.secret = kwargs.get('secret') def deserialize(self, value): """Cast raw string to appropriate type.""" @@ -70,8 +81,6 @@ class ConfigValue(object): def serialize(self, value): """Convert value back to string for saving.""" - if value is None: - return '' return str(value) def format(self, value): @@ -84,7 +93,10 @@ class ConfigValue(object): class String(ConfigValue): def deserialize(self, value): value = value.strip() + validate_required(value, not self.optional) validate_choice(value, self.choices) + if not value: + return None return value def serialize(self, value): @@ -93,7 +105,7 @@ class String(ConfigValue): class Integer(ConfigValue): def deserialize(self, value): - value = int(value.strip()) + value = int(value) validate_choice(value, self.choices) validate_minimum(value, self.minimum) validate_maximum(value, self.maximum) @@ -121,10 +133,12 @@ class Boolean(ConfigValue): class List(ConfigValue): def deserialize(self, value): + validate_required(value, not self.optional) if '\n' in value: - return re.split(r'\s*\n\s*', value.strip()) + values = re.split(r'\s*\n\s*', value.strip()) else: - return re.split(r'\s*,\s*', value.strip()) + values = re.split(r'\s*,\s*', value.strip()) + return [v for v in values if v] def serialize(self, value): return '\n '.join(v.encode('utf-8') for v in value) @@ -138,8 +152,7 @@ class LogLevel(ConfigValue): 'debug' : logging.DEBUG} def deserialize(self, value): - if value.lower() not in self.levels: - raise ValueError('%r must be one of %s.' % (value, ', '.join(self.levels))) + validate_choice(value.lower(), self.levels.keys()) return self.levels.get(value.lower()) def serialize(self, value): @@ -148,6 +161,9 @@ class LogLevel(ConfigValue): class Hostname(ConfigValue): def deserialize(self, value): + validate_required(value, not self.optional) + if not value.strip(): + return None try: socket.getaddrinfo(value, None) except socket.error: @@ -156,6 +172,7 @@ class Hostname(ConfigValue): class Port(Integer): + # TODO: consider probing if port is free or not? def __init__(self, **kwargs): super(Port, self).__init__(**kwargs) self.minimum = 1 @@ -197,10 +214,7 @@ class ConfigSchema(object): for key, value in items: try: - if value.strip(): - values[key] = self._schema[key].deserialize(value) - else: # treat blank entries as none - values[key] = None + values[key] = self._schema[key].deserialize(value) except KeyError: # not in our schema errors[key] = 'unknown config key.' except ValueError as e: # deserialization failed diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index a98c37b5..527ec8d3 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -55,20 +55,38 @@ class ValidateMaximumTest(unittest.TestCase): self.assertRaises(ValueError, config.validate_maximum, 5, 0) +class ValidateRequiredTest(unittest.TestCase): + def test_passes_when_false(self): + config.validate_required('foo', False) + config.validate_required('', False) + config.validate_required(' ', False) + + def test_passes_when_required_and_set(self): + config.validate_required('foo', True) + config.validate_required(' foo ', True) + + def test_blocks_when_required_and_emtpy(self): + self.assertRaises(ValueError, config.validate_required, '', True) + self.assertRaises(ValueError, config.validate_required, ' ', True) + + class ConfigValueTest(unittest.TestCase): def test_init(self): value = config.ConfigValue() self.assertIsNone(value.choices) - self.assertIsNone(value.minimum) self.assertIsNone(value.maximum) + self.assertIsNone(value.minimum) + self.assertIsNone(value.optional) self.assertIsNone(value.secret) def test_init_with_params(self): - value = config.ConfigValue( - choices=['foo'], minimum=0, maximum=10, secret=True) + kwargs = {'choices': ['foo'], 'minimum': 0, 'maximum': 10, + 'secret': True, 'optional': True} + value = config.ConfigValue(**kwargs) self.assertEqual(['foo'], value.choices) self.assertEqual(0, value.minimum) self.assertEqual(10, value.maximum) + self.assertEqual(True, value.optional) self.assertEqual(True, value.secret) def test_deserialize_passes_through(self): @@ -91,7 +109,7 @@ class ConfigValueTest(unittest.TestCase): class StringTest(unittest.TestCase): - def test_deserialize_strips_whitespace(self): + def test_deserialize_converts_success(self): value = config.String() self.assertEqual('foo', value.deserialize(' foo ')) @@ -100,22 +118,34 @@ class StringTest(unittest.TestCase): self.assertEqual('foo', value.deserialize('foo')) self.assertRaises(ValueError, value.deserialize, 'foobar') + def test_deserialize_enforces_required(self): + value = config.String() + self.assertRaises(ValueError, value.deserialize, '') + self.assertRaises(ValueError, value.deserialize, ' ') + + def test_deserialize_respects_optional(self): + value = config.String(optional=True) + self.assertIsNone(value.deserialize('')) + self.assertIsNone(value.deserialize(' ')) + def test_format_masks_secrets(self): value = config.String(secret=True) self.assertEqual('********', value.format('s3cret')) class IntegerTest(unittest.TestCase): - def test_deserialize_converts_to_int(self): + def test_deserialize_converts_success(self): value = config.Integer() self.assertEqual(123, value.deserialize('123')) self.assertEqual(0, value.deserialize('0')) self.assertEqual(-10, value.deserialize('-10')) - def test_deserialize_fails_on_bad_data(self): + def test_deserialize_conversion_failure(self): value = config.Integer() self.assertRaises(ValueError, value.deserialize, 'asd') self.assertRaises(ValueError, value.deserialize, '3.14') + self.assertRaises(ValueError, value.deserialize, '') + self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_enforces_choices(self): value = config.Integer(choices=[1, 2, 3]) @@ -138,7 +168,7 @@ class IntegerTest(unittest.TestCase): class BooleanTest(unittest.TestCase): - def test_deserialize_converts_to_bool(self): + def test_deserialize_converts_success(self): value = config.Boolean() for true in ('1', 'yes', 'true', 'on'): self.assertIs(value.deserialize(true), True) @@ -149,12 +179,13 @@ class BooleanTest(unittest.TestCase): self.assertIs(value.deserialize(false.upper()), False) self.assertIs(value.deserialize(false.capitalize()), False) - def test_deserialize_fails_on_bad_data(self): + def test_deserialize_conversion_failure(self): value = config.Boolean() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') + self.assertRaises(ValueError, value.deserialize, '') - def test_serialize_normalises_strings(self): + def test_serialize(self): value = config.Boolean() self.assertEqual('true', value.serialize(True)) self.assertEqual('false', value.serialize(False)) @@ -165,20 +196,29 @@ class BooleanTest(unittest.TestCase): class ListTest(unittest.TestCase): - def test_deserialize_splits_commas(self): + def test_deserialize_converts_success(self): value = config.List() - self.assertEqual(['foo', 'bar', 'baz'], - value.deserialize('foo, bar,baz')) - def test_deserialize_splits_newlines(self): - value = config.List() - self.assertEqual(['foo,bar', 'bar', 'baz'], - value.deserialize('foo,bar\nbar\nbaz')) + expected = ['foo', 'bar', 'baz'] + self.assertEqual(expected, value.deserialize('foo, bar ,baz ')) - def test_serialize_joins_by_newlines(self): + expected = ['foo,bar', 'bar', 'baz'] + self.assertEqual(expected, value.deserialize(' foo,bar\nbar\nbaz')) + + def test_deserialize_enforces_required(self): value = config.List() - self.assertRegexpMatches(value.serialize(['foo', 'bar', 'baz']), - r'foo\n\s*bar\n\s*baz') + self.assertRaises(ValueError, value.deserialize, '') + self.assertRaises(ValueError, value.deserialize, ' ') + + def test_deserialize_respects_optional(self): + value = config.List(optional=True) + self.assertEqual([], value.deserialize('')) + self.assertEqual([], value.deserialize(' ')) + + def test_serialize(self): + value = config.List() + result = value.serialize(['foo', 'bar', 'baz']) + self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') class BooleanTest(unittest.TestCase): @@ -188,41 +228,54 @@ class BooleanTest(unittest.TestCase): 'info': logging.INFO, 'debug': logging.DEBUG} - def test_deserialize_converts_to_numeric_loglevel(self): + def test_deserialize_converts_success(self): value = config.LogLevel() for name, level in self.levels.items(): self.assertEqual(level, value.deserialize(name)) self.assertEqual(level, value.deserialize(name.upper())) self.assertEqual(level, value.deserialize(name.capitalize())) - def test_deserialize_fails_on_bad_data(self): + def test_deserialize_conversion_failure(self): value = config.LogLevel() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') + self.assertRaises(ValueError, value.deserialize, '') + self.assertRaises(ValueError, value.deserialize, ' ') - def test_serialize_converts_to_string(self): + def test_serialize(self): value = config.LogLevel() for name, level in self.levels.items(): self.assertEqual(name, value.serialize(level)) - - def test_serialize_unknown_level(self): - value = config.LogLevel() self.assertIsNone(value.serialize(1337)) class HostnameTest(unittest.TestCase): @mock.patch('socket.getaddrinfo') - def test_deserialize_checks_addrinfo(self, getaddrinfo_mock): + def test_deserialize_converts_success(self, getaddrinfo_mock): value = config.Hostname() value.deserialize('example.com') getaddrinfo_mock.assert_called_once_with('example.com', None) @mock.patch('socket.getaddrinfo') - def test_deserialize_handles_failures(self, getaddrinfo_mock): + def test_deserialize_conversion_failure(self, getaddrinfo_mock): value = config.Hostname() getaddrinfo_mock.side_effect = socket.error self.assertRaises(ValueError, value.deserialize, 'example.com') + @mock.patch('socket.getaddrinfo') + def test_deserialize_enforces_required(self, getaddrinfo_mock): + value = config.Hostname() + self.assertRaises(ValueError, value.deserialize, '') + self.assertRaises(ValueError, value.deserialize, ' ') + self.assertEqual(0, getaddrinfo_mock.call_count) + + @mock.patch('socket.getaddrinfo') + def test_deserialize_respects_optional(self, getaddrinfo_mock): + value = config.Hostname(optional=True) + self.assertIsNone(value.deserialize('')) + self.assertIsNone(value.deserialize(' ')) + self.assertEqual(0, getaddrinfo_mock.call_count) + class PortTest(unittest.TestCase): def test_valid_ports(self): @@ -238,6 +291,7 @@ class PortTest(unittest.TestCase): self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '0') self.assertRaises(ValueError, value.deserialize, '-1') + self.assertRaises(ValueError, value.deserialize, '') class ConfigSchemaTest(unittest.TestCase): @@ -285,11 +339,6 @@ class ConfigSchemaTest(unittest.TestCase): self.assertIn('unknown', cm.exception['extra']) - def test_convert_with_blank_value(self): - self.values['foo'] = '' - result = self.schema.convert(self.values.items()) - self.assertIsNone(result['foo']) - def test_convert_with_deserialization_error(self): self.schema['foo'].deserialize.side_effect = ValueError('failure') From e504ca02079bd2963dc0223e09a299821d5560be Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 23:30:05 +0200 Subject: [PATCH 062/403] config: Fix typo. --- mopidy/utils/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index e20afd26..3b695087 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -63,7 +63,7 @@ class ConfigValue(object): maximum = None #: Indicate if this field is required. - opitional = None + optional = None #: Indicate if we should mask the when printing for human consumption. secret = None From c18df183957fdd1e20af5ea860125d42f7825f8b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 23:38:44 +0200 Subject: [PATCH 063/403] config: Document what each config value supports. --- mopidy/utils/config.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 3b695087..a1dd46c2 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -91,6 +91,10 @@ class ConfigValue(object): class String(ConfigValue): + """String values. + + Supports: optional choices and secret. + """ def deserialize(self, value): value = value.strip() validate_required(value, not self.optional) @@ -104,6 +108,10 @@ class String(ConfigValue): class Integer(ConfigValue): + """Integer values. + + Supports: choices, minimum, maximum and secret. + """ def deserialize(self, value): value = int(value) validate_choice(value, self.choices) @@ -113,6 +121,10 @@ class Integer(ConfigValue): class Boolean(ConfigValue): + """Boolean values. + + Supports: secret. + """ true_values = ('1', 'yes', 'true', 'on') false_values = ('0', 'no', 'false', 'off') @@ -132,6 +144,10 @@ class Boolean(ConfigValue): class List(ConfigValue): + """List values split by comma or newline. + + Supports: optional and secret. + """ def deserialize(self, value): validate_required(value, not self.optional) if '\n' in value: @@ -145,6 +161,10 @@ class List(ConfigValue): class LogLevel(ConfigValue): + """Log level values. + + Supports: secret. + """ levels = {'critical' : logging.CRITICAL, 'error' : logging.ERROR, 'warning' : logging.WARNING, @@ -160,6 +180,10 @@ class LogLevel(ConfigValue): class Hostname(ConfigValue): + """Hostname values. + + Supports: optional and secret. + """ def deserialize(self, value): validate_required(value, not self.optional) if not value.strip(): @@ -172,6 +196,10 @@ class Hostname(ConfigValue): class Port(Integer): + """Port values limited to 1-65535. + + Supports: choices and secret. + """ # TODO: consider probing if port is free or not? def __init__(self, **kwargs): super(Port, self).__init__(**kwargs) From 6c5ac2803498392cf38fd402da678952c86859ed Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 23:40:05 +0200 Subject: [PATCH 064/403] config: Update test names for consistency. --- tests/utils/config_test.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 527ec8d3..43bb1679 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -94,7 +94,7 @@ class ConfigValueTest(unittest.TestCase): obj = object() self.assertEqual(obj, value.deserialize(obj)) - def test_serialize_converts_to_string(self): + def test_serialize_conversion_to_string(self): value = config.ConfigValue() self.assertIsInstance(value.serialize(object()), basestring) @@ -109,7 +109,7 @@ class ConfigValueTest(unittest.TestCase): class StringTest(unittest.TestCase): - def test_deserialize_converts_success(self): + def test_deserialize_conversion_success(self): value = config.String() self.assertEqual('foo', value.deserialize(' foo ')) @@ -134,7 +134,7 @@ class StringTest(unittest.TestCase): class IntegerTest(unittest.TestCase): - def test_deserialize_converts_success(self): + def test_deserialize_conversion_success(self): value = config.Integer() self.assertEqual(123, value.deserialize('123')) self.assertEqual(0, value.deserialize('0')) @@ -168,7 +168,7 @@ class IntegerTest(unittest.TestCase): class BooleanTest(unittest.TestCase): - def test_deserialize_converts_success(self): + def test_deserialize_conversion_success(self): value = config.Boolean() for true in ('1', 'yes', 'true', 'on'): self.assertIs(value.deserialize(true), True) @@ -196,7 +196,7 @@ class BooleanTest(unittest.TestCase): class ListTest(unittest.TestCase): - def test_deserialize_converts_success(self): + def test_deserialize_conversion_success(self): value = config.List() expected = ['foo', 'bar', 'baz'] @@ -228,7 +228,7 @@ class BooleanTest(unittest.TestCase): 'info': logging.INFO, 'debug': logging.DEBUG} - def test_deserialize_converts_success(self): + def test_deserialize_conversion_success(self): value = config.LogLevel() for name, level in self.levels.items(): self.assertEqual(level, value.deserialize(name)) @@ -251,7 +251,7 @@ class BooleanTest(unittest.TestCase): class HostnameTest(unittest.TestCase): @mock.patch('socket.getaddrinfo') - def test_deserialize_converts_success(self, getaddrinfo_mock): + def test_deserialize_conversion_success(self, getaddrinfo_mock): value = config.Hostname() value.deserialize('example.com') getaddrinfo_mock.assert_called_once_with('example.com', None) From d6d3e2be35e15793b5de5e7fbf6458ae260cd490 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:16:01 +0200 Subject: [PATCH 065/403] spotify: Add config schema --- mopidy/backends/spotify/__init__.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 503d9eb6..409fb48d 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -3,10 +3,10 @@ from __future__ import unicode_literals import mopidy from mopidy import ext from mopidy.exceptions import ExtensionError -from mopidy.utils.formatting import indent +from mopidy.utils import config, formatting -config = """ +default_config = """ [ext.spotify] # If the Spotify extension should be enabled or not @@ -62,7 +62,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend .. code-block:: ini %(config)s -""" % {'config': indent(config)} +""" % {'config': formatting.indent(default_config)} class Extension(ext.Extension): @@ -71,15 +71,19 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return config + return default_config - def validate_config(self, config): - if not config.getboolean('spotify', 'enabled'): - return - if not config.get('spotify', 'username'): - raise ExtensionError('Config spotify.username not set') - if not config.get('spotify', 'password'): - raise ExtensionError('Config spotify.password not set') + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['username'] = config.String() + schema['password'] = config.String(secret=True) + schema['bitrate'] = config.Integer(choices=(96, 160, 320)) + schema['timeout'] = config.Integer(minimum=0) + schema['cache_path'] = config.String() + schema['proxy_host'] = config.Hostname(optional=True) + schema['proxy_username'] = config.String(optional=True) + schema['proxy_password'] = config.String(optional=True, secret=True) + return schema def validate_environment(self): try: From 74adefcad1fe411b596981da7d124cda2f8e936d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:29:45 +0200 Subject: [PATCH 066/403] local: Add default config and config schema --- mopidy/backends/local/__init__.py | 36 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index c2001da5..5499cac7 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -2,8 +2,25 @@ from __future__ import unicode_literals import mopidy from mopidy import ext +from mopidy.utils import config, formatting +default_config = """ +[ext.local] + +# If the local extension should be enabled or not +enabled = true + +# Path to folder with local music +music_path = $XDG_MUSIC_DIR + +# Path to playlist folder with m3u files for local music +playlist_path = $XDG_DATA_DIR/mopidy/playlists + +# Path to tag cache for local music +tag_cache_file = $XDG_DATA_DIR/mopidy/tag_cache +""" + __doc__ = """A backend for playing music from a local music archive. This backend handles URIs starting with ``file:``. @@ -19,12 +36,12 @@ https://github.com/mopidy/mopidy/issues?labels=Local+backend - None -**Settings:** +**Default config:** -- :attr:`mopidy.settings.LOCAL_MUSIC_PATH` -- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` -- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` -""" +.. code-block:: ini + +%(config)s +""" % {'config': formatting.indent(default_config)} class Extension(ext.Extension): @@ -33,10 +50,13 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[ext.local]' + return default_config - def validate_config(self, config): - pass + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['music_path'] = config.String() + schema['playlist_path'] = config.String() + schema['tag_cache_file'] = config.String() def validate_environment(self): pass From 8b538c452242050e468b71ca937e3d4feb57887b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:33:21 +0200 Subject: [PATCH 067/403] stream: Add default config and config schema --- mopidy/backends/stream/__init__.py | 33 ++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 4096476e..31b16eea 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -2,8 +2,25 @@ from __future__ import unicode_literals import mopidy from mopidy import ext +from mopidy.utils import config, formatting +default_config = """ +[ext.stream] + +# If the stream extension should be enabled or not +enabled = true + +# Whitelist of URI schemas to support streaming from +protocols = + http + https + mms + rtmp + rtmps + rtsp +""" + __doc__ = """A backend for playing music for streaming music. This backend will handle streaming of URIs in @@ -18,10 +35,12 @@ https://github.com/mopidy/mopidy/issues?labels=Stream+backend - None -**Settings:** +**Default config:** -- :attr:`mopidy.settings.STREAM_PROTOCOLS` -""" +.. code-block:: ini + +%(config)s +""" % {'config': formatting.indent(default_config)} class Extension(ext.Extension): @@ -30,10 +49,12 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[ext.stream]' + return default_config - def validate_config(self, config): - pass + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['protocols'] = config.List() + return schema def validate_environment(self): pass From aab7c01c94088594258e33e3074f76d8735b8c2e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:42:17 +0200 Subject: [PATCH 068/403] mpd: Add default config and config schema --- mopidy/frontends/mpd/__init__.py | 55 +++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 5cb8b8c0..87b21d8d 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -2,8 +2,41 @@ from __future__ import unicode_literals import mopidy from mopidy import ext +from mopidy.utils import config, formatting +default_config = """ +[ext.mpd] + +# If the MPD extension should be enabled or not +enabled = true + +# Which address the MPD server should bind to +# +# 127.0.0.1 +# Listens only on the IPv4 loopback interface +# ::1 +# Listens only on the IPv6 loopback interface +# 0.0.0.0 +# Listens on all IPv4 interfaces +# :: +# Listens on all interfaces, both IPv4 and IPv6 +hostname = 127.0.0.1 + +# Which TCP port the MPD server should listen to +port = 6600 + +# The password required for connecting to the MPD server +password = + +# The maximum number of concurrent connections the MPD server will accept +max_connections = 20 + +# Number of seconds an MPD client can stay inactive before the connection is +# closed by the server +connection_timeout = 60 +""" + __doc__ = """The MPD server frontend. MPD stands for Music Player Daemon. MPD is an independent project and server. @@ -14,11 +47,11 @@ original MPD server. - None -**Settings:** +**Default config:** -- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` -- :attr:`mopidy.settings.MPD_SERVER_PORT` -- :attr:`mopidy.settings.MPD_SERVER_PASSWORD` +.. code-block:: ini + +%(config)s **Usage:** @@ -46,7 +79,7 @@ near future: - ``tagtypes`` is not supported - Browsing the file system is not supported - Live update of the music database is not supported -""" +""" % {'config': formatting.indent(default_config)} class Extension(ext.Extension): @@ -55,10 +88,16 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[ext.mpd]' + return default_config - def validate_config(self, config): - pass + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['hostname'] = config.Hostname() + schema['port'] = config.Port() + schema['password'] = config.String(optional=True, secret=True) + schema['max_connections'] = config.Integer(minimum=1) + schema['connection_timeout'] = config.Integer(minimum=1) + return schema def validate_environment(self): pass From 7ea233b7f955f7dbb291d0662fe321cddfceba80 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:51:17 +0200 Subject: [PATCH 069/403] lastfm: Add default config and config schema --- mopidy/frontends/lastfm/__init__.py | 37 +++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index 439ada50..7b3b2d58 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -1,10 +1,23 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.exceptions import ExtensionError +from mopidy import exceptions, ext +from mopidy.utils import config, formatting +default_config = """ +[ext.lastfm] + +# If the Last.fm extension should be enabled or not +enabled = true + +# Your Last.fm username +username = + +# Your Last.fm password +password = +""" + __doc__ = """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. @@ -17,15 +30,16 @@ Frontend which scrobbles the music you play to your `Last.fm .. literalinclude:: ../../../requirements/lastfm.txt -**Settings:** +**Default config:** -- :attr:`mopidy.settings.LASTFM_USERNAME` -- :attr:`mopidy.settings.LASTFM_PASSWORD` +.. code-block:: ini + +%(config)s **Usage:** The frontend is enabled by default if all dependencies are available. -""" +""" % {'config': formatting.indent(default_config)} class Extension(ext.Extension): @@ -34,16 +48,19 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[ext.lastfm]' + return default_config - def validate_config(self, config): - pass + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['username'] = config.String() + schema['password'] = config.String(secret=True) + return schema def validate_environment(self): try: import pylast # noqa except ImportError as e: - raise ExtensionError('pylast library not found', e) + raise exceptions.ExtensionError('pylast library not found', e) def get_frontend_classes(self): from .actor import LastfmFrontend From 7fd21bf0c17f320ec372d15afca78b34f728b5bc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 22:54:49 +0200 Subject: [PATCH 070/403] mpris: Add default config and config schema --- mopidy/frontends/mpris/__init__.py | 32 +++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 940c4210..28829b8f 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,10 +1,20 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.exceptions import ExtensionError +from mopidy import exceptions, ext +from mopidy.utils import formatting, config +default_config = """ +[ext.mpris] + +# If the MPRIS extension should be enabled or not +enabled = true + +# Location of the Mopidy .desktop file +desktop_file = /usr/share/applications/mopidy.desktop +""" + __doc__ = """ Frontend which lets you control Mopidy through the Media Player Remote Interfacing Specification (`MPRIS `_) D-Bus @@ -26,9 +36,11 @@ An example of an MPRIS client is the `Ubuntu Sound Menu :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install-desktop-file` for details. -**Settings:** +**Default config:** -- :attr:`mopidy.settings.DESKTOP_FILE` +.. code-block:: ini + +%(config)s **Usage:** @@ -53,7 +65,7 @@ Now you can control Mopidy through the player object. Examples: - To quit Mopidy through D-Bus, run:: player.Quit(dbus_interface='org.mpris.MediaPlayer2') -""" +""" % {'config': formatting.indent(default_config)} class Extension(ext.Extension): @@ -62,16 +74,18 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[ext.mpris]' + return default_config - def validate_config(self, config): - pass + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['desktop_file'] = config.String() + return schema def validate_environment(self): try: import dbus # noqa except ImportError as e: - raise ExtensionError('Library dbus not found', e) + raise exceptions.ExtensionError('dbus library not found', e) def get_frontend_classes(self): from .actor import MprisFrontend From fc33bdaedbe4a4db956e7711c002dd1bb01e5741 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 23:02:29 +0200 Subject: [PATCH 071/403] http: Add default config and config schema --- mopidy/frontends/http/__init__.py | 59 +++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 7b99efd0..8c864629 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,25 +1,52 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.exceptions import ExtensionError +from mopidy import exceptions, ext +from mopidy.utils import config, formatting +default_config = """ +[ext.http] + +# If the HTTP extension should be enabled or not +enabled = true + +# Which address the HTTP server should bind to +# +# 127.0.0.1 +# Listens only on the IPv4 loopback interface +# ::1 +# Listens only on the IPv6 loopback interface +# 0.0.0.0 +# Listens on all IPv4 interfaces +# :: +# Listens on all interfaces, both IPv4 and IPv6 +hostname = 127.0.0.1 + +# Which TCP port the HTTP server should listen to +port = 6680 + +# Which directory the HTTP server should serve at "/" +# +# Change this to have Mopidy serve e.g. files for your JavaScript client. +# "/mopidy" will continue to work as usual even if you change this setting. +# +static_dir = +""" + __doc__ = """ The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g. from a web based client. -**Dependencies** +**Dependencies:** .. literalinclude:: ../../../requirements/http.txt -**Settings** +**Default config:** -- :attr:`mopidy.settings.HTTP_SERVER_HOSTNAME` +.. code-block:: ini -- :attr:`mopidy.settings.HTTP_SERVER_PORT` - -- :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` +%(config)s Setup @@ -483,7 +510,7 @@ Example to get started with 9. The web page should now queue and play your first playlist every time your load it. See the browser's console for output from the function, any errors, and all events that are emitted. -""" +""" % {'config': formatting.indent(default_config)} class Extension(ext.Extension): @@ -492,21 +519,25 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return '[ext.http]' + return default_config - def validate_config(self, config): - pass + def get_config_schema(self): + schema = config.ExtensionConfigSchema() + schema['hostname'] = config.Hostname() + schema['port'] = config.Port() + schema['static_dir'] = config.String(optional=True) + return schema def validate_environment(self): try: import cherrypy # noqa except ImportError as e: - raise ExtensionError('Library cherrypy not found', e) + raise exceptions.ExtensionError('cherrypy library not found', e) try: import ws4py # noqa except ImportError as e: - raise ExtensionError('Library ws4py not found', e) + raise exceptions.ExtensionError('ws4py library not found', e) def get_frontend_classes(self): from .actor import HttpFrontend From 1ac2fec4a51bdf172073045d830b1285ee8961c7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 23:18:58 +0200 Subject: [PATCH 072/403] docs: Tweak module docs formatting --- mopidy/audio/mixers/auto.py | 10 +++++----- mopidy/audio/mixers/fake.py | 8 ++++---- mopidy/audio/mixers/nad.py | 8 ++++---- mopidy/backends/dummy.py | 8 ++++---- mopidy/backends/local/__init__.py | 8 ++++---- mopidy/backends/spotify/__init__.py | 6 +++--- mopidy/backends/stream/__init__.py | 8 ++++---- mopidy/frontends/http/__init__.py | 8 ++++++-- mopidy/frontends/lastfm/__init__.py | 6 +++--- mopidy/frontends/mpd/__init__.py | 10 +++++++--- mopidy/frontends/mpris/__init__.py | 6 +++--- 11 files changed, 47 insertions(+), 39 deletions(-) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index bd61445e..96359da1 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -2,14 +2,14 @@ This is Mopidy's default mixer. -**Dependencies:** +**Dependencies** -- None +None -**Settings:** +**Settings** -- If this wasn't the default, you would set :attr:`mopidy.settings.MIXER` - to ``autoaudiomixer`` to use this mixer. +If this wasn't the default, you would set :attr:`mopidy.settings.MIXER` to +``autoaudiomixer`` to use this mixer. """ from __future__ import unicode_literals diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 948ab82e..738491b5 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -1,12 +1,12 @@ """Fake mixer for use in tests. -**Dependencies:** +**Dependencies** -- None +None -**Settings:** +**Settings** -- Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer. +Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer. """ from __future__ import unicode_literals diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 52ab4757..8481de55 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -3,14 +3,14 @@ The NAD amplifier must be connected to the machine running Mopidy using a serial cable. -**Dependencies:** +**Dependencies** .. literalinclude:: ../../../../requirements/external_mixers.txt -**Settings:** +**Settings** -- Set :attr:`mopidy.settings.MIXER` to ``nadmixer`` to use it. You probably - also needs to add some properties to the ``MIXER`` setting. +Set :attr:`mopidy.settings.MIXER` to ``nadmixer`` to use it. You probably also +needs to add some properties to the ``MIXER`` setting. Supported properties includes: diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index c6997b12..dd021445 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -5,13 +5,13 @@ used in tests of the frontends. The backend handles URIs starting with ``dummy:``. -**Dependencies:** +**Dependencies** -- None +None -**Settings:** +**Default config** -- None +None """ from __future__ import unicode_literals diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 5499cac7..0e7b089d 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -28,15 +28,15 @@ This backend handles URIs starting with ``file:``. See :ref:`music-from-local-storage` for further instructions on using this backend. -**Issues:** +**Issues** https://github.com/mopidy/mopidy/issues?labels=Local+backend -**Dependencies:** +**Dependencies** -- None +None -**Default config:** +**Default config** .. code-block:: ini diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 409fb48d..4c3b67fe 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -49,15 +49,15 @@ See :ref:`music-from-spotify` for further instructions on using this backend. otherwise approved in any way by Spotify. Spotify is the registered trade mark of the Spotify Group. -**Issues:** +**Issues** https://github.com/mopidy/mopidy/issues?labels=Spotify+backend -**Dependencies:** +**Dependencies** .. literalinclude:: ../../../requirements/spotify.txt -**Default config:** +**Default config** .. code-block:: ini diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 31b16eea..097efc45 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -27,15 +27,15 @@ This backend will handle streaming of URIs in :attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are installed. -**Issues:** +**Issues** https://github.com/mopidy/mopidy/issues?labels=Stream+backend -**Dependencies:** +**Dependencies** -- None +None -**Default config:** +**Default config** .. code-block:: ini diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 8c864629..6160d47c 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -38,11 +38,15 @@ __doc__ = """ The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g. from a web based client. -**Dependencies:** +**Issues** + +https://github.com/mopidy/mopidy/issues?labels=HTTP+frontend + +**Dependencies** .. literalinclude:: ../../../requirements/http.txt -**Default config:** +**Default config** .. code-block:: ini diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index 7b3b2d58..f24e8f81 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -26,17 +26,17 @@ Frontend which scrobbles the music you play to your `Last.fm This frontend requires a free user account at Last.fm. -**Dependencies:** +**Dependencies** .. literalinclude:: ../../../requirements/lastfm.txt -**Default config:** +**Default config** .. code-block:: ini %(config)s -**Usage:** +**Usage** The frontend is enabled by default if all dependencies are available. """ % {'config': formatting.indent(default_config)} diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 87b21d8d..dacbf69e 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -43,11 +43,15 @@ MPD stands for Music Player Daemon. MPD is an independent project and server. Mopidy implements the MPD protocol, and is thus compatible with clients for the original MPD server. -**Dependencies:** +**Issues** -- None +https://github.com/mopidy/mopidy/issues?labels=MPD+frontend -**Default config:** +**Dependencies** + +None + +**Default config** .. code-block:: ini diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 28829b8f..407d4800 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -23,7 +23,7 @@ interface. An example of an MPRIS client is the `Ubuntu Sound Menu `_. -**Dependencies:** +**Dependencies** - D-Bus Python bindings. The package is named ``python-dbus`` in Ubuntu/Debian. @@ -36,13 +36,13 @@ An example of an MPRIS client is the `Ubuntu Sound Menu :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install-desktop-file` for details. -**Default config:** +**Default config** .. code-block:: ini %(config)s -**Usage:** +**Usage** The frontend is enabled by default if all dependencies are available. From 54164007c54d08eaedac1532a15eff6677e8b635 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 23:20:41 +0200 Subject: [PATCH 073/403] docs: Update extensiondev docs to match real usage --- docs/extensiondev.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 43b0b723..8e636b1e 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -219,13 +219,19 @@ meaningful defaults blank, like ``username`` and ``password``. import gst import gobject - from mopidy import exceptions - from mopidy import ext + from mopidy import exceptions, ext from mopidy.utils import config __version__ = '0.1' + default_config = """ + [ext.soundspot] + enabled = true + username = + password = + """ + class Extension(ext.Extension): @@ -233,12 +239,7 @@ meaningful defaults blank, like ``username`` and ``password``. version = __version__ def get_default_config(self): - return """ - [ext.soundspot] - enabled = true - username = - password = - """ + return default_config def get_config_schema(self): schema = config.ExtensionConfigSchema() @@ -269,7 +270,6 @@ meaningful defaults blank, like ``username`` and ``password``. def register_gstreamer_elements(self): from .mixer import SoundspotMixer - gobject.type_register(SoundspotMixer) gst.element_register( SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) From 0c2d16adc671c9b70e0224353c455d67b55dc4e8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 23:49:23 +0200 Subject: [PATCH 074/403] local: Return schema --- mopidy/backends/local/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 0e7b089d..42477b53 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -57,6 +57,7 @@ class Extension(ext.Extension): schema['music_path'] = config.String() schema['playlist_path'] = config.String() schema['tag_cache_file'] = config.String() + return schema def validate_environment(self): pass From 09b6859752948f9aa8ed230c910aa960a09eb80e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 1 Apr 2013 23:57:33 +0200 Subject: [PATCH 075/403] docs: Update extensiondev implementation plan --- docs/extensiondev.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 8e636b1e..5d1af244 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -382,7 +382,9 @@ extensions work. - any config values provided via command line arguments. -9. Add command line options for: +9. Replace all use of ``mopidy.settings`` with the new config object. + +10. Add command line options for: - loading an additional config file for this execution of Mopidy, @@ -392,3 +394,5 @@ extensions work. - write a config value permanently to ``~/.config/mopidy/mopidy.conf``, or ``/etc/mopidy/mopidy.conf`` if root, and exit. + +11. Reimplement ``--list-deps`` based upon information provided by extensions. From 32277d158a5f87236b9f067033b946725e0c8aa6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 00:54:32 +0200 Subject: [PATCH 076/403] main: Load extensions earlier --- mopidy/__main__.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 3831bd7e..8d8d7d62 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -53,8 +53,8 @@ def main(): try: log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() - setup_settings(options.interactive) extensions = load_extensions() + setup_settings(options.interactive) audio = setup_audio() backends = setup_backends(extensions, audio) core = setup_core(audio, backends) @@ -128,17 +128,6 @@ def check_old_folders(): 'further instructions.', old_settings_folder, path.SETTINGS_PATH) -def setup_settings(interactive): - path.get_or_create_folder(path.SETTINGS_PATH) - path.get_or_create_folder(path.DATA_PATH) - path.get_or_create_file(path.SETTINGS_FILE) - try: - settings.validate(interactive) - except exceptions.SettingsError as ex: - logger.error(ex.message) - sys.exit(1) - - def load_extensions(): extensions = [] for entry_point in pkg_resources.iter_entry_points('mopidy.extension'): @@ -172,6 +161,17 @@ def load_extensions(): return extensions +def setup_settings(interactive): + path.get_or_create_folder(path.SETTINGS_PATH) + path.get_or_create_folder(path.DATA_PATH) + path.get_or_create_file(path.SETTINGS_FILE) + try: + settings.validate(interactive) + except exceptions.SettingsError as ex: + logger.error(ex.message) + sys.exit(1) + + def setup_audio(): logger.info('Starting Mopidy audio') return Audio.start().proxy() From 28096ce2592737b151120f4c0893d43f6b22eea9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 00:54:04 +0200 Subject: [PATCH 077/403] config: Add default core config --- mopidy/config.py | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/mopidy/config.py b/mopidy/config.py index 2c713a52..70e4b7af 100644 --- a/mopidy/config.py +++ b/mopidy/config.py @@ -1,25 +1,40 @@ +from __future__ import unicode_literals + from mopidy.utils import config -schemas = {} # TODO: use ordered dict? -schemas['logging'] = config.ConfigSchema() -schemas['logging']['config_file'] = config.String() -schemas['logging']['console_format'] = config.String() -schemas['logging']['debug_format'] = config.String() -schemas['logging']['debug_file'] = config.String() -schemas['logging']['debug_thread'] = config.Boolean() -schemas['logging.levels'] = config.LogLevelConfigSchema() +default_config = """ +[logging] +console_format = %(levelname)-8s $(message)s +debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s +debug_file = mopidy.log -schemas['audio'] = config.ConfigSchema() -schemas['audio']['mixer'] = config.String() -schemas['audio']['mixer_track'] = config.String() -schemas['audio']['output'] = config.String() +[logging.levels] + +[audio] +mixer = autoaudiomixer +mixer_track = +output = autoaudiosink +""" + +config_schemas = {} # TODO: use ordered dict? +config_schemas['logging'] = config.ConfigSchema() +config_schemas['logging']['console_format'] = config.String() +config_schemas['logging']['debug_format'] = config.String() +config_schemas['logging']['debug_file'] = config.String() + +config_schemas['logging.levels'] = config.LogLevelConfigSchema() + +config_schemas['audio'] = config.ConfigSchema() +config_schemas['audio']['mixer'] = config.String() +config_schemas['audio']['mixer_track'] = config.String(optional=True) +config_schemas['audio']['output'] = config.String() # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema -#schemas['audio.outputs'] = config.AudioOutputConfigSchema() +#config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() def register_schema(name, schema): - if name in schemas: + if name in config_schemas: raise Exception - schemas[name] = schema + config_schemas[name] = schema From 12ff51ceba81d1d28d666ee226a9cc3a77506878 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 11:34:33 +0200 Subject: [PATCH 078/403] ext: Replace validate_config() with get_config_schema() --- mopidy/ext.py | 7 ++++--- tests/ext_test.py | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 6cc35139..0097d8c6 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +from mopidy.utils import config + class Extension(object): @@ -10,9 +12,8 @@ class Extension(object): raise NotImplementedError( 'Add at least a config section with "enabled = true"') - def validate_config(self, config): - raise NotImplementedError( - 'You must explicitly pass config validation if not needed') + def get_config_schema(self): + return config.ExtensionConfigSchema() def validate_environment(self): pass diff --git a/tests/ext_test.py b/tests/ext_test.py index ac238ca5..d279b52f 100644 --- a/tests/ext_test.py +++ b/tests/ext_test.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from mopidy.ext import Extension +from mopidy.utils import config from tests import unittest @@ -18,8 +19,10 @@ class ExtensionTest(unittest.TestCase): def test_get_default_config_raises_not_implemented(self): self.assertRaises(NotImplementedError, self.ext.get_default_config) - def test_validate_config_raises_not_implemented(self): - self.assertRaises(NotImplementedError, self.ext.validate_config, None) + def test_get_config_schema_returns_extension_schema(self): + schema = self.ext.get_config_schema() + self.assertIsInstance(schema, config.ExtensionConfigSchema) + self.assertIsInstance(schema['enabled'], config.Boolean) def test_validate_environment_does_nothing_by_default(self): self.assertIsNone(self.ext.validate_environment()) From f80f9d878e657e490bcc18d0533c9a0c5ee5a40f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 11:51:48 +0200 Subject: [PATCH 079/403] docs: Tweak extensiondev docs --- docs/extensiondev.rst | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 5d1af244..73457c54 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -32,22 +32,21 @@ extension to behave. Anatomy of an extension ======================= -Extensions are all located in a Python package called ``mopidy_something`` -where "something" is the name of the application, library or web service you -want to integrated with Mopidy. So for example if you plan to add support for a -service named Soundspot to Mopidy, you would name your extension's Python -package ``mopidy_soundspot``. +Extensions are located in a Python package called ``mopidy_something`` where +"something" is the name of the application, library or web service you want to +integrated with Mopidy. So for example if you plan to add support for a service +named Soundspot to Mopidy, you would name your extension's Python package +``mopidy_soundspot``. -The name of the actual extension (the human readable name) however would be -something like "Mopidy-Soundspot". Make sure to include the name "Mopidy" +The extension must be shipped with a ``setup.py`` file and be registered on +`PyPI `_. The name of the distribution on PyPI would +be something like "Mopidy-Soundspot". Make sure to include the name "Mopidy" somewhere in that name and that you check the capitalization. This is the name users will use when they install your extension from PyPI. -The extension must be shipped with a ``setup.py`` file and be registered on -`PyPI `_. Also make sure the development version link -in your package details work so that people can easily install the development -version into their virtualenv simply by running e.g. ``pip install -Mopidy-Soundspot==dev``. +Also make sure the development version link in your package details work so +that people can easily install the development version into their virtualenv +simply by running e.g. ``pip install Mopidy-Soundspot==dev``. Mopidy extensions must be licensed under an Apache 2.0 (like Mopidy itself), BSD, MIT or more liberal license to be able to be enlisted in the Mopidy @@ -74,8 +73,8 @@ Example README.rst The README file should quickly tell what the extension does, how to install it, and how to configure it. The README should contain a development snapshot link to a tarball of the latest development version of the extension. It's important -that the development snapshot link ends with ``#egg=mopidy-something-dev`` for -installation using ``pip install mopidy-something==dev`` to work. +that the development snapshot link ends with ``#egg=Mopidy-Something-dev`` for +installation using ``pip install Mopidy-Something==dev`` to work. .. code-block:: rst @@ -100,7 +99,7 @@ installation using ``pip install mopidy-something==dev`` to work. Before starting Mopidy, you must add your Soundspot username and password to the Mopidy configuration file:: - [soundspot] + [ext.soundspot] username = alice password = secret @@ -109,7 +108,7 @@ installation using ``pip install mopidy-something==dev`` to work. - `Source code `_ - `Issue tracker `_ - - `Download development snapshot `_ + - `Download development snapshot `_ Example setup.py @@ -129,10 +128,10 @@ The package must have ``install_requires`` on ``setuptools`` and ``Mopidy``, in addition to any other dependencies required by your extension. The ``entry_points`` part must be included. The ``mopidy.extension`` part cannot be changed, but the innermost string should be changed. It's format is -``my_ext_name = my_py_module:MyExtClass``. ``my_ext_name`` should be a short +``ext_name = package_name:Extension``. ``ext_name`` should be a short name for your extension, typically the part after "Mopidy-" in lowercase. This name is used e.g. to name the config section for your extension. The -``my_py_module:MyExtClass`` part is simply the Python path to the extension +``package_name:Extension`` part is simply the Python path to the extension class that will connect the rest of the dots. :: From 7d78d02d590e6f9ebdd6524d2b21bf52983320ea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 12:04:03 +0200 Subject: [PATCH 080/403] ext: Add both dist_name and ext_name to Extension class --- docs/extensiondev.rst | 3 ++- mopidy/__main__.py | 11 +++++++++-- mopidy/backends/local/__init__.py | 3 ++- mopidy/backends/spotify/__init__.py | 3 ++- mopidy/backends/stream/__init__.py | 3 ++- mopidy/ext.py | 3 ++- mopidy/frontends/http/__init__.py | 3 ++- mopidy/frontends/lastfm/__init__.py | 3 ++- mopidy/frontends/mpd/__init__.py | 3 ++- mopidy/frontends/mpris/__init__.py | 3 ++- tests/ext_test.py | 7 +++++-- 11 files changed, 32 insertions(+), 13 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 73457c54..c55eb6e4 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -234,7 +234,8 @@ meaningful defaults blank, like ``username`` and ``password``. class Extension(ext.Extension): - name = 'Mopidy-Soundspot' + dist_name = 'Mopidy-Soundspot' + ext_name = 'soundspot' version = __version__ def get_default_config(self): diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 8d8d7d62..44ec38d5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -145,18 +145,25 @@ def load_extensions(): extension = extension_class() + if entry_point.name != extension.ext_name: + logger.warning( + 'Disabled extension %(ep)s: entry point name (%(ep)s) ' + 'does not match extension name (%(ext)s)', + {'ep': entry_point.name, 'ext': extension.ext_name}) + continue + # TODO Validate configuration try: extension.validate_environment() except exceptions.ExtensionError as ex: logger.info( - 'Disabled extension: %s (%s)', extension.name, ex.message) + 'Disabled extension %s: %s', entry_point.name, ex.message) continue logger.info( 'Loaded extension %s: %s %s', - entry_point.name, extension.name, extension.version) + entry_point.name, extension.dist_name, extension.version) extensions.append(extension) return extensions diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 42477b53..bd9a6d1a 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -46,7 +46,8 @@ None class Extension(ext.Extension): - name = 'Mopidy-Local' + dist_name = 'Mopidy-Local' + ext_name = 'local' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 4c3b67fe..d9fdb630 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -67,7 +67,8 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend class Extension(ext.Extension): - name = 'Mopidy-Spotify' + dist_name = 'Mopidy-Spotify' + ext_name = 'spotify' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 097efc45..d14275b0 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -45,7 +45,8 @@ None class Extension(ext.Extension): - name = 'Mopidy-Stream' + dist_name = 'Mopidy-Stream' + ext_name = 'stream' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/ext.py b/mopidy/ext.py index 0097d8c6..bc26069c 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -5,7 +5,8 @@ from mopidy.utils import config class Extension(object): - name = None + dist_name = None + ext_name = None version = None def get_default_config(self): diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 6160d47c..f7f9f659 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -519,7 +519,8 @@ Example to get started with class Extension(ext.Extension): - name = 'Mopidy-HTTP' + dist_name = 'Mopidy-HTTP' + ext_name = 'http' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index f24e8f81..510856ea 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -44,7 +44,8 @@ The frontend is enabled by default if all dependencies are available. class Extension(ext.Extension): - name = 'Mopidy-Lastfm' + dist_name = 'Mopidy-Lastfm' + ext_name = 'lastfm' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index dacbf69e..518da54a 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -88,7 +88,8 @@ near future: class Extension(ext.Extension): - name = 'Mopidy-MPD' + dist_name = 'Mopidy-MPD' + ext_name = 'mpd' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 407d4800..20ef0ea7 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -70,7 +70,8 @@ Now you can control Mopidy through the player object. Examples: class Extension(ext.Extension): - name = 'Mopidy-MPRIS' + dist_name = 'Mopidy-MPRIS' + ext_name = 'mpris' version = mopidy.__version__ def get_default_config(self): diff --git a/tests/ext_test.py b/tests/ext_test.py index d279b52f..b58333c2 100644 --- a/tests/ext_test.py +++ b/tests/ext_test.py @@ -10,8 +10,11 @@ class ExtensionTest(unittest.TestCase): def setUp(self): self.ext = Extension() - def test_name_is_none(self): - self.assertIsNone(self.ext.name) + def test_dist_name_is_none(self): + self.assertIsNone(self.ext.dist_name) + + def test_ext_name_is_none(self): + self.assertIsNone(self.ext.ext_name) def test_version_is_none(self): self.assertIsNone(self.ext.version) From 2e678ffe8fc29990643cd6fe64299ce7f849039d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 12:07:35 +0200 Subject: [PATCH 081/403] ext: Change entry point from 'mopidy.extension' to 'mopidy.ext' to be more consistent --- docs/extensiondev.rst | 4 ++-- mopidy/__main__.py | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index c55eb6e4..71ea9ec0 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -126,7 +126,7 @@ the PyPI registration. The package must have ``install_requires`` on ``setuptools`` and ``Mopidy``, in addition to any other dependencies required by your extension. The -``entry_points`` part must be included. The ``mopidy.extension`` part cannot be +``entry_points`` part must be included. The ``mopidy.ext`` part cannot be changed, but the innermost string should be changed. It's format is ``ext_name = package_name:Extension``. ``ext_name`` should be a short name for your extension, typically the part after "Mopidy-" in lowercase. This @@ -166,7 +166,7 @@ class that will connect the rest of the dots. 'pysoundspot', ], entry_points={ - b'mopidy.extension': [ + b'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', ], }, diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 44ec38d5..87a93865 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -130,7 +130,7 @@ def check_old_folders(): def load_extensions(): extensions = [] - for entry_point in pkg_resources.iter_entry_points('mopidy.extension'): + for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): logger.debug('Loading extension %s', entry_point.name) # TODO Filter out disabled extensions diff --git a/setup.py b/setup.py index 8d3d6d5a..3c0b7c4c 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ setup( 'mopidy = mopidy.__main__:main', 'mopidy-scan = mopidy.scanner:main', ], - b'mopidy.extension': [ + b'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', 'lastfm = mopidy.frontends.lastfm:Extension [lastfm]', 'local = mopidy.backends.local:Extension', From 477baf3db7b44ebc57fb6be5a7ea766ef4bcd6ea Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 12:14:09 +0200 Subject: [PATCH 082/403] http: Fix docs typo --- mopidy/frontends/http/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 6160d47c..9bf482d2 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -427,7 +427,7 @@ Example to get started with .. code-block:: js - var consoleError = console.error.bind(error); + var consoleError = console.error.bind(console); var trackDesc = function (track) { return track.name + " by " + track.artists[0].name + @@ -457,7 +457,7 @@ Example to get started with .. code-block:: js - var consoleError = console.error.bind(error); + var consoleError = console.error.bind(console); var getFirst = function (list) { return list[0]; From a341d3e0c45299b40629d2677b005382f10f9d04 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 01:45:45 +0200 Subject: [PATCH 083/403] main: Collect and validate config from all sources --- mopidy/__main__.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 87a93865..2aeff179 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,9 +1,12 @@ from __future__ import unicode_literals +import codecs +import ConfigParser import logging import optparse import os import signal +import StringIO import sys import gobject @@ -35,6 +38,7 @@ sys.path.insert( from mopidy import exceptions, settings from mopidy.audio import Audio +from mopidy.config import default_config, config_schemas from mopidy.core import Core from mopidy.utils import ( deps, log, path, process, settings as settings_utils, versioning) @@ -54,6 +58,7 @@ def main(): log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() extensions = load_extensions() + load_config(options, extensions) setup_settings(options.interactive) audio = setup_audio() backends = setup_backends(extensions, audio) @@ -168,6 +173,62 @@ def load_extensions(): return extensions +def load_config(options, extensions): + parser = ConfigParser.RawConfigParser() + + files = [ + '/etc/mopidy/mopidy.conf', + '~/.config/mopidy/mopidy.conf', + ] + # TODO Add config file given through `options` to `files` + # TODO Replace `files` with single file given through `options` + + # Read default core config + parser.readfp(StringIO.StringIO(default_config)) + + # Read default extension config + for extension in extensions: + parser.readfp(StringIO.StringIO(extension.get_default_config())) + + # Load config from a series of config files + for filename in files: + filename = os.path.expanduser(filename) + try: + filehandle = codecs.open(filename, encoding='utf-8') + parser.readfp(filehandle) + except IOError: + logger.debug('Config file %s not found; skipping', filename) + continue + except UnicodeDecodeError: + logger.error('Config file %s is not UTF-8 encoded', filename) + process.exit_process() + + # TODO Merge config values given through `options` into `config` + + # Collect config schemas to validate against + sections_and_schemas = config_schemas.items() + for extension in extensions: + section_name = 'ext.%s' % extension.ext_name + if parser.getboolean(section_name, 'enabled'): + sections_and_schemas.append( + (section_name, extension.get_config_schema())) + + # Get validated config + config = {} + for section_name, schema in sections_and_schemas: + if not parser.has_section(section_name): + logger.error('Config section %s not found', section_name) + process.exit_process() + try: + config[section_name] = schema.convert(parser.items(section_name)) + except exceptions.ConfigError as error: + for key in error: + logger.error('Config error: %s: %s', key, error[key]) + process.exit_process() + + return config + + def setup_settings(interactive): path.get_or_create_folder(path.SETTINGS_PATH) path.get_or_create_folder(path.DATA_PATH) From 8dddc6d566c891346ec69780020bddf43b4e364a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 2 Apr 2013 12:14:09 +0200 Subject: [PATCH 084/403] http: Fix docs typo --- mopidy/frontends/http/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index f7f9f659..5e9629a7 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -427,7 +427,7 @@ Example to get started with .. code-block:: js - var consoleError = console.error.bind(error); + var consoleError = console.error.bind(console); var trackDesc = function (track) { return track.name + " by " + track.artists[0].name + @@ -457,7 +457,7 @@ Example to get started with .. code-block:: js - var consoleError = console.error.bind(error); + var consoleError = console.error.bind(console); var getFirst = function (list) { return list[0]; From 6f39bde566fa32190f724754993494d8e38f203a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 16:18:15 +0200 Subject: [PATCH 085/403] config: Start passing dummy config explicitly to audio/backends/frontends. --- mopidy/__main__.py | 30 ++++++++++--------- mopidy/audio/actor.py | 2 +- mopidy/backends/dummy.py | 6 +++- mopidy/backends/local/actor.py | 2 +- mopidy/backends/spotify/actor.py | 2 +- mopidy/backends/stream/actor.py | 2 +- mopidy/frontends/http/actor.py | 2 +- mopidy/frontends/lastfm/actor.py | 2 +- mopidy/frontends/mpd/actor.py | 2 +- mopidy/frontends/mpris/actor.py | 2 +- mopidy/utils/log.py | 2 +- tests/audio/actor_test.py | 6 ++-- tests/backends/base/events.py | 5 +++- tests/backends/base/library.py | 4 ++- tests/backends/base/playback.py | 4 ++- tests/backends/base/playlists.py | 5 +++- tests/backends/base/tracklist.py | 4 ++- tests/backends/local/events_test.py | 1 + tests/backends/local/library_test.py | 2 +- tests/backends/local/playback_test.py | 1 + tests/backends/local/playlists_test.py | 3 +- tests/backends/local/tracklist_test.py | 1 + tests/core/events_test.py | 2 +- tests/frontends/http/events_test.py | 2 +- tests/frontends/mpd/dispatcher_test.py | 2 +- tests/frontends/mpd/protocol/__init__.py | 2 +- tests/frontends/mpd/status_test.py | 2 +- tests/frontends/mpris/events_test.py | 2 +- .../frontends/mpris/player_interface_test.py | 2 +- .../mpris/playlists_interface_test.py | 2 +- tests/frontends/mpris/root_interface_test.py | 2 +- tests/utils/jsonrpc_test.py | 2 +- 32 files changed, 66 insertions(+), 44 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 2aeff179..4f531000 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -53,17 +53,18 @@ def main(): loop = gobject.MainLoop() options = parse_options() + config = {} # TODO: replace dummy placeholder try: - log.setup_logging(options.verbosity_level, options.save_debug_log) + log.setup_logging(config, options.verbosity_level, options.save_debug_log) check_old_folders() extensions = load_extensions() load_config(options, extensions) setup_settings(options.interactive) - audio = setup_audio() - backends = setup_backends(extensions, audio) + audio = setup_audio(config) + backends = setup_backends(config, extensions, audio) core = setup_core(audio, backends) - setup_frontends(extensions, core) + setup_frontends(config, extensions, core) loop.run() except exceptions.SettingsError as ex: logger.error(ex.message) @@ -122,6 +123,7 @@ def parse_options(): def check_old_folders(): + # TODO: add old settings and pre extension storage locations? old_settings_folder = os.path.expanduser('~/.mopidy') if not os.path.isdir(old_settings_folder): @@ -138,8 +140,6 @@ def load_extensions(): for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): logger.debug('Loading extension %s', entry_point.name) - # TODO Filter out disabled extensions - try: extension_class = entry_point.load() except pkg_resources.DistributionNotFound as ex: @@ -157,8 +157,6 @@ def load_extensions(): {'ep': entry_point.name, 'ext': extension.ext_name}) continue - # TODO Validate configuration - try: extension.validate_environment() except exceptions.ExtensionError as ex: @@ -166,6 +164,10 @@ def load_extensions(): 'Disabled extension %s: %s', entry_point.name, ex.message) continue + # TODO: due to order we do things in we can't know for sure if we are + # going to use it at this point, should we perhaps just log a single + # line with all extenions we found and then log an enabled line for + # each one after we check configs etc? logger.info( 'Loaded extension %s: %s %s', entry_point.name, extension.dist_name, extension.version) @@ -240,9 +242,9 @@ def setup_settings(interactive): sys.exit(1) -def setup_audio(): +def setup_audio(config): logger.info('Starting Mopidy audio') - return Audio.start().proxy() + return Audio.start(config=config).proxy() def stop_audio(): @@ -250,12 +252,12 @@ def stop_audio(): process.stop_actors_by_class(Audio) -def setup_backends(extensions, audio): +def setup_backends(config, extensions, audio): logger.info('Starting Mopidy backends') backends = [] for extension in extensions: for backend_class in extension.get_backend_classes(): - backend = backend_class.start(audio=audio).proxy() + backend = backend_class.start(config=config, audio=audio).proxy() backends.append(backend) return backends @@ -277,11 +279,11 @@ def stop_core(): process.stop_actors_by_class(Core) -def setup_frontends(extensions, core): +def setup_frontends(config, extensions, core): logger.info('Starting Mopidy frontends') for extension in extensions: for frontend_class in extension.get_frontend_classes(): - frontend_class.start(core=core) + frontend_class.start(config=config, core=core) def stop_frontends(extensions): diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 11d2741f..42dee084 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -38,7 +38,7 @@ class Audio(pykka.ThreadingActor): #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` state = PlaybackState.STOPPED - def __init__(self): + def __init__(self, config): super(Audio, self).__init__() self._playbin = None diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index dd021445..65477ea2 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -22,8 +22,12 @@ from mopidy.backends import base from mopidy.models import Playlist, SearchResult +def create_dummy_backend_proxy(config=None, audio=None): + return DummyBackend.start(config=config, audio=audio).proxy() + + class DummyBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, audio): + def __init__(self, config, audio): super(DummyBackend, self).__init__() self.library = DummyLibraryProvider(backend=self) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index 75baeab2..abad75ca 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -13,7 +13,7 @@ logger = logging.getLogger('mopidy.backends.local') class LocalBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, audio): + def __init__(self, config, audio): super(LocalBackend, self).__init__() self.library = LocalLibraryProvider(backend=self) diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 5e90205b..67b4acdc 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -14,7 +14,7 @@ class SpotifyBackend(pykka.ThreadingActor, base.Backend): # Imports inside methods are to prevent loading of __init__.py to fail on # missing spotify dependencies. - def __init__(self, audio): + def __init__(self, config, audio): super(SpotifyBackend, self).__init__() from .library import SpotifyLibraryProvider diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index f80ac7a9..d6eb31d3 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -13,7 +13,7 @@ logger = logging.getLogger('mopidy.backends.stream') class StreamBackend(pykka.ThreadingActor, base.Backend): - def __init__(self, audio): + def __init__(self, config, audio): super(StreamBackend, self).__init__() self.library = StreamLibraryProvider(backend=self) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 8ad0f026..54085471 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -23,7 +23,7 @@ logger = logging.getLogger('mopidy.frontends.http') class HttpFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, core): + def __init__(self, config, core): super(HttpFrontend, self).__init__() self.core = core self._setup_server() diff --git a/mopidy/frontends/lastfm/actor.py b/mopidy/frontends/lastfm/actor.py index 60a909e0..1e157d4f 100644 --- a/mopidy/frontends/lastfm/actor.py +++ b/mopidy/frontends/lastfm/actor.py @@ -20,7 +20,7 @@ API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' class LastfmFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, core): + def __init__(self, config, core): super(LastfmFrontend, self).__init__() self.lastfm = None self.last_start_time = None diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 8907fe22..e288c24e 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -14,7 +14,7 @@ logger = logging.getLogger('mopidy.frontends.mpd') class MpdFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, core): + def __init__(self, config, core): super(MpdFrontend, self).__init__() hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 5e171826..11f87922 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -18,7 +18,7 @@ except ImportError as import_error: class MprisFrontend(pykka.ThreadingActor, CoreListener): - def __init__(self, core): + def __init__(self, config, core): super(MprisFrontend, self).__init__() self.core = core self.indicate_server = None diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index ae4ea0d9..d50f107f 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -7,7 +7,7 @@ from mopidy import settings from . import deps, versioning -def setup_logging(verbosity_level, save_debug_log): +def setup_logging(config, verbosity_level, save_debug_log): setup_root_logger() setup_console_logging(verbosity_level) if save_debug_log: diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 73c8c165..35503472 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -17,7 +17,7 @@ class AudioTest(unittest.TestCase): settings.MIXER = 'fakemixer track_max_volume=65536' settings.OUTPUT = 'fakesink' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.audio = audio.Audio.start().proxy() + self.audio = audio.Audio.start(None).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() @@ -60,7 +60,7 @@ class AudioTest(unittest.TestCase): def test_set_volume_with_mixer_max_below_100(self): settings.MIXER = 'fakemixer track_max_volume=40' - self.audio = audio.Audio.start().proxy() + self.audio = audio.Audio.start(None).proxy() for value in range(0, 101): self.assertTrue(self.audio.set_volume(value).get()) @@ -81,7 +81,7 @@ class AudioTest(unittest.TestCase): class AudioStateTest(unittest.TestCase): def setUp(self): - self.audio = audio.Audio() + self.audio = audio.Audio(None) def test_state_starts_as_stopped(self): self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) diff --git a/tests/backends/base/events.py b/tests/backends/base/events.py index 1d31a721..a5d9fa7b 100644 --- a/tests/backends/base/events.py +++ b/tests/backends/base/events.py @@ -9,9 +9,12 @@ from mopidy.backends import listener @mock.patch.object(listener.BackendListener, 'send') class BackendEventsTest(object): + config = {} + def setUp(self): self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start(audio=self.audio).proxy() + self.backend = self.backend_class.start( + config=self.config, audio=self.audio).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index c75bec74..8390d2d6 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -23,9 +23,11 @@ class LibraryControllerTest(object): uri='file://' + path_to_data_dir('uri2'), name='track2', artists=artists[1:2], album=albums[1], date='2002', length=4000), Track()] + config = {} def setUp(self): - self.backend = self.backend_class.start(audio=None).proxy() + self.backend = self.backend_class.start( + config=self.config, audio=None).proxy() self.core = core.Core(backends=[self.backend]) self.library = self.core.library diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index e12d54a5..9ce73d31 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -18,10 +18,12 @@ from tests.backends.base import populate_tracklist class PlaybackControllerTest(object): tracks = [] + config = {} def setUp(self): self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start(audio=self.audio).proxy() + self.backend = self.backend_class.start( + config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) self.playback = self.core.playback self.tracklist = self.core.tracklist diff --git a/tests/backends/base/playlists.py b/tests/backends/base/playlists.py index 2184168f..00e32a6f 100644 --- a/tests/backends/base/playlists.py +++ b/tests/backends/base/playlists.py @@ -13,13 +13,16 @@ from tests import unittest, path_to_data_dir class PlaylistsControllerTest(object): + config = {} + def setUp(self): settings.LOCAL_PLAYLIST_PATH = tempfile.mkdtemp() settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') settings.LOCAL_MUSIC_PATH = path_to_data_dir('') self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start(audio=self.audio).proxy() + self.backend = self.backend_class.start( + config=self.config, audio=self.audio).proxy() self.core = core.Core(backends=[self.backend]) def tearDown(self): diff --git a/tests/backends/base/tracklist.py b/tests/backends/base/tracklist.py index 39fb020d..5140d3aa 100644 --- a/tests/backends/base/tracklist.py +++ b/tests/backends/base/tracklist.py @@ -13,10 +13,12 @@ from tests.backends.base import populate_tracklist class TracklistControllerTest(object): tracks = [] + config = {} def setUp(self): self.audio = audio.DummyAudio.start().proxy() - self.backend = self.backend_class.start(audio=self.audio).proxy() + self.backend = self.backend_class.start( + config=self.config, audio=self.audio).proxy() self.core = core.Core(audio=self.audio, backends=[self.backend]) self.controller = self.core.tracklist self.playback = self.core.playback diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index b35fad1a..5ccf0886 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -7,6 +7,7 @@ from tests.backends.base import events class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): backend_class = actor.LocalBackend + # TODO: setup config def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 7bf8d565..ca90e40b 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -8,8 +8,8 @@ from tests.backends.base.library import LibraryControllerTest class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): - backend_class = actor.LocalBackend + # TODO: setup config def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 8d997d2e..e9b3954c 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -15,6 +15,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = actor.LocalBackend tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + # TODO: setup config def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index f3794cee..3dbc3a2a 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -17,6 +17,7 @@ class LocalPlaylistsControllerTest( PlaylistsControllerTest, unittest.TestCase): backend_class = actor.LocalBackend + # TODO: setup config def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') @@ -96,7 +97,7 @@ class LocalPlaylistsControllerTest( playlist = playlist.copy(tracks=[track]) playlist = self.core.playlists.save(playlist) - backend = self.backend_class(audio=self.audio) + backend = self.backend_class(config=self.config, audio=self.audio) self.assert_(backend.playlists.playlists) self.assertEqual( diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 0c47a5db..24c400fa 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -13,6 +13,7 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): backend_class = actor.LocalBackend tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] + # TODO: setup config def setUp(self): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') diff --git a/tests/core/events_test.py b/tests/core/events_test.py index 11881db7..7f673b02 100644 --- a/tests/core/events_test.py +++ b/tests/core/events_test.py @@ -13,7 +13,7 @@ from tests import unittest @mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index 77438fd4..7661ac6e 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -24,7 +24,7 @@ from tests import unittest @mock.patch('cherrypy.engine.publish') class HttpEventsTest(unittest.TestCase): def setUp(self): - self.http = actor.HttpFrontend(core=mock.Mock()) + self.http = actor.HttpFrontend(config=None, core=mock.Mock()) def test_track_playback_paused_is_broadcasted(self, publish): publish.reset_mock() diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 3404db95..3c32cd32 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -13,7 +13,7 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher() diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 00594206..9d24c3fa 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -24,7 +24,7 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.connection = MockConnection() diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index d508cbf0..8868eef7 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -22,7 +22,7 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 78e40071..f79202c0 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -19,7 +19,7 @@ from tests import unittest class BackendEventsTest(unittest.TestCase): def setUp(self): # As a plain class, not an actor: - self.mpris_frontend = actor.MprisFrontend(core=None) + self.mpris_frontend = actor.MprisFrontend(config=None, core=None) self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index 0c477dc8..ec4a17a9 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -26,7 +26,7 @@ STOPPED = PlaybackState.STOPPED class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.mpris = objects.MprisObject(core=self.core) diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py index 2adffaf3..745a858c 100644 --- a/tests/frontends/mpris/playlists_interface_test.py +++ b/tests/frontends/mpris/playlists_interface_test.py @@ -23,7 +23,7 @@ from tests import unittest class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.mpris = objects.MprisObject(core=self.core) diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 722fd2cd..36d689a2 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -21,7 +21,7 @@ class RootInterfaceTest(unittest.TestCase): def setUp(self): objects.exit_process = mock.Mock() objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.mpris = objects.MprisObject(core=self.core) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 226d4614..7fb8a55e 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -38,7 +38,7 @@ class Calculator(object): class JsonRpcTestBase(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.jrw = jsonrpc.JsonRpcWrapper( From f8b22f32e70031ecd453a0b97f8dd2c1647853b7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 18:20:52 +0200 Subject: [PATCH 086/403] ext: Make info logging for loading extensions less verbose. --- mopidy/__main__.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 4f531000..86013885 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -138,7 +138,7 @@ def check_old_folders(): def load_extensions(): extensions = [] for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): - logger.debug('Loading extension %s', entry_point.name) + logger.debug('Loading entrypoint: %s', entry_point) try: extension_class = entry_point.load() @@ -150,6 +150,9 @@ def load_extensions(): extension = extension_class() + logger.debug( + 'Loaded extension: %s %s', extension.dist_name, extension.version) + if entry_point.name != extension.ext_name: logger.warning( 'Disabled extension %(ep)s: entry point name (%(ep)s) ' @@ -164,14 +167,8 @@ def load_extensions(): 'Disabled extension %s: %s', entry_point.name, ex.message) continue - # TODO: due to order we do things in we can't know for sure if we are - # going to use it at this point, should we perhaps just log a single - # line with all extenions we found and then log an enabled line for - # each one after we check configs etc? - logger.info( - 'Loaded extension %s: %s %s', - entry_point.name, extension.dist_name, extension.version) extensions.append(extension) + logging.info('Loaded extensions: %s', ', '.join(e.ext_name for e in extensions)) return extensions From 91be3e379f3120fe94d33e3c3d34587a20f0bf6b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 18:21:18 +0200 Subject: [PATCH 087/403] config: Let user know what files we are loading. --- mopidy/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 86013885..a7af9fb2 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -182,6 +182,8 @@ def load_config(options, extensions): # TODO Add config file given through `options` to `files` # TODO Replace `files` with single file given through `options` + logging.info('Loading config from: builtin-defaults, %s', ', '.join(files)) + # Read default core config parser.readfp(StringIO.StringIO(default_config)) @@ -222,7 +224,7 @@ def load_config(options, extensions): config[section_name] = schema.convert(parser.items(section_name)) except exceptions.ConfigError as error: for key in error: - logger.error('Config error: %s: %s', key, error[key]) + logger.error('Config error: %s:%s %s', section_name, key, error[key]) process.exit_process() return config From 7051c15539ca7f062fc509dfd80ecb622347cc2b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 18:38:07 +0200 Subject: [PATCH 088/403] config: Start reworking flow in main towards using new config. --- mopidy/__main__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a7af9fb2..d18cd1a5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -53,13 +53,14 @@ def main(): loop = gobject.MainLoop() options = parse_options() - config = {} # TODO: replace dummy placeholder try: - log.setup_logging(config, options.verbosity_level, options.save_debug_log) - check_old_folders() + # TODO: we need a two stage logging setup as we want logging for + # extension loading and config loading. + log.setup_logging(None, options.verbosity_level, options.save_debug_log) extensions = load_extensions() - load_config(options, extensions) + config = load_config(options, extensions) + check_old_folders() setup_settings(options.interactive) audio = setup_audio(config) backends = setup_backends(config, extensions, audio) From 47570fcddc7555519c382e9738aea3f776487fda Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 18:58:48 +0200 Subject: [PATCH 089/403] config: Split load and validate config into two. --- mopidy/__main__.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index d18cd1a5..c3c627e4 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -59,7 +59,8 @@ def main(): # extension loading and config loading. log.setup_logging(None, options.verbosity_level, options.save_debug_log) extensions = load_extensions() - config = load_config(options, extensions) + raw_config = load_config(options, extensions) + config = validate_config(raw_config, extensions) check_old_folders() setup_settings(options.interactive) audio = setup_audio(config) @@ -169,7 +170,9 @@ def load_extensions(): continue extensions.append(extension) - logging.info('Loaded extensions: %s', ', '.join(e.ext_name for e in extensions)) + + names = (e.ext_name for e in extensions) + logging.info('Found following runnable extensions: %s', ', '.join(names)) return extensions @@ -205,24 +208,31 @@ def load_config(options, extensions): logger.error('Config file %s is not UTF-8 encoded', filename) process.exit_process() - # TODO Merge config values given through `options` into `config` + raw_config = {} + for section in parser.sections(): + raw_config[section] = dict(parser.items(section)) + # TODO Merge config values given through `options` into `raw_config` + return raw_config + + +def validate_config(raw_config, extensions): # Collect config schemas to validate against sections_and_schemas = config_schemas.items() for extension in extensions: section_name = 'ext.%s' % extension.ext_name - if parser.getboolean(section_name, 'enabled'): - sections_and_schemas.append( - (section_name, extension.get_config_schema())) + sections_and_schemas.append( + (section_name, extension.get_config_schema())) # Get validated config config = {} for section_name, schema in sections_and_schemas: - if not parser.has_section(section_name): + if section_name not in raw_config: logger.error('Config section %s not found', section_name) process.exit_process() try: - config[section_name] = schema.convert(parser.items(section_name)) + items = raw_config[section_name].items() + config[section_name] = schema.convert(items) except exceptions.ConfigError as error: for key in error: logger.error('Config error: %s:%s %s', section_name, key, error[key]) From 7c124d0f7285b7661b7f1068e8d3d18fab6b4e41 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 19:00:00 +0200 Subject: [PATCH 090/403] config/ext: fitler enabled extensions. --- mopidy/__main__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index c3c627e4..020252c9 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -41,7 +41,8 @@ from mopidy.audio import Audio from mopidy.config import default_config, config_schemas from mopidy.core import Core from mopidy.utils import ( - deps, log, path, process, settings as settings_utils, versioning) + config as config_utils, deps, log, path, process, + settings as settings_utils, versioning) logger = logging.getLogger('mopidy.main') @@ -60,6 +61,7 @@ def main(): log.setup_logging(None, options.verbosity_level, options.save_debug_log) extensions = load_extensions() raw_config = load_config(options, extensions) + extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, extensions) check_old_folders() setup_settings(options.interactive) @@ -176,6 +178,21 @@ def load_extensions(): return extensions +def filter_enabled_extensions(raw_config, extensions): + boolean = config_utils.Boolean() + filtered_extensions = [] + + for extension in extensions: + # TODO: handle key and value errors. + enabled = raw_config['ext.%s' % extension.ext_name]['enabled'] + if boolean.deserialize(enabled): + filtered_extensions.append(extension) + + names = (e.ext_name for e in filtered_extensions) + logging.info('Following extensions will be started: %s', ', '.join(names)) + return filtered_extensions + + def load_config(options, extensions): parser = ConfigParser.RawConfigParser() From dcd0d7e8136b0b30fdd230709dcdeb48d0f776b7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 19:07:17 +0200 Subject: [PATCH 091/403] ext: Rename ext_name to name and provide ext_name property that gives ext.$name --- mopidy/__main__.py | 13 ++++++------- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/__init__.py | 2 +- mopidy/backends/stream/__init__.py | 2 +- mopidy/ext.py | 8 +++++++- mopidy/frontends/http/__init__.py | 2 +- mopidy/frontends/lastfm/__init__.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- tests/ext_test.py | 7 +++++++ 10 files changed, 27 insertions(+), 15 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 020252c9..dc5d8d8d 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -157,11 +157,11 @@ def load_extensions(): logger.debug( 'Loaded extension: %s %s', extension.dist_name, extension.version) - if entry_point.name != extension.ext_name: + if entry_point.name != extension.name: logger.warning( 'Disabled extension %(ep)s: entry point name (%(ep)s) ' 'does not match extension name (%(ext)s)', - {'ep': entry_point.name, 'ext': extension.ext_name}) + {'ep': entry_point.name, 'ext': extension.name}) continue try: @@ -173,7 +173,7 @@ def load_extensions(): extensions.append(extension) - names = (e.ext_name for e in extensions) + names = (e.name for e in extensions) logging.info('Found following runnable extensions: %s', ', '.join(names)) return extensions @@ -184,11 +184,11 @@ def filter_enabled_extensions(raw_config, extensions): for extension in extensions: # TODO: handle key and value errors. - enabled = raw_config['ext.%s' % extension.ext_name]['enabled'] + enabled = raw_config[extension.ext_name]['enabled'] if boolean.deserialize(enabled): filtered_extensions.append(extension) - names = (e.ext_name for e in filtered_extensions) + names = (e.name for e in filtered_extensions) logging.info('Following extensions will be started: %s', ', '.join(names)) return filtered_extensions @@ -237,9 +237,8 @@ def validate_config(raw_config, extensions): # Collect config schemas to validate against sections_and_schemas = config_schemas.items() for extension in extensions: - section_name = 'ext.%s' % extension.ext_name sections_and_schemas.append( - (section_name, extension.get_config_schema())) + (extension.ext_name, extension.get_config_schema())) # Get validated config config = {} diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index bd9a6d1a..a93c8fa8 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -47,7 +47,7 @@ None class Extension(ext.Extension): dist_name = 'Mopidy-Local' - ext_name = 'local' + name = 'local' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index d9fdb630..a19d7ea8 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -68,7 +68,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend class Extension(ext.Extension): dist_name = 'Mopidy-Spotify' - ext_name = 'spotify' + name = 'spotify' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index d14275b0..321e4a03 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -46,7 +46,7 @@ None class Extension(ext.Extension): dist_name = 'Mopidy-Stream' - ext_name = 'stream' + name = 'stream' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/ext.py b/mopidy/ext.py index bc26069c..78283617 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -6,9 +6,15 @@ from mopidy.utils import config class Extension(object): dist_name = None - ext_name = None + name = None version = None + @property + def ext_name(self): + if self.name is None: + return None + return 'ext.%s' % self.name + def get_default_config(self): raise NotImplementedError( 'Add at least a config section with "enabled = true"') diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 5e9629a7..d2fe24fc 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -520,7 +520,7 @@ Example to get started with class Extension(ext.Extension): dist_name = 'Mopidy-HTTP' - ext_name = 'http' + name = 'http' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index 510856ea..7a252170 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -45,7 +45,7 @@ The frontend is enabled by default if all dependencies are available. class Extension(ext.Extension): dist_name = 'Mopidy-Lastfm' - ext_name = 'lastfm' + name = 'lastfm' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 518da54a..d782c545 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -89,7 +89,7 @@ near future: class Extension(ext.Extension): dist_name = 'Mopidy-MPD' - ext_name = 'mpd' + name = 'mpd' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 20ef0ea7..4b817c73 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -71,7 +71,7 @@ Now you can control Mopidy through the player object. Examples: class Extension(ext.Extension): dist_name = 'Mopidy-MPRIS' - ext_name = 'mpris' + name = 'mpris' version = mopidy.__version__ def get_default_config(self): diff --git a/tests/ext_test.py b/tests/ext_test.py index b58333c2..b967f951 100644 --- a/tests/ext_test.py +++ b/tests/ext_test.py @@ -13,9 +13,16 @@ class ExtensionTest(unittest.TestCase): def test_dist_name_is_none(self): self.assertIsNone(self.ext.dist_name) + def test_name_is_none(self): + self.assertIsNone(self.ext.name) + def test_ext_name_is_none(self): self.assertIsNone(self.ext.ext_name) + def test_ext_name_prefixes_ext(self): + self.ext.name = 'foo' + self.assertEqual('ext.foo', self.ext.ext_name) + def test_version_is_none(self): self.assertIsNone(self.ext.version) From 29a4ff040e79d19e908ee6bda5e0e5ceabe81621 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 19:19:24 +0200 Subject: [PATCH 092/403] config: Make list return tuples so we get imuttable data. --- mopidy/utils/config.py | 2 +- tests/utils/config_test.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 6358796b..7bc5ebe1 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -154,7 +154,7 @@ class List(ConfigValue): values = re.split(r'\s*\n\s*', value.strip()) else: values = re.split(r'\s*,\s*', value.strip()) - return [v for v in values if v] + return tuple([v for v in values if v]) def serialize(self, value): return '\n '.join(v.encode('utf-8') for v in value) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 43bb1679..9db2922e 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -199,10 +199,10 @@ class ListTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = config.List() - expected = ['foo', 'bar', 'baz'] + expected = ('foo', 'bar', 'baz') self.assertEqual(expected, value.deserialize('foo, bar ,baz ')) - expected = ['foo,bar', 'bar', 'baz'] + expected = ('foo,bar', 'bar', 'baz') self.assertEqual(expected, value.deserialize(' foo,bar\nbar\nbaz')) def test_deserialize_enforces_required(self): @@ -212,12 +212,12 @@ class ListTest(unittest.TestCase): def test_deserialize_respects_optional(self): value = config.List(optional=True) - self.assertEqual([], value.deserialize('')) - self.assertEqual([], value.deserialize(' ')) + self.assertEqual(tuple(), value.deserialize('')) + self.assertEqual(tuple(), value.deserialize(' ')) def test_serialize(self): value = config.List() - result = value.serialize(['foo', 'bar', 'baz']) + result = value.serialize(('foo', 'bar', 'baz')) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') From 77cdb5b0658afe0751b1e9debc06210838d8eb01 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 19:33:26 +0200 Subject: [PATCH 093/403] config: Add config based list_settings_callback --- mopidy/__main__.py | 21 ++++++++++++++++++++- mopidy/utils/config.py | 6 +----- tests/utils/config_test.py | 4 ---- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index dc5d8d8d..b6c797fd 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -113,7 +113,7 @@ def parse_options(): parser.add_option( b'--list-settings', action='callback', - callback=settings_utils.list_settings_optparse_callback, + callback=list_settings_callback, help='list current settings') parser.add_option( b'--list-deps', @@ -126,6 +126,25 @@ def parse_options(): return parser.parse_args(args=mopidy_args)[0] +def list_settings_callback(options, opt, value, parser): + extensions = load_extensions() + raw_config = load_config(options, extensions) + extensions = filter_enabled_extensions(raw_config, extensions) + config = validate_config(raw_config, extensions) + + # TODO: this code is duplicated, figure out a way to reuse it? + sections_and_schemas = config_schemas.items() + for extension in extensions: + sections_and_schemas.append( + (extension.ext_name, extension.get_config_schema())) + + output = ['# Settings for disabled extensions are not shown.'] + for section_name, schema in sections_and_schemas: + output.append(schema.format(section_name, config.get(section_name, {}))) + print '\n\n'.join(output) + sys.exit(0) + + def check_old_folders(): # TODO: add old settings and pre extension storage locations? old_settings_folder = os.path.expanduser('~/.mopidy') diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 7bc5ebe1..00f2b595 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -157,7 +157,7 @@ class List(ConfigValue): return tuple([v for v in values if v]) def serialize(self, value): - return '\n '.join(v.encode('utf-8') for v in value) + return '\n ' + '\n '.join(v.encode('utf-8') for v in value) class LogLevel(ConfigValue): @@ -270,10 +270,6 @@ class ExtensionConfigSchema(ConfigSchema): super(ExtensionConfigSchema, self).__init__() self['enabled'] = Boolean() - def format(self, name, values): - return super(ExtensionConfigSchema, self).format( - 'ext.%s' % name, values) - class LogLevelConfigSchema(object): """Special cased schema for handling a config section with loglevels. diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 9db2922e..f2465b4e 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -376,10 +376,6 @@ class ExtensionConfigSchemaTest(unittest.TestCase): schema = config.ExtensionConfigSchema() self.assertIsInstance(schema['enabled'], config.Boolean) - def test_section_name_is_prefixed(self): - schema = config.ExtensionConfigSchema() - self.assertEqual('[ext.foo]', schema.format('foo', {})) - class LogLevelConfigSchemaTest(unittest.TestCase): def test_conversion(self): From 3b8fe2fd9fff117ebb6cfa40bab635315796db3a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 19:37:51 +0200 Subject: [PATCH 094/403] config: Remove old list settings and mask secret code. --- mopidy/utils/settings.py | 36 --------------------- tests/utils/settings_test.py | 62 ------------------------------------ 2 files changed, 98 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 051f0f1c..b61476f5 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -184,42 +184,6 @@ def validate_settings(defaults, settings): return errors -def list_settings_optparse_callback(*args): - """ - Prints a list of all settings. - - Called by optparse when Mopidy is run with the :option:`--list-settings` - option. - """ - from mopidy import settings - print format_settings_list(settings) - sys.exit(0) - - -def format_settings_list(settings): - errors = settings.get_errors() - lines = [] - for (key, value) in sorted(settings.current.iteritems()): - default_value = settings.default.get(key) - masked_value = mask_value_if_secret(key, value) - lines.append('%s: %s' % ( - key, formatting.indent(pprint.pformat(masked_value), places=2))) - if value != default_value and default_value is not None: - lines.append( - ' Default: %s' % - formatting.indent(pprint.pformat(default_value), places=4)) - if errors.get(key) is not None: - lines.append(' Error: %s' % errors[key]) - return '\n'.join(lines) - - -def mask_value_if_secret(key, value): - if key.endswith('PASSWORD') and value: - return '********' - else: - return value - - def did_you_mean(setting, defaults): """Suggest most likely setting based on levenshtein.""" if not defaults: diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 2c13066c..787337d2 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -59,20 +59,6 @@ class ValidateSettingsTest(unittest.TestCase): self.defaults, {'FOO': '', 'BAR': ''}) self.assertEqual(len(result), 2) - def test_masks_value_if_secret(self): - secret = setting_utils.mask_value_if_secret('SPOTIFY_PASSWORD', 'bar') - self.assertEqual('********', secret) - - def test_does_not_mask_value_if_not_secret(self): - not_secret = setting_utils.mask_value_if_secret( - 'SPOTIFY_USERNAME', 'foo') - self.assertEqual('foo', not_secret) - - def test_does_not_mask_value_if_none(self): - not_secret = setting_utils.mask_value_if_secret( - 'SPOTIFY_USERNAME', None) - self.assertEqual(None, not_secret) - class SettingsProxyTest(unittest.TestCase): def setUp(self): @@ -179,54 +165,6 @@ class SettingsProxyTest(unittest.TestCase): self.settings.validate(interactive=True) -class FormatSettingListTest(unittest.TestCase): - def setUp(self): - self.settings = setting_utils.SettingsProxy(settings) - - def test_contains_the_setting_name(self): - self.settings.TEST = 'test' - result = setting_utils.format_settings_list(self.settings) - self.assertIn('TEST:', result, result) - - def test_repr_of_a_string_value(self): - self.settings.TEST = 'test' - result = setting_utils.format_settings_list(self.settings) - self.assertIn("TEST: u'test'", result, result) - - def test_repr_of_an_int_value(self): - self.settings.TEST = 123 - result = setting_utils.format_settings_list(self.settings) - self.assertIn("TEST: 123", result, result) - - def test_repr_of_a_tuple_value(self): - self.settings.TEST = (123, 'abc') - result = setting_utils.format_settings_list(self.settings) - self.assertIn("TEST: (123, u'abc')", result, result) - - def test_passwords_are_masked(self): - self.settings.TEST_PASSWORD = 'secret' - result = setting_utils.format_settings_list(self.settings) - self.assertNotIn("TEST_PASSWORD: u'secret'", result, result) - self.assertIn("TEST_PASSWORD: u'********'", result, result) - - def test_short_values_are_not_pretty_printed(self): - self.settings.FRONTEND = ('mopidy.frontends.mpd.MpdFrontend',) - result = setting_utils.format_settings_list(self.settings) - self.assertIn( - "FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) - - def test_long_values_are_pretty_printed(self): - self.settings.FRONTEND = ( - u'mopidy.frontends.mpd.MpdFrontend', - u'mopidy.frontends.lastfm.LastfmFrontend') - result = setting_utils.format_settings_list(self.settings) - self.assertIn( - "FRONTEND: \n" - " (u'mopidy.frontends.mpd.MpdFrontend',\n" - " u'mopidy.frontends.lastfm.LastfmFrontend')", - result) - - class DidYouMeanTest(unittest.TestCase): def testSuggestoins(self): defaults = { From 15d0c7a13fdb4e48aacde1bab834b3a19a5f2ab0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 20:10:35 +0200 Subject: [PATCH 095/403] config: Merge command line overrides into config. --- mopidy/__main__.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index b6c797fd..60c0d73d 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -85,9 +85,24 @@ def main(): process.stop_remaining_actors() +def check_config_override(option, opt, override): + try: + section, remainder = override.split(':', 1) + key, value = remainder.split('=', 1) + return (section, key, value) + except ValueError: + raise optparse.OptionValueError( + 'option %s: must have the format section:key=value' % opt) + + def parse_options(): parser = optparse.OptionParser( version='Mopidy %s' % versioning.get_version()) + + # Ugly extension of optparse type checking magic :/ + optparse.Option.TYPES += ('setting',) + optparse.Option.TYPE_CHECKER['setting'] = check_config_override + # NOTE First argument to add_option must be bytestrings on Python < 2.6.2 # See https://github.com/mopidy/mopidy/issues/302 for details parser.add_option( @@ -112,8 +127,7 @@ def parse_options(): help='save debug log to "./mopidy.log"') parser.add_option( b'--list-settings', - action='callback', - callback=list_settings_callback, + action='callback', callback=list_settings_callback, help='list current settings') parser.add_option( b'--list-deps', @@ -123,12 +137,16 @@ def parse_options(): b'--debug-thread', action='store_true', dest='debug_thread', help='run background thread that dumps tracebacks on SIGUSR1') + parser.add_option( + b'-s', b'--setting', + action='append', dest='settings', type='setting', + help='`section_name:setting_key=value` values to override settings.') return parser.parse_args(args=mopidy_args)[0] -def list_settings_callback(options, opt, value, parser): +def list_settings_callback(option, opt, value, parser): extensions = load_extensions() - raw_config = load_config(options, extensions) + raw_config = load_config(parser.values, extensions) extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, extensions) @@ -221,8 +239,10 @@ def load_config(options, extensions): ] # TODO Add config file given through `options` to `files` # TODO Replace `files` with single file given through `options` + # TODO expand_path and use xdg when loading. - logging.info('Loading config from: builtin-defaults, %s', ', '.join(files)) + sources = ['builtin-defaults'] + files + ['command-line'] + logging.info('Loading config from: %s', ', '.join(sources)) # Read default core config parser.readfp(StringIO.StringIO(default_config)) @@ -248,7 +268,9 @@ def load_config(options, extensions): for section in parser.sections(): raw_config[section] = dict(parser.items(section)) - # TODO Merge config values given through `options` into `raw_config` + for section, key, value in options.settings or []: + raw_config.setdefault(section, {})[key] = value + return raw_config From e226ddd6529fbc5461e9fab640cbc66789f5a83d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 22:12:00 +0200 Subject: [PATCH 096/403] config/ext: Remove ext. prefix from configs. --- docs/extensiondev.rst | 18 +++++++++--------- mopidy/__main__.py | 8 ++++---- mopidy/backends/local/__init__.py | 4 ++-- mopidy/backends/spotify/__init__.py | 4 ++-- mopidy/backends/stream/__init__.py | 4 ++-- mopidy/ext.py | 8 +------- mopidy/frontends/http/__init__.py | 4 ++-- mopidy/frontends/lastfm/__init__.py | 4 ++-- mopidy/frontends/mpd/__init__.py | 4 ++-- mopidy/frontends/mpris/__init__.py | 4 ++-- mopidy/utils/config.py | 3 +-- tests/ext_test.py | 7 ------- 12 files changed, 29 insertions(+), 43 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 71ea9ec0..b64ac3b6 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -99,7 +99,7 @@ installation using ``pip install Mopidy-Something==dev`` to work. Before starting Mopidy, you must add your Soundspot username and password to the Mopidy configuration file:: - [ext.soundspot] + [soundspot] username = alice password = secret @@ -199,13 +199,13 @@ The default configuration for the extension is defined by the ``get_default_config()`` method in the ``Extension`` class which returns a :mod:`ConfigParser` compatible config section. The config section's name should be the same as the extension's short name, as defined in the ``entry_points`` -part of ``setup.py``, but prefixed with ``ext.``, for example -``ext.soundspot``. All extensions should include an ``enabled`` config which -should default to ``true``. Provide good defaults for all config values so that -as few users as possible will need to change them. The exception is if the -config value has security implications; in that case you should default to the -most secure configuration. Leave any configurations that doesn't have -meaningful defaults blank, like ``username`` and ``password``. +part of ``setup.py``, for example ``soundspot``. All extensions should include +an ``enabled`` config which should default to ``true``. Provide good defaults +for all config values so that as few users as possible will need to change +them. The exception is if the config value has security implications; in that +case you should default to the most secure configuration. Leave any +configurations that doesn't have meaningful defaults blank, like ``username`` +and ``password``. :: @@ -225,7 +225,7 @@ meaningful defaults blank, like ``username`` and ``password``. __version__ = '0.1' default_config = """ - [ext.soundspot] + [soundspot] enabled = true username = password = diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 60c0d73d..5c2f7cfd 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -194,11 +194,11 @@ def load_extensions(): logger.debug( 'Loaded extension: %s %s', extension.dist_name, extension.version) - if entry_point.name != extension.name: + if entry_point.name != extension.ext_name: logger.warning( 'Disabled extension %(ep)s: entry point name (%(ep)s) ' 'does not match extension name (%(ext)s)', - {'ep': entry_point.name, 'ext': extension.name}) + {'ep': entry_point.name, 'ext': extension.ext_name}) continue try: @@ -210,7 +210,7 @@ def load_extensions(): extensions.append(extension) - names = (e.name for e in extensions) + names = (e.ext_name for e in extensions) logging.info('Found following runnable extensions: %s', ', '.join(names)) return extensions @@ -225,7 +225,7 @@ def filter_enabled_extensions(raw_config, extensions): if boolean.deserialize(enabled): filtered_extensions.append(extension) - names = (e.name for e in filtered_extensions) + names = (e.ext_name for e in filtered_extensions) logging.info('Following extensions will be started: %s', ', '.join(names)) return filtered_extensions diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index a93c8fa8..17fd659e 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -6,7 +6,7 @@ from mopidy.utils import config, formatting default_config = """ -[ext.local] +[local] # If the local extension should be enabled or not enabled = true @@ -47,7 +47,7 @@ None class Extension(ext.Extension): dist_name = 'Mopidy-Local' - name = 'local' + ext_name = 'local' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index a19d7ea8..0e32d4cd 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -7,7 +7,7 @@ from mopidy.utils import config, formatting default_config = """ -[ext.spotify] +[spotify] # If the Spotify extension should be enabled or not enabled = true @@ -68,7 +68,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend class Extension(ext.Extension): dist_name = 'Mopidy-Spotify' - name = 'spotify' + ext_name = 'spotify' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 321e4a03..9a393bed 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -6,7 +6,7 @@ from mopidy.utils import config, formatting default_config = """ -[ext.stream] +[stream] # If the stream extension should be enabled or not enabled = true @@ -46,7 +46,7 @@ None class Extension(ext.Extension): dist_name = 'Mopidy-Stream' - name = 'stream' + ext_name = 'stream' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/ext.py b/mopidy/ext.py index 78283617..bc26069c 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -6,15 +6,9 @@ from mopidy.utils import config class Extension(object): dist_name = None - name = None + ext_name = None version = None - @property - def ext_name(self): - if self.name is None: - return None - return 'ext.%s' % self.name - def get_default_config(self): raise NotImplementedError( 'Add at least a config section with "enabled = true"') diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index d2fe24fc..03bf0a87 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -6,7 +6,7 @@ from mopidy.utils import config, formatting default_config = """ -[ext.http] +[http] # If the HTTP extension should be enabled or not enabled = true @@ -520,7 +520,7 @@ Example to get started with class Extension(ext.Extension): dist_name = 'Mopidy-HTTP' - name = 'http' + ext_name = 'http' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/lastfm/__init__.py index 7a252170..f4bff0e5 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/lastfm/__init__.py @@ -6,7 +6,7 @@ from mopidy.utils import config, formatting default_config = """ -[ext.lastfm] +[lastfm] # If the Last.fm extension should be enabled or not enabled = true @@ -45,7 +45,7 @@ The frontend is enabled by default if all dependencies are available. class Extension(ext.Extension): dist_name = 'Mopidy-Lastfm' - name = 'lastfm' + ext_name = 'lastfm' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index d782c545..08bafd26 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -6,7 +6,7 @@ from mopidy.utils import config, formatting default_config = """ -[ext.mpd] +[mpd] # If the MPD extension should be enabled or not enabled = true @@ -89,7 +89,7 @@ near future: class Extension(ext.Extension): dist_name = 'Mopidy-MPD' - name = 'mpd' + ext_name = 'mpd' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 4b817c73..79806c47 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -6,7 +6,7 @@ from mopidy.utils import formatting, config default_config = """ -[ext.mpris] +[mpris] # If the MPRIS extension should be enabled or not enabled = true @@ -71,7 +71,7 @@ Now you can control Mopidy through the player object. Examples: class Extension(ext.Extension): dist_name = 'Mopidy-MPRIS' - name = 'mpris' + ext_name = 'mpris' version = mopidy.__version__ def get_default_config(self): diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 00f2b595..d2b34e7d 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -263,8 +263,7 @@ class ConfigSchema(object): class ExtensionConfigSchema(ConfigSchema): """Sub-classed :class:`ConfigSchema` for use in extensions. - Ensures that `enabled` config value is present and that section name is - prefixed with ext. + Ensures that `enabled` config value is present. """ def __init__(self): super(ExtensionConfigSchema, self).__init__() diff --git a/tests/ext_test.py b/tests/ext_test.py index b967f951..b58333c2 100644 --- a/tests/ext_test.py +++ b/tests/ext_test.py @@ -13,16 +13,9 @@ class ExtensionTest(unittest.TestCase): def test_dist_name_is_none(self): self.assertIsNone(self.ext.dist_name) - def test_name_is_none(self): - self.assertIsNone(self.ext.name) - def test_ext_name_is_none(self): self.assertIsNone(self.ext.ext_name) - def test_ext_name_prefixes_ext(self): - self.ext.name = 'foo' - self.assertEqual('ext.foo', self.ext.ext_name) - def test_version_is_none(self): self.assertIsNone(self.ext.version) From 0b416bc892f64d1506951085c178648ebda8fa97 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 22:19:12 +0200 Subject: [PATCH 097/403] config: Update override format and flag name. --- mopidy/__main__.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 5c2f7cfd..ce615b11 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -54,13 +54,14 @@ def main(): loop = gobject.MainLoop() options = parse_options() + config_overrides = getattr(options, 'overrides', []) try: # TODO: we need a two stage logging setup as we want logging for # extension loading and config loading. log.setup_logging(None, options.verbosity_level, options.save_debug_log) extensions = load_extensions() - raw_config = load_config(options, extensions) + raw_config = load_config(config_overrides, extensions) extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, extensions) check_old_folders() @@ -87,12 +88,12 @@ def main(): def check_config_override(option, opt, override): try: - section, remainder = override.split(':', 1) + section, remainder = override.split('/', 1) key, value = remainder.split('=', 1) return (section, key, value) except ValueError: raise optparse.OptionValueError( - 'option %s: must have the format section:key=value' % opt) + 'option %s: must have the format section/key=value' % opt) def parse_options(): @@ -100,8 +101,8 @@ def parse_options(): version='Mopidy %s' % versioning.get_version()) # Ugly extension of optparse type checking magic :/ - optparse.Option.TYPES += ('setting',) - optparse.Option.TYPE_CHECKER['setting'] = check_config_override + optparse.Option.TYPES += ('config_override',) + optparse.Option.TYPE_CHECKER['config_override'] = check_config_override # NOTE First argument to add_option must be bytestrings on Python < 2.6.2 # See https://github.com/mopidy/mopidy/issues/302 for details @@ -138,15 +139,17 @@ def parse_options(): action='store_true', dest='debug_thread', help='run background thread that dumps tracebacks on SIGUSR1') parser.add_option( - b'-s', b'--setting', - action='append', dest='settings', type='setting', - help='`section_name:setting_key=value` values to override settings.') + b'-o', b'--option', + action='append', dest='overrides', type='config_override', + help='`section/key=value` values to override config options.') return parser.parse_args(args=mopidy_args)[0] def list_settings_callback(option, opt, value, parser): + overrides = getattr(parser.values, 'overrides', []) + extensions = load_extensions() - raw_config = load_config(parser.values, extensions) + raw_config = load_config(overrides, extensions) extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, extensions) @@ -268,7 +271,7 @@ def load_config(options, extensions): for section in parser.sections(): raw_config[section] = dict(parser.items(section)) - for section, key, value in options.settings or []: + for section, key, value in options or []: raw_config.setdefault(section, {})[key] = value return raw_config From 3f59e16f7c32c92cbd34dd5ae546be1450f4a070 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 22:26:03 +0200 Subject: [PATCH 098/403] config: Collect all config errors on startup. --- mopidy/__main__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ce615b11..102dec8c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -286,6 +286,7 @@ def validate_config(raw_config, extensions): # Get validated config config = {} + errors = {} for section_name, schema in sections_and_schemas: if section_name not in raw_config: logger.error('Config section %s not found', section_name) @@ -294,9 +295,14 @@ def validate_config(raw_config, extensions): items = raw_config[section_name].items() config[section_name] = schema.convert(items) except exceptions.ConfigError as error: + errors[section_name] = error + + if errors: + for section_name, error in errors.items(): + logger.error('[%s] config errors:', section_name) for key in error: - logger.error('Config error: %s:%s %s', section_name, key, error[key]) - process.exit_process() + logger.error('%s %s', key, error[key]) + process.exit_process() return config From f73c081ddfeaec5875b9bf3bdcdfa3c55afc3403 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 22:36:22 +0200 Subject: [PATCH 099/403] config: Improve list settings output with disabled settings. --- mopidy/__main__.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 102dec8c..99d6cf2a 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -150,18 +150,24 @@ def list_settings_callback(option, opt, value, parser): extensions = load_extensions() raw_config = load_config(overrides, extensions) - extensions = filter_enabled_extensions(raw_config, extensions) - config = validate_config(raw_config, extensions) - - # TODO: this code is duplicated, figure out a way to reuse it? - sections_and_schemas = config_schemas.items() - for extension in extensions: - sections_and_schemas.append( - (extension.ext_name, extension.get_config_schema())) + enabled_extensions = filter_enabled_extensions(raw_config, extensions) + config = validate_config(raw_config, enabled_extensions) output = ['# Settings for disabled extensions are not shown.'] - for section_name, schema in sections_and_schemas: - output.append(schema.format(section_name, config.get(section_name, {}))) + for section_name, schema in config_schemas.items(): + options = config.get(section_name, {}) + if not options: + continue + output.append(schema.format(section_name, options)) + + for extension in extensions: + if extension in enabled_extensions: + schema = extension.get_config_schema() + options = config.get(extension.ext_name, {}) + output.append(schema.format(extension.ext_name, options)) + else: + output.append('[%s]\nenabled = false' % extension.ext_name) + print '\n\n'.join(output) sys.exit(0) From 3c14d09c87817dc96c12ff74ceddf46dd8bab914 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 22:51:40 +0200 Subject: [PATCH 100/403] config: Rename ConfigParser to configparser. --- mopidy/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 99d6cf2a..78968dc7 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import codecs -import ConfigParser +import ConfigParser as configparser import logging import optparse import os @@ -240,7 +240,7 @@ def filter_enabled_extensions(raw_config, extensions): def load_config(options, extensions): - parser = ConfigParser.RawConfigParser() + parser = configparser.RawConfigParser() files = [ '/etc/mopidy/mopidy.conf', From 9310c08f4d3387dbfb8b12162ff2d904383a3c25 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 22:56:01 +0200 Subject: [PATCH 101/403] config: Use path.expand_path and $XDG_CONFIG_DIR instead of hardcoded .config --- mopidy/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 78968dc7..16ef61bc 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -244,7 +244,7 @@ def load_config(options, extensions): files = [ '/etc/mopidy/mopidy.conf', - '~/.config/mopidy/mopidy.conf', + '$XDG_CONFIG_DIR/mopidy/mopidy.conf', ] # TODO Add config file given through `options` to `files` # TODO Replace `files` with single file given through `options` @@ -262,7 +262,7 @@ def load_config(options, extensions): # Load config from a series of config files for filename in files: - filename = os.path.expanduser(filename) + filename = path.expand_path(filename) try: filehandle = codecs.open(filename, encoding='utf-8') parser.readfp(filehandle) From 4802d8033398b675d4dbf5a326b8eb1e6cfcb381 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:03:22 +0200 Subject: [PATCH 102/403] config: Rename --list-settings to --show-config --- mopidy/__main__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 16ef61bc..258c1cfd 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -127,9 +127,9 @@ def parse_options(): action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') parser.add_option( - b'--list-settings', - action='callback', callback=list_settings_callback, - help='list current settings') + b'--show-config', + action='callback', callback=show_config_callback, + help='show current config') parser.add_option( b'--list-deps', action='callback', callback=deps.list_deps_optparse_callback, @@ -145,7 +145,7 @@ def parse_options(): return parser.parse_args(args=mopidy_args)[0] -def list_settings_callback(option, opt, value, parser): +def show_config_callback(option, opt, value, parser): overrides = getattr(parser.values, 'overrides', []) extensions = load_extensions() From cbfafbad1a45aadef2c043c3782489ff6f0b9d62 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:09:16 +0200 Subject: [PATCH 103/403] config: Review nitpicks. --- mopidy/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 258c1cfd..d7d74db5 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -141,7 +141,7 @@ def parse_options(): parser.add_option( b'-o', b'--option', action='append', dest='overrides', type='config_override', - help='`section/key=value` values to override config options.') + help='`section/key=value` values to override config options') return parser.parse_args(args=mopidy_args)[0] @@ -153,7 +153,7 @@ def show_config_callback(option, opt, value, parser): enabled_extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, enabled_extensions) - output = ['# Settings for disabled extensions are not shown.'] + output = ['# Config for disabled extensions are not shown.'] for section_name, schema in config_schemas.items(): options = config.get(section_name, {}) if not options: From 5450905d49b172c38341fc599919a62ab2f97a85 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:12:37 +0200 Subject: [PATCH 104/403] main: Remove dead --debug-thread flag. --- mopidy/__main__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index d7d74db5..71330050 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -134,10 +134,6 @@ def parse_options(): b'--list-deps', action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') - parser.add_option( - b'--debug-thread', - action='store_true', dest='debug_thread', - help='run background thread that dumps tracebacks on SIGUSR1') parser.add_option( b'-o', b'--option', action='append', dest='overrides', type='config_override', From e0330e56c1472244704486b2fe494dfc759c71f1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:21:34 +0200 Subject: [PATCH 105/403] config: Get files to load from --config --- mopidy/__main__.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 71330050..b274f768 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -54,14 +54,15 @@ def main(): loop = gobject.MainLoop() options = parse_options() - config_overrides = getattr(options, 'overrides', []) + config_files = options.config.split(':') + config_overrides = options.overrides try: # TODO: we need a two stage logging setup as we want logging for # extension loading and config loading. log.setup_logging(None, options.verbosity_level, options.save_debug_log) extensions = load_extensions() - raw_config = load_config(config_overrides, extensions) + raw_config = load_config(config_files, config_overrides, extensions) extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, extensions) check_old_folders() @@ -134,6 +135,11 @@ def parse_options(): b'--list-deps', action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') + parser.add_option( + b'-c', b'--config', + action='store', dest='config', + default='/etc/mopidy/mopidy.conf:$XDG_CONFIG_DIR/mopidy/mopidy.conf', + help='config files to use, colon seperated, later files override') parser.add_option( b'-o', b'--option', action='append', dest='overrides', type='config_override', @@ -142,10 +148,11 @@ def parse_options(): def show_config_callback(option, opt, value, parser): + files = getattr(parser.values, 'config', '').split(':') overrides = getattr(parser.values, 'overrides', []) extensions = load_extensions() - raw_config = load_config(overrides, extensions) + raw_config = load_config(files, overrides, extensions) enabled_extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, enabled_extensions) @@ -235,17 +242,9 @@ def filter_enabled_extensions(raw_config, extensions): return filtered_extensions -def load_config(options, extensions): +def load_config(files, overrides, extensions): parser = configparser.RawConfigParser() - files = [ - '/etc/mopidy/mopidy.conf', - '$XDG_CONFIG_DIR/mopidy/mopidy.conf', - ] - # TODO Add config file given through `options` to `files` - # TODO Replace `files` with single file given through `options` - # TODO expand_path and use xdg when loading. - sources = ['builtin-defaults'] + files + ['command-line'] logging.info('Loading config from: %s', ', '.join(sources)) @@ -273,7 +272,7 @@ def load_config(options, extensions): for section in parser.sections(): raw_config[section] = dict(parser.items(section)) - for section, key, value in options or []: + for section, key, value in overrides or []: raw_config.setdefault(section, {})[key] = value return raw_config From 54a59f5968abbc15415c69fc812cc60ee92118f8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:24:07 +0200 Subject: [PATCH 106/403] config: Add TODO regarding --show-config behaviour. --- mopidy/__main__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index b274f768..47d9d33c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -148,6 +148,8 @@ def parse_options(): def show_config_callback(option, opt, value, parser): + # TODO: don't use callback for this as --config or -o set after + # --show-config will be ignored. files = getattr(parser.values, 'config', '').split(':') overrides = getattr(parser.values, 'overrides', []) From 0d30db7e5f120994930d9096b1147a0f20f7c12c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:32:36 +0200 Subject: [PATCH 107/403] config: More review fixes and fixed help test. --- mopidy/__main__.py | 7 +++---- mopidy/utils/config.py | 2 +- tests/audio/actor_test.py | 6 +++--- tests/help_test.py | 4 +++- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 47d9d33c..280f80e2 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -193,7 +193,7 @@ def check_old_folders(): def load_extensions(): extensions = [] for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): - logger.debug('Loading entrypoint: %s', entry_point) + logger.debug('Loading entry point: %s', entry_point) try: extension_class = entry_point.load() @@ -292,8 +292,7 @@ def validate_config(raw_config, extensions): errors = {} for section_name, schema in sections_and_schemas: if section_name not in raw_config: - logger.error('Config section %s not found', section_name) - process.exit_process() + errors[section_name] = {section_name: 'section not found'} try: items = raw_config[section_name].items() config[section_name] = schema.convert(items) @@ -305,7 +304,7 @@ def validate_config(raw_config, extensions): logger.error('[%s] config errors:', section_name) for key in error: logger.error('%s %s', key, error[key]) - process.exit_process() + sys.exit(1) return config diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index d2b34e7d..64e0d9ff 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -263,7 +263,7 @@ class ConfigSchema(object): class ExtensionConfigSchema(ConfigSchema): """Sub-classed :class:`ConfigSchema` for use in extensions. - Ensures that `enabled` config value is present. + Ensures that ``enabled`` config value is present. """ def __init__(self): super(ExtensionConfigSchema, self).__init__() diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 35503472..51786adb 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -17,7 +17,7 @@ class AudioTest(unittest.TestCase): settings.MIXER = 'fakemixer track_max_volume=65536' settings.OUTPUT = 'fakesink' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.audio = audio.Audio.start(None).proxy() + self.audio = audio.Audio.start(config=None).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() @@ -60,7 +60,7 @@ class AudioTest(unittest.TestCase): def test_set_volume_with_mixer_max_below_100(self): settings.MIXER = 'fakemixer track_max_volume=40' - self.audio = audio.Audio.start(None).proxy() + self.audio = audio.Audio.start(config=None).proxy() for value in range(0, 101): self.assertTrue(self.audio.set_volume(value).get()) @@ -81,7 +81,7 @@ class AudioTest(unittest.TestCase): class AudioStateTest(unittest.TestCase): def setUp(self): - self.audio = audio.Audio(None) + self.audio = audio.Audio(config=None) def test_state_starts_as_stopped(self): self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) diff --git a/tests/help_test.py b/tests/help_test.py index fdef0f52..15c51d2a 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -22,7 +22,9 @@ class HelpTest(unittest.TestCase): self.assertIn('--quiet', output) self.assertIn('--verbose', output) self.assertIn('--save-debug-log', output) - self.assertIn('--list-settings', output) + self.assertIn('--show-config', output) + self.assertIn('--config', output) + self.assertIn('--option', output) def test_help_gst_has_gstreamer_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) From 07fa9548e6925944e9aa7c803f8184ae766b80f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:35:07 +0200 Subject: [PATCH 108/403] config: Fix backticks in docstring. --- mopidy/utils/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 64e0d9ff..30cf873f 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -213,9 +213,9 @@ class ConfigSchema(object): """Logical group of config values that correspond to a config section. Schemas are set up by assigning config keys with config values to - instances. Once setup :meth:`convert` can be called with a list of `(key, - value)` tuples to process. For convienience we also support :meth:`format` - method that can used for printing out the converted values. + instances. Once setup :meth:`convert` can be called with a list of + ``(key, value)`` tuples to process. For convienience we also support + :meth:`format` method that can used for printing out the converted values. """ # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) def __init__(self): From 8087efb319a3703fa5f78947d8ffd541d1a8134a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:57:18 +0200 Subject: [PATCH 109/403] config: Improve printing of disabled extensions in --show-config --- mopidy/__main__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 280f80e2..f2531261 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -158,7 +158,7 @@ def show_config_callback(option, opt, value, parser): enabled_extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, enabled_extensions) - output = ['# Config for disabled extensions are not shown.'] + output = [] for section_name, schema in config_schemas.items(): options = config.get(section_name, {}) if not options: @@ -171,7 +171,9 @@ def show_config_callback(option, opt, value, parser): options = config.get(extension.ext_name, {}) output.append(schema.format(extension.ext_name, options)) else: - output.append('[%s]\nenabled = false' % extension.ext_name) + lines = ['[%s]' % extension.ext_name, 'enabled = false', + '# Config hidden as extension is disabled'] + output.append('\n'.join(lines)) print '\n\n'.join(output) sys.exit(0) From a9445ab251372825db2f70b0d03a894e57c87ea2 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:57:54 +0200 Subject: [PATCH 110/403] ext: Log only enabled/disabled extensions at info level --- mopidy/__main__.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index f2531261..c22ee13c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -227,23 +227,28 @@ def load_extensions(): extensions.append(extension) names = (e.ext_name for e in extensions) - logging.info('Found following runnable extensions: %s', ', '.join(names)) + logging.debug('Discovered extensions: %s', ', '.join(names)) return extensions def filter_enabled_extensions(raw_config, extensions): boolean = config_utils.Boolean() - filtered_extensions = [] + enabled_extensions = [] + enabled_names = [] + disabled_names = [] for extension in extensions: # TODO: handle key and value errors. enabled = raw_config[extension.ext_name]['enabled'] if boolean.deserialize(enabled): - filtered_extensions.append(extension) + enabled_extensions.append(extension) + enabled_names.append(extension.ext_name) + else: + disabled_names.append(extension.ext_name) - names = (e.ext_name for e in filtered_extensions) - logging.info('Following extensions will be started: %s', ', '.join(names)) - return filtered_extensions + logging.info('Enabled extensions: %s', ', '.join(enabled_names)) + logging.info('Disabled extensions: %s', ', '.join(disabled_names)) + return enabled_extensions def load_config(files, overrides, extensions): From 5214100854b8e03695434a2c0e8fffb98eb6a138 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:58:13 +0200 Subject: [PATCH 111/403] config: Expand files before printing sources. --- mopidy/__main__.py | 2 +- mopidy/utils/path.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index c22ee13c..fe215636 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -254,6 +254,7 @@ def filter_enabled_extensions(raw_config, extensions): def load_config(files, overrides, extensions): parser = configparser.RawConfigParser() + files = [path.expand_path(f) for f in files] sources = ['builtin-defaults'] + files + ['command-line'] logging.info('Loading config from: %s', ', '.join(sources)) @@ -266,7 +267,6 @@ def load_config(files, overrides, extensions): # Load config from a series of config files for filename in files: - filename = path.expand_path(filename) try: filehandle = codecs.open(filename, encoding='utf-8') parser.readfp(filehandle) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 7d988a90..4e5a66cd 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -99,6 +99,7 @@ def split_path(path): def expand_path(path): + # TODO: expandvars as well? path = string.Template(path).safe_substitute(XDG_DIRS) path = os.path.expanduser(path) path = os.path.abspath(path) From 8f7e991903dfff467e56ecc6671e53f8d3b5b553 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 2 Apr 2013 23:58:54 +0200 Subject: [PATCH 112/403] config: Do not support -c as allias for --config --- mopidy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index fe215636..1c0a8515 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -136,7 +136,7 @@ def parse_options(): action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') parser.add_option( - b'-c', b'--config', + b'--config', action='store', dest='config', default='/etc/mopidy/mopidy.conf:$XDG_CONFIG_DIR/mopidy/mopidy.conf', help='config files to use, colon seperated, later files override') From 77204f6b464dc5cbb3705d0ca0d3145d2e397455 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Apr 2013 00:05:55 +0200 Subject: [PATCH 113/403] main: Fix flake8 warnings --- mopidy/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1c0a8515..2a427f02 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -41,8 +41,7 @@ from mopidy.audio import Audio from mopidy.config import default_config, config_schemas from mopidy.core import Core from mopidy.utils import ( - config as config_utils, deps, log, path, process, - settings as settings_utils, versioning) + config as config_utils, deps, log, path, process, versioning) logger = logging.getLogger('mopidy.main') @@ -60,7 +59,8 @@ def main(): try: # TODO: we need a two stage logging setup as we want logging for # extension loading and config loading. - log.setup_logging(None, options.verbosity_level, options.save_debug_log) + log.setup_logging( + None, options.verbosity_level, options.save_debug_log) extensions = load_extensions() raw_config = load_config(config_files, config_overrides, extensions) extensions = filter_enabled_extensions(raw_config, extensions) From c00a49ebe1ac1d1e26b7f6ab855eabf27163acde Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Apr 2013 00:07:14 +0200 Subject: [PATCH 114/403] main: Remove sys.path modification --- mopidy/__main__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 2a427f02..3c312ba0 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -30,12 +30,6 @@ mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] sys.argv[1:] = gstreamer_args -# Add ../ to the path so we can run Mopidy from a Git checkout without -# installing it on the system. -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) - - from mopidy import exceptions, settings from mopidy.audio import Audio from mopidy.config import default_config, config_schemas From 6c4ec7e0c2a2f5459d092dcd6683150e08ae77c4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Apr 2013 00:10:23 +0200 Subject: [PATCH 115/403] main: Remove support for --gst-* and --help-gst Use GStreamer's environment variables instead, e.g. GST_DEBUG=3. --- mopidy/__main__.py | 20 ++++---------------- tests/help_test.py | 8 -------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 3c312ba0..f6b3127b 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -16,18 +16,10 @@ import pkg_resources import pykka.debug -# Extract any non-GStreamer arguments, and leave the GStreamer arguments for -# processing by GStreamer. This needs to be done before GStreamer is imported, -# so that GStreamer doesn't hijack e.g. ``--help``. -# NOTE This naive fix does not support values like ``bar`` in -# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. - -def is_gst_arg(argument): - return argument.startswith('--gst') or argument == '--help-gst' - -gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] -mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] -sys.argv[1:] = gstreamer_args +# Extract any command line arguments. This needs to be done before GStreamer is +# imported, so that GStreamer doesn't hijack e.g. ``--help``. +mopidy_args = sys.argv[1:] +sys.argv[1:] = [] from mopidy import exceptions, settings @@ -101,10 +93,6 @@ def parse_options(): # NOTE First argument to add_option must be bytestrings on Python < 2.6.2 # See https://github.com/mopidy/mopidy/issues/302 for details - parser.add_option( - b'--help-gst', - action='store_true', dest='help_gst', - help='show GStreamer help options') parser.add_option( b'-i', '--interactive', action='store_true', dest='interactive', diff --git a/tests/help_test.py b/tests/help_test.py index 15c51d2a..4a852804 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -17,7 +17,6 @@ class HelpTest(unittest.TestCase): output = process.communicate()[0] self.assertIn('--version', output) self.assertIn('--help', output) - self.assertIn('--help-gst', output) self.assertIn('--interactive', output) self.assertIn('--quiet', output) self.assertIn('--verbose', output) @@ -25,10 +24,3 @@ class HelpTest(unittest.TestCase): self.assertIn('--show-config', output) self.assertIn('--config', output) self.assertIn('--option', output) - - def test_help_gst_has_gstreamer_options(self): - mopidy_dir = os.path.dirname(mopidy.__file__) - args = [sys.executable, mopidy_dir, '--help-gst'] - process = subprocess.Popen(args, stdout=subprocess.PIPE) - output = process.communicate()[0] - self.assertIn('--gst-version', output) From f9fb30c76ca4ba6573d12c7a138ed33ac46a5d34 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Apr 2013 00:16:37 +0200 Subject: [PATCH 116/403] main/settings: Remove --interactive support --- mopidy/__main__.py | 10 +++------- mopidy/utils/settings.py | 9 +-------- tests/help_test.py | 1 - tests/utils/settings_test.py | 14 -------------- 4 files changed, 4 insertions(+), 30 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index f6b3127b..6c1eb2ad 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -52,7 +52,7 @@ def main(): extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, extensions) check_old_folders() - setup_settings(options.interactive) + setup_settings() audio = setup_audio(config) backends = setup_backends(config, extensions, audio) core = setup_core(audio, backends) @@ -93,10 +93,6 @@ def parse_options(): # NOTE First argument to add_option must be bytestrings on Python < 2.6.2 # See https://github.com/mopidy/mopidy/issues/302 for details - parser.add_option( - b'-i', '--interactive', - action='store_true', dest='interactive', - help='ask interactively for required settings which are missing') parser.add_option( b'-q', '--quiet', action='store_const', const=0, dest='verbosity_level', @@ -298,12 +294,12 @@ def validate_config(raw_config, extensions): return config -def setup_settings(interactive): +def setup_settings(): path.get_or_create_folder(path.SETTINGS_PATH) path.get_or_create_folder(path.DATA_PATH) path.get_or_create_file(path.SETTINGS_FILE) try: - settings.validate(interactive) + settings.validate() except exceptions.SettingsError as ex: logger.error(ex.message) sys.exit(1) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index b61476f5..5916ee24 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -70,20 +70,13 @@ class SettingsProxy(object): else: super(SettingsProxy, self).__setattr__(attr, value) - def validate(self, interactive): - if interactive: - self._read_missing_settings_from_stdin(self.current, self.runtime) + def validate(self): if self.get_errors(): logger.error( 'Settings validation errors: %s', formatting.indent(self.get_errors_as_string())) raise exceptions.SettingsError('Settings validation failed.') - def _read_missing_settings_from_stdin(self, current, runtime): - for setting, value in sorted(current.iteritems()): - if isinstance(value, basestring) and len(value) == 0: - runtime[setting] = self._read_from_stdin(setting + ': ') - def _read_from_stdin(self, prompt): if '_PASSWORD' in prompt: return ( diff --git a/tests/help_test.py b/tests/help_test.py index 4a852804..b1ba9d64 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -17,7 +17,6 @@ class HelpTest(unittest.TestCase): output = process.communicate()[0] self.assertIn('--version', output) self.assertIn('--help', output) - self.assertIn('--interactive', output) self.assertIn('--quiet', output) self.assertIn('--verbose', output) self.assertIn('--save-debug-log', output) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 787337d2..3b1e67b0 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -150,20 +150,6 @@ class SettingsProxyTest(unittest.TestCase): self.settings.TEST_PATH = None self.assertEqual(self.settings.TEST_PATH, None) - def test_interactive_input_of_missing_defaults(self): - self.settings.default['TEST'] = '' - interactive_input = 'input' - self.settings._read_from_stdin = lambda _: interactive_input - self.settings.validate(interactive=True) - self.assertEqual(interactive_input, self.settings.TEST) - - def test_interactive_input_not_needed_when_setting_is_set_locally(self): - self.settings.default['TEST'] = '' - self.settings.local['TEST'] = 'test' - self.settings._read_from_stdin = lambda _: self.fail( - 'Should not read from stdin') - self.settings.validate(interactive=True) - class DidYouMeanTest(unittest.TestCase): def testSuggestoins(self): From 99f26dd41cc0810c207b6838e0ad19ee34004c34 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Apr 2013 00:39:57 +0200 Subject: [PATCH 117/403] Revert "main: Remove sys.path modification" This reverts commit c00a49ebe1ac1d1e26b7f6ab855eabf27163acde. --- mopidy/__main__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 6c1eb2ad..a5613d33 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -22,6 +22,12 @@ mopidy_args = sys.argv[1:] sys.argv[1:] = [] +# Add ../ to the path so we can run Mopidy from a Git checkout without +# installing it on the system. +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) + + from mopidy import exceptions, settings from mopidy.audio import Audio from mopidy.config import default_config, config_schemas From 73fb143f45166c97541852a201ab52e24980071b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 3 Apr 2013 01:00:34 +0200 Subject: [PATCH 118/403] config: String escape strings --- mopidy/utils/config.py | 2 +- tests/utils/config_test.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 30cf873f..1a3127b5 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -104,7 +104,7 @@ class String(ConfigValue): return value def serialize(self, value): - return value.encode('utf-8') + return value.encode('utf-8').encode('string-escape') class Integer(ConfigValue): diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index f2465b4e..77c846df 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -128,6 +128,10 @@ class StringTest(unittest.TestCase): self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) + def test_serialize_string_escapes(self): + value = config.String() + self.assertEqual(r'\r\n\t', value.serialize('\r\n\t')) + def test_format_masks_secrets(self): value = config.String(secret=True) self.assertEqual('********', value.format('s3cret')) From dcb28259f4eabc29c001bee27e9a7a5f8ef0b40f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 3 Apr 2013 00:56:12 +0200 Subject: [PATCH 119/403] config: Switch logging to new config system --- mopidy/__main__.py | 28 ++++++++++++++++------------ mopidy/config.py | 3 ++- mopidy/frontends/http/__init__.py | 3 +++ mopidy/utils/log.py | 31 +++++++++++++++---------------- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a5613d33..ac45c66b 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -49,16 +49,18 @@ def main(): config_overrides = options.overrides try: - # TODO: we need a two stage logging setup as we want logging for - # extension loading and config loading. + logging_config = load_config(config_files, config_overrides) log.setup_logging( - None, options.verbosity_level, options.save_debug_log) + logging_config, options.verbosity_level, options.save_debug_log) extensions = load_extensions() raw_config = load_config(config_files, config_overrides, extensions) extensions = filter_enabled_extensions(raw_config, extensions) - config = validate_config(raw_config, extensions) + config = validate_config(raw_config, config_schemas, extensions) + log.setup_log_levels(config) check_old_folders() setup_settings() + # Anything that wants to exit after this point must use + # process.exit_proces as actors have been started. audio = setup_audio(config) backends = setup_backends(config, extensions, audio) core = setup_core(audio, backends) @@ -140,7 +142,7 @@ def show_config_callback(option, opt, value, parser): extensions = load_extensions() raw_config = load_config(files, overrides, extensions) enabled_extensions = filter_enabled_extensions(raw_config, extensions) - config = validate_config(raw_config, enabled_extensions) + config = validate_config(raw_config, config_schemas, enabled_extensions) output = [] for section_name, schema in config_schemas.items(): @@ -235,22 +237,24 @@ def filter_enabled_extensions(raw_config, extensions): return enabled_extensions -def load_config(files, overrides, extensions): +def load_config(files, overrides, extensions=None): parser = configparser.RawConfigParser() files = [path.expand_path(f) for f in files] sources = ['builtin-defaults'] + files + ['command-line'] - logging.info('Loading config from: %s', ', '.join(sources)) + logger.info('Loading config from: %s', ', '.join(sources)) # Read default core config parser.readfp(StringIO.StringIO(default_config)) # Read default extension config - for extension in extensions: + for extension in extensions or []: parser.readfp(StringIO.StringIO(extension.get_default_config())) # Load config from a series of config files for filename in files: + # TODO: if this is the initial load of logging config we might not have + # a logger at this point, we might want to handle this better. try: filehandle = codecs.open(filename, encoding='utf-8') parser.readfp(filehandle) @@ -259,7 +263,7 @@ def load_config(files, overrides, extensions): continue except UnicodeDecodeError: logger.error('Config file %s is not UTF-8 encoded', filename) - process.exit_process() + sys.exit(1) raw_config = {} for section in parser.sections(): @@ -271,10 +275,10 @@ def load_config(files, overrides, extensions): return raw_config -def validate_config(raw_config, extensions): +def validate_config(raw_config, schemas, extensions=None): # Collect config schemas to validate against - sections_and_schemas = config_schemas.items() - for extension in extensions: + sections_and_schemas = schemas.items() + for extension in extensions or []: sections_and_schemas.append( (extension.ext_name, extension.get_config_schema())) diff --git a/mopidy/config.py b/mopidy/config.py index 70e4b7af..85feffcc 100644 --- a/mopidy/config.py +++ b/mopidy/config.py @@ -5,11 +5,12 @@ from mopidy.utils import config default_config = """ [logging] -console_format = %(levelname)-8s $(message)s +console_format = %(levelname)-8s %(message)s debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s debug_file = mopidy.log [logging.levels] +pykka = info [audio] mixer = autoaudiomixer diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 03bf0a87..d588a376 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -32,6 +32,9 @@ port = 6680 # "/mopidy" will continue to work as usual even if you change this setting. # static_dir = + +[logging.levels] +cherrypy = warning """ __doc__ = """ diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index d50f107f..859289ad 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,39 +3,44 @@ from __future__ import unicode_literals import logging import logging.handlers -from mopidy import settings from . import deps, versioning def setup_logging(config, verbosity_level, save_debug_log): setup_root_logger() - setup_console_logging(verbosity_level) + setup_console_logging(config, verbosity_level) if save_debug_log: - setup_debug_logging_to_file() + setup_debug_logging_to_file(config) if hasattr(logging, 'captureWarnings'): # New in Python 2.7 logging.captureWarnings(True) + logger = logging.getLogger('mopidy.utils.log') logger.info('Starting Mopidy %s', versioning.get_version()) logger.info('%(name)s: %(version)s', deps.platform_info()) logger.info('%(name)s: %(version)s', deps.python_info()) +def setup_log_levels(config): + for name, level in config['logging.levels'].items(): + logging.getLogger(name).setLevel(level) + + def setup_root_logger(): root = logging.getLogger('') root.setLevel(logging.DEBUG) -def setup_console_logging(verbosity_level): +def setup_console_logging(config, verbosity_level): if verbosity_level == 0: log_level = logging.WARNING - log_format = settings.CONSOLE_LOG_FORMAT + log_format = config['logging']['console_format'] elif verbosity_level >= 2: log_level = logging.DEBUG - log_format = settings.DEBUG_LOG_FORMAT + log_format = config['logging']['debug_format'] else: log_level = logging.INFO - log_format = settings.CONSOLE_LOG_FORMAT + log_format = config['logging']['console_format'] formatter = logging.Formatter(log_format) handler = logging.StreamHandler() handler.setFormatter(formatter) @@ -43,17 +48,11 @@ def setup_console_logging(verbosity_level): root = logging.getLogger('') root.addHandler(handler) - if verbosity_level < 3: - logging.getLogger('pykka').setLevel(logging.INFO) - if verbosity_level < 2: - logging.getLogger('cherrypy').setLevel(logging.WARNING) - - -def setup_debug_logging_to_file(): - formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) +def setup_debug_logging_to_file(config): + formatter = logging.Formatter(config['logging']['debug_format']) handler = logging.handlers.RotatingFileHandler( - settings.DEBUG_LOG_FILENAME, maxBytes=10485760, backupCount=3) + config['logging']['debug_file'], maxBytes=10485760, backupCount=3) handler.setFormatter(formatter) handler.setLevel(logging.DEBUG) root = logging.getLogger('') From 33c430a3cbb259719df2a7224a4b4db589e23539 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Apr 2013 10:37:49 +0200 Subject: [PATCH 120/403] spotify: Rename 'proxy_host' to 'proxy_hostname' for consistency --- mopidy/backends/spotify/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 0e32d4cd..b03849eb 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -28,7 +28,7 @@ timeout = 10 cache_path = $XDG_CACHE_DIR/mopidy/spotify # Connect to Spotify through a proxy -proxy_host = +proxy_hostname = proxy_username = proxy_password = """ @@ -81,7 +81,7 @@ class Extension(ext.Extension): schema['bitrate'] = config.Integer(choices=(96, 160, 320)) schema['timeout'] = config.Integer(minimum=0) schema['cache_path'] = config.String() - schema['proxy_host'] = config.Hostname(optional=True) + schema['proxy_hostname'] = config.Hostname(optional=True) schema['proxy_username'] = config.String(optional=True) schema['proxy_password'] = config.String(optional=True, secret=True) return schema From 6822f1248d9a34d87091706cc62fc49ddaa6150b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 3 Apr 2013 10:38:04 +0200 Subject: [PATCH 121/403] spotify: Switch to new config system --- mopidy/backends/spotify/actor.py | 24 ++++---------- mopidy/backends/spotify/library.py | 17 ++++++---- mopidy/backends/spotify/session_manager.py | 37 ++++++++++++++-------- mopidy/backends/spotify/translator.py | 16 ++++------ 4 files changed, 45 insertions(+), 49 deletions(-) diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py index 67b4acdc..1f90ba51 100644 --- a/mopidy/backends/spotify/actor.py +++ b/mopidy/backends/spotify/actor.py @@ -4,23 +4,20 @@ import logging import pykka -from mopidy import settings from mopidy.backends import base +from mopidy.backends.spotify.library import SpotifyLibraryProvider +from mopidy.backends.spotify.playback import SpotifyPlaybackProvider +from mopidy.backends.spotify.session_manager import SpotifySessionManager +from mopidy.backends.spotify.playlists import SpotifyPlaylistsProvider logger = logging.getLogger('mopidy.backends.spotify') class SpotifyBackend(pykka.ThreadingActor, base.Backend): - # Imports inside methods are to prevent loading of __init__.py to fail on - # missing spotify dependencies. - def __init__(self, config, audio): super(SpotifyBackend, self).__init__() - from .library import SpotifyLibraryProvider - from .playback import SpotifyPlaybackProvider - from .session_manager import SpotifySessionManager - from .playlists import SpotifyPlaylistsProvider + self.config = config self.library = SpotifyLibraryProvider(backend=self) self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) @@ -28,17 +25,8 @@ class SpotifyBackend(pykka.ThreadingActor, base.Backend): self.uri_schemes = ['spotify'] - # Fail early if settings are not present - username = settings.SPOTIFY_USERNAME - password = settings.SPOTIFY_PASSWORD - proxy = settings.SPOTIFY_PROXY_HOST - proxy_username = settings.SPOTIFY_PROXY_USERNAME - proxy_password = settings.SPOTIFY_PROXY_PASSWORD - self.spotify = SpotifySessionManager( - username, password, audio=audio, backend_ref=self.actor_ref, - proxy=proxy, proxy_username=proxy_username, - proxy_password=proxy_password) + config, audio=audio, backend_ref=self.actor_ref) def on_start(self): logger.info('Mopidy uses SPOTIFY(R) CORE') diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 7afde913..b8192dad 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -7,7 +7,6 @@ import urllib import pykka from spotify import Link, SpotifyError -from mopidy import settings from mopidy.backends import base from mopidy.models import Track, SearchResult @@ -62,6 +61,10 @@ class SpotifyTrack(Track): class SpotifyLibraryProvider(base.BaseLibraryProvider): + def __init__(self, *args, **kwargs): + super(SpotifyLibraryProvider).__init__(*args, **kwargs) + self._timeout = self.backend.config['spotify']['timeout'] + def find_exact(self, query=None, uris=None): return self.search(query=query, uris=uris) @@ -116,10 +119,11 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): SpotifyTrack(track=t) for t in playlist if t.availability() == TRACK_AVAILABLE] - def _wait_for_object_to_load( - self, spotify_obj, timeout=settings.SPOTIFY_TIMEOUT): + def _wait_for_object_to_load(self, spotify_obj, timeout=None): # XXX Sleeping to wait for the Spotify object to load is an ugly hack, # but it works. We should look into other solutions for this. + if timeout is None: + timeout = self._timeout wait_until = time.time() + timeout while not spotify_obj.is_loaded(): time.sleep(0.1) @@ -166,7 +170,7 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): future.set(search_result) # Wait always returns None on python 2.6 :/ - self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT) + self.backend.spotify.connected.wait(self._timeout) if not self.backend.spotify.connected.is_set(): logger.debug('Not connected: Spotify search cancelled') return SearchResult(uri='spotify:search') @@ -176,11 +180,10 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): album_count=200, artist_count=200, track_count=200) try: - return future.get(timeout=settings.SPOTIFY_TIMEOUT) + return future.get(timeout=self._timeout) except pykka.Timeout: logger.debug( - 'Timeout: Spotify search did not return in %ds', - settings.SPOTIFY_TIMEOUT) + 'Timeout: Spotify search did not return in %ds', self._timeout) return SearchResult(uri='spotify:search') def _get_all_tracks(self): diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index cedff8d1..1281050b 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -6,7 +6,7 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from mopidy import audio, settings +from mopidy import audio from mopidy.backends.listener import BackendListener from mopidy.utils import process, versioning @@ -23,17 +23,21 @@ BITRATES = {96: 2, 160: 0, 320: 1} class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): - cache_location = settings.SPOTIFY_CACHE_PATH + cache_location = None settings_location = cache_location appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % versioning.get_version() - def __init__(self, username, password, audio, backend_ref, proxy=None, - proxy_username=None, proxy_password=None): + def __init__(self, config, audio, backend_ref): + + self.cache_location = config['spotify']['cache_path'] + PyspotifySessionManager.__init__( - self, username, password, proxy=proxy, - proxy_username=proxy_username, - proxy_password=proxy_password) + self, config['spotify']['username'], config['spotify']['password'], + proxy=config['spotify']['proxy_hostname'], + proxy_username=config['spotify']['proxy_username'], + proxy_password=config['spotify']['proxy_password']) + process.BaseThread.__init__(self) self.name = 'SpotifyThread' @@ -41,6 +45,8 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend = None self.backend_ref = backend_ref + self.bitrate = config['spotify']['bitrate'] + self.connected = threading.Event() self.push_audio_data = True self.buffer_timestamp = 0 @@ -66,10 +72,8 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): if not hasattr(self, 'session'): self.session = session - logger.debug( - 'Preferred Spotify bitrate is %s kbps', - settings.SPOTIFY_BITRATE) - session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) + logger.debug('Preferred Spotify bitrate is %d kbps', self.bitrate) + session.set_preferred_bitrate(BITRATES[self.bitrate]) self.container_manager = SpotifyContainerManager(self) self.playlist_manager = SpotifyPlaylistManager(self) @@ -167,9 +171,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): if not self._initial_data_receive_completed: logger.debug('Still getting data; skipped refresh of playlists') return - playlists = map( - translator.to_mopidy_playlist, self.session.playlist_container()) - playlists.append(translator.to_mopidy_playlist(self.session.starred())) + playlists = [] + for spotify_playlist in self.session.playlist_container(): + playlists.append(translator.to_mopidy_playlist( + spotify_playlist, + bitrate=self.bitrate, username=self.username)) + playlists.append(translator.to_mopidy_playlist( + self.session.starred(), + bitrate=self.bitrate, username=self.username)) playlists = filter(None, playlists) self.backend.playlists.playlists = playlists logger.info('Loaded %d Spotify playlist(s)', len(playlists)) diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index dfd9d99a..1a96e27c 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals from spotify import Link -from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist @@ -39,7 +38,7 @@ def to_mopidy_album(spotify_album): return album_cache[uri] -def to_mopidy_track(spotify_track): +def to_mopidy_track(spotify_track, bitrate=None): if spotify_track is None: return uri = str(Link.from_track(spotify_track, 0)) @@ -60,11 +59,11 @@ def to_mopidy_track(spotify_track): track_no=spotify_track.index(), date=date, length=spotify_track.duration(), - bitrate=settings.SPOTIFY_BITRATE) + bitrate=bitrate) return track_cache[uri] -def to_mopidy_playlist(spotify_playlist): +def to_mopidy_playlist(spotify_playlist, bitrate=None, username=None): if spotify_playlist is None or spotify_playlist.type() != 'playlist': return uri = str(Link.from_playlist(spotify_playlist)) @@ -72,7 +71,7 @@ def to_mopidy_playlist(spotify_playlist): return Playlist(uri=uri, name='[loading...]') name = spotify_playlist.name() tracks = [ - to_mopidy_track(spotify_track) + to_mopidy_track(spotify_track, bitrate=bitrate) for spotify_track in spotify_playlist if not spotify_track.is_local() ] @@ -81,9 +80,6 @@ def to_mopidy_playlist(spotify_playlist): # Tracks in the Starred playlist are in reverse order from the official # client. tracks.reverse() - if spotify_playlist.owner().canonical_name() != settings.SPOTIFY_USERNAME: + if spotify_playlist.owner().canonical_name() != username: name += ' by ' + spotify_playlist.owner().canonical_name() - return Playlist( - uri=uri, - name=name, - tracks=tracks) + return Playlist(uri=uri, name=name, tracks=tracks) From 082650188415e1fd9b72394a01dad5bdc709190e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 3 Apr 2013 21:49:01 +0200 Subject: [PATCH 122/403] main: Make it more obvious what helper to use for exiting --- mopidy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ac45c66b..e97ce5dc 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -60,7 +60,7 @@ def main(): check_old_folders() setup_settings() # Anything that wants to exit after this point must use - # process.exit_proces as actors have been started. + # mopidy.utils.process.exit_process as actors have been started. audio = setup_audio(config) backends = setup_backends(config, extensions, audio) core = setup_core(audio, backends) From b3952503b8ec808055f51f5814d3dfc52157a848 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 15:18:54 +0200 Subject: [PATCH 123/403] spotify: Fix errors after switch to new config system --- mopidy/backends/spotify/library.py | 2 +- mopidy/backends/spotify/session_manager.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index b8192dad..af6d99c7 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -62,7 +62,7 @@ class SpotifyTrack(Track): class SpotifyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): - super(SpotifyLibraryProvider).__init__(*args, **kwargs) + super(SpotifyLibraryProvider, self).__init__(*args, **kwargs) self._timeout = self.backend.config['spotify']['timeout'] def find_exact(self, query=None, uris=None): diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 1281050b..c27d8215 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -24,13 +24,14 @@ BITRATES = {96: 2, 160: 0, 320: 1} class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): cache_location = None - settings_location = cache_location + settings_location = None appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') user_agent = 'Mopidy %s' % versioning.get_version() def __init__(self, config, audio, backend_ref): self.cache_location = config['spotify']['cache_path'] + self.settings_location = config['spotify']['cache_path'] PyspotifySessionManager.__init__( self, config['spotify']['username'], config['spotify']['password'], From c9115aa48014b3b88e51907571700b2d4b5c5238 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 15:23:18 +0200 Subject: [PATCH 124/403] main: Make sure 'extensions' is defined before 'finally' block is executed --- mopidy/__main__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index e97ce5dc..3783d150 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -49,6 +49,7 @@ def main(): config_overrides = options.overrides try: + extensions = [] # Make sure it is defined before the finally block logging_config = load_config(config_files, config_overrides) log.setup_logging( logging_config, options.verbosity_level, options.save_debug_log) @@ -59,6 +60,7 @@ def main(): log.setup_log_levels(config) check_old_folders() setup_settings() + # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors have been started. audio = setup_audio(config) From 775b276169642f8b0e73e6a9ea1057c97d272ede Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 15:40:18 +0200 Subject: [PATCH 125/403] local: Use new config system --- mopidy/backends/local/actor.py | 2 ++ mopidy/backends/local/library.py | 9 ++++---- mopidy/backends/local/playlists.py | 24 ++++++++++--------- tests/backends/base/playlists.py | 17 ++------------ tests/backends/local/events_test.py | 19 ++++++++------- tests/backends/local/library_test.py | 20 ++++++---------- tests/backends/local/playback_test.py | 17 ++++++-------- tests/backends/local/playlists_test.py | 32 +++++++++++++++++--------- tests/backends/local/tracklist_test.py | 17 ++++++-------- 9 files changed, 73 insertions(+), 84 deletions(-) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index abad75ca..a1655dd9 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -16,6 +16,8 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): def __init__(self, config, audio): super(LocalBackend, self).__init__() + self.config = config + self.library = LocalLibraryProvider(backend=self) self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index f2a1a520..2b1c93f7 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals import logging -from mopidy import settings from mopidy.backends import base from mopidy.models import Album, SearchResult @@ -15,15 +14,17 @@ class LocalLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(LocalLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} + self._music_path = self.backend.config['local']['music_path'] + self._playlist_path = self.backend.config['local']['playlist_path'] + self._tag_cache_file = self.backend.config['local']['tag_cache_file'] self.refresh() def refresh(self, uri=None): - tracks = parse_mpd_tag_cache( - settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) + tracks = parse_mpd_tag_cache(self._tag_cache_file, self._music_path) logger.info( 'Loading tracks from %s using %s', - settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) + self._music_path, self._tag_cache_file) for track in tracks: self._uri_mapping[track.uri] = track diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 53f7aaae..063d044d 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -5,7 +5,6 @@ import logging import os import shutil -from mopidy import settings from mopidy.backends import base, listener from mopidy.models import Playlist from mopidy.utils import formatting, path @@ -19,7 +18,8 @@ logger = logging.getLogger('mopidy.backends.local') class LocalPlaylistsProvider(base.BasePlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) - self._path = settings.LOCAL_PLAYLIST_PATH + self._music_path = self.backend.config['local']['music_path'] + self._playlist_path = self.backend.config['local']['playlist_path'] self.refresh() def create(self, name): @@ -42,16 +42,16 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): return playlist def refresh(self): - logger.info('Loading playlists from %s', self._path) + logger.info('Loading playlists from %s', self._playlist_path) playlists = [] - for m3u in glob.glob(os.path.join(self._path, '*.m3u')): + for m3u in glob.glob(os.path.join(self._playlist_path, '*.m3u')): uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] tracks = [] - for track_uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): + for track_uri in parse_m3u(m3u, self._music_path): try: # TODO We must use core.library.lookup() to support tracks # from other backends @@ -86,13 +86,13 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): def _get_m3u_path(self, name): name = formatting.slugify(name) - file_path = os.path.join(self._path, name + '.m3u') - path.check_file_path_is_inside_base_dir(file_path, self._path) + file_path = os.path.join(self._playlist_path, name + '.m3u') + path.check_file_path_is_inside_base_dir(file_path, self._playlist_path) return file_path def _save_m3u(self, playlist): file_path = path.uri_to_path(playlist.uri) - path.check_file_path_is_inside_base_dir(file_path, self._path) + path.check_file_path_is_inside_base_dir(file_path, self._playlist_path) with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): @@ -103,16 +103,18 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): def _delete_m3u(self, uri): file_path = path.uri_to_path(uri) - path.check_file_path_is_inside_base_dir(file_path, self._path) + path.check_file_path_is_inside_base_dir(file_path, self._playlist_path) if os.path.exists(file_path): os.remove(file_path) def _rename_m3u(self, playlist): src_file_path = path.uri_to_path(playlist.uri) - path.check_file_path_is_inside_base_dir(src_file_path, self._path) + path.check_file_path_is_inside_base_dir( + src_file_path, self._playlist_path) dst_file_path = self._get_m3u_path(playlist.name) - path.check_file_path_is_inside_base_dir(dst_file_path, self._path) + path.check_file_path_is_inside_base_dir( + dst_file_path, self._playlist_path) shutil.move(src_file_path, dst_file_path) diff --git a/tests/backends/base/playlists.py b/tests/backends/base/playlists.py index 00e32a6f..ad5648f9 100644 --- a/tests/backends/base/playlists.py +++ b/tests/backends/base/playlists.py @@ -1,25 +1,17 @@ from __future__ import unicode_literals -import os -import shutil -import tempfile - import pykka -from mopidy import audio, core, settings +from mopidy import audio, core from mopidy.models import Playlist -from tests import unittest, path_to_data_dir +from tests import unittest class PlaylistsControllerTest(object): config = {} def setUp(self): - settings.LOCAL_PLAYLIST_PATH = tempfile.mkdtemp() - settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') - settings.LOCAL_MUSIC_PATH = path_to_data_dir('') - self.audio = audio.DummyAudio.start().proxy() self.backend = self.backend_class.start( config=self.config, audio=self.audio).proxy() @@ -28,11 +20,6 @@ class PlaylistsControllerTest(object): def tearDown(self): pykka.ActorRegistry.stop_all() - if os.path.exists(settings.LOCAL_PLAYLIST_PATH): - shutil.rmtree(settings.LOCAL_PLAYLIST_PATH) - - settings.runtime.clear() - def test_create_returns_playlist_with_name_set(self): playlist = self.core.playlists.create('test') self.assertEqual(playlist.name, 'test') diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 5ccf0886..83d77a2f 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -1,4 +1,5 @@ -from mopidy import settings +from __future__ import unicode_literals + from mopidy.backends.local import actor from tests import unittest, path_to_data_dir @@ -7,12 +8,10 @@ from tests.backends.base import events class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): backend_class = actor.LocalBackend - # TODO: setup config - - def setUp(self): - settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') - super(LocalBackendEventsTest, self).setUp() - - def tearDown(self): - super(LocalBackendEventsTest, self).tearDown() - settings.runtime.clear() + config = { + 'local': { + 'music_path': path_to_data_dir(''), + 'playlist_path': '', + 'tag_cache_file': path_to_data_dir('empty_tag_cache'), + } + } diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index ca90e40b..e582c788 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -from mopidy import settings from mopidy.backends.local import actor from tests import unittest, path_to_data_dir @@ -9,15 +8,10 @@ from tests.backends.base.library import LibraryControllerTest class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = actor.LocalBackend - # TODO: setup config - - def setUp(self): - settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') - settings.LOCAL_MUSIC_PATH = path_to_data_dir('') - - super(LocalLibraryControllerTest, self).setUp() - - def tearDown(self): - settings.runtime.clear() - - super(LocalLibraryControllerTest, self).tearDown() + config = { + 'local': { + 'music_path': path_to_data_dir(''), + 'playlist_path': '', + 'tag_cache_file': path_to_data_dir('library_tag_cache'), + } + } diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index e9b3954c..4c304590 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -from mopidy import settings from mopidy.backends.local import actor from mopidy.core import PlaybackState from mopidy.models import Track @@ -13,17 +12,15 @@ from tests.backends.local import generate_song class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = actor.LocalBackend + config = { + 'local': { + 'music_path': path_to_data_dir(''), + 'playlist_path': '', + 'tag_cache_file': path_to_data_dir('empty_tag_cache'), + } + } tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] - # TODO: setup config - - def setUp(self): - settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') - super(LocalPlaybackControllerTest, self).setUp() - - def tearDown(self): - super(LocalPlaybackControllerTest, self).tearDown() - settings.runtime.clear() def add_track(self, path): uri = path_to_uri(path_to_data_dir(path)) diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 3dbc3a2a..8528adf4 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals import os +import shutil +import tempfile -from mopidy import settings from mopidy.backends.local import actor from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -17,25 +18,34 @@ class LocalPlaylistsControllerTest( PlaylistsControllerTest, unittest.TestCase): backend_class = actor.LocalBackend - # TODO: setup config + config = { + 'local': { + 'music_path': path_to_data_dir(''), + 'tag_cache_file': path_to_data_dir('library_tag_cache'), + } + } def setUp(self): - settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') + self.config['local']['playlist_path'] = tempfile.mkdtemp() + self.playlist_path = self.config['local']['playlist_path'] + super(LocalPlaylistsControllerTest, self).setUp() def tearDown(self): super(LocalPlaylistsControllerTest, self).tearDown() - settings.runtime.clear() + + if os.path.exists(self.playlist_path): + shutil.rmtree(self.playlist_path) def test_created_playlist_is_persisted(self): - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + path = os.path.join(self.playlist_path, 'test.m3u') self.assertFalse(os.path.exists(path)) self.core.playlists.create('test') self.assertTrue(os.path.exists(path)) def test_create_slugifies_playlist_name(self): - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + path = os.path.join(self.playlist_path, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test FOO baR') @@ -43,7 +53,7 @@ class LocalPlaylistsControllerTest( self.assertTrue(os.path.exists(path)) def test_create_slugifies_names_which_tries_to_change_directory(self): - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + path = os.path.join(self.playlist_path, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('../../test FOO baR') @@ -51,8 +61,8 @@ class LocalPlaylistsControllerTest( self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') - path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2-foo-bar.m3u') + path1 = os.path.join(self.playlist_path, 'test1.m3u') + path2 = os.path.join(self.playlist_path, 'test2-foo-bar.m3u') playlist = self.core.playlists.create('test1') @@ -67,7 +77,7 @@ class LocalPlaylistsControllerTest( self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + path = os.path.join(self.playlist_path, 'test.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') @@ -90,7 +100,7 @@ class LocalPlaylistsControllerTest( self.assertEqual(track_path, contents.strip()) def test_playlists_are_loaded_at_startup(self): - playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + playlist_path = os.path.join(self.playlist_path, 'test.m3u') track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) playlist = self.core.playlists.create('test') diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 24c400fa..3fc8a0be 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -from mopidy import settings from mopidy.backends.local import actor from mopidy.models import Track @@ -11,14 +10,12 @@ from tests.backends.local import generate_song class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): backend_class = actor.LocalBackend + config = { + 'local': { + 'music_path': path_to_data_dir(''), + 'playlist_path': '', + 'tag_cache_file': path_to_data_dir('empty_tag_cache'), + } + } tracks = [ Track(uri=generate_song(i), length=4464) for i in range(1, 4)] - # TODO: setup config - - def setUp(self): - settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') - super(LocalTracklistControllerTest, self).setUp() - - def tearDown(self): - super(LocalTracklistControllerTest, self).tearDown() - settings.runtime.clear() From 5894e7afe03d92bee416040f2b1586126ee87674 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 15:41:59 +0200 Subject: [PATCH 126/403] stream: Use new config system --- mopidy/backends/stream/__init__.py | 5 ++--- mopidy/backends/stream/actor.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 9a393bed..17b85d33 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -23,9 +23,8 @@ protocols = __doc__ = """A backend for playing music for streaming music. -This backend will handle streaming of URIs in -:attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are -installed. +This backend will handle streaming of URIs matching the ``stream/protocols`` +config value, assuming the needed GStreamer plugins are installed. **Issues** diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index d6eb31d3..86df447d 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -5,7 +5,7 @@ import urlparse import pykka -from mopidy import audio as audio_lib, settings +from mopidy import audio as audio_lib from mopidy.backends import base from mopidy.models import Track @@ -21,7 +21,7 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): self.playlists = None self.uri_schemes = audio_lib.supported_uri_schemes( - settings.STREAM_PROTOCOLS) + config['stream']['protocols']) # TODO: Should we consider letting lookup know how to expand common playlist From 85c2b9b1ebb7a425e2edefd461b888b4f6f7b1e0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 15:44:37 +0200 Subject: [PATCH 127/403] lastfm: Use new config system --- mopidy/frontends/lastfm/actor.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/lastfm/actor.py b/mopidy/frontends/lastfm/actor.py index 1e157d4f..0e5b9c6e 100644 --- a/mopidy/frontends/lastfm/actor.py +++ b/mopidy/frontends/lastfm/actor.py @@ -5,7 +5,7 @@ import time import pykka -from mopidy import exceptions, settings +from mopidy import exceptions from mopidy.core import CoreListener try: @@ -22,21 +22,17 @@ API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' class LastfmFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(LastfmFrontend, self).__init__() + self.config = config self.lastfm = None self.last_start_time = None def on_start(self): try: - username = settings.LASTFM_USERNAME - password_hash = pylast.md5(settings.LASTFM_PASSWORD) self.lastfm = pylast.LastFMNetwork( api_key=API_KEY, api_secret=API_SECRET, - username=username, password_hash=password_hash) + username=self.config['lastfm']['username'], + password_hash=pylast.md5(self.config['lastfm']['password'])) logger.info('Connected to Last.fm') - except exceptions.SettingsError as e: - logger.info('Last.fm scrobbler not started') - logger.debug('Last.fm settings error: %s', e) - self.stop() except (pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError) as e: logger.error('Error during Last.fm setup: %s', e) From bcc7c3a890499cddb7d687559a0942761b22c2e7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 15:48:24 +0200 Subject: [PATCH 128/403] mpris: Use new config system --- mopidy/frontends/mpris/__init__.py | 4 ++-- mopidy/frontends/mpris/actor.py | 7 ++++--- mopidy/frontends/mpris/objects.py | 7 ++++--- tests/frontends/mpris/player_interface_test.py | 2 +- .../frontends/mpris/playlists_interface_test.py | 2 +- tests/frontends/mpris/root_interface_test.py | 16 ++++++++-------- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 79806c47..4dde2e20 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -32,8 +32,8 @@ An example of an MPRIS client is the `Ubuntu Sound Menu Ubuntu Sound Menu. The package is named ``python-indicate`` in Ubuntu/Debian. -- An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install-desktop-file` for +- An ``.desktop`` file for Mopidy installed at the path set in the + ``mpris/desktop_file`` config value. See :ref:`install-desktop-file` for details. **Default config** diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 11f87922..92805bd3 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -4,7 +4,6 @@ import logging import pykka -from mopidy import settings from mopidy.core import CoreListener from mopidy.frontends.mpris import objects @@ -20,13 +19,14 @@ except ImportError as import_error: class MprisFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(MprisFrontend, self).__init__() + self.config = config self.core = core self.indicate_server = None self.mpris_object = None def on_start(self): try: - self.mpris_object = objects.MprisObject(self.core) + self.mpris_object = objects.MprisObject(self.config, self.core) self._send_startup_notification() except Exception as e: logger.error('MPRIS frontend setup failed (%s)', e) @@ -53,7 +53,8 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): logger.debug('Sending startup notification...') self.indicate_server = indicate.Server() self.indicate_server.set_type('music.mopidy') - self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) + self.indicate_server.set_desktop_file( + self.config['mpris']['desktop_file']) self.indicate_server.show() logger.debug('Startup notification sent') diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 04a72676..696e39bd 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -13,7 +13,6 @@ except ImportError as import_error: from mopidy.exceptions import OptionalDependencyError raise OptionalDependencyError(import_error) -from mopidy import settings from mopidy.core import PlaybackState from mopidy.utils.process import exit_process @@ -36,7 +35,8 @@ class MprisObject(dbus.service.Object): properties = None - def __init__(self, core): + def __init__(self, config, core): + self.config = config self.core = core self.properties = { ROOT_IFACE: self._get_root_iface_properties(), @@ -175,7 +175,8 @@ class MprisObject(dbus.service.Object): ### Root interface properties def get_DesktopEntry(self): - return os.path.splitext(os.path.basename(settings.DESKTOP_FILE))[0] + return os.path.splitext(os.path.basename( + self.config['mpris']['desktop_file']))[0] def get_SupportedUriSchemes(self): return dbus.Array(self.core.uri_schemes.get(), signature='s') diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index ec4a17a9..e1e13084 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -28,7 +28,7 @@ class PlayerInterfaceTest(unittest.TestCase): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(core=self.core) + self.mpris = objects.MprisObject(config={}, core=self.core) def tearDown(self): pykka.ActorRegistry.stop_all() diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py index 745a858c..67f9e9be 100644 --- a/tests/frontends/mpris/playlists_interface_test.py +++ b/tests/frontends/mpris/playlists_interface_test.py @@ -25,7 +25,7 @@ class PlayerInterfaceTest(unittest.TestCase): objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(core=self.core) + self.mpris = objects.MprisObject(config={}, core=self.core) foo = self.core.playlists.create('foo').get() foo = foo.copy(last_modified=datetime.datetime(2012, 3, 1, 6, 0, 0)) diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 36d689a2..806b2162 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -5,7 +5,7 @@ import sys import mock import pykka -from mopidy import core, exceptions, settings +from mopidy import core, exceptions from mopidy.backends import dummy try: @@ -19,11 +19,17 @@ from tests import unittest @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') class RootInterfaceTest(unittest.TestCase): def setUp(self): + config = { + 'mpris': { + 'desktop_file': '/tmp/foo.desktop', + } + } + objects.exit_process = mock.Mock() objects.MprisObject._connect_to_dbus = mock.Mock() self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - self.mpris = objects.MprisObject(core=self.core) + self.mpris = objects.MprisObject(config=config, core=self.core) def tearDown(self): pykka.ActorRegistry.stop_all() @@ -66,15 +72,9 @@ class RootInterfaceTest(unittest.TestCase): result = self.mpris.Get(objects.ROOT_IFACE, 'Identity') self.assertEquals(result, 'Mopidy') - def test_desktop_entry_is_mopidy(self): - result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') - self.assertEquals(result, 'mopidy') - def test_desktop_entry_is_based_on_DESKTOP_FILE_setting(self): - settings.runtime['DESKTOP_FILE'] = '/tmp/foo.desktop' result = self.mpris.Get(objects.ROOT_IFACE, 'DesktopEntry') self.assertEquals(result, 'foo') - settings.runtime.clear() def test_supported_uri_schemes_includes_backend_uri_schemes(self): result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedUriSchemes') From 8a8a78e025cf30bba4460d714668a3148d6e9940 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 15:52:38 +0200 Subject: [PATCH 129/403] http: Use new config system --- mopidy/frontends/http/__init__.py | 28 ++++++++++++++-------------- mopidy/frontends/http/actor.py | 11 ++++++----- tests/frontends/http/events_test.py | 11 ++++++++++- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index d588a376..1b93fb8a 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -61,19 +61,19 @@ Setup The frontend is enabled by default if all dependencies are available. -When it is enabled it starts a web server at the port specified by -:attr:`mopidy.settings.HTTP_SERVER_PORT`. +When it is enabled it starts a web server at the port specified by the +``http/port`` config value. .. warning:: Security As a simple security measure, the web server is by default only available - from localhost. To make it available from other computers, change - :attr:`mopidy.settings.HTTP_SERVER_HOSTNAME`. Before you do so, note that - the HTTP frontend does not feature any form of user authentication or - authorization. Anyone able to access the web server can use the full core - API of Mopidy. Thus, you probably only want to make the web server - available from your local network or place it behind a web proxy which - takes care or user authentication. You have been warned. + from localhost. To make it available from other computers, change the + ``http/hostname`` config value. Before you do so, note that the HTTP + frontend does not feature any form of user authentication or authorization. + Anyone able to access the web server can use the full core API of Mopidy. + Thus, you probably only want to make the web server available from your + local network or place it behind a web proxy which takes care or user + authentication. You have been warned. Using a web based Mopidy client @@ -81,10 +81,11 @@ Using a web based Mopidy client The web server can also host any static files, for example the HTML, CSS, JavaScript, and images needed for a web based Mopidy client. To host static -files, change :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point to the -root directory of your web client, e.g.:: +files, change the ``http/static_dir`` to point to the root directory of your +web client, e.g.:: - HTTP_SERVER_STATIC_DIR = u'/home/alice/dev/the-client' + [http] + static_dir = /home/alice/dev/the-client If the directory includes a file named ``index.html``, it will be served on the root of Mopidy's web server. @@ -405,8 +406,7 @@ Example to get started with 2. Create an empty directory for your web client. -3. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point - to your new directory. +3. Change the ``http/static_dir`` config value to point to your new directory. 4. Start/restart Mopidy. diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 54085471..149cbc7f 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -6,7 +6,7 @@ import os import pykka -from mopidy import exceptions, models, settings +from mopidy import exceptions, models from mopidy.core import CoreListener try: @@ -25,6 +25,7 @@ logger = logging.getLogger('mopidy.frontends.http') class HttpFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(HttpFrontend, self).__init__() + self.config = config self.core = core self._setup_server() self._setup_websocket_plugin() @@ -35,8 +36,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): cherrypy.config.update({ 'engine.autoreload_on': False, 'server.socket_host': ( - settings.HTTP_SERVER_HOSTNAME.encode('utf-8')), - 'server.socket_port': settings.HTTP_SERVER_PORT, + self.config['http']['hostname'].encode('utf-8')), + 'server.socket_port': self.config['http']['port'], }) def _setup_websocket_plugin(self): @@ -48,8 +49,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): root.mopidy = MopidyResource() root.mopidy.ws = ws.WebSocketResource(self.core) - if settings.HTTP_SERVER_STATIC_DIR: - static_dir = settings.HTTP_SERVER_STATIC_DIR + if self.config['http']['static_dir']: + static_dir = self.config['http']['static_dir'] else: static_dir = os.path.join(os.path.dirname(__file__), 'data') logger.debug('HTTP server will serve "%s" at /', static_dir) diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index 7661ac6e..c334eefa 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import json try: @@ -24,7 +26,14 @@ from tests import unittest @mock.patch('cherrypy.engine.publish') class HttpEventsTest(unittest.TestCase): def setUp(self): - self.http = actor.HttpFrontend(config=None, core=mock.Mock()) + config = { + 'http': { + 'hostname': '127.0.0.1', + 'port': 6680, + 'static_dir': None, + } + } + self.http = actor.HttpFrontend(config=config, core=mock.Mock()) def test_track_playback_paused_is_broadcasted(self, publish): publish.reset_mock() From c452f0115a386db2e8aa89d26f0a6b877241ba93 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 5 Apr 2013 16:07:00 +0200 Subject: [PATCH 130/403] mpd: Use new config system --- mopidy/frontends/mpd/actor.py | 15 +++--- mopidy/frontends/mpd/dispatcher.py | 15 ++++-- mopidy/frontends/mpd/protocol/connection.py | 3 +- mopidy/frontends/mpd/session.py | 5 +- mopidy/frontends/mpd/translator.py | 21 ++++---- mopidy/scanner.py | 4 +- tests/frontends/mpd/dispatcher_test.py | 7 ++- tests/frontends/mpd/protocol/__init__.py | 10 +++- .../mpd/protocol/authentication_test.py | 41 ++++++-------- .../frontends/mpd/protocol/connection_test.py | 17 ------ .../frontends/mpd/protocol/reflection_test.py | 53 ++++++++++--------- tests/frontends/mpd/translator_test.py | 46 +++++++--------- 12 files changed, 117 insertions(+), 120 deletions(-) diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index e288c24e..45ed753e 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -5,7 +5,6 @@ import sys import pykka -from mopidy import settings from mopidy.core import CoreListener from mopidy.frontends.mpd import session from mopidy.utils import encoding, network, process @@ -16,17 +15,21 @@ logger = logging.getLogger('mopidy.frontends.mpd') class MpdFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(MpdFrontend, self).__init__() - hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) - port = settings.MPD_SERVER_PORT + hostname = network.format_hostname(config['mpd']['hostname']) + port = config['mpd']['port'] # NOTE kwargs dict keys must be bytestrings to work on Python < 2.6.5 # See https://github.com/mopidy/mopidy/issues/302 for details. try: network.Server( hostname, port, - protocol=session.MpdSession, protocol_kwargs={b'core': core}, - max_connections=settings.MPD_SERVER_MAX_CONNECTIONS, - timeout=settings.MPD_SERVER_CONNECTION_TIMEOUT) + protocol=session.MpdSession, + protocol_kwargs={ + b'config': config, + b'core': core, + }, + max_connections=config['mpd']['max_connections'], + timeout=config['mpd']['connection_timeout']) except IOError as error: logger.error( 'MPD server startup failed: %s', diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 4f0001ac..dc665abc 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -5,7 +5,6 @@ import re import pykka -from mopidy import settings from mopidy.frontends.mpd import exceptions, protocol logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') @@ -22,13 +21,15 @@ class MpdDispatcher(object): _noidle = re.compile(r'^noidle$') - def __init__(self, session=None, core=None): + def __init__(self, session=None, config=None, core=None): + self.config = config self.authenticated = False self.command_list_receiving = False self.command_list_ok = False self.command_list = [] self.command_list_index = None - self.context = MpdContext(self, session=session, core=core) + self.context = MpdContext( + self, session=session, config=config, core=core) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -82,7 +83,7 @@ class MpdDispatcher(object): def _authenticate_filter(self, request, response, filter_chain): if self.authenticated: return self._call_next_filter(request, response, filter_chain) - elif settings.MPD_SERVER_PASSWORD is None: + elif self.config['mpd']['password'] is None: self.authenticated = True return self._call_next_filter(request, response, filter_chain) else: @@ -223,6 +224,9 @@ class MpdContext(object): #: The current :class:`mopidy.frontends.mpd.MpdSession`. session = None + #: The Mopidy configuration. + config = None + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. core = None @@ -232,9 +236,10 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - def __init__(self, dispatcher, session=None, core=None): + def __init__(self, dispatcher, session=None, config=None, core=None): self.dispatcher = dispatcher self.session = session + self.config = config self.core = core self.events = set() self.subscriptions = set() diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index f7898d21..44696705 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -from mopidy import settings from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import ( MpdPasswordError, MpdPermissionError) @@ -40,7 +39,7 @@ def password_(context, password): This is used for authentication with the server. ``PASSWORD`` is simply the plaintext password. """ - if password == settings.MPD_SERVER_PASSWORD: + if password == context.config['mpd']['password']: context.dispatcher.authenticated = True else: raise MpdPasswordError('incorrect password', command='password') diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 8a5deecd..14173308 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -18,9 +18,10 @@ class MpdSession(network.LineProtocol): encoding = protocol.ENCODING delimiter = r'\r?\n' - def __init__(self, connection, core=None): + def __init__(self, connection, config=None, core=None): super(MpdSession, self).__init__(connection) - self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) + self.dispatcher = dispatcher.MpdDispatcher( + session=self, config=config, core=core) def on_start(self): logger.info('New MPD connection from [%s]:%s', self.host, self.port) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 15ca181d..d820b0e0 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -5,7 +5,6 @@ import re import shlex import urllib -from mopidy import settings from mopidy.frontends.mpd import protocol from mopidy.frontends.mpd.exceptions import MpdArgError from mopidy.models import TlTrack @@ -216,12 +215,14 @@ def query_from_mpd_search_format(mpd_query): return query -def tracks_to_tag_cache_format(tracks): +def tracks_to_tag_cache_format(tracks, music_path): """ Format list of tracks for output to MPD tag cache :param tracks: the tracks :type tracks: list of :class:`mopidy.models.Track` + :param music_path: the path to the music dir + :type music_path: string :rtype: list of lists of two-tuples """ result = [ @@ -231,14 +232,15 @@ def tracks_to_tag_cache_format(tracks): ('info_end',) ] tracks.sort(key=lambda t: t.uri) - _add_to_tag_cache(result, *tracks_to_directory_tree(tracks)) + folders, files = tracks_to_directory_tree(tracks, music_path) + _add_to_tag_cache(result, folders, files, music_path) return result -def _add_to_tag_cache(result, folders, files): - base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') +def _add_to_tag_cache(result, folders, files, music_path): + base_path = music_path.encode('utf-8') - for path, entry in folders.items(): + for path, (entry_folders, entry_files) in folders.items(): try: text_path = path.decode('utf-8') except UnicodeDecodeError: @@ -247,7 +249,7 @@ def _add_to_tag_cache(result, folders, files): result.append(('directory', text_path)) result.append(('mtime', get_mtime(os.path.join(base_path, path)))) result.append(('begin', name)) - _add_to_tag_cache(result, *entry) + _add_to_tag_cache(result, entry_folders, entry_files, music_path) result.append(('end', name)) result.append(('songList begin',)) @@ -273,7 +275,7 @@ def _add_to_tag_cache(result, folders, files): result.append(('songList end',)) -def tracks_to_directory_tree(tracks): +def tracks_to_directory_tree(tracks, music_path): directories = ({}, []) for track in tracks: @@ -282,8 +284,7 @@ def tracks_to_directory_tree(tracks): absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) relative_track_dir_path = re.sub( - '^' + re.escape(settings.LOCAL_MUSIC_PATH), b'', - absolute_track_dir_path) + '^' + re.escape(music_path), b'', absolute_track_dir_path) for part in split_path(relative_track_dir_path): path = os.path.join(path, part) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 4b057774..8bc57349 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -42,6 +42,7 @@ from mopidy.utils import log, path, versioning def main(): options = parse_options() + config = {} # TODO Read config from new config system log.setup_root_logger() log.setup_console_logging(options.verbosity_level) @@ -67,7 +68,8 @@ def main(): logging.info('Done scanning; writing tag cache...') - for row in mpd_translator.tracks_to_tag_cache_format(tracks): + for row in mpd_translator.tracks_to_tag_cache_format( + tracks, config['mpd']['music_path']): if len(row) == 1: print ('%s' % row).encode('utf-8') else: diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 3c32cd32..35e18c3b 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -13,9 +13,14 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): + config = { + 'mpd': { + 'password': None, + } + } self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - self.dispatcher = MpdDispatcher() + self.dispatcher = MpdDispatcher(config=config) def tearDown(self): pykka.ActorRegistry.stop_all() diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 9d24c3fa..e62141ee 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -23,12 +23,20 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): + def get_config(self): + return { + 'mpd': { + 'password': None, + } + } + def setUp(self): self.backend = dummy.create_dummy_backend_proxy() self.core = core.Core.start(backends=[self.backend]).proxy() self.connection = MockConnection() - self.session = session.MpdSession(self.connection, core=self.core) + self.session = session.MpdSession( + self.connection, config=self.get_config(), core=self.core) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py index 26b03f45..2597ddef 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -1,63 +1,56 @@ from __future__ import unicode_literals -from mopidy import settings - from tests.frontends.mpd import protocol -class AuthenticationTest(protocol.BaseTestCase): - def test_authentication_with_valid_password_is_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' +class AuthenticationActiveTest(protocol.BaseTestCase): + def get_config(self): + config = super(AuthenticationActiveTest, self).get_config() + config['mpd']['password'] = 'topsecret' + return config + def test_authentication_with_valid_password_is_accepted(self): self.sendRequest('password "topsecret"') self.assertTrue(self.dispatcher.authenticated) self.assertInResponse('OK') def test_authentication_with_invalid_password_is_not_accepted(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest('password "secret"') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse('ACK [3@0] {password} incorrect password') - def test_authentication_with_anything_when_password_check_turned_off(self): - settings.MPD_SERVER_PASSWORD = None - - self.sendRequest('any request at all') - self.assertTrue(self.dispatcher.authenticated) - self.assertEqualResponse('ACK [5@0] {} unknown command "any"') - def test_anything_when_not_authenticated_should_fail(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest('any request at all') self.assertFalse(self.dispatcher.authenticated) self.assertEqualResponse( u'ACK [4@0] {any} you don\'t have permission for "any"') def test_close_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest('close') self.assertFalse(self.dispatcher.authenticated) def test_commands_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest('commands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_notcommands_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest('notcommands') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') def test_ping_is_allowed_without_authentication(self): - settings.MPD_SERVER_PASSWORD = u'topsecret' - self.sendRequest('ping') self.assertFalse(self.dispatcher.authenticated) self.assertInResponse('OK') + + +class AuthenticationInactiveTest(protocol.BaseTestCase): + def test_authentication_with_anything_when_password_check_turned_off(self): + self.sendRequest('any request at all') + self.assertTrue(self.dispatcher.authenticated) + self.assertEqualResponse('ACK [5@0] {} unknown command "any"') + + def test_any_password_is_not_accepted_when_password_check_turned_off(self): + self.sendRequest('password "secret"') + self.assertEqualResponse('ACK [3@0] {password} incorrect password') diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/frontends/mpd/protocol/connection_test.py index 840ce48f..01deb7a7 100644 --- a/tests/frontends/mpd/protocol/connection_test.py +++ b/tests/frontends/mpd/protocol/connection_test.py @@ -2,8 +2,6 @@ from __future__ import unicode_literals from mock import patch -from mopidy import settings - from tests.frontends.mpd import protocol @@ -26,21 +24,6 @@ class ConnectionHandlerTest(protocol.BaseTestCase): self.assertEqualResponse( 'ACK [4@0] {kill} you don\'t have permission for "kill"') - def test_valid_password_is_accepted(self): - settings.MPD_SERVER_PASSWORD = 'topsecret' - self.sendRequest('password "topsecret"') - self.assertEqualResponse('OK') - - def test_invalid_password_is_not_accepted(self): - settings.MPD_SERVER_PASSWORD = 'topsecret' - self.sendRequest('password "secret"') - self.assertEqualResponse('ACK [3@0] {password} incorrect password') - - def test_any_password_is_not_accepted_when_password_check_turned_off(self): - settings.MPD_SERVER_PASSWORD = None - self.sendRequest('password "secret"') - self.assertEqualResponse('ACK [3@0] {password} incorrect password') - def test_ping(self): self.sendRequest('ping') self.assertEqualResponse('OK') diff --git a/tests/frontends/mpd/protocol/reflection_test.py b/tests/frontends/mpd/protocol/reflection_test.py index f2720473..16f4579f 100644 --- a/tests/frontends/mpd/protocol/reflection_test.py +++ b/tests/frontends/mpd/protocol/reflection_test.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -from mopidy import settings - from tests.frontends.mpd import protocol @@ -29,19 +27,6 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('command: sticker') self.assertInResponse('OK') - def test_commands_show_less_if_auth_required_and_not_authed(self): - settings.MPD_SERVER_PASSWORD = u'secret' - self.sendRequest('commands') - # Not requiring auth - self.assertInResponse('command: close') - self.assertInResponse('command: commands') - self.assertInResponse('command: notcommands') - self.assertInResponse('command: password') - self.assertInResponse('command: ping') - # Requiring auth - self.assertNotInResponse('command: play') - self.assertNotInResponse('command: status') - def test_decoders(self): self.sendRequest('decoders') self.assertInResponse('OK') @@ -53,8 +38,35 @@ class ReflectionHandlerTest(protocol.BaseTestCase): self.assertInResponse('command: kill') self.assertInResponse('OK') + def test_tagtypes(self): + self.sendRequest('tagtypes') + self.assertInResponse('OK') + + def test_urlhandlers(self): + self.sendRequest('urlhandlers') + self.assertInResponse('OK') + self.assertInResponse('handler: dummy') + + +class ReflectionWhenNotAuthedTest(protocol.BaseTestCase): + def get_config(self): + config = super(ReflectionWhenNotAuthedTest, self).get_config() + config['mpd']['password'] = 'topsecret' + return config + + def test_commands_show_less_if_auth_required_and_not_authed(self): + self.sendRequest('commands') + # Not requiring auth + self.assertInResponse('command: close') + self.assertInResponse('command: commands') + self.assertInResponse('command: notcommands') + self.assertInResponse('command: password') + self.assertInResponse('command: ping') + # Requiring auth + self.assertNotInResponse('command: play') + self.assertNotInResponse('command: status') + def test_notcommands_returns_more_if_auth_required_and_not_authed(self): - settings.MPD_SERVER_PASSWORD = u'secret' self.sendRequest('notcommands') # Not requiring auth self.assertNotInResponse('command: close') @@ -65,12 +77,3 @@ class ReflectionHandlerTest(protocol.BaseTestCase): # Requiring auth self.assertInResponse('command: play') self.assertInResponse('command: status') - - def test_tagtypes(self): - self.sendRequest('tagtypes') - self.assertInResponse('OK') - - def test_urlhandlers(self): - self.sendRequest('urlhandlers') - self.assertInResponse('OK') - self.assertInResponse('handler: dummy') diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py index 088ae137..5df65c5c 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import datetime import os -from mopidy import settings from mopidy.utils.path import mtime, uri_to_path from mopidy.frontends.mpd import translator, protocol from mopidy.models import Album, Artist, TlTrack, Playlist, Track @@ -24,11 +23,10 @@ class TrackMpdFormatTest(unittest.TestCase): ) def setUp(self): - settings.LOCAL_MUSIC_PATH = '/dir/subdir' + self.music_path = '/dir/subdir' mtime.set_fake_time(1234567) def tearDown(self): - settings.runtime.clear() mtime.undo_fake() def test_track_to_mpd_format_for_empty_track(self): @@ -137,15 +135,14 @@ class QueryFromMpdListFormatTest(unittest.TestCase): class TracksToTagCacheFormatTest(unittest.TestCase): def setUp(self): - settings.LOCAL_MUSIC_PATH = '/dir/subdir' + self.music_path = '/dir/subdir' mtime.set_fake_time(1234567) def tearDown(self): - settings.runtime.clear() mtime.undo_fake() def translate(self, track): - base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') + base_path = self.music_path.encode('utf-8') result = dict(translator.track_to_mpd_format(track)) result['file'] = uri_to_path(result['file'])[len(base_path) + 1:] result['key'] = os.path.basename(result['file']) @@ -177,11 +174,11 @@ class TracksToTagCacheFormatTest(unittest.TestCase): self.fail("Couldn't find end %s in result" % directory) def test_empty_tag_cache_has_header(self): - result = translator.tracks_to_tag_cache_format([]) + result = translator.tracks_to_tag_cache_format([], self.music_path) result = self.consume_headers(result) def test_empty_tag_cache_has_song_list(self): - result = translator.tracks_to_tag_cache_format([]) + result = translator.tracks_to_tag_cache_format([], self.music_path) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -190,12 +187,12 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_has_header(self): track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track]) + result = translator.tracks_to_tag_cache_format([track], self.music_path) result = self.consume_headers(result) def test_tag_cache_has_song_list(self): track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track]) + result = translator.tracks_to_tag_cache_format([track], self.music_path) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -205,7 +202,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_has_formated_track(self): track = Track(uri='file:///dir/subdir/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track]) + result = translator.tracks_to_tag_cache_format([track], self.music_path) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -216,7 +213,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_has_formated_track_with_key_and_mtime(self): track = Track(uri='file:///dir/subdir/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track]) + result = translator.tracks_to_tag_cache_format([track], self.music_path) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -227,7 +224,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_suports_directories(self): track = Track(uri='file:///dir/subdir/folder/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track]) + result = translator.tracks_to_tag_cache_format([track], self.music_path) result = self.consume_headers(result) folder, result = self.consume_directory(result) @@ -241,7 +238,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_diretory_header_is_right(self): track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - result = translator.tracks_to_tag_cache_format([track]) + result = translator.tracks_to_tag_cache_format([track], self.music_path) result = self.consume_headers(result) folder, result = self.consume_directory(result) @@ -253,7 +250,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_suports_sub_directories(self): track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track]) + result = translator.tracks_to_tag_cache_format([track], self.music_path) result = self.consume_headers(result) @@ -281,7 +278,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): formated.extend(self.translate(tracks[0])) formated.extend(self.translate(tracks[1])) - result = translator.tracks_to_tag_cache_format(tracks) + result = translator.tracks_to_tag_cache_format(tracks, self.music_path) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -299,7 +296,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): formated.append(self.translate(tracks[0])) formated.append(self.translate(tracks[1])) - result = translator.tracks_to_tag_cache_format(tracks) + result = translator.tracks_to_tag_cache_format(tracks, self.music_path) result = self.consume_headers(result) folder, result = self.consume_directory(result) @@ -315,13 +312,10 @@ class TracksToTagCacheFormatTest(unittest.TestCase): class TracksToDirectoryTreeTest(unittest.TestCase): def setUp(self): - settings.LOCAL_MUSIC_PATH = '/root/' - - def tearDown(self): - settings.runtime.clear() + self.music_path = '/root/' def test_no_tracks_gives_emtpy_tree(self): - tree = translator.tracks_to_directory_tree([]) + tree = translator.tracks_to_directory_tree([], self.music_path) self.assertEqual(tree, ({}, [])) def test_top_level_files(self): @@ -330,18 +324,18 @@ class TracksToDirectoryTreeTest(unittest.TestCase): Track(uri='file:///root/file2.mp3'), Track(uri='file:///root/file3.mp3'), ] - tree = translator.tracks_to_directory_tree(tracks) + tree = translator.tracks_to_directory_tree(tracks, self.music_path) self.assertEqual(tree, ({}, tracks)) def test_single_file_in_subdir(self): tracks = [Track(uri='file:///root/dir/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks) + tree = translator.tracks_to_directory_tree(tracks, self.music_path) expected = ({'dir': ({}, tracks)}, []) self.assertEqual(tree, expected) def test_single_file_in_sub_subdir(self): tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks) + tree = translator.tracks_to_directory_tree(tracks, self.music_path) expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, []) self.assertEqual(tree, expected) @@ -353,7 +347,7 @@ class TracksToDirectoryTreeTest(unittest.TestCase): Track(uri='file:///root/dir2/file4.mp3'), Track(uri='file:///root/dir2/sub/file5.mp3'), ] - tree = translator.tracks_to_directory_tree(tracks) + tree = translator.tracks_to_directory_tree(tracks, self.music_path) expected = ( { 'dir1': ({}, [tracks[1], tracks[2]]), From 90efbb6be74d39640ded918230497b316d784ead Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 5 Apr 2013 22:49:33 +0200 Subject: [PATCH 131/403] config: Add a Path config value and an ExpandedPath wrapper. Allows us to easily use expanded paths, without losing the original value for display and storage. In theory we could be using same trick for passwords. --- mopidy/utils/config.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 1a3127b5..675d92af 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -5,6 +5,7 @@ import re import socket from mopidy import exceptions +from mopidy.utils import path def validate_required(value, required): @@ -93,7 +94,7 @@ class ConfigValue(object): class String(ConfigValue): """String values. - Supports: optional choices and secret. + Supports: optional, choices and secret. """ def deserialize(self, value): value = value.strip() @@ -209,6 +210,34 @@ class Port(Integer): self.maximum = 2 ** 16 - 1 +class ExpandedPath(bytes): + def __new__(self, value): + expanded = path.expand_path(value) + return super(ExpandedPath, self).__new__(self, expanded) + + def __init__(self, value): + self.original = value + + +class Path(ConfigValue): + """File system path that will be expanded with mopidy.utils.path.expand_path + + Supports: optional, choices and secret. + """ + def deserialize(self, value): + value = value.strip() + validate_required(value, not self.optional) + validate_choice(value, self.choices) + if not value: + return None + return ExpandedPath(value) + + def serialize(self, value): + if isinstance(value, ExpandedPath): + return value.original + return value + + class ConfigSchema(object): """Logical group of config values that correspond to a config section. @@ -231,6 +260,8 @@ class ConfigSchema(object): return self._schema[key] def format(self, name, values): + # TODO: should the output be encoded utf-8 since we use that in + # serialize for strings? lines = ['[%s]' % name] for key in self._order: value = values.get(key) From 36266064d324dd6c99a1610084ecb3c165045dc0 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 5 Apr 2013 22:50:31 +0200 Subject: [PATCH 132/403] config: Switch all paths and files to use the Path in schemas. Also renames static_dir to static_path for better consistency. --- mopidy/backends/local/__init__.py | 6 +++--- mopidy/backends/spotify/__init__.py | 2 +- mopidy/config.py | 2 +- mopidy/frontends/http/__init__.py | 4 ++-- mopidy/frontends/mpris/__init__.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 17fd659e..54a1c7a4 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -55,9 +55,9 @@ class Extension(ext.Extension): def get_config_schema(self): schema = config.ExtensionConfigSchema() - schema['music_path'] = config.String() - schema['playlist_path'] = config.String() - schema['tag_cache_file'] = config.String() + schema['music_path'] = config.Path() + schema['playlist_path'] = config.Path() + schema['tag_cache_file'] = config.Path() return schema def validate_environment(self): diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index b03849eb..6de15aab 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -80,7 +80,7 @@ class Extension(ext.Extension): schema['password'] = config.String(secret=True) schema['bitrate'] = config.Integer(choices=(96, 160, 320)) schema['timeout'] = config.Integer(minimum=0) - schema['cache_path'] = config.String() + schema['cache_path'] = config.Path() schema['proxy_hostname'] = config.Hostname(optional=True) schema['proxy_username'] = config.String(optional=True) schema['proxy_password'] = config.String(optional=True, secret=True) diff --git a/mopidy/config.py b/mopidy/config.py index 85feffcc..4335f877 100644 --- a/mopidy/config.py +++ b/mopidy/config.py @@ -22,7 +22,7 @@ config_schemas = {} # TODO: use ordered dict? config_schemas['logging'] = config.ConfigSchema() config_schemas['logging']['console_format'] = config.String() config_schemas['logging']['debug_format'] = config.String() -config_schemas['logging']['debug_file'] = config.String() +config_schemas['logging']['debug_file'] = config.Path() config_schemas['logging.levels'] = config.LogLevelConfigSchema() diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index d588a376..4ca1d9b4 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -31,7 +31,7 @@ port = 6680 # Change this to have Mopidy serve e.g. files for your JavaScript client. # "/mopidy" will continue to work as usual even if you change this setting. # -static_dir = +static_path = [logging.levels] cherrypy = warning @@ -533,7 +533,7 @@ class Extension(ext.Extension): schema = config.ExtensionConfigSchema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() - schema['static_dir'] = config.String(optional=True) + schema['static_path'] = config.Path(optional=True) return schema def validate_environment(self): diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 79806c47..82d15e9d 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -79,7 +79,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = config.ExtensionConfigSchema() - schema['desktop_file'] = config.String() + schema['desktop_file'] = config.Path() return schema def validate_environment(self): From bd3d8f693201f7018a2e62c2eaeb469f57e9aa93 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 5 Apr 2013 23:29:45 +0200 Subject: [PATCH 133/403] config: Steal did you mean code from settings. --- mopidy/utils/config.py | 36 ++++++++++++++++++++++++++++++++++++ tests/utils/config_test.py | 19 +++++++++++++++++++ tests/utils/settings_test.py | 24 ------------------------ 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 1a3127b5..aa1b06fd 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -33,6 +33,39 @@ def validate_maximum(value, maximum): raise ValueError('%r must be smaller than %r.' % (value, maximum)) +# TODO: move this and levenshtein to a more appropriate class. +def did_you_mean(name, choices): + """Suggest most likely setting based on levenshtein.""" + if not choices: + return None + + name = name.lower() + candidates = [(levenshtein(name, c), c) for c in choices] + candidates.sort() + + if candidates[0][0] <= 3: + return candidates[0][1] + return None + + +def levenshtein(a, b): + """Calculates the Levenshtein distance between a and b.""" + n, m = len(a), len(b) + if n > m: + return levenshtein(b, a) + + current = xrange(n + 1) + for i in xrange(1, m + 1): + previous, current = current, [i] + [0] * n + for j in xrange(1, n + 1): + add, delete = previous[j] + 1, current[j - 1] + 1 + change = previous[j - 1] + if a[j - 1] != b[i - 1]: + change += 1 + current[j] = min(add, delete, change) + return current[n] + + class ConfigValue(object): """Represents a config key's value and how to handle it. @@ -248,6 +281,9 @@ class ConfigSchema(object): values[key] = self._schema[key].deserialize(value) except KeyError: # not in our schema errors[key] = 'unknown config key.' + suggestion = did_you_mean(key, self._schema.keys()) + if suggestion: + errors[key] += ' Did you mean %s?' % suggestion except ValueError as e: # deserialization failed errors[key] = str(e) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 77c846df..ad86b961 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -395,3 +395,22 @@ class LogLevelConfigSchemaTest(unittest.TestCase): result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) self.assertEqual('\n'.join(expected), result) + +class DidYouMeanTest(unittest.TestCase): + def testSuggestoins(self): + choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') + + suggestion = config.did_you_mean('bitrate', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = config.did_you_mean('bitrote', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = config.did_you_mean('Bitrot', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = config.did_you_mean('BTROT', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = config.did_you_mean('btro', choices) + self.assertEqual(suggestion, None) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 3b1e67b0..3aa595e3 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -149,27 +149,3 @@ class SettingsProxyTest(unittest.TestCase): def test_value_ending_in_path_can_be_none(self): self.settings.TEST_PATH = None self.assertEqual(self.settings.TEST_PATH, None) - - -class DidYouMeanTest(unittest.TestCase): - def testSuggestoins(self): - defaults = { - 'MPD_SERVER_HOSTNAME': '::', - 'MPD_SERVER_PORT': 6600, - 'SPOTIFY_BITRATE': 160, - } - - suggestion = setting_utils.did_you_mean('spotify_bitrate', defaults) - self.assertEqual(suggestion, 'SPOTIFY_BITRATE') - - suggestion = setting_utils.did_you_mean('SPOTIFY_BITROTE', defaults) - self.assertEqual(suggestion, 'SPOTIFY_BITRATE') - - suggestion = setting_utils.did_you_mean('SPITIFY_BITROT', defaults) - self.assertEqual(suggestion, 'SPOTIFY_BITRATE') - - suggestion = setting_utils.did_you_mean('SPTIFY_BITROT', defaults) - self.assertEqual(suggestion, 'SPOTIFY_BITRATE') - - suggestion = setting_utils.did_you_mean('SPTIFY_BITRO', defaults) - self.assertEqual(suggestion, None) From 5a79b65d47abad90e80d6eb37a3e581d98f727e9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 5 Apr 2013 23:31:31 +0200 Subject: [PATCH 134/403] settings: Remove did you mean. --- mopidy/utils/settings.py | 36 ------------------------------------ tests/utils/settings_test.py | 3 +-- 2 files changed, 1 insertion(+), 38 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 5916ee24..f903a70d 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -169,41 +169,5 @@ def validate_settings(defaults, settings): elif setting not in defaults and not setting.startswith('CUSTOM_'): errors[setting] = 'Unknown setting.' - suggestion = did_you_mean(setting, defaults) - - if suggestion: - errors[setting] += ' Did you mean %s?' % suggestion return errors - - -def did_you_mean(setting, defaults): - """Suggest most likely setting based on levenshtein.""" - if not defaults: - return None - - setting = setting.upper() - candidates = [(levenshtein(setting, d), d) for d in defaults] - candidates.sort() - - if candidates[0][0] <= 3: - return candidates[0][1] - return None - - -def levenshtein(a, b): - """Calculates the Levenshtein distance between a and b.""" - n, m = len(a), len(b) - if n > m: - return levenshtein(b, a) - - current = xrange(n + 1) - for i in xrange(1, m + 1): - previous, current = current, [i] + [0] * n - for j in xrange(1, n + 1): - add, delete = previous[j] + 1, current[j - 1] + 1 - change = previous[j - 1] - if a[j - 1] != b[i - 1]: - change += 1 - current[j] = min(add, delete, change) - return current[n] diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 3aa595e3..ce763486 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -24,8 +24,7 @@ class ValidateSettingsTest(unittest.TestCase): result = setting_utils.validate_settings( self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) self.assertEqual( - result['MPD_SERVER_HOSTNMAE'], - 'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') + result['MPD_SERVER_HOSTNMAE'], 'Unknown setting.') def test_custom_settings_does_not_return_errors(self): result = setting_utils.validate_settings( From 7ac629a675a39a9440c503ffd79d53f9e5ab5951 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 5 Apr 2013 23:39:22 +0200 Subject: [PATCH 135/403] config: Create global proxy config section. --- mopidy/backends/spotify/__init__.py | 8 -------- mopidy/backends/spotify/session_manager.py | 6 +++--- mopidy/config.py | 9 +++++++++ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index b03849eb..8ea0ca11 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -26,11 +26,6 @@ timeout = 10 # Path to the Spotify data cache. Cannot be shared with other Spotify apps cache_path = $XDG_CACHE_DIR/mopidy/spotify - -# Connect to Spotify through a proxy -proxy_hostname = -proxy_username = -proxy_password = """ __doc__ = """A backend for playing music from Spotify @@ -81,9 +76,6 @@ class Extension(ext.Extension): schema['bitrate'] = config.Integer(choices=(96, 160, 320)) schema['timeout'] = config.Integer(minimum=0) schema['cache_path'] = config.String() - schema['proxy_hostname'] = config.Hostname(optional=True) - schema['proxy_username'] = config.String(optional=True) - schema['proxy_password'] = config.String(optional=True, secret=True) return schema def validate_environment(self): diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index c27d8215..22ad4632 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -35,9 +35,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): PyspotifySessionManager.__init__( self, config['spotify']['username'], config['spotify']['password'], - proxy=config['spotify']['proxy_hostname'], - proxy_username=config['spotify']['proxy_username'], - proxy_password=config['spotify']['proxy_password']) + proxy=config['proxy']['hostname'], + proxy_username=config['proxy']['username'], + proxy_password=config['proxy']['password']) process.BaseThread.__init__(self) self.name = 'SpotifyThread' diff --git a/mopidy/config.py b/mopidy/config.py index 85feffcc..8c55dd9d 100644 --- a/mopidy/config.py +++ b/mopidy/config.py @@ -16,6 +16,11 @@ pykka = info mixer = autoaudiomixer mixer_track = output = autoaudiosink + +[proxy] +hostname = +username = +password = """ config_schemas = {} # TODO: use ordered dict? @@ -31,6 +36,10 @@ config_schemas['audio']['mixer'] = config.String() config_schemas['audio']['mixer_track'] = config.String(optional=True) config_schemas['audio']['output'] = config.String() +config_schemas['proxy']['hostname'] = config.Hostname(optional=True) +config_schemas['proxy']['username'] = config.String(optional=True) +config_schemas['proxy']['password'] = config.String(optional=True, secret=True) + # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() From 7d44f9967dc86c37ba8936cf0c0d2e9f92de5cef Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 5 Apr 2013 23:57:35 +0200 Subject: [PATCH 136/403] scrobbler: Renamed lastfm to scrobbler (fixes #375) --- docs/api/frontends.rst | 2 +- docs/modules/frontends/lastfm.rst | 6 ------ docs/modules/frontends/scrobbler.rst | 6 ++++++ docs/settings.rst | 4 ++-- mopidy/frontends/{lastfm => scrobbler}/__init__.py | 12 ++++++------ mopidy/frontends/{lastfm => scrobbler}/actor.py | 6 +++--- requirements/{lastfm.txt => scrobbler.txt} | 0 setup.py | 4 ++-- 8 files changed, 20 insertions(+), 20 deletions(-) delete mode 100644 docs/modules/frontends/lastfm.rst create mode 100644 docs/modules/frontends/scrobbler.rst rename mopidy/frontends/{lastfm => scrobbler}/__init__.py (85%) rename mopidy/frontends/{lastfm => scrobbler}/actor.py (95%) rename requirements/{lastfm.txt => scrobbler.txt} (100%) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index a6e6f500..ac1f8bd1 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -45,6 +45,6 @@ Frontend implementations ======================== * :mod:`mopidy.frontends.http` -* :mod:`mopidy.frontends.lastfm` +* :mod:`mopidy.frontends.scrobbler` * :mod:`mopidy.frontends.mpd` * :mod:`mopidy.frontends.mpris` diff --git a/docs/modules/frontends/lastfm.rst b/docs/modules/frontends/lastfm.rst deleted file mode 100644 index 0dba922f..00000000 --- a/docs/modules/frontends/lastfm.rst +++ /dev/null @@ -1,6 +0,0 @@ -*************************************************** -:mod:`mopidy.frontends.lastfm` -- Last.fm Scrobbler -*************************************************** - -.. automodule:: mopidy.frontends.lastfm - :synopsis: Last.fm scrobbler frontend diff --git a/docs/modules/frontends/scrobbler.rst b/docs/modules/frontends/scrobbler.rst new file mode 100644 index 00000000..eee65724 --- /dev/null +++ b/docs/modules/frontends/scrobbler.rst @@ -0,0 +1,6 @@ +********************************************** +:mod:`mopidy.frontends.scrobble` -- Scrobbler +********************************************** + +.. automodule:: mopidy.frontends.scrobbler + :synopsis: Music scrobbler frontend diff --git a/docs/settings.rst b/docs/settings.rst index cb47a71f..4c9acd96 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -106,8 +106,8 @@ Scrobbling tracks to Last.fm If you want to submit the tracks you are playing to your `Last.fm `_ profile, make sure you've installed the dependencies -found at :mod:`mopidy.frontends.lastfm` and add the following to your settings -file:: +found at :mod:`mopidy.frontends.scrobbler` and add the following to your +settings file:: LASTFM_USERNAME = u'myusername' LASTFM_PASSWORD = u'mysecret' diff --git a/mopidy/frontends/lastfm/__init__.py b/mopidy/frontends/scrobbler/__init__.py similarity index 85% rename from mopidy/frontends/lastfm/__init__.py rename to mopidy/frontends/scrobbler/__init__.py index f4bff0e5..c33a5fa3 100644 --- a/mopidy/frontends/lastfm/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -6,7 +6,7 @@ from mopidy.utils import config, formatting default_config = """ -[lastfm] +[scrobbler] # If the Last.fm extension should be enabled or not enabled = true @@ -28,7 +28,7 @@ Frontend which scrobbles the music you play to your `Last.fm **Dependencies** -.. literalinclude:: ../../../requirements/lastfm.txt +.. literalinclude:: ../../../requirements/scrobbler.txt **Default config** @@ -44,8 +44,8 @@ The frontend is enabled by default if all dependencies are available. class Extension(ext.Extension): - dist_name = 'Mopidy-Lastfm' - ext_name = 'lastfm' + dist_name = 'Mopidy-Scrobbler' + ext_name = 'scrobbler' version = mopidy.__version__ def get_default_config(self): @@ -64,5 +64,5 @@ class Extension(ext.Extension): raise exceptions.ExtensionError('pylast library not found', e) def get_frontend_classes(self): - from .actor import LastfmFrontend - return [LastfmFrontend] + from .actor import ScrobblerFrontend + return [ScrobblerFrontend] diff --git a/mopidy/frontends/lastfm/actor.py b/mopidy/frontends/scrobbler/actor.py similarity index 95% rename from mopidy/frontends/lastfm/actor.py rename to mopidy/frontends/scrobbler/actor.py index 1e157d4f..eea088de 100644 --- a/mopidy/frontends/lastfm/actor.py +++ b/mopidy/frontends/scrobbler/actor.py @@ -13,15 +13,15 @@ try: except ImportError as import_error: raise exceptions.OptionalDependencyError(import_error) -logger = logging.getLogger('mopidy.frontends.lastfm') +logger = logging.getLogger('mopidy.frontends.scrobbler') API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(pykka.ThreadingActor, CoreListener): +class ScrobblerFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): - super(LastfmFrontend, self).__init__() + super(ScrobblerFrontend, self).__init__() self.lastfm = None self.last_start_time = None diff --git a/requirements/lastfm.txt b/requirements/scrobbler.txt similarity index 100% rename from requirements/lastfm.txt rename to requirements/scrobbler.txt diff --git a/setup.py b/setup.py index 3c0b7c4c..2f4d98d6 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setup( ], extras_require={ b'spotify': ['pyspotify >= 1.9, < 1.11'], - b'lastfm': ['pylast >= 0.5.7'], + b'scrobbler': ['pylast >= 0.5.7'], b'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], b'external_mixers': ['pyserial'], }, @@ -46,7 +46,7 @@ setup( ], b'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', - 'lastfm = mopidy.frontends.lastfm:Extension [lastfm]', + 'scrobbler = mopidy.frontends.scrobbler:Extension [scrobbler]', 'local = mopidy.backends.local:Extension', 'mpd = mopidy.frontends.mpd:Extension', 'mpris = mopidy.frontends.mpris:Extension', From 60ce7507476af0a0cc626bafb9cb51ec47f5af56 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 6 Apr 2013 01:44:05 +0200 Subject: [PATCH 137/403] config: Add missing config section schema --- mopidy/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/config.py b/mopidy/config.py index 8c55dd9d..3d242a95 100644 --- a/mopidy/config.py +++ b/mopidy/config.py @@ -36,6 +36,7 @@ config_schemas['audio']['mixer'] = config.String() config_schemas['audio']['mixer_track'] = config.String(optional=True) config_schemas['audio']['output'] = config.String() +config_schemas['proxy'] = config.ConfigSchema() config_schemas['proxy']['hostname'] = config.Hostname(optional=True) config_schemas['proxy']['username'] = config.String(optional=True) config_schemas['proxy']['password'] = config.String(optional=True, secret=True) From 837db09aff3345db74719e84ddd1b2df3b067c72 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 6 Apr 2013 01:45:09 +0200 Subject: [PATCH 138/403] docs: Sort list --- docs/api/frontends.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index ac1f8bd1..58436c03 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -45,6 +45,6 @@ Frontend implementations ======================== * :mod:`mopidy.frontends.http` -* :mod:`mopidy.frontends.scrobbler` * :mod:`mopidy.frontends.mpd` * :mod:`mopidy.frontends.mpris` +* :mod:`mopidy.frontends.scrobbler` From 87ce309749b7395759127c56b653575b48f467ea Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Sat, 6 Apr 2013 21:27:59 +0200 Subject: [PATCH 139/403] mpd: playlist name disambiguation --- mopidy/frontends/mpd/dispatcher.py | 24 +++++++++++++++++++ mopidy/frontends/mpd/protocol/music_db.py | 5 ++-- .../mpd/protocol/stored_playlists.py | 4 +++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 4f0001ac..96a7794e 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -232,9 +232,33 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None + #: a map from playlists printing names to uris (necessary as mpd requires + #: playlists names to be unique) + to_uri = None + + #: the invert map (uri to printing name) + from_uri = None + def __init__(self, dispatcher, session=None, core=None): self.dispatcher = dispatcher self.session = session self.core = core self.events = set() self.subscriptions = set() + self.to_uri = dict() + self.from_uri = dict() + self.refresh_maps() + + def refresh_maps(self): + self.to_uri.clear() + self.from_uri.clear() + for playlist in self.core.playlists.playlists.get(): + if not playlist.name: + continue + name = playlist.name + i = 1 + while name in self.to_uri: + name = '%s [%d]' % playlist.name % i + i += 1 + self.to_uri[name] = playlist.uri + self.from_uri[playlist.uri] = name diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index c457ee02..efb9a0fd 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -381,9 +381,8 @@ def searchaddpl(context, playlist_name, mpd_query): return results = context.core.library.search(**query).get() - playlists = context.core.playlists.filter(name=playlist_name).get() - if playlists: - playlist = playlists[0] + if playlist_name in context.to_uri: + playlist = context.core.playlists.lookup(context.to_uri[playlist_name]) else: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index b1fe87de..4ec2f40c 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -80,7 +80,9 @@ def listplaylists(context): for playlist in context.core.playlists.playlists.get(): if not playlist.name: continue - result.append(('playlist', playlist.name)) + if playlist.uri not in context.from_uri: + context.refresh_maps() # the maps are not synced, we refresh them + result.append(('playlist', context.from_uri[playlist.uri])) last_modified = ( playlist.last_modified or dt.datetime.utcnow()).isoformat() # Remove microseconds From 1ca6ffc6fb9ac0649cea18464c3cc295a82b7aeb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 6 Apr 2013 23:47:03 +0200 Subject: [PATCH 140/403] mpd: Fix two test failures --- tests/frontends/mpd/translator_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py index 5df65c5c..828acf1a 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -312,7 +312,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): class TracksToDirectoryTreeTest(unittest.TestCase): def setUp(self): - self.music_path = '/root/' + self.music_path = '/root' def test_no_tracks_gives_emtpy_tree(self): tree = translator.tracks_to_directory_tree([], self.music_path) From 42707f50df0bec18a1458a0fbf1020c522275b92 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Apr 2013 00:04:35 +0200 Subject: [PATCH 141/403] audio: Use new config system --- mopidy/audio/actor.py | 43 ++++++++++++++++++++++--------------- mopidy/audio/mixers/auto.py | 4 ++-- mopidy/audio/mixers/fake.py | 4 ++-- mopidy/audio/mixers/nad.py | 16 ++++++-------- tests/audio/actor_test.py | 24 +++++++++++++++------ 5 files changed, 54 insertions(+), 37 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 42dee084..5d92f3c4 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -9,7 +9,6 @@ import logging import pykka -from mopidy import settings from mopidy.utils import process from . import mixers, utils @@ -28,11 +27,14 @@ class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. - **Settings:** + **Default config:** - - :attr:`mopidy.settings.OUTPUT` - - :attr:`mopidy.settings.MIXER` - - :attr:`mopidy.settings.MIXER_TRACK` + .. code-block:: ini + + [audio] + mixer = autoaudiomixer + mixer_track = + output = autoaudiosink """ #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` @@ -41,6 +43,8 @@ class Audio(pykka.ThreadingActor): def __init__(self, config): super(Audio, self).__init__() + self._config = config + self._playbin = None self._signal_ids = {} # {(element, event): signal_id} @@ -143,47 +147,51 @@ class Audio(pykka.ThreadingActor): self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): + output_desc = self._config['audio']['output'] try: output = gst.parse_bin_from_description( - settings.OUTPUT, ghost_unconnected_pads=True) + output_desc, ghost_unconnected_pads=True) self._playbin.set_property('audio-sink', output) - logger.info('Audio output set to "%s"', settings.OUTPUT) + logger.info('Audio output set to "%s"', output_desc) except gobject.GError as ex: logger.error( - 'Failed to create audio output "%s": %s', settings.OUTPUT, ex) + 'Failed to create audio output "%s": %s', output_desc, ex) process.exit_process() def _setup_mixer(self): - if not settings.MIXER: + mixer_desc = self._config['audio']['mixer'] + track_desc = self._config['audio']['mixer_track'] + + if mixer_desc is None: logger.info('Not setting up audio mixer') return - if settings.MIXER == 'software': + if mixer_desc == 'software': self._software_mixing = True logger.info('Audio mixer is using software mixing') return try: mixerbin = gst.parse_bin_from_description( - settings.MIXER, ghost_unconnected_pads=False) + mixer_desc, ghost_unconnected_pads=False) except gobject.GError as ex: logger.warning( - 'Failed to create audio mixer "%s": %s', settings.MIXER, ex) + 'Failed to create audio mixer "%s": %s', mixer_desc, ex) return # We assume that the bin will contain a single mixer. mixer = mixerbin.get_by_interface(b'GstMixer') if not mixer: logger.warning( - 'Did not find any audio mixers in "%s"', settings.MIXER) + 'Did not find any audio mixers in "%s"', mixer_desc) return if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: logger.warning( - 'Setting audio mixer "%s" to READY failed', settings.MIXER) + 'Setting audio mixer "%s" to READY failed', mixer_desc) return - track = self._select_mixer_track(mixer, settings.MIXER_TRACK) + track = self._select_mixer_track(mixer, track_desc) if not track: logger.warning('Could not find usable audio mixer track') return @@ -198,8 +206,9 @@ class Audio(pykka.ThreadingActor): def _select_mixer_track(self, mixer, track_label): # Ignore tracks without volumes, then look for track with - # label == settings.MIXER_TRACK, otherwise fallback to first usable - # track hoping the mixer gave them to us in a sensible order. + # label equal to the audio/mixer_track config value, otherwise fallback + # to first usable track hoping the mixer gave them to us in a sensible + # order. usable_tracks = [] for track in mixer.list_tracks(): diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 96359da1..b24bcf4c 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -6,9 +6,9 @@ This is Mopidy's default mixer. None -**Settings** +**Configuration** -If this wasn't the default, you would set :attr:`mopidy.settings.MIXER` to +If this wasn't the default, you would set the ``audio/mixer`` config value to ``autoaudiomixer`` to use this mixer. """ diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 738491b5..05e86923 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -4,9 +4,9 @@ None -**Settings** +**Configuration** -Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer. +Set the ``audio/mixer`` config value to ``fakemixer`` to use this mixer. """ from __future__ import unicode_literals diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 8481de55..058333d1 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -7,10 +7,10 @@ serial cable. .. literalinclude:: ../../../../requirements/external_mixers.txt -**Settings** +**Configuration** -Set :attr:`mopidy.settings.MIXER` to ``nadmixer`` to use it. You probably also -needs to add some properties to the ``MIXER`` setting. +Set the ``audio/mixer`` config value to ``nadmixer`` to use it. You probably +also needs to add some properties to the ``audio/mixer`` config value. Supported properties includes: @@ -34,15 +34,13 @@ Supported properties includes: Configuration examples:: # Minimum configuration, if the amplifier is available at /dev/ttyUSB0 - MIXER = u'nadmixer' + mixer = nadmixer # Minimum configuration, if the amplifier is available elsewhere - MIXER = u'nadmixer port=/dev/ttyUSB3' + mixer = nadmixer port=/dev/ttyUSB3 # Full configuration - MIXER = ( - u'nadmixer port=/dev/ttyUSB0 ' - u'source=aux speakers-a=on speakers-b=off') + mixer = nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off """ from __future__ import unicode_literals @@ -132,7 +130,7 @@ class NadTalker(pykka.ThreadingActor): calibrating the NAD amplifier's volume. """ - # Serial link settings + # Serial link config BAUDRATE = 115200 BYTESIZE = 8 PARITY = 'N' diff --git a/tests/audio/actor_test.py b/tests/audio/actor_test.py index 51786adb..a40b0572 100644 --- a/tests/audio/actor_test.py +++ b/tests/audio/actor_test.py @@ -6,7 +6,7 @@ import gst import pykka -from mopidy import audio, settings +from mopidy import audio from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir @@ -14,14 +14,18 @@ from tests import unittest, path_to_data_dir class AudioTest(unittest.TestCase): def setUp(self): - settings.MIXER = 'fakemixer track_max_volume=65536' - settings.OUTPUT = 'fakesink' + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=65536', + 'mixer_track': None, + 'output': 'fakesink', + } + } self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.audio = audio.Audio.start(config=None).proxy() + self.audio = audio.Audio.start(config=config).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() - settings.runtime.clear() def prepare_uri(self, uri): self.audio.prepare_change() @@ -59,8 +63,14 @@ class AudioTest(unittest.TestCase): self.assertEqual(value, self.audio.get_volume().get()) def test_set_volume_with_mixer_max_below_100(self): - settings.MIXER = 'fakemixer track_max_volume=40' - self.audio = audio.Audio.start(config=None).proxy() + config = { + 'audio': { + 'mixer': 'fakemixer track_max_volume=40', + 'mixer_track': None, + 'output': 'fakesink', + } + } + self.audio = audio.Audio.start(config=config).proxy() for value in range(0, 101): self.assertTrue(self.audio.set_volume(value).get()) From 2276130758519b4240fb6004d74a1b98dab0c26f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Apr 2013 00:33:34 +0200 Subject: [PATCH 142/403] main: Use new config system --- mopidy/__main__.py | 45 +++++++++++++++++++++----------------------- mopidy/utils/path.py | 2 ++ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 3783d150..94e245d0 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -28,7 +28,7 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import exceptions, settings +from mopidy import exceptions from mopidy.audio import Audio from mopidy.config import default_config, config_schemas from mopidy.core import Core @@ -48,8 +48,10 @@ def main(): config_files = options.config.split(':') config_overrides = options.overrides + extensions = [] # Make sure it is defined before the finally block + try: - extensions = [] # Make sure it is defined before the finally block + create_file_structures() logging_config = load_config(config_files, config_overrides) log.setup_logging( logging_config, options.verbosity_level, options.save_debug_log) @@ -58,8 +60,7 @@ def main(): extensions = filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, config_schemas, extensions) log.setup_log_levels(config) - check_old_folders() - setup_settings() + check_old_locations() # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors have been started. @@ -68,8 +69,6 @@ def main(): core = setup_core(audio, backends) setup_frontends(config, extensions, core) loop.run() - except exceptions.SettingsError as ex: - logger.error(ex.message) except KeyboardInterrupt: logger.info('Interrupted. Exiting...') except Exception as ex: @@ -167,17 +166,20 @@ def show_config_callback(option, opt, value, parser): sys.exit(0) -def check_old_folders(): - # TODO: add old settings and pre extension storage locations? - old_settings_folder = os.path.expanduser('~/.mopidy') +def check_old_locations(): + dot_mopidy_dir = path.expand_path('~/.mopidy') + if os.path.isdir(dot_mopidy_dir): + logger.warning( + 'Old Mopidy dot dir found at %s. Please migrate your config to ' + 'the ini-file based config format. See release notes for further ' + 'instructions.', dot_mopidy_dir) - if not os.path.isdir(old_settings_folder): - return - - logger.warning( - 'Old settings folder found at %s, settings.py should be moved ' - 'to %s, any cache data should be deleted. See release notes for ' - 'further instructions.', old_settings_folder, path.SETTINGS_PATH) + old_settings_file = path.expand_path('$XDG_CONFIG_DIR/mopidy/settings.py') + if os.path.isfile(old_settings_file): + logger.warning( + 'Old Mopidy settings file found at %s. Please migrate your ' + 'config to the ini-file based config format. See release notes ' + 'for further instructions.', old_settings_file) def load_extensions(): @@ -306,15 +308,10 @@ def validate_config(raw_config, schemas, extensions=None): return config -def setup_settings(): - path.get_or_create_folder(path.SETTINGS_PATH) +def create_file_structures(): path.get_or_create_folder(path.DATA_PATH) - path.get_or_create_file(path.SETTINGS_FILE) - try: - settings.validate() - except exceptions.SettingsError as ex: - logger.error(ex.message) - sys.exit(1) + path.get_or_create_folder(path.CONFIG_PATH) + path.get_or_create_file(path.CONFIG_FILE) def setup_audio(config): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 4e5a66cd..88b8be3e 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -26,6 +26,8 @@ XDG_DIRS = { 'XDG_MUSIC_DIR': XDG_MUSIC_DIR, } DATA_PATH = os.path.join(unicode(XDG_DATA_DIR), 'mopidy') +CONFIG_PATH = os.path.join(unicode(XDG_CONFIG_DIR), 'mopidy') +CONFIG_FILE = os.path.join(unicode(CONFIG_PATH), 'mopidy.conf') SETTINGS_PATH = os.path.join(unicode(XDG_CONFIG_DIR), 'mopidy') SETTINGS_FILE = os.path.join(unicode(SETTINGS_PATH), 'settings.py') From e92a7628f25a151f3f83fd0b90da7d590cdc757e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Apr 2013 00:36:41 +0200 Subject: [PATCH 143/403] settings: Remove SettingsProxy at mopidy.settings --- mopidy/__init__.py | 5 ----- tests/__init__.py | 5 ----- tests/frontends/mpd/protocol/__init__.py | 3 +-- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 416f4fbf..fc2b611b 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -24,8 +24,3 @@ warnings.filterwarnings('ignore', 'could not open display') __version__ = '0.13.0' - - -from mopidy import settings as default_settings_module -from mopidy.utils.settings import SettingsProxy -settings = SettingsProxy(default_settings_module) diff --git a/tests/__init__.py b/tests/__init__.py index 7f7a9c36..b4e1d283 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -8,11 +8,6 @@ if sys.version_info < (2, 7): else: import unittest # noqa -from mopidy import settings - -# Nuke any local settings to ensure same test env all over -settings.local.clear() - def path_to_data_dir(name): path = os.path.dirname(__file__) diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index e62141ee..21ba1c25 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import mock import pykka -from mopidy import core, settings +from mopidy import core from mopidy.backends import dummy from mopidy.frontends.mpd import session @@ -42,7 +42,6 @@ class BaseTestCase(unittest.TestCase): def tearDown(self): pykka.ActorRegistry.stop_all() - settings.runtime.clear() def sendRequest(self, request): self.connection.response = [] From 777993583f4d7faef3b93c9094fbf5fd86311670 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Apr 2013 00:39:26 +0200 Subject: [PATCH 144/403] settings: Remove settings module --- docs/settings.rst | 4 +- mopidy/settings.py | 285 --------------------------------------------- 2 files changed, 1 insertion(+), 288 deletions(-) delete mode 100644 mopidy/settings.py diff --git a/docs/settings.rst b/docs/settings.rst index 4c9acd96..e16cfbfd 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -218,6 +218,4 @@ used at the same time without any danger of naming collisions. Available settings ================== -.. automodule:: mopidy.settings - :synopsis: Available settings and their default values - :members: +.. note:: TODO: Document config values of the new config system diff --git a/mopidy/settings.py b/mopidy/settings.py deleted file mode 100644 index cde6430a..00000000 --- a/mopidy/settings.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -All available settings and their default values. - -.. warning:: - - Do *not* change settings directly in :mod:`mopidy.settings`. Instead, add a - file called ``~/.config/mopidy/settings.py`` and redefine settings there. -""" - -from __future__ import unicode_literals - -#: The log format used for informational logging. -#: -#: See http://docs.python.org/2/library/logging.html#formatter-objects for -#: details on the format. -CONSOLE_LOG_FORMAT = '%(levelname)-8s %(message)s' - -#: The log format used for debug logging. -#: -#: See http://docs.python.org/library/logging.html#formatter-objects for -#: details on the format. -DEBUG_LOG_FORMAT = '%(levelname)-8s %(asctime)s' + \ - ' [%(process)d:%(threadName)s] %(name)s\n %(message)s' - -#: The file to dump debug log data to when Mopidy is run with the -#: :option:`--save-debug-log` option. -#: -#: Default:: -#: -#: DEBUG_LOG_FILENAME = u'mopidy.log' -DEBUG_LOG_FILENAME = 'mopidy.log' - -#: Location of the Mopidy .desktop file. -#: -#: Used by :mod:`mopidy.frontends.mpris`. -#: -#: Default:: -#: -#: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' -DESKTOP_FILE = '/usr/share/applications/mopidy.desktop' - -#: Which address Mopidy's HTTP server should bind to. -#: -#: Used by :mod:`mopidy.frontends.http`. -#: -#: Examples: -#: -#: ``127.0.0.1`` -#: Listens only on the IPv4 loopback interface. Default. -#: ``::1`` -#: Listens only on the IPv6 loopback interface. -#: ``0.0.0.0`` -#: Listens on all IPv4 interfaces. -#: ``::`` -#: Listens on all interfaces, both IPv4 and IPv6. -HTTP_SERVER_HOSTNAME = u'127.0.0.1' - -#: Which TCP port Mopidy's HTTP server should listen to. -#: -#: Used by :mod:`mopidy.frontends.http`. -#: -#: Default: 6680 -HTTP_SERVER_PORT = 6680 - -#: Which directory Mopidy's HTTP server should serve at ``/``. -#: -#: Change this to have Mopidy serve e.g. files for your JavaScript client. -#: ``/mopidy`` will continue to work as usual even if you change this setting. -#: -#: Used by :mod:`mopidy.frontends.http`. -#: -#: Default: None -HTTP_SERVER_STATIC_DIR = None - -#: Your `Last.fm `_ username. -#: -#: Used by :mod:`mopidy.frontends.lastfm`. -LASTFM_USERNAME = '' - -#: Your `Last.fm `_ password. -#: -#: Used by :mod:`mopidy.frontends.lastfm`. -LASTFM_PASSWORD = '' - -#: Path to folder with local music. -#: -#: Used by :mod:`mopidy.backends.local`. -#: -#: Default:: -#: -#: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' -LOCAL_MUSIC_PATH = '$XDG_MUSIC_DIR' - -#: Path to playlist folder with m3u files for local music. -#: -#: Used by :mod:`mopidy.backends.local`. -#: -#: Default:: -#: -#: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' -LOCAL_PLAYLIST_PATH = '$XDG_DATA_DIR/mopidy/playlists' - -#: Path to tag cache for local music. -#: -#: Used by :mod:`mopidy.backends.local`. -#: -#: Default:: -#: -#: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' -LOCAL_TAG_CACHE_FILE = '$XDG_DATA_DIR/mopidy/tag_cache' - -#: Audio mixer to use. -#: -#: Expects a GStreamer mixer to use, typical values are: -#: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. -#: -#: Setting this to :class:`None` turns off volume control. ``software`` -#: can be used to force software mixing in the application. -#: -#: Default:: -#: -#: MIXER = u'autoaudiomixer' -MIXER = 'autoaudiomixer' - -#: Audio mixer track to use. -#: -#: Name of the mixer track to use. If this is not set we will try to find the -#: master output track. As an example, using ``alsamixer`` you would -#: typically set this to ``Master`` or ``PCM``. -#: -#: Default:: -#: -#: MIXER_TRACK = None -MIXER_TRACK = None - -#: Number of seconds an MPD client can stay inactive before the connection is -#: closed by the server. -#: -#: Used by :mod:`mopidy.frontends.mpd`. -#: -#: Default:: -#: -#: MPD_SERVER_CONNECTION_TIMEOUT = 60 -MPD_SERVER_CONNECTION_TIMEOUT = 60 - -#: Which address Mopidy's MPD server should bind to. -#: -#: Used by :mod:`mopidy.frontends.mpd`. -#: -#: Examples: -#: -#: ``127.0.0.1`` -#: Listens only on the IPv4 loopback interface. Default. -#: ``::1`` -#: Listens only on the IPv6 loopback interface. -#: ``0.0.0.0`` -#: Listens on all IPv4 interfaces. -#: ``::`` -#: Listens on all interfaces, both IPv4 and IPv6. -MPD_SERVER_HOSTNAME = '127.0.0.1' - -#: Which TCP port Mopidy's MPD server should listen to. -#: -#: Used by :mod:`mopidy.frontends.mpd`. -#: -#: Default: 6600 -MPD_SERVER_PORT = 6600 - -#: The password required for connecting to the MPD server. -#: -#: Used by :mod:`mopidy.frontends.mpd`. -#: -#: Default: :class:`None`, which means no password required. -MPD_SERVER_PASSWORD = None - -#: The maximum number of concurrent connections the MPD server will accept. -#: -#: Used by :mod:`mopidy.frontends.mpd`. -#: -#: Default: 20 -MPD_SERVER_MAX_CONNECTIONS = 20 - -#: Audio output to use. -#: -#: Expects a GStreamer sink. Typical values are ``autoaudiosink``, -#: ``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``, -#: and additional arguments specific to each sink. -#: -#: Default:: -#: -#: OUTPUT = u'autoaudiosink' -OUTPUT = 'autoaudiosink' - -#: Path to the Spotify cache. -#: -#: Used by :mod:`mopidy.backends.spotify`. -#: -#: Default:: -#: -#: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' -SPOTIFY_CACHE_PATH = '$XDG_CACHE_DIR/mopidy/spotify' - -#: Your Spotify Premium username. -#: -#: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_USERNAME = '' - -#: Your Spotify Premium password. -#: -#: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_PASSWORD = '' - -#: Spotify preferred bitrate. -#: -#: Available values are 96, 160, and 320. -#: -#: Used by :mod:`mopidy.backends.spotify`. -#: -#: Default:: -#: -#: SPOTIFY_BITRATE = 160 -SPOTIFY_BITRATE = 160 - -#: Spotify proxy host. -#: -#: Used by :mod:`mopidy.backends.spotify`. -#: -#: Example:: -#: -#: SPOTIFY_PROXY_HOST = u'protocol://host:port' -#: -#: Default:: -#: -#: SPOTIFY_PROXY_HOST = None -SPOTIFY_PROXY_HOST = None - -#: Spotify proxy username. -#: -#: Used by :mod:`mopidy.backends.spotify`. -#: -#: Default:: -#: -#: SPOTIFY_PROXY_USERNAME = None -SPOTIFY_PROXY_USERNAME = None - -#: Spotify proxy password. -#: -#: Used by :mod:`mopidy.backends.spotify`. -#: -#: Default:: -#: -#: SPOTIFY_PROXY_PASSWORD = None -SPOTIFY_PROXY_PASSWORD = None - -#: Max number of seconds to wait for Spotify operations to complete. -#: -#: Used by :mod:`mopidy.backends.spotify`. -#: -#: Default:: -#: -#: SPOTIFY_TIMEOUT = 10 -SPOTIFY_TIMEOUT = 10 - -#: Whitelist of URIs to support streaming from. -#: -#: Used by :mod:`mopidy.backends.stream`. -#: -#: Default:: -#: -#: STREAM_PROTOCOLS = ( -#: u'http', -#: u'https', -#: u'mms', -#: u'rtmp', -#: u'rtmps', -#: u'rtsp', -#: ) -STREAM_PROTOCOLS = ( - 'http', - 'https', - 'mms', - 'rtmp', - 'rtmps', - 'rtsp', -) From 63b7260c01e0d3ed68bad49932112c6bbb0f4103 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Apr 2013 00:41:09 +0200 Subject: [PATCH 145/403] settings: Remove settings utils --- mopidy/utils/settings.py | 173 ----------------------------------- tests/utils/settings_test.py | 150 ------------------------------ 2 files changed, 323 deletions(-) delete mode 100644 mopidy/utils/settings.py delete mode 100644 tests/utils/settings_test.py diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py deleted file mode 100644 index f903a70d..00000000 --- a/mopidy/utils/settings.py +++ /dev/null @@ -1,173 +0,0 @@ -# Absolute import needed to import ~/.config/mopidy/settings.py and not -# ourselves -from __future__ import absolute_import, unicode_literals - -import copy -import getpass -import logging -import os -import pprint -import sys - -from mopidy import exceptions -from mopidy.utils import formatting, path - -logger = logging.getLogger('mopidy.utils.settings') - - -class SettingsProxy(object): - def __init__(self, default_settings_module): - self.default = self._get_settings_dict_from_module( - default_settings_module) - self.local = self._get_local_settings() - self.runtime = {} - - def _get_local_settings(self): - if not os.path.isfile(path.SETTINGS_FILE): - return {} - sys.path.insert(0, path.SETTINGS_PATH) - # pylint: disable = F0401 - import settings as local_settings_module - # pylint: enable = F0401 - return self._get_settings_dict_from_module(local_settings_module) - - def _get_settings_dict_from_module(self, module): - settings = filter( - lambda (key, value): self._is_setting(key), - module.__dict__.iteritems()) - return dict(settings) - - def _is_setting(self, name): - return name.isupper() - - @property - def current(self): - current = copy.copy(self.default) - current.update(self.local) - current.update(self.runtime) - return current - - def __getattr__(self, attr): - if not self._is_setting(attr): - return - - current = self.current # bind locally to avoid copying+updates - if attr not in current: - raise exceptions.SettingsError('Setting "%s" is not set.' % attr) - - value = current[attr] - if isinstance(value, basestring) and len(value) == 0: - raise exceptions.SettingsError('Setting "%s" is empty.' % attr) - if not value: - return value - if attr.endswith('_PATH') or attr.endswith('_FILE'): - value = path.expand_path(value) - return value - - def __setattr__(self, attr, value): - if self._is_setting(attr): - self.runtime[attr] = value - else: - super(SettingsProxy, self).__setattr__(attr, value) - - def validate(self): - if self.get_errors(): - logger.error( - 'Settings validation errors: %s', - formatting.indent(self.get_errors_as_string())) - raise exceptions.SettingsError('Settings validation failed.') - - def _read_from_stdin(self, prompt): - if '_PASSWORD' in prompt: - return ( - getpass.getpass(prompt) - .decode(sys.stdin.encoding, 'ignore')) - else: - sys.stdout.write(prompt) - return ( - sys.stdin.readline().strip() - .decode(sys.stdin.encoding, 'ignore')) - - def get_errors(self): - return validate_settings(self.default, self.local) - - def get_errors_as_string(self): - lines = [] - for (setting, error) in self.get_errors().iteritems(): - lines.append('%s: %s' % (setting, error)) - return '\n'.join(lines) - - -def validate_settings(defaults, settings): - """ - Checks the settings for both errors like misspellings and against a set of - rules for renamed settings, etc. - - Returns mapping from setting names to associated errors. - - :param defaults: Mopidy's default settings - :type defaults: dict - :param settings: the user's local settings - :type settings: dict - :rtype: dict - """ - errors = {} - - changed = { - 'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME', - 'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT', - 'GSTREAMER_AUDIO_SINK': 'OUTPUT', - 'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH', - 'LOCAL_OUTPUT_OVERRIDE': 'OUTPUT', - 'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH', - 'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE', - 'MIXER_ALSA_CONTROL': None, - 'MIXER_EXT_PORT': None, - 'MIXER_EXT_SPEAKERS_A': None, - 'MIXER_EXT_SPEAKERS_B': None, - 'MIXER_MAX_VOLUME': None, - 'SERVER': None, - 'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME', - 'SERVER_PORT': 'MPD_SERVER_PORT', - 'SPOTIFY_HIGH_BITRATE': 'SPOTIFY_BITRATE', - 'SPOTIFY_LIB_APPKEY': None, - 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', - } - - must_be_iterable = [ - 'STREAM_PROTOCOLS', - ] - - for setting, value in settings.iteritems(): - if setting in changed: - if changed[setting] is None: - errors[setting] = 'Deprecated setting. It may be removed.' - else: - errors[setting] = 'Deprecated setting. Use %s.' % ( - changed[setting],) - - elif setting == 'OUTPUTS': - errors[setting] = ( - 'Deprecated setting, please change to OUTPUT. OUTPUT expects ' - 'a GStreamer bin description string for your desired output.') - - elif setting == 'SPOTIFY_BITRATE': - if value not in (96, 160, 320): - errors[setting] = ( - 'Unavailable Spotify bitrate. Available bitrates are 96, ' - '160, and 320.') - - elif setting.startswith('SHOUTCAST_OUTPUT_'): - errors[setting] = ( - 'Deprecated setting, please set the value via the GStreamer ' - 'bin in OUTPUT.') - - elif setting in must_be_iterable and not hasattr(value, '__iter__'): - errors[setting] = ( - 'Must be a tuple. ' - "Remember the comma after single values: (u'value',)") - - elif setting not in defaults and not setting.startswith('CUSTOM_'): - errors[setting] = 'Unknown setting.' - - return errors diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py deleted file mode 100644 index ce763486..00000000 --- a/tests/utils/settings_test.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import unicode_literals - -import os - -from mopidy import exceptions, settings -from mopidy.utils import settings as setting_utils - -from tests import unittest - - -class ValidateSettingsTest(unittest.TestCase): - def setUp(self): - self.defaults = { - 'MPD_SERVER_HOSTNAME': '::', - 'MPD_SERVER_PORT': 6600, - 'SPOTIFY_BITRATE': 160, - } - - def test_no_errors_yields_empty_dict(self): - result = setting_utils.validate_settings(self.defaults, {}) - self.assertEqual(result, {}) - - def test_unknown_setting_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) - self.assertEqual( - result['MPD_SERVER_HOSTNMAE'], 'Unknown setting.') - - def test_custom_settings_does_not_return_errors(self): - result = setting_utils.validate_settings( - self.defaults, {'CUSTOM_MYAPP_SETTING': 'foobar'}) - self.assertNotIn('CUSTOM_MYAPP_SETTING', result) - - def test_not_renamed_setting_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) - self.assertEqual( - result['SERVER_HOSTNAME'], - 'Deprecated setting. Use MPD_SERVER_HOSTNAME.') - - def test_unneeded_settings_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) - self.assertEqual( - result['SPOTIFY_LIB_APPKEY'], - 'Deprecated setting. It may be removed.') - - def test_unavailable_bitrate_setting_returns_error(self): - result = setting_utils.validate_settings( - self.defaults, {'SPOTIFY_BITRATE': 50}) - self.assertEqual( - result['SPOTIFY_BITRATE'], - 'Unavailable Spotify bitrate. ' - 'Available bitrates are 96, 160, and 320.') - - def test_two_errors_are_both_reported(self): - result = setting_utils.validate_settings( - self.defaults, {'FOO': '', 'BAR': ''}) - self.assertEqual(len(result), 2) - - -class SettingsProxyTest(unittest.TestCase): - def setUp(self): - self.settings = setting_utils.SettingsProxy(settings) - self.settings.local.clear() - - def test_set_and_get_attr(self): - self.settings.TEST = 'test' - self.assertEqual(self.settings.TEST, 'test') - - def test_getattr_raises_error_on_missing_setting(self): - try: - self.settings.TEST - self.fail('Should raise exception') - except exceptions.SettingsError as e: - self.assertEqual('Setting "TEST" is not set.', e.message) - - def test_getattr_raises_error_on_empty_setting(self): - self.settings.TEST = '' - try: - self.settings.TEST - self.fail('Should raise exception') - except exceptions.SettingsError as e: - self.assertEqual('Setting "TEST" is empty.', e.message) - - def test_getattr_does_not_raise_error_if_setting_is_false(self): - self.settings.TEST = False - self.assertEqual(False, self.settings.TEST) - - def test_getattr_does_not_raise_error_if_setting_is_none(self): - self.settings.TEST = None - self.assertEqual(None, self.settings.TEST) - - def test_getattr_does_not_raise_error_if_setting_is_zero(self): - self.settings.TEST = 0 - self.assertEqual(0, self.settings.TEST) - - def test_setattr_updates_runtime_settings(self): - self.settings.TEST = 'test' - self.assertIn('TEST', self.settings.runtime) - - def test_setattr_updates_runtime_with_value(self): - self.settings.TEST = 'test' - self.assertEqual(self.settings.runtime['TEST'], 'test') - - def test_runtime_value_included_in_current(self): - self.settings.TEST = 'test' - self.assertEqual(self.settings.current['TEST'], 'test') - - def test_value_ending_in_path_is_expanded(self): - self.settings.TEST_PATH = '~/test' - actual = self.settings.TEST_PATH - expected = os.path.expanduser('~/test') - self.assertEqual(actual, expected) - - def test_value_ending_in_path_is_absolute(self): - self.settings.TEST_PATH = './test' - actual = self.settings.TEST_PATH - expected = os.path.abspath('./test') - self.assertEqual(actual, expected) - - def test_value_ending_in_file_is_expanded(self): - self.settings.TEST_FILE = '~/test' - actual = self.settings.TEST_FILE - expected = os.path.expanduser('~/test') - self.assertEqual(actual, expected) - - def test_value_ending_in_file_is_absolute(self): - self.settings.TEST_FILE = './test' - actual = self.settings.TEST_FILE - expected = os.path.abspath('./test') - self.assertEqual(actual, expected) - - def test_value_not_ending_in_path_or_file_is_not_expanded(self): - self.settings.TEST = '~/test' - actual = self.settings.TEST - self.assertEqual(actual, '~/test') - - def test_value_not_ending_in_path_or_file_is_not_absolute(self): - self.settings.TEST = './test' - actual = self.settings.TEST - self.assertEqual(actual, './test') - - def test_value_ending_in_file_can_be_none(self): - self.settings.TEST_FILE = None - self.assertEqual(self.settings.TEST_FILE, None) - - def test_value_ending_in_path_can_be_none(self): - self.settings.TEST_PATH = None - self.assertEqual(self.settings.TEST_PATH, None) From 698f505da6487abd055f9cd68fb3c665ef858c99 Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Sun, 7 Apr 2013 14:17:28 +0200 Subject: [PATCH 146/403] mpd: fix style issues and type errors introduced in 87ce309 Tests are now OK --- mopidy/frontends/mpd/dispatcher.py | 33 ++++++++++--------- mopidy/frontends/mpd/protocol/music_db.py | 5 ++- .../mpd/protocol/stored_playlists.py | 2 +- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 96a7794e..f0842fa7 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -245,20 +245,21 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self.to_uri = dict() - self.from_uri = dict() - self.refresh_maps() + self.to_uri = {} + self.from_uri = {} + self.refresh_playlists_mapping() - def refresh_maps(self): - self.to_uri.clear() - self.from_uri.clear() - for playlist in self.core.playlists.playlists.get(): - if not playlist.name: - continue - name = playlist.name - i = 1 - while name in self.to_uri: - name = '%s [%d]' % playlist.name % i - i += 1 - self.to_uri[name] = playlist.uri - self.from_uri[playlist.uri] = name + def refresh_playlists_mapping(self): + if self.core is not None: + self.to_uri.clear() + self.from_uri.clear() + for playlist in self.core.playlists.playlists.get(): + if not playlist.name: + continue + name = playlist.name + i = 1 + while name in self.to_uri: + name = '%s [%d]' % playlist.name % i + i += 1 + self.to_uri[name] = playlist.uri + self.from_uri[playlist.uri] = name diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index efb9a0fd..be641467 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -381,8 +381,11 @@ def searchaddpl(context, playlist_name, mpd_query): return results = context.core.library.search(**query).get() + if len(context.to_uri) == 0: + context.refresh_playlists_mapping() + if playlist_name in context.to_uri: - playlist = context.core.playlists.lookup(context.to_uri[playlist_name]) + playlist = context.core.playlists.lookup(context.to_uri[playlist_name]).get() else: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 4ec2f40c..5d43bc3c 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -81,7 +81,7 @@ def listplaylists(context): if not playlist.name: continue if playlist.uri not in context.from_uri: - context.refresh_maps() # the maps are not synced, we refresh them + context.refresh_playlists_mapping() # the maps are not synced, we refresh them result.append(('playlist', context.from_uri[playlist.uri])) last_modified = ( playlist.last_modified or dt.datetime.utcnow()).isoformat() From a5d81b86c0c5ba768a8434491b2bb1b67439119a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 7 Apr 2013 20:11:12 +0200 Subject: [PATCH 147/403] scanner: Use new config --- mopidy/scanner.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 8bc57349..364828f4 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -34,7 +34,6 @@ import pygst pygst.require('0.10') import gst -from mopidy import settings from mopidy.frontends.mpd import translator as mpd_translator from mopidy.models import Track, Artist, Album from mopidy.utils import log, path, versioning @@ -58,9 +57,9 @@ def main(): logging.warning('Failed %s: %s', uri, error) logging.debug('Debug info for %s: %s', uri, debug) - logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) + logging.info('Scanning %s', config['local']['music_path']) - scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) + scanner = Scanner(config['local']['music_path'], store, debug) try: scanner.start() except KeyboardInterrupt: From 4f0e1e448cced6cf83d568320c08b77b6d554848 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 7 Apr 2013 22:01:34 +0000 Subject: [PATCH 148/403] config: Add path config value tests --- tests/utils/config_test.py | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 77c846df..5439dcc7 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -298,6 +298,55 @@ class PortTest(unittest.TestCase): self.assertRaises(ValueError, value.deserialize, '') +class ExpandedPathTest(unittest.TestCase): + def test_is_bytes(self): + self.assertIsInstance(config.ExpandedPath('/tmp'), bytes) + + @mock.patch('mopidy.utils.path.expand_path') + def test_defaults_to_expanded(self, expand_path_mock): + expand_path_mock.return_value = 'expanded_path' + self.assertEqual('expanded_path', config.ExpandedPath('~')) + + @mock.patch('mopidy.utils.path.expand_path') + def test_orginal_stores_unexpanded(self, expand_path_mock): + self.assertEqual('~', config.ExpandedPath('~').original) + + +class PathTest(unittest.TestCase): + def test_deserialize_conversion_success(self): + result = config.Path().deserialize('/foo') + self.assertEqual('/foo', result) + self.assertIsInstance(result, config.ExpandedPath) + self.assertIsInstance(result, bytes) + + def test_deserialize_enforces_choices(self): + value = config.Path(choices=['/foo', '/bar', '/baz']) + self.assertEqual('/foo', value.deserialize('/foo')) + self.assertRaises(ValueError, value.deserialize, '/foobar') + + def test_deserialize_enforces_required(self): + value = config.Path() + self.assertRaises(ValueError, value.deserialize, '') + self.assertRaises(ValueError, value.deserialize, ' ') + + def test_deserialize_respects_optional(self): + value = config.Path(optional=True) + self.assertIsNone(value.deserialize('')) + self.assertIsNone(value.deserialize(' ')) + + @mock.patch('mopidy.utils.path.expand_path') + def test_serialize_uses_original(self, expand_path_mock): + expand_path_mock.return_value = 'expanded_path' + path = config.ExpandedPath('original_path') + value = config.Path() + self.assertEqual('expanded_path', path) + self.assertEqual('original_path', value.serialize(path)) + + def test_serialize_plain_string(self): + value = config.Path() + self.assertEqual('path', value.serialize('path')) + + class ConfigSchemaTest(unittest.TestCase): def setUp(self): self.schema = config.ConfigSchema() From d83d33aece9559f0c82b76e936e4ff478ee067e2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 10:01:24 +0200 Subject: [PATCH 149/403] scrobbler: Update to use 'scrobbler' config values --- mopidy/frontends/scrobbler/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/scrobbler/actor.py b/mopidy/frontends/scrobbler/actor.py index 469df17a..1809661a 100644 --- a/mopidy/frontends/scrobbler/actor.py +++ b/mopidy/frontends/scrobbler/actor.py @@ -30,8 +30,8 @@ class ScrobblerFrontend(pykka.ThreadingActor, CoreListener): try: self.lastfm = pylast.LastFMNetwork( api_key=API_KEY, api_secret=API_SECRET, - username=self.config['lastfm']['username'], - password_hash=pylast.md5(self.config['lastfm']['password'])) + username=self.config['scrobbler']['username'], + password_hash=pylast.md5(self.config['scrobbler']['password'])) logger.info('Connected to Last.fm') except (pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError) as e: From 05c74abbe321681909312e91254c1c45a3121545 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 10:01:36 +0200 Subject: [PATCH 150/403] http: Revert to use 'static_dir' --- mopidy/frontends/http/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 1b7e7c7d..4297378c 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -30,8 +30,7 @@ port = 6680 # # Change this to have Mopidy serve e.g. files for your JavaScript client. # "/mopidy" will continue to work as usual even if you change this setting. -# -static_path = +static_dir = [logging.levels] cherrypy = warning @@ -533,7 +532,7 @@ class Extension(ext.Extension): schema = config.ExtensionConfigSchema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() - schema['static_path'] = config.Path(optional=True) + schema['static_dir'] = config.Path(optional=True) return schema def validate_environment(self): From 335c4d9fd8ca4f4bf1642da72b3025e37727d05e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 20:42:33 +0200 Subject: [PATCH 151/403] main/path: Inline constants used once --- mopidy/__main__.py | 7 ++++--- mopidy/utils/path.py | 5 ----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 94e245d0..ea7bea32 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -309,9 +309,10 @@ def validate_config(raw_config, schemas, extensions=None): def create_file_structures(): - path.get_or_create_folder(path.DATA_PATH) - path.get_or_create_folder(path.CONFIG_PATH) - path.get_or_create_file(path.CONFIG_FILE) + path.get_or_create_folder(path.expand_path('$XDG_DATA_DIR/mopidy')) + path.get_or_create_folder(path.expand_path('$XDG_CONFIG_DIR/mopidy')) + path.get_or_create_file( + path.expand_path('$XDG_CONFIG_DIR/mopidy/mopidy.conf')) def setup_audio(config): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 88b8be3e..bcd610da 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -25,11 +25,6 @@ XDG_DIRS = { 'XDG_DATA_DIR': XDG_DATA_DIR, 'XDG_MUSIC_DIR': XDG_MUSIC_DIR, } -DATA_PATH = os.path.join(unicode(XDG_DATA_DIR), 'mopidy') -CONFIG_PATH = os.path.join(unicode(XDG_CONFIG_DIR), 'mopidy') -CONFIG_FILE = os.path.join(unicode(CONFIG_PATH), 'mopidy.conf') -SETTINGS_PATH = os.path.join(unicode(XDG_CONFIG_DIR), 'mopidy') -SETTINGS_FILE = os.path.join(unicode(SETTINGS_PATH), 'settings.py') def get_or_create_folder(folder): From 6e5cdb85b0369480b9638dab65ae3f4d77e66dbc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 20:44:27 +0200 Subject: [PATCH 152/403] spotify: Rename 'cache_path' to 'cache_dir' --- mopidy/backends/spotify/__init__.py | 4 ++-- mopidy/backends/spotify/session_manager.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 8aa0f4c0..704471b1 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -25,7 +25,7 @@ bitrate = 160 timeout = 10 # Path to the Spotify data cache. Cannot be shared with other Spotify apps -cache_path = $XDG_CACHE_DIR/mopidy/spotify +cache_dir = $XDG_CACHE_DIR/mopidy/spotify """ __doc__ = """A backend for playing music from Spotify @@ -75,7 +75,7 @@ class Extension(ext.Extension): schema['password'] = config.String(secret=True) schema['bitrate'] = config.Integer(choices=(96, 160, 320)) schema['timeout'] = config.Integer(minimum=0) - schema['cache_path'] = config.Path() + schema['cache_dir'] = config.Path() return schema def validate_environment(self): diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 22ad4632..a830138f 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -30,8 +30,8 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def __init__(self, config, audio, backend_ref): - self.cache_location = config['spotify']['cache_path'] - self.settings_location = config['spotify']['cache_path'] + self.cache_location = config['spotify']['cache_dir'] + self.settings_location = config['spotify']['cache_dir'] PyspotifySessionManager.__init__( self, config['spotify']['username'], config['spotify']['password'], From 3339b79c1e269d2706427bbda8153deab56d4a28 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 20:55:27 +0200 Subject: [PATCH 153/403] local: Rename 'music_path' to 'music_dir', 'playlist_path' to 'playlists_dir' --- mopidy/backends/local/__init__.py | 14 +++++++------- mopidy/backends/local/library.py | 7 +++---- mopidy/backends/local/playlists.py | 22 +++++++++++----------- mopidy/scanner.py | 6 +++--- tests/backends/local/events_test.py | 4 ++-- tests/backends/local/library_test.py | 4 ++-- tests/backends/local/playback_test.py | 4 ++-- tests/backends/local/playlists_test.py | 24 ++++++++++++------------ tests/backends/local/tracklist_test.py | 4 ++-- 9 files changed, 44 insertions(+), 45 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 54a1c7a4..abd3eab6 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -11,13 +11,13 @@ default_config = """ # If the local extension should be enabled or not enabled = true -# Path to folder with local music -music_path = $XDG_MUSIC_DIR +# Path to directory with local media files +media_dir = $XDG_MUSIC_DIR -# Path to playlist folder with m3u files for local music -playlist_path = $XDG_DATA_DIR/mopidy/playlists +# Path to playlists directory with m3u files for local media +playlists_dir = $XDG_DATA_DIR/mopidy/playlists -# Path to tag cache for local music +# Path to tag cache for local media tag_cache_file = $XDG_DATA_DIR/mopidy/tag_cache """ @@ -55,8 +55,8 @@ class Extension(ext.Extension): def get_config_schema(self): schema = config.ExtensionConfigSchema() - schema['music_path'] = config.Path() - schema['playlist_path'] = config.Path() + schema['media_dir'] = config.Path() + schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Path() return schema diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 2b1c93f7..a76ce594 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -14,17 +14,16 @@ class LocalLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(LocalLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} - self._music_path = self.backend.config['local']['music_path'] - self._playlist_path = self.backend.config['local']['playlist_path'] + self._media_dir = self.backend.config['local']['media_dir'] self._tag_cache_file = self.backend.config['local']['tag_cache_file'] self.refresh() def refresh(self, uri=None): - tracks = parse_mpd_tag_cache(self._tag_cache_file, self._music_path) + tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) logger.info( 'Loading tracks from %s using %s', - self._music_path, self._tag_cache_file) + self._media_dir, self._tag_cache_file) for track in tracks: self._uri_mapping[track.uri] = track diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 063d044d..3b9e1d73 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -18,8 +18,8 @@ logger = logging.getLogger('mopidy.backends.local') class LocalPlaylistsProvider(base.BasePlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalPlaylistsProvider, self).__init__(*args, **kwargs) - self._music_path = self.backend.config['local']['music_path'] - self._playlist_path = self.backend.config['local']['playlist_path'] + self._media_dir = self.backend.config['local']['media_dir'] + self._playlists_dir = self.backend.config['local']['playlists_dir'] self.refresh() def create(self, name): @@ -42,16 +42,16 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): return playlist def refresh(self): - logger.info('Loading playlists from %s', self._playlist_path) + logger.info('Loading playlists from %s', self._playlists_dir) playlists = [] - for m3u in glob.glob(os.path.join(self._playlist_path, '*.m3u')): + for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')): uri = path.path_to_uri(m3u) name = os.path.splitext(os.path.basename(m3u))[0] tracks = [] - for track_uri in parse_m3u(m3u, self._music_path): + for track_uri in parse_m3u(m3u, self._media_dir): try: # TODO We must use core.library.lookup() to support tracks # from other backends @@ -86,13 +86,13 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): def _get_m3u_path(self, name): name = formatting.slugify(name) - file_path = os.path.join(self._playlist_path, name + '.m3u') - path.check_file_path_is_inside_base_dir(file_path, self._playlist_path) + file_path = os.path.join(self._playlists_dir, name + '.m3u') + path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) return file_path def _save_m3u(self, playlist): file_path = path.uri_to_path(playlist.uri) - path.check_file_path_is_inside_base_dir(file_path, self._playlist_path) + path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) with open(file_path, 'w') as file_handle: for track in playlist.tracks: if track.uri.startswith('file://'): @@ -103,18 +103,18 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): def _delete_m3u(self, uri): file_path = path.uri_to_path(uri) - path.check_file_path_is_inside_base_dir(file_path, self._playlist_path) + path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir) if os.path.exists(file_path): os.remove(file_path) def _rename_m3u(self, playlist): src_file_path = path.uri_to_path(playlist.uri) path.check_file_path_is_inside_base_dir( - src_file_path, self._playlist_path) + src_file_path, self._playlists_dir) dst_file_path = self._get_m3u_path(playlist.name) path.check_file_path_is_inside_base_dir( - dst_file_path, self._playlist_path) + dst_file_path, self._playlists_dir) shutil.move(src_file_path, dst_file_path) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 364828f4..0f87a4d1 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -57,9 +57,9 @@ def main(): logging.warning('Failed %s: %s', uri, error) logging.debug('Debug info for %s: %s', uri, debug) - logging.info('Scanning %s', config['local']['music_path']) + logging.info('Scanning %s', config['local']['media_dir']) - scanner = Scanner(config['local']['music_path'], store, debug) + scanner = Scanner(config['local']['media_dir'], store, debug) try: scanner.start() except KeyboardInterrupt: @@ -68,7 +68,7 @@ def main(): logging.info('Done scanning; writing tag cache...') for row in mpd_translator.tracks_to_tag_cache_format( - tracks, config['mpd']['music_path']): + tracks, config['mpd']['media_dir']): if len(row) == 1: print ('%s' % row).encode('utf-8') else: diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index 83d77a2f..e09bf4b9 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -10,8 +10,8 @@ class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): backend_class = actor.LocalBackend config = { 'local': { - 'music_path': path_to_data_dir(''), - 'playlist_path': '', + 'media_dir': path_to_data_dir(''), + 'playlists_dir': '', 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index e582c788..74635b3e 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -10,8 +10,8 @@ class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = actor.LocalBackend config = { 'local': { - 'music_path': path_to_data_dir(''), - 'playlist_path': '', + 'media_dir': path_to_data_dir(''), + 'playlists_dir': '', 'tag_cache_file': path_to_data_dir('library_tag_cache'), } } diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 4c304590..16592539 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -14,8 +14,8 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = actor.LocalBackend config = { 'local': { - 'music_path': path_to_data_dir(''), - 'playlist_path': '', + 'media_dir': path_to_data_dir(''), + 'playlists_dir': '', 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index 8528adf4..b35b504f 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -20,32 +20,32 @@ class LocalPlaylistsControllerTest( backend_class = actor.LocalBackend config = { 'local': { - 'music_path': path_to_data_dir(''), + 'media_dir': path_to_data_dir(''), 'tag_cache_file': path_to_data_dir('library_tag_cache'), } } def setUp(self): - self.config['local']['playlist_path'] = tempfile.mkdtemp() - self.playlist_path = self.config['local']['playlist_path'] + self.config['local']['playlists_dir'] = tempfile.mkdtemp() + self.playlists_dir = self.config['local']['playlists_dir'] super(LocalPlaylistsControllerTest, self).setUp() def tearDown(self): super(LocalPlaylistsControllerTest, self).tearDown() - if os.path.exists(self.playlist_path): - shutil.rmtree(self.playlist_path) + if os.path.exists(self.playlists_dir): + shutil.rmtree(self.playlists_dir) def test_created_playlist_is_persisted(self): - path = os.path.join(self.playlist_path, 'test.m3u') + path = os.path.join(self.playlists_dir, 'test.m3u') self.assertFalse(os.path.exists(path)) self.core.playlists.create('test') self.assertTrue(os.path.exists(path)) def test_create_slugifies_playlist_name(self): - path = os.path.join(self.playlist_path, 'test-foo-bar.m3u') + path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test FOO baR') @@ -53,7 +53,7 @@ class LocalPlaylistsControllerTest( self.assertTrue(os.path.exists(path)) def test_create_slugifies_names_which_tries_to_change_directory(self): - path = os.path.join(self.playlist_path, 'test-foo-bar.m3u') + path = os.path.join(self.playlists_dir, 'test-foo-bar.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('../../test FOO baR') @@ -61,8 +61,8 @@ class LocalPlaylistsControllerTest( self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path1 = os.path.join(self.playlist_path, 'test1.m3u') - path2 = os.path.join(self.playlist_path, 'test2-foo-bar.m3u') + path1 = os.path.join(self.playlists_dir, 'test1.m3u') + path2 = os.path.join(self.playlists_dir, 'test2-foo-bar.m3u') playlist = self.core.playlists.create('test1') @@ -77,7 +77,7 @@ class LocalPlaylistsControllerTest( self.assertTrue(os.path.exists(path2)) def test_deleted_playlist_is_removed(self): - path = os.path.join(self.playlist_path, 'test.m3u') + path = os.path.join(self.playlists_dir, 'test.m3u') self.assertFalse(os.path.exists(path)) playlist = self.core.playlists.create('test') @@ -100,7 +100,7 @@ class LocalPlaylistsControllerTest( self.assertEqual(track_path, contents.strip()) def test_playlists_are_loaded_at_startup(self): - playlist_path = os.path.join(self.playlist_path, 'test.m3u') + playlist_path = os.path.join(self.playlists_dir, 'test.m3u') track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) playlist = self.core.playlists.create('test') diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index 3fc8a0be..2d8e87b6 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -12,8 +12,8 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): backend_class = actor.LocalBackend config = { 'local': { - 'music_path': path_to_data_dir(''), - 'playlist_path': '', + 'media_dir': path_to_data_dir(''), + 'playlists_dir': '', 'tag_cache_file': path_to_data_dir('empty_tag_cache'), } } From f9ed1ba4d3045aec5292fda2a0703ef4a34b07fc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 20:58:34 +0200 Subject: [PATCH 154/403] mpd: Rename 'music_path' to 'music_dir' --- mopidy/frontends/mpd/translator.py | 20 ++++++------- tests/frontends/mpd/translator_test.py | 40 +++++++++++++------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index d820b0e0..21be37cc 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -215,14 +215,14 @@ def query_from_mpd_search_format(mpd_query): return query -def tracks_to_tag_cache_format(tracks, music_path): +def tracks_to_tag_cache_format(tracks, media_dir): """ Format list of tracks for output to MPD tag cache :param tracks: the tracks :type tracks: list of :class:`mopidy.models.Track` - :param music_path: the path to the music dir - :type music_path: string + :param media_dir: the path to the music dir + :type media_dir: string :rtype: list of lists of two-tuples """ result = [ @@ -232,13 +232,13 @@ def tracks_to_tag_cache_format(tracks, music_path): ('info_end',) ] tracks.sort(key=lambda t: t.uri) - folders, files = tracks_to_directory_tree(tracks, music_path) - _add_to_tag_cache(result, folders, files, music_path) + folders, files = tracks_to_directory_tree(tracks, media_dir) + _add_to_tag_cache(result, folders, files, media_dir) return result -def _add_to_tag_cache(result, folders, files, music_path): - base_path = music_path.encode('utf-8') +def _add_to_tag_cache(result, folders, files, media_dir): + base_path = media_dir.encode('utf-8') for path, (entry_folders, entry_files) in folders.items(): try: @@ -249,7 +249,7 @@ def _add_to_tag_cache(result, folders, files, music_path): result.append(('directory', text_path)) result.append(('mtime', get_mtime(os.path.join(base_path, path)))) result.append(('begin', name)) - _add_to_tag_cache(result, entry_folders, entry_files, music_path) + _add_to_tag_cache(result, entry_folders, entry_files, media_dir) result.append(('end', name)) result.append(('songList begin',)) @@ -275,7 +275,7 @@ def _add_to_tag_cache(result, folders, files, music_path): result.append(('songList end',)) -def tracks_to_directory_tree(tracks, music_path): +def tracks_to_directory_tree(tracks, media_dir): directories = ({}, []) for track in tracks: @@ -284,7 +284,7 @@ def tracks_to_directory_tree(tracks, music_path): absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) relative_track_dir_path = re.sub( - '^' + re.escape(music_path), b'', absolute_track_dir_path) + '^' + re.escape(media_dir), b'', absolute_track_dir_path) for part in split_path(relative_track_dir_path): path = os.path.join(path, part) diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py index 828acf1a..2f646faf 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -23,7 +23,7 @@ class TrackMpdFormatTest(unittest.TestCase): ) def setUp(self): - self.music_path = '/dir/subdir' + self.media_dir = '/dir/subdir' mtime.set_fake_time(1234567) def tearDown(self): @@ -135,14 +135,14 @@ class QueryFromMpdListFormatTest(unittest.TestCase): class TracksToTagCacheFormatTest(unittest.TestCase): def setUp(self): - self.music_path = '/dir/subdir' + self.media_dir = '/dir/subdir' mtime.set_fake_time(1234567) def tearDown(self): mtime.undo_fake() def translate(self, track): - base_path = self.music_path.encode('utf-8') + base_path = self.media_dir.encode('utf-8') result = dict(translator.track_to_mpd_format(track)) result['file'] = uri_to_path(result['file'])[len(base_path) + 1:] result['key'] = os.path.basename(result['file']) @@ -174,11 +174,11 @@ class TracksToTagCacheFormatTest(unittest.TestCase): self.fail("Couldn't find end %s in result" % directory) def test_empty_tag_cache_has_header(self): - result = translator.tracks_to_tag_cache_format([], self.music_path) + result = translator.tracks_to_tag_cache_format([], self.media_dir) result = self.consume_headers(result) def test_empty_tag_cache_has_song_list(self): - result = translator.tracks_to_tag_cache_format([], self.music_path) + result = translator.tracks_to_tag_cache_format([], self.media_dir) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -187,12 +187,12 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_has_header(self): track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.music_path) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) def test_tag_cache_has_song_list(self): track = Track(uri='file:///dir/subdir/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.music_path) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -202,7 +202,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_has_formated_track(self): track = Track(uri='file:///dir/subdir/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.music_path) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -213,7 +213,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_has_formated_track_with_key_and_mtime(self): track = Track(uri='file:///dir/subdir/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.music_path) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -224,7 +224,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_suports_directories(self): track = Track(uri='file:///dir/subdir/folder/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.music_path) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) folder, result = self.consume_directory(result) @@ -238,7 +238,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_diretory_header_is_right(self): track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') - result = translator.tracks_to_tag_cache_format([track], self.music_path) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) folder, result = self.consume_directory(result) @@ -250,7 +250,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): def test_tag_cache_suports_sub_directories(self): track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') formated = self.translate(track) - result = translator.tracks_to_tag_cache_format([track], self.music_path) + result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) @@ -278,7 +278,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): formated.extend(self.translate(tracks[0])) formated.extend(self.translate(tracks[1])) - result = translator.tracks_to_tag_cache_format(tracks, self.music_path) + result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) result = self.consume_headers(result) song_list, result = self.consume_song_list(result) @@ -296,7 +296,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): formated.append(self.translate(tracks[0])) formated.append(self.translate(tracks[1])) - result = translator.tracks_to_tag_cache_format(tracks, self.music_path) + result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) result = self.consume_headers(result) folder, result = self.consume_directory(result) @@ -312,10 +312,10 @@ class TracksToTagCacheFormatTest(unittest.TestCase): class TracksToDirectoryTreeTest(unittest.TestCase): def setUp(self): - self.music_path = '/root' + self.media_dir = '/root' def test_no_tracks_gives_emtpy_tree(self): - tree = translator.tracks_to_directory_tree([], self.music_path) + tree = translator.tracks_to_directory_tree([], self.media_dir) self.assertEqual(tree, ({}, [])) def test_top_level_files(self): @@ -324,18 +324,18 @@ class TracksToDirectoryTreeTest(unittest.TestCase): Track(uri='file:///root/file2.mp3'), Track(uri='file:///root/file3.mp3'), ] - tree = translator.tracks_to_directory_tree(tracks, self.music_path) + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) self.assertEqual(tree, ({}, tracks)) def test_single_file_in_subdir(self): tracks = [Track(uri='file:///root/dir/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.music_path) + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) expected = ({'dir': ({}, tracks)}, []) self.assertEqual(tree, expected) def test_single_file_in_sub_subdir(self): tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')] - tree = translator.tracks_to_directory_tree(tracks, self.music_path) + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, []) self.assertEqual(tree, expected) @@ -347,7 +347,7 @@ class TracksToDirectoryTreeTest(unittest.TestCase): Track(uri='file:///root/dir2/file4.mp3'), Track(uri='file:///root/dir2/sub/file5.mp3'), ] - tree = translator.tracks_to_directory_tree(tracks, self.music_path) + tree = translator.tracks_to_directory_tree(tracks, self.media_dir) expected = ( { 'dir1': ({}, [tracks[1], tracks[2]]), From 41d7ae9a2c093a7e0f70be97868f73f4662c1324 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 21:13:59 +0200 Subject: [PATCH 155/403] Replace 'folder' with 'dir' --- mopidy/__main__.py | 4 +- mopidy/backends/local/translator.py | 4 +- mopidy/frontends/mpd/translator.py | 10 ++-- mopidy/scanner.py | 4 +- mopidy/utils/path.py | 18 +++---- tests/backends/local/playlists_test.py | 2 +- tests/backends/local/translator_test.py | 2 +- tests/frontends/mpd/translator_test.py | 24 ++++----- tests/scanner_test.py | 2 +- tests/utils/path_test.py | 66 ++++++++++++------------- 10 files changed, 68 insertions(+), 68 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index ea7bea32..4e81856a 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -309,8 +309,8 @@ def validate_config(raw_config, schemas, extensions=None): def create_file_structures(): - path.get_or_create_folder(path.expand_path('$XDG_DATA_DIR/mopidy')) - path.get_or_create_folder(path.expand_path('$XDG_CONFIG_DIR/mopidy')) + path.get_or_create_dir(path.expand_path('$XDG_DATA_DIR/mopidy')) + path.get_or_create_dir(path.expand_path('$XDG_CONFIG_DIR/mopidy')) path.get_or_create_file( path.expand_path('$XDG_CONFIG_DIR/mopidy/mopidy.conf')) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index a36be927..683ad6b4 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -10,7 +10,7 @@ from mopidy.utils.path import path_to_uri logger = logging.getLogger('mopidy.backends.local') -def parse_m3u(file_path, music_folder): +def parse_m3u(file_path, media_dir): r""" Convert M3U file list of uris @@ -49,7 +49,7 @@ def parse_m3u(file_path, music_folder): if line.startswith('file://'): uris.append(line) else: - path = path_to_uri(music_folder, line) + path = path_to_uri(media_dir, line) uris.append(path) return uris diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 21be37cc..7cf5b0c6 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -232,15 +232,15 @@ def tracks_to_tag_cache_format(tracks, media_dir): ('info_end',) ] tracks.sort(key=lambda t: t.uri) - folders, files = tracks_to_directory_tree(tracks, media_dir) - _add_to_tag_cache(result, folders, files, media_dir) + dirs, files = tracks_to_directory_tree(tracks, media_dir) + _add_to_tag_cache(result, dirs, files, media_dir) return result -def _add_to_tag_cache(result, folders, files, media_dir): +def _add_to_tag_cache(result, dirs, files, media_dir): base_path = media_dir.encode('utf-8') - for path, (entry_folders, entry_files) in folders.items(): + for path, (entry_dirs, entry_files) in dirs.items(): try: text_path = path.decode('utf-8') except UnicodeDecodeError: @@ -249,7 +249,7 @@ def _add_to_tag_cache(result, folders, files, media_dir): result.append(('directory', text_path)) result.append(('mtime', get_mtime(os.path.join(base_path, path)))) result.append(('begin', name)) - _add_to_tag_cache(result, entry_folders, entry_files, media_dir) + _add_to_tag_cache(result, entry_dirs, entry_files, media_dir) result.append(('end', name)) result.append(('songList begin',)) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 0f87a4d1..0c78839b 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -142,9 +142,9 @@ def translator(data): class Scanner(object): - def __init__(self, folder, data_callback, error_callback=None): + def __init__(self, base_dir, data_callback, error_callback=None): self.data = {} - self.files = path.find_files(folder) + self.files = path.find_files(base_dir) self.data_callback = data_callback self.error_callback = error_callback self.loop = gobject.MainLoop() diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index bcd610da..eb0cbbb0 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -27,16 +27,16 @@ XDG_DIRS = { } -def get_or_create_folder(folder): - folder = os.path.expanduser(folder) - if os.path.isfile(folder): +def get_or_create_dir(dir_path): + dir_path = os.path.expanduser(dir_path) + if os.path.isfile(dir_path): raise OSError( 'A file with the same name as the desired dir, ' - '"%s", already exists.' % folder) - elif not os.path.isdir(folder): - logger.info('Creating dir %s', folder) - os.makedirs(folder, 0755) - return folder + '"%s", already exists.' % dir_path) + elif not os.path.isdir(dir_path): + logger.info('Creating dir %s', dir_path) + os.makedirs(dir_path, 0755) + return dir_path def get_or_create_file(filename): @@ -121,7 +121,7 @@ def find_files(path): for dirpath, dirnames, filenames in os.walk(path, followlinks=True): for dirname in dirnames: if dirname.startswith(b'.'): - # Skip hidden folders by modifying dirnames inplace + # Skip hidden dirs by modifying dirnames inplace dirnames.remove(dirname) for filename in filenames: diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index b35b504f..acaac941 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -123,5 +123,5 @@ class LocalPlaylistsControllerTest( pass @unittest.SkipTest - def test_playlist_folder_is_createad(self): + def test_playlist_dir_is_created(self): pass diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 61a86672..67907ff1 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -35,7 +35,7 @@ class M3UToUriTest(unittest.TestCase): uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) self.assertEqual([song1_uri], uris) - def test_file_is_relative_to_correct_folder(self): + def test_file_is_relative_to_correct_dir(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write('song1.mp3') try: diff --git a/tests/frontends/mpd/translator_test.py b/tests/frontends/mpd/translator_test.py index 2f646faf..e4755d01 100644 --- a/tests/frontends/mpd/translator_test.py +++ b/tests/frontends/mpd/translator_test.py @@ -221,18 +221,18 @@ class TracksToTagCacheFormatTest(unittest.TestCase): self.assertEqual(formated, song_list) self.assertEqual(len(result), 0) - def test_tag_cache_suports_directories(self): + def test_tag_cache_supports_directories(self): track = Track(uri='file:///dir/subdir/folder/song.mp3') formated = self.translate(track) result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) - folder, result = self.consume_directory(result) + dir_data, result = self.consume_directory(result) song_list, result = self.consume_song_list(result) self.assertEqual(len(song_list), 0) self.assertEqual(len(result), 0) - song_list, result = self.consume_song_list(folder) + song_list, result = self.consume_song_list(dir_data) self.assertEqual(len(result), 0) self.assertEqual(formated, song_list) @@ -241,11 +241,11 @@ class TracksToTagCacheFormatTest(unittest.TestCase): result = translator.tracks_to_tag_cache_format([track], self.media_dir) result = self.consume_headers(result) - folder, result = self.consume_directory(result) + dir_data, result = self.consume_directory(result) - self.assertEqual(('directory', 'folder/sub'), folder[0]) - self.assertEqual(('mtime', mtime('.')), folder[1]) - self.assertEqual(('begin', 'sub'), folder[2]) + self.assertEqual(('directory', 'folder/sub'), dir_data[0]) + self.assertEqual(('mtime', mtime('.')), dir_data[1]) + self.assertEqual(('begin', 'sub'), dir_data[2]) def test_tag_cache_suports_sub_directories(self): track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') @@ -254,17 +254,17 @@ class TracksToTagCacheFormatTest(unittest.TestCase): result = self.consume_headers(result) - folder, result = self.consume_directory(result) + dir_data, result = self.consume_directory(result) song_list, result = self.consume_song_list(result) self.assertEqual(len(song_list), 0) self.assertEqual(len(result), 0) - folder, result = self.consume_directory(folder) + dir_data, result = self.consume_directory(dir_data) song_list, result = self.consume_song_list(result) self.assertEqual(len(result), 0) self.assertEqual(len(song_list), 0) - song_list, result = self.consume_song_list(folder) + song_list, result = self.consume_song_list(dir_data) self.assertEqual(len(result), 0) self.assertEqual(formated, song_list) @@ -299,8 +299,8 @@ class TracksToTagCacheFormatTest(unittest.TestCase): result = translator.tracks_to_tag_cache_format(tracks, self.media_dir) result = self.consume_headers(result) - folder, result = self.consume_directory(result) - song_list, song_result = self.consume_song_list(folder) + dir_data, result = self.consume_directory(result) + song_list, song_result = self.consume_song_list(dir_data) self.assertEqual(formated[1], song_list) self.assertEqual(len(song_result), 0) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 617f2537..edcc2242 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -196,7 +196,7 @@ class ScannerTest(unittest.TestCase): self.check('scanner/simple/song1.mp3', 'title', 'trackname') self.check('scanner/simple/song1.ogg', 'title', 'trackname') - def test_nonexistant_folder_does_not_fail(self): + def test_nonexistant_dir_does_not_fail(self): self.scan('scanner/does-not-exist') self.assert_(not self.errors) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 461f0809..a73cb8e4 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -13,7 +13,7 @@ from mopidy.utils import path from tests import unittest, path_to_data_dir -class GetOrCreateFolderTest(unittest.TestCase): +class GetOrCreateDirTest(unittest.TestCase): def setUp(self): self.parent = tempfile.mkdtemp() @@ -21,40 +21,40 @@ class GetOrCreateFolderTest(unittest.TestCase): if os.path.isdir(self.parent): shutil.rmtree(self.parent) - def test_creating_folder(self): - folder = os.path.join(self.parent, 'test') - self.assert_(not os.path.exists(folder)) - self.assert_(not os.path.isdir(folder)) - created = path.get_or_create_folder(folder) - self.assert_(os.path.exists(folder)) - self.assert_(os.path.isdir(folder)) - self.assertEqual(created, folder) + def test_creating_dir(self): + dir_path = os.path.join(self.parent, 'test') + self.assert_(not os.path.exists(dir_path)) + self.assert_(not os.path.isdir(dir_path)) + created = path.get_or_create_dir(dir_path) + self.assert_(os.path.exists(dir_path)) + self.assert_(os.path.isdir(dir_path)) + self.assertEqual(created, dir_path) - def test_creating_nested_folders(self): - level2_folder = os.path.join(self.parent, 'test') - level3_folder = os.path.join(self.parent, 'test', 'test') - self.assert_(not os.path.exists(level2_folder)) - self.assert_(not os.path.isdir(level2_folder)) - self.assert_(not os.path.exists(level3_folder)) - self.assert_(not os.path.isdir(level3_folder)) - created = path.get_or_create_folder(level3_folder) - self.assert_(os.path.exists(level2_folder)) - self.assert_(os.path.isdir(level2_folder)) - self.assert_(os.path.exists(level3_folder)) - self.assert_(os.path.isdir(level3_folder)) - self.assertEqual(created, level3_folder) + def test_creating_nested_dirs(self): + level2_dir = os.path.join(self.parent, 'test') + level3_dir = os.path.join(self.parent, 'test', 'test') + self.assert_(not os.path.exists(level2_dir)) + self.assert_(not os.path.isdir(level2_dir)) + self.assert_(not os.path.exists(level3_dir)) + self.assert_(not os.path.isdir(level3_dir)) + created = path.get_or_create_dir(level3_dir) + self.assert_(os.path.exists(level2_dir)) + self.assert_(os.path.isdir(level2_dir)) + self.assert_(os.path.exists(level3_dir)) + self.assert_(os.path.isdir(level3_dir)) + self.assertEqual(created, level3_dir) - def test_creating_existing_folder(self): - created = path.get_or_create_folder(self.parent) + def test_creating_existing_dir(self): + created = path.get_or_create_dir(self.parent) self.assert_(os.path.exists(self.parent)) self.assert_(os.path.isdir(self.parent)) self.assertEqual(created, self.parent) - def test_create_folder_with_name_of_existing_file_throws_oserror(self): + def test_create_dir_with_name_of_existing_file_throws_oserror(self): conflicting_file = os.path.join(self.parent, 'test') open(conflicting_file, 'w').close() - folder = os.path.join(self.parent, 'test') - self.assertRaises(OSError, path.get_or_create_folder, folder) + dir_path = os.path.join(self.parent, 'test') + self.assertRaises(OSError, path.get_or_create_dir, dir_path) class PathToFileURITest(unittest.TestCase): @@ -66,7 +66,7 @@ class PathToFileURITest(unittest.TestCase): result = path.path_to_uri('/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') - def test_folder_and_path(self): + def test_dir_and_path(self): if sys.platform == 'win32': result = path.path_to_uri('C:/WINDOWS/', 'clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') @@ -145,10 +145,10 @@ class SplitPathTest(unittest.TestCase): def test_empty_path(self): self.assertEqual([], path.split_path('')) - def test_single_folder(self): + def test_single_dir(self): self.assertEqual(['foo'], path.split_path('foo')) - def test_folders(self): + def test_dirs(self): self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_initial_slash_is_ignored(self): @@ -190,10 +190,10 @@ class FindFilesTest(unittest.TestCase): def find(self, value): return list(path.find_files(path_to_data_dir(value))) - def test_basic_folder(self): + def test_basic_dir(self): self.assert_(self.find('')) - def test_nonexistant_folder(self): + def test_nonexistant_dir(self): self.assertEqual(self.find('does-not-exist'), []) def test_file(self): @@ -207,7 +207,7 @@ class FindFilesTest(unittest.TestCase): self.assert_( is_bytes(name), '%s is not bytes object' % repr(name)) - def test_ignores_hidden_folders(self): + def test_ignores_hidden_dirs(self): self.assertEqual(self.find('.hidden'), []) def test_ignores_hidden_files(self): From ef57c905262822afa518d7faa6aba7ab73901da6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 00:09:33 +0200 Subject: [PATCH 156/403] path: Use our expand_path instead of os.path.expanduser --- mopidy/__main__.py | 7 +++---- mopidy/utils/path.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 4e81856a..6a691cce 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -309,10 +309,9 @@ def validate_config(raw_config, schemas, extensions=None): def create_file_structures(): - path.get_or_create_dir(path.expand_path('$XDG_DATA_DIR/mopidy')) - path.get_or_create_dir(path.expand_path('$XDG_CONFIG_DIR/mopidy')) - path.get_or_create_file( - path.expand_path('$XDG_CONFIG_DIR/mopidy/mopidy.conf')) + path.get_or_create_dir('$XDG_DATA_DIR/mopidy') + path.get_or_create_dir('$XDG_CONFIG_DIR/mopidy') + path.get_or_create_file('$XDG_CONFIG_DIR/mopidy/mopidy.conf') def setup_audio(config): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index eb0cbbb0..699b361f 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -28,7 +28,7 @@ XDG_DIRS = { def get_or_create_dir(dir_path): - dir_path = os.path.expanduser(dir_path) + dir_path = expand_path(dir_path) if os.path.isfile(dir_path): raise OSError( 'A file with the same name as the desired dir, ' @@ -40,7 +40,7 @@ def get_or_create_dir(dir_path): def get_or_create_file(filename): - filename = os.path.expanduser(filename) + filename = expand_path(filename) if not os.path.isfile(filename): logger.info('Creating file %s', filename) open(filename, 'w') From 5be8fa347fd3430aa30ecb4522d8e3d46888af36 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 8 Apr 2013 23:36:37 +0200 Subject: [PATCH 157/403] docs: Add 'confval' object type to Sphinx --- docs/conf.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 55f415b4..7f111014 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -266,3 +266,12 @@ latex_documents = [ needs_sphinx = '1.0' extlinks = {'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#')} + + +def setup(app): + from sphinx.ext.autodoc import cut_lines + app.connect(b'autodoc-process-docstring', cut_lines(4, what=['module'])) + app.add_object_type( + b'confval', 'confval', + objname='configuration value', + indextemplate='pair: %s; configuration value') From 2cd0fdc57197663606134b9371b45693d93bef1c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 00:14:59 +0200 Subject: [PATCH 158/403] docs: Fix typo --- docs/modules/frontends/scrobbler.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/modules/frontends/scrobbler.rst b/docs/modules/frontends/scrobbler.rst index eee65724..2af9fcff 100644 --- a/docs/modules/frontends/scrobbler.rst +++ b/docs/modules/frontends/scrobbler.rst @@ -1,6 +1,6 @@ -********************************************** -:mod:`mopidy.frontends.scrobble` -- Scrobbler -********************************************** +*********************************************** +:mod:`mopidy.frontends.scrobbler` -- Scrobbler +*********************************************** .. automodule:: mopidy.frontends.scrobbler :synopsis: Music scrobbler frontend From 09a38d95238cf889ba035c36133b9c90d296fbe8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 00:22:24 +0200 Subject: [PATCH 159/403] docs: Use :confval: to make config docs linkable --- mopidy/audio/mixers/auto.py | 4 +- mopidy/audio/mixers/fake.py | 2 +- mopidy/audio/mixers/nad.py | 5 ++- mopidy/backends/local/__init__.py | 26 ++++++++---- mopidy/backends/spotify/__init__.py | 38 +++++++++++------ mopidy/backends/stream/__init__.py | 22 +++++++--- mopidy/frontends/http/__init__.py | 57 +++++++++++++++---------- mopidy/frontends/mpd/__init__.py | 59 ++++++++++++++++---------- mopidy/frontends/mpris/__init__.py | 18 +++++--- mopidy/frontends/scrobbler/__init__.py | 24 +++++++---- 10 files changed, 164 insertions(+), 91 deletions(-) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index b24bcf4c..f1dde3f9 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -8,8 +8,8 @@ None **Configuration** -If this wasn't the default, you would set the ``audio/mixer`` config value to -``autoaudiomixer`` to use this mixer. +If this wasn't the default, you would set the :confval:`audio/mixer` config +value to ``autoaudiomixer`` to use this mixer. """ from __future__ import unicode_literals diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 05e86923..f78c7c64 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -6,7 +6,7 @@ None **Configuration** -Set the ``audio/mixer`` config value to ``fakemixer`` to use this mixer. +Set the :confval:`audio/mixe:` config value to ``fakemixer`` to use this mixer. """ from __future__ import unicode_literals diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 058333d1..9259d291 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -9,8 +9,9 @@ serial cable. **Configuration** -Set the ``audio/mixer`` config value to ``nadmixer`` to use it. You probably -also needs to add some properties to the ``audio/mixer`` config value. +Set the :confval:`audio/mixer` config value to ``nadmixer`` to use it. You +probably also needs to add some properties to the :confval:`audio/mixer` config +value. Supported properties includes: diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index abd3eab6..ffcf5869 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,17 +7,9 @@ from mopidy.utils import config, formatting default_config = """ [local] - -# If the local extension should be enabled or not enabled = true - -# Path to directory with local media files media_dir = $XDG_MUSIC_DIR - -# Path to playlists directory with m3u files for local media playlists_dir = $XDG_DATA_DIR/mopidy/playlists - -# Path to tag cache for local media tag_cache_file = $XDG_DATA_DIR/mopidy/tag_cache """ @@ -36,6 +28,24 @@ https://github.com/mopidy/mopidy/issues?labels=Local+backend None +**Configuration** + +.. confval:: local/enabled + + If the local extension should be enabled or not. + +.. confval:: local/media_dir + + Path to directory with local media files. + +.. confval:: local/playlists_dir + + Path to playlists directory with m3u files for local media. + +.. confval:: local/tag_cache_file + + Path to tag cache for local media. + **Default config** .. code-block:: ini diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 704471b1..c26a42e7 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -8,23 +8,11 @@ from mopidy.utils import config, formatting default_config = """ [spotify] - -# If the Spotify extension should be enabled or not enabled = true - -# Your Spotify Premium username username = - -# Your Spotify Premium password password = - -# The preferred audio bitrate. Valid values are 96, 160, 320 bitrate = 160 - -# Max number of seconds to wait for Spotify operations to complete timeout = 10 - -# Path to the Spotify data cache. Cannot be shared with other Spotify apps cache_dir = $XDG_CACHE_DIR/mopidy/spotify """ @@ -52,6 +40,32 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend .. literalinclude:: ../../../requirements/spotify.txt +**Configuration** + +.. confval:: spotify/enabled + + If the Spotify extension should be enabled or not. + +.. confval:: spotify/username + + Your Spotify Premium username. + +.. confval:: spotify/password + + Your Spotify Premium password. + +.. confval:: spotify/bitrate + + The preferred audio bitrate. Valid values are 96, 160, 320. + +.. confval:: spotify/timeout + + Max number of seconds to wait for Spotify operations to complete. + +.. confval:: spotify/cache_dir + + Path to the Spotify data cache. Cannot be shared with other Spotify apps. + **Default config** .. code-block:: ini diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 17b85d33..11918500 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -7,11 +7,7 @@ from mopidy.utils import config, formatting default_config = """ [stream] - -# If the stream extension should be enabled or not enabled = true - -# Whitelist of URI schemas to support streaming from protocols = http https @@ -21,10 +17,12 @@ protocols = rtsp """ -__doc__ = """A backend for playing music for streaming music. +__doc__ = """ +A backend for playing music for streaming music. -This backend will handle streaming of URIs matching the ``stream/protocols`` -config value, assuming the needed GStreamer plugins are installed. +This backend will handle streaming of URIs matching the +:confval:`stream/protocols` config value, assuming the needed GStreamer plugins +are installed. **Issues** @@ -34,6 +32,16 @@ https://github.com/mopidy/mopidy/issues?labels=Stream+backend None +**Configuration** + +.. confval:: stream/enabled + + If the stream extension should be enabled or not. + +.. confval:: stream/protocols + + Whitelist of URI schemas to allow streaming from. + **Default config** .. code-block:: ini diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 4297378c..32d55f23 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -7,29 +7,9 @@ from mopidy.utils import config, formatting default_config = """ [http] - -# If the HTTP extension should be enabled or not enabled = true - -# Which address the HTTP server should bind to -# -# 127.0.0.1 -# Listens only on the IPv4 loopback interface -# ::1 -# Listens only on the IPv6 loopback interface -# 0.0.0.0 -# Listens on all IPv4 interfaces -# :: -# Listens on all interfaces, both IPv4 and IPv6 hostname = 127.0.0.1 - -# Which TCP port the HTTP server should listen to port = 6680 - -# Which directory the HTTP server should serve at "/" -# -# Change this to have Mopidy serve e.g. files for your JavaScript client. -# "/mopidy" will continue to work as usual even if you change this setting. static_dir = [logging.levels] @@ -48,6 +28,36 @@ https://github.com/mopidy/mopidy/issues?labels=HTTP+frontend .. literalinclude:: ../../../requirements/http.txt +**Configuration** + +.. confval:: http/enabled + + If the HTTP extension should be enabled or not. + +.. confval:: http/hostname + + Which address the HTTP server should bind to. + + ``127.0.0.1`` + Listens only on the IPv4 loopback interface + ``::1`` + Listens only on the IPv6 loopback interface + ``0.0.0.0`` + Listens on all IPv4 interfaces + ``::`` + Listens on all interfaces, both IPv4 and IPv6 + +.. confval:: http/port + + Which TCP port the HTTP server should listen to. + +.. confval:: http/static_dir + + Which directory the HTTP server should serve at "/" + + Change this to have Mopidy serve e.g. files for your JavaScript client. + "/mopidy" will continue to work as usual even if you change this setting. + **Default config** .. code-block:: ini @@ -61,13 +71,13 @@ Setup The frontend is enabled by default if all dependencies are available. When it is enabled it starts a web server at the port specified by the -``http/port`` config value. +:confval:`http/port` config value. .. warning:: Security As a simple security measure, the web server is by default only available from localhost. To make it available from other computers, change the - ``http/hostname`` config value. Before you do so, note that the HTTP + :confval:`http/hostname` config value. Before you do so, note that the HTTP frontend does not feature any form of user authentication or authorization. Anyone able to access the web server can use the full core API of Mopidy. Thus, you probably only want to make the web server available from your @@ -405,7 +415,8 @@ Example to get started with 2. Create an empty directory for your web client. -3. Change the ``http/static_dir`` config value to point to your new directory. +3. Change the :confval:`http/static_dir` config value to point to your new + directory. 4. Start/restart Mopidy. diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 08bafd26..69297374 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -7,33 +7,11 @@ from mopidy.utils import config, formatting default_config = """ [mpd] - -# If the MPD extension should be enabled or not enabled = true - -# Which address the MPD server should bind to -# -# 127.0.0.1 -# Listens only on the IPv4 loopback interface -# ::1 -# Listens only on the IPv6 loopback interface -# 0.0.0.0 -# Listens on all IPv4 interfaces -# :: -# Listens on all interfaces, both IPv4 and IPv6 hostname = 127.0.0.1 - -# Which TCP port the MPD server should listen to port = 6600 - -# The password required for connecting to the MPD server password = - -# The maximum number of concurrent connections the MPD server will accept max_connections = 20 - -# Number of seconds an MPD client can stay inactive before the connection is -# closed by the server connection_timeout = 60 """ @@ -51,6 +29,43 @@ https://github.com/mopidy/mopidy/issues?labels=MPD+frontend None +**Configuration** + +.. confval:: mpd/enabled + + If the MPD extension should be enabled or not. + +.. confval:: mpd/hostname + + Which address the MPD server should bind to. + + ``127.0.0.1`` + Listens only on the IPv4 loopback interface + ``::1`` + Listens only on the IPv6 loopback interface + ``0.0.0.0`` + Listens on all IPv4 interfaces + ``::`` + Listens on all interfaces, both IPv4 and IPv6 + +.. confval:: mpd/port + + Which TCP port the MPD server should listen to. + +.. confval:: mpd/password + + The password required for connecting to the MPD server. If blank, no + password is required. + +.. confval:: mpd/max_connections + + The maximum number of concurrent connections the MPD server will accept. + +.. confval:: mpd/connection_timeout + + Number of seconds an MPD client can stay inactive before the connection is + closed by the server. + **Default config** .. code-block:: ini diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 43061013..813e03a2 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -7,11 +7,7 @@ from mopidy.utils import formatting, config default_config = """ [mpris] - -# If the MPRIS extension should be enabled or not enabled = true - -# Location of the Mopidy .desktop file desktop_file = /usr/share/applications/mopidy.desktop """ @@ -33,8 +29,18 @@ An example of an MPRIS client is the `Ubuntu Sound Menu Ubuntu/Debian. - An ``.desktop`` file for Mopidy installed at the path set in the - ``mpris/desktop_file`` config value. See :ref:`install-desktop-file` for - details. + :confval:`mpris/desktop_file` config value. See :ref:`install-desktop-file` + for details. + +**Configuration** + +.. confval:: mpris/enabled + + If the MPRIS extension should be enabled or not. + +.. confval:: mpris/desktop_file + + Location of the Mopidy ``.desktop`` file. **Default config** diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index c33a5fa3..f3127040 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -7,20 +7,14 @@ from mopidy.utils import config, formatting default_config = """ [scrobbler] - -# If the Last.fm extension should be enabled or not enabled = true - -# Your Last.fm username username = - -# Your Last.fm password password = """ __doc__ = """ -Frontend which scrobbles the music you play to your `Last.fm -`_ profile. +Frontend which scrobbles the music you play to your +`Last.fm `_ profile. .. note:: @@ -30,6 +24,20 @@ Frontend which scrobbles the music you play to your `Last.fm .. literalinclude:: ../../../requirements/scrobbler.txt +**Configuration** + +.. confval:: scrobbler/enabled + + If the scrobbler extension should be enabled or not. + +.. confval:: scrobbler/username + + Your Last.fm username. + +.. confval:: scrobbler/password + + Your Last.fm password. + **Default config** .. code-block:: ini From 74032e02ea8de8c80e79edb6154547d8a8fbee91 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 00:23:37 +0200 Subject: [PATCH 160/403] docs: Check for 'MIXER_TRACK_' to not confuse with old 'MIXER_TRACK' setting --- docs/conf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 7f111014..1e9f9cb2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,8 @@ class Mock(object): def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' - elif name[0] == name[0].upper() and not name.startswith('MIXER_TRACK'): + elif (name[0] == name[0].upper() + and not name.startswith('MIXER_TRACK_')): return type(name, (), {}) else: return Mock() From 18d34d6ffc0772af9a0082fcb0315b954c4a49b1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 00:27:22 +0200 Subject: [PATCH 161/403] docs: Fix typo --- mopidy/audio/mixers/fake.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index f78c7c64..98afca2a 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -6,7 +6,8 @@ None **Configuration** -Set the :confval:`audio/mixe:` config value to ``fakemixer`` to use this mixer. +Set the :confval:`audio/mixer:` config value to ``fakemixer`` to use this +mixer. """ from __future__ import unicode_literals From 130347f9940b60fa5aa48024f0eb1b9abceaa8a6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 01:04:47 +0200 Subject: [PATCH 162/403] docs: Update config page howtos --- docs/{settings.rst => config.rst} | 147 +++++++++++++++++------------- docs/index.rst | 2 +- docs/installation/index.rst | 14 +-- 3 files changed, 91 insertions(+), 72 deletions(-) rename docs/{settings.rst => config.rst} (58%) diff --git a/docs/settings.rst b/docs/config.rst similarity index 58% rename from docs/settings.rst rename to docs/config.rst index e16cfbfd..cd75e6dc 100644 --- a/docs/settings.rst +++ b/docs/config.rst @@ -1,32 +1,38 @@ -******** -Settings -******** +************* +Configuration +************* -Mopidy has lots of settings. Luckily, you only need to change a few, and stay -ignorant of the rest. Below you can find guides for typical configuration -changes you may want to do, and a complete listing of available settings. +Mopidy has quite a few config values to tweak. Luckily, you only need to change +a few, and stay ignorant of the rest. Below you can find guides for typical +configuration changes you may want to do, and a listing of the available config +values. -Changing settings -================= +Changing configuration +====================== -Mopidy reads settings from the file ``~/.config/mopidy/settings.py``, where -``~`` means your *home directory*. If your username is ``alice`` and you are -running Linux, the settings file should probably be at -``/home/alice/.config/mopidy/settings.py``. +Mopidy primarily reads config from the file ``~/.config/mopidy/mopidy.conf``, +where ``~`` means your *home directory*. If your username is ``alice`` and you +are running Linux, the settings file should probably be at +``/home/alice/.config/mopidy/mopidy.conf``. -You can either create the settings file yourself, or run the ``mopidy`` +You can either create the configuration file yourself, or run the ``mopidy`` command, and it will create an empty settings file for you. -When you have created the settings file, open it in a text editor, and add +When you have created the configuration file, open it in a text editor, and add settings you want to change. If you want to keep the default value for a setting, you should *not* redefine it in your own settings file. -A complete ``~/.config/mopidy/settings.py`` may look as simple as this:: +A complete ``~/.config/mopidy/mopidy.conf`` may look as simple as this: - MPD_SERVER_HOSTNAME = u'::' - SPOTIFY_USERNAME = u'alice' - SPOTIFY_PASSWORD = u'mysecret' +.. code-block:: ini + + [mpd] + hostname = :: + + [spotify] + username = alice + password = mysecret .. _music-from-spotify: @@ -35,10 +41,16 @@ Music from Spotify ================== If you are using the Spotify backend, which is the default, enter your Spotify -Premium account's username and password into the file, like this:: +Premium account's username and password into the file, like this: - SPOTIFY_USERNAME = u'myusername' - SPOTIFY_PASSWORD = u'mysecret' +.. code-block:: ini + + [spotify] + username = myusername + password = mysecret + +This will only work if you have the Spotify Premium subscription. Spotify +Unlimited will not work. .. _music-from-local-storage: @@ -48,9 +60,8 @@ Music from local storage If you want use Mopidy to play music you have locally at your machine instead of or in addition to using Spotify, you need to review and maybe change some of -the ``LOCAL_*`` settings. See :mod:`mopidy.settings`, for a full list of -available settings. Then you need to generate a tag cache for your local -music... +the local backend config values. See :ref:`local-backend`, for a complete list. +Then you need to generate a tag cache for your local music... .. _generating-a-tag-cache: @@ -58,28 +69,26 @@ music... Generating a tag cache ---------------------- -Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache`` -files generated by the original MPD server. To remedy this the command -:command:`mopidy-scan` was created. The program will scan your current -:attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible -``tag_cache``. +The program :command:`mopidy-scan` will scan the path set in the +:confval:`local/media_dir` config value for any media files and build a MPD +compatible ``tag_cache``. To make a ``tag_cache`` of your local music available for Mopidy: -#. Ensure that :attr:`mopidy.settings.LOCAL_MUSIC_PATH` points to where your +#. Ensure that the :confval:`local/media_dir` config value points to where your music is located. Check the current setting by running:: - mopidy --list-settings + mopidy --show-config -#. Scan your music library. The command outputs the ``tag_cache`` to - ``stdout``, which means that you will need to redirect the output to a file - yourself:: +#. Scan your media library. The command outputs the ``tag_cache`` to + standard output, which means that you will need to redirect the output to a + file yourself:: mopidy-scan > tag_cache #. Move the ``tag_cache`` file to the location - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` is set to, or change the - setting to point to where your ``tag_cache`` file is. + set in the :confval:`local/tag_cache_file` config value, or change the + config value to point to where your ``tag_cache`` file is. #. Start Mopidy, find the music library in a client, and play some local music! @@ -91,14 +100,14 @@ Connecting from other machines on the network As a secure default, Mopidy only accepts connections from ``localhost``. If you want to open it for connections from other machines on your network, see -the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`. +the documentation for the :confval:`mpd/hostname` config value. If you open up Mopidy for your local network, you should consider turning on -MPD password authentication by setting -:attr:`mopidy.settings.MPD_SERVER_PASSWORD` to the password you want to use. -If the password is set, Mopidy will require MPD clients to provide the password -before they can do anything else. Mopidy only supports a single password, and -do not support different permission schemes like the original MPD server. +MPD password authentication by setting the :confval:`mpd/password` config value +to the password you want to use. If the password is set, Mopidy will require +MPD clients to provide the password before they can do anything else. Mopidy +only supports a single password, and do not support different permission +schemes like the original MPD server. Scrobbling tracks to Last.fm @@ -107,10 +116,13 @@ Scrobbling tracks to Last.fm If you want to submit the tracks you are playing to your `Last.fm `_ profile, make sure you've installed the dependencies found at :mod:`mopidy.frontends.scrobbler` and add the following to your -settings file:: +settings file: - LASTFM_USERNAME = u'myusername' - LASTFM_PASSWORD = u'mysecret' +.. code-block:: ini + + [scrobbler] + username = myusername + password = mysecret .. _install-desktop-file: @@ -137,7 +149,7 @@ in the Ubuntu Sound Menu, and may be restarted by selecting it there. The Ubuntu Sound Menu interacts with Mopidy's MPRIS frontend, :mod:`mopidy.frontends.mpris`. The MPRIS frontend supports the minimum requirements of the `MPRIS specification `_. The -``TrackList`` and the ``Playlists`` interfaces of the spec are not supported. +``TrackList`` interface of the spec is not supported. Using a custom audio sink @@ -161,13 +173,16 @@ sound from Mopidy either, as Mopidy by default uses GStreamer's against Mopidy. If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can set the setting :attr:`mopidy.settings.OUTPUT` to a +``autoaudiosink``, you can set the :confval:`audio/output` config value to a partial GStreamer pipeline description describing the GStreamer sink you want to use. -Example of ``settings.py`` for using OSS4:: +Example ``mopidy.conf`` for using OSS4: - OUTPUT = u'oss4sink' +.. code-block:: ini + + [audio] + output = oss4sink Again, this is the equivalent of the following ``gst-inspect`` command, so make this work first:: @@ -186,33 +201,37 @@ server simultaneously. To use the SHOUTcast output, do the following: #. Install, configure and start the Icecast server. It can be found in the ``icecast2`` package in Debian/Ubuntu. -#. Set :attr:`mopidy.settings.OUTPUT` to ``lame ! shout2send``. An Ogg Vorbis - encoder could be used instead of the lame MP3 encoder. +#. Set the :confval:`audio/output` config value to ``lame ! shout2send``. An + Ogg Vorbis encoder could be used instead of the lame MP3 encoder. #. You might also need to change the ``shout2send`` default settings, run ``gst-inspect-0.10 shout2send`` to see the available settings. Most likely you want to change ``ip``, ``username``, ``password``, and ``mount``. For example, to set the username and password, use: - ``lame ! shout2send username="foobar" password="s3cret"``. + + .. code-block:: ini + + [audio] + output = lame ! shout2send username="alice" password="secret" Other advanced setups are also possible for outputs. Basically, anything you can use with the ``gst-launch-0.10`` command can be plugged into -:attr:`mopidy.settings.OUTPUT`. +:confval:`audio/output`. -Custom settings -=============== +Custom configuration values +=========================== -Mopidy's settings validator will stop you from defining any settings in your -settings file that Mopidy doesn't know about. This may sound obnoxious, but it -helps you detect typos in your settings, and deprecated settings that should be -removed or updated. +Mopidy's settings validator will stop you from defining any config values in +your settings file that Mopidy doesn't know about. This may sound obnoxious, +but it helps us detect typos in your settings, and deprecated settings that +should be removed or updated. -If you're extending Mopidy in some way, and want to use Mopidy's settings -system, you can prefix your settings with ``CUSTOM_`` to get around the -settings validator. We recommend that you choose names like -``CUSTOM_MYAPP_MYSETTING`` so that multiple custom extensions to Mopidy can be -used at the same time without any danger of naming collisions. +If you're extending Mopidy, and want to use Mopidy's configuration +system, you can add new sections to the config without triggering the config +validator. We recommend that you choose a good and unique name for the config +section so that multiple extensions to Mopidy can be used at the same time +without any danger of naming collisions. Available settings diff --git a/docs/index.rst b/docs/index.rst index 54745298..aae7e675 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,7 +42,7 @@ User documentation installation/index installation/raspberrypi - settings + config running clients/index authors diff --git a/docs/installation/index.rst b/docs/installation/index.rst index ab81b753..2eade257 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -42,7 +42,7 @@ in the same way as you get updates to the rest of your distribution. sudo apt-get update sudo apt-get install mopidy -#. Finally, you need to set a couple of :doc:`settings `, and then +#. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. When a new release of Mopidy is out, and you can't wait for you system to @@ -89,8 +89,8 @@ package found in AUR. install `python2-pylast `_ from AUR. -#. Finally, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. OS X: Install from Homebrew and Pip @@ -140,8 +140,8 @@ Pip. sudo pip install -U pyspotify pylast mopidy -#. Finally, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. Otherwise: Install from source using Pip @@ -264,5 +264,5 @@ can install Mopidy from PyPI using Pip. sudo pip install mopidy==dev -#. Finally, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. +#. Finally, you need to set a couple of :doc:`config values `, and + then you're ready to :doc:`run Mopidy `. From f9f80e264e872b6815d7818a4a08d7aaa420d694 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 11:05:32 +0200 Subject: [PATCH 163/403] local: Create empty tag_cache if it doesn't exist --- mopidy/backends/local/library.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index a76ce594..148ff24f 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -1,9 +1,11 @@ from __future__ import unicode_literals import logging +import os from mopidy.backends import base from mopidy.models import Album, SearchResult +from mopidy.utils.encoding import locale_decode from .translator import parse_mpd_tag_cache @@ -19,12 +21,23 @@ class LocalLibraryProvider(base.BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) + try: + if not os.path.exists(self._tag_cache_file): + basedir = os.path.dirname(self._tag_cache_file) + if not os.path.exists(basedir): + os.makedirs(basedir) + open(self._tag_cache_file, 'a').close() + except IOError as error: + logger.warning( + 'Could not create empty tag cache: %s', locale_decode(error)) + return logger.info( 'Loading tracks from %s using %s', self._media_dir, self._tag_cache_file) + tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) + for track in tracks: self._uri_mapping[track.uri] = track From 796f7302aa97767aaef6a9ca8f8be6b09f21ac13 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 11:05:55 +0200 Subject: [PATCH 164/403] local: Log how many tracks and playlists are loaded --- mopidy/backends/local/library.py | 8 ++++---- mopidy/backends/local/playlists.py | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 148ff24f..f7a1225a 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -32,15 +32,15 @@ class LocalLibraryProvider(base.BaseLibraryProvider): 'Could not create empty tag cache: %s', locale_decode(error)) return - logger.info( - 'Loading tracks from %s using %s', - self._media_dir, self._tag_cache_file) - tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) for track in tracks: self._uri_mapping[track.uri] = track + logger.info( + 'Loaded %d local tracks from %s using %s', + len(tracks), self._media_dir, self._tag_cache_file) + def lookup(self, uri): try: return [self._uri_mapping[uri]] diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 3b9e1d73..cd370eaa 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -42,8 +42,6 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): return playlist def refresh(self): - logger.info('Loading playlists from %s', self._playlists_dir) - playlists = [] for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')): @@ -65,6 +63,10 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider): self.playlists = playlists listener.BackendListener.send('playlists_loaded') + logger.info( + 'Loaded %d local playlists from %s', + len(playlists), self._playlists_dir) + def save(self, playlist): assert playlist.uri, 'Cannot save playlist without URI' From da7e36d94465ff3842b6cd1774a4cfbf66dc6a4d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 11:06:24 +0200 Subject: [PATCH 165/403] spotify: Use 'playlists' instead of 'playlist(s)' for consistency --- mopidy/backends/spotify/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index a830138f..c0592ea7 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -182,7 +182,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): bitrate=self.bitrate, username=self.username)) playlists = filter(None, playlists) self.backend.playlists.playlists = playlists - logger.info('Loaded %d Spotify playlist(s)', len(playlists)) + logger.info('Loaded %d Spotify playlists', len(playlists)) BackendListener.send('playlists_loaded') def logout(self): From 919becd05a38300268686c40a5087bcd3010c33c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 11:07:52 +0200 Subject: [PATCH 166/403] main: Show 'Disabled extensions: none' instead of nothing --- mopidy/__main__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 6a691cce..2607db13 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -236,8 +236,10 @@ def filter_enabled_extensions(raw_config, extensions): else: disabled_names.append(extension.ext_name) - logging.info('Enabled extensions: %s', ', '.join(enabled_names)) - logging.info('Disabled extensions: %s', ', '.join(disabled_names)) + logging.info( + 'Enabled extensions: %s', ', '.join(enabled_names) or 'none') + logging.info( + 'Disabled extensions: %s', ', '.join(disabled_names) or 'none') return enabled_extensions From 0b09ea34110a0a01b5860d8e98bc16e5d552c6d1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 11:21:25 +0200 Subject: [PATCH 167/403] main: Log what backends/frontends are started --- mopidy/__main__.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 2607db13..2709d519 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -327,12 +327,19 @@ def stop_audio(): def setup_backends(config, extensions, audio): - logger.info('Starting Mopidy backends') - backends = [] + backend_classes = [] for extension in extensions: - for backend_class in extension.get_backend_classes(): - backend = backend_class.start(config=config, audio=audio).proxy() - backends.append(backend) + backend_classes.extend(extension.get_backend_classes()) + + logger.info( + 'Starting Mopidy backends: %s', + ', '.join(b.__name__ for b in backend_classes)) + + backends = [] + for backend_class in backend_classes: + backend = backend_class.start(config=config, audio=audio).proxy() + backends.append(backend) + return backends @@ -354,10 +361,16 @@ def stop_core(): def setup_frontends(config, extensions, core): - logger.info('Starting Mopidy frontends') + frontend_classes = [] for extension in extensions: - for frontend_class in extension.get_frontend_classes(): - frontend_class.start(config=config, core=core) + frontend_classes.extend(extension.get_frontend_classes()) + + logger.info( + 'Starting Mopidy frontends: %s', + ', '.join(f.__name__ for f in frontend_classes)) + + for frontend_class in frontend_classes: + frontend_class.start(config=config, core=core) def stop_frontends(extensions): From 2ffef53b9a00a83f12aa085c05750365b161bff9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 11:22:56 +0200 Subject: [PATCH 168/403] mpris: Log who connected to D-Bus --- mopidy/frontends/mpris/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 696e39bd..a7f049d2 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -93,7 +93,7 @@ class MprisObject(dbus.service.Object): mainloop = dbus.mainloop.glib.DBusGMainLoop() bus_name = dbus.service.BusName( BUS_NAME, dbus.SessionBus(mainloop=mainloop)) - logger.info('Connected to D-Bus') + logger.info('MPRIS server connected to D-Bus') return bus_name def get_playlist_id(self, playlist_uri): From 3b7d38e8bcf1efda324e5cc863fd5db50f29c8ed Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 11:23:06 +0200 Subject: [PATCH 169/403] scrobbler: Log who connected to Last.fm --- mopidy/frontends/scrobbler/actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/scrobbler/actor.py b/mopidy/frontends/scrobbler/actor.py index 1809661a..74a11f82 100644 --- a/mopidy/frontends/scrobbler/actor.py +++ b/mopidy/frontends/scrobbler/actor.py @@ -32,7 +32,7 @@ class ScrobblerFrontend(pykka.ThreadingActor, CoreListener): api_key=API_KEY, api_secret=API_SECRET, username=self.config['scrobbler']['username'], password_hash=pylast.md5(self.config['scrobbler']['password'])) - logger.info('Connected to Last.fm') + logger.info('Scrobbler connected to Last.fm') except (pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError) as e: logger.error('Error during Last.fm setup: %s', e) From 7d59d03ec060f5f3c5b21421a2f85ee5262d70bb Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Tue, 9 Apr 2013 11:44:53 +0200 Subject: [PATCH 170/403] mpd: minor style commit --- mopidy/frontends/mpd/dispatcher.py | 36 ++++++++++--------- mopidy/frontends/mpd/protocol/music_db.py | 7 ++-- .../mpd/protocol/stored_playlists.py | 9 ++--- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index f0842fa7..ed2c82a1 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -232,12 +232,9 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - #: a map from playlists printing names to uris (necessary as mpd requires - #: playlists names to be unique) - to_uri = None + playlist_uri_from_name = None - #: the invert map (uri to printing name) - from_uri = None + playlist_name_from_uri = None def __init__(self, dispatcher, session=None, core=None): self.dispatcher = dispatcher @@ -245,21 +242,28 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self.to_uri = {} - self.from_uri = {} + self.playlist_uri_from_name = {} + self.playlist_name_from_uri = {} self.refresh_playlists_mapping() + def create_unique_name(self, name): + i = 1 + while name in self.playlist_uri_from_name: + name = '%s [%d]' % playlist.name % i + i += 1 + return name + def refresh_playlists_mapping(self): + """ + Maintain map between playlists and unique playlist names to be used by + MPD + """ if self.core is not None: - self.to_uri.clear() - self.from_uri.clear() + self.playlist_uri_from_name.clear() + self.playlist_name_from_uri.clear() for playlist in self.core.playlists.playlists.get(): if not playlist.name: continue - name = playlist.name - i = 1 - while name in self.to_uri: - name = '%s [%d]' % playlist.name % i - i += 1 - self.to_uri[name] = playlist.uri - self.from_uri[playlist.uri] = name + name = self.create_unique_name(playlist.name) + self.playlist_uri_from_name[name] = playlist.uri + self.playlist_name_from_uri[playlist.uri] = name diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index be641467..11def309 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -381,11 +381,12 @@ def searchaddpl(context, playlist_name, mpd_query): return results = context.core.library.search(**query).get() - if len(context.to_uri) == 0: + if len(context.playlist_uri_from_name) == 0: context.refresh_playlists_mapping() - if playlist_name in context.to_uri: - playlist = context.core.playlists.lookup(context.to_uri[playlist_name]).get() + if playlist_name in context.playlist_uri_from_name: + uri = context.playlist_uri_from_name[playlist_name] + playlist = context.core.playlists.lookup(uri).get() else: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 5d43bc3c..b9fbad06 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -80,9 +80,11 @@ def listplaylists(context): for playlist in context.core.playlists.playlists.get(): if not playlist.name: continue - if playlist.uri not in context.from_uri: - context.refresh_playlists_mapping() # the maps are not synced, we refresh them - result.append(('playlist', context.from_uri[playlist.uri])) + if playlist.uri not in context.playlist_name_from_uri: + # the maps are not synced, we refresh them + context.refresh_playlists_mapping() + name = context.playlist_name_from_uri[playlist.uri] + result.append(('playlist', name)) last_modified = ( playlist.last_modified or dt.datetime.utcnow()).isoformat() # Remove microseconds @@ -92,7 +94,6 @@ def listplaylists(context): result.append(('Last-Modified', last_modified)) return result - @handle_request(r'^load "(?P[^"]+)"( "(?P\d+):(?P\d+)*")*$') def load(context, name, start=None, end=None): """ From 339fbbc2ddbe1d502c7e9cf34d5f1fbddad11dee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:20:36 +0200 Subject: [PATCH 171/403] main: Use 'none' instead of emptry string Same logic as for extensions are now applied to backends and frontends. --- mopidy/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 2709d519..d5f8c93e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -333,7 +333,7 @@ def setup_backends(config, extensions, audio): logger.info( 'Starting Mopidy backends: %s', - ', '.join(b.__name__ for b in backend_classes)) + ', '.join(b.__name__ for b in backend_classes) or 'none') backends = [] for backend_class in backend_classes: @@ -367,7 +367,7 @@ def setup_frontends(config, extensions, core): logger.info( 'Starting Mopidy frontends: %s', - ', '.join(f.__name__ for f in frontend_classes)) + ', '.join(f.__name__ for f in frontend_classes) or 'none') for frontend_class in frontend_classes: frontend_class.start(config=config, core=core) From 40dd539d061040f99da00aa955a7ce3cf78c9763 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:38:47 +0200 Subject: [PATCH 172/403] path: Test get_or_create_file() --- tests/utils/path_test.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index a73cb8e4..d40c822f 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -24,7 +24,6 @@ class GetOrCreateDirTest(unittest.TestCase): def test_creating_dir(self): dir_path = os.path.join(self.parent, 'test') self.assert_(not os.path.exists(dir_path)) - self.assert_(not os.path.isdir(dir_path)) created = path.get_or_create_dir(dir_path) self.assert_(os.path.exists(dir_path)) self.assert_(os.path.isdir(dir_path)) @@ -34,9 +33,7 @@ class GetOrCreateDirTest(unittest.TestCase): level2_dir = os.path.join(self.parent, 'test') level3_dir = os.path.join(self.parent, 'test', 'test') self.assert_(not os.path.exists(level2_dir)) - self.assert_(not os.path.isdir(level2_dir)) self.assert_(not os.path.exists(level3_dir)) - self.assert_(not os.path.isdir(level3_dir)) created = path.get_or_create_dir(level3_dir) self.assert_(os.path.exists(level2_dir)) self.assert_(os.path.isdir(level2_dir)) @@ -57,6 +54,35 @@ class GetOrCreateDirTest(unittest.TestCase): self.assertRaises(OSError, path.get_or_create_dir, dir_path) +class GetOrCreateFileTest(unittest.TestCase): + def setUp(self): + self.parent = tempfile.mkdtemp() + + def tearDown(self): + if os.path.isdir(self.parent): + shutil.rmtree(self.parent) + + def test_creating_file(self): + file_path = os.path.join(self.parent, 'test') + self.assert_(not os.path.exists(file_path)) + created = path.get_or_create_file(file_path) + self.assert_(os.path.exists(file_path)) + self.assert_(os.path.isfile(file_path)) + self.assertEqual(created, file_path) + + def test_creating_existing_file(self): + file_path = os.path.join(self.parent, 'test') + path.get_or_create_file(file_path) + created = path.get_or_create_file(file_path) + self.assert_(os.path.exists(file_path)) + self.assert_(os.path.isfile(file_path)) + self.assertEqual(created, file_path) + + def test_create_file_with_name_of_existing_dir_throws_ioerror(self): + conflicting_dir = os.path.join(self.parent) + self.assertRaises(IOError, path.get_or_create_file, conflicting_dir) + + class PathToFileURITest(unittest.TestCase): def test_simple_path(self): if sys.platform == 'win32': From 53827aa0227fd81e03f0ffe60766f4df8c09639e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:39:29 +0200 Subject: [PATCH 173/403] path: Close create file right away --- mopidy/utils/path.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 699b361f..8f842741 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -39,12 +39,12 @@ def get_or_create_dir(dir_path): return dir_path -def get_or_create_file(filename): - filename = expand_path(filename) - if not os.path.isfile(filename): - logger.info('Creating file %s', filename) - open(filename, 'w') - return filename +def get_or_create_file(file_path): + file_path = expand_path(file_path) + if not os.path.isfile(file_path): + logger.info('Creating file %s', file_path) + open(file_path, 'w').close() + return file_path def path_to_uri(*paths): From 02f9db451836ee1e6de0459ba7e6a3480dfe7650 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:40:28 +0200 Subject: [PATCH 174/403] path: Let get_or_create_file() create missing dirs --- mopidy/utils/path.py | 1 + tests/utils/path_test.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 8f842741..2ad51368 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -41,6 +41,7 @@ def get_or_create_dir(dir_path): def get_or_create_file(file_path): file_path = expand_path(file_path) + get_or_create_dir(os.path.dirname(file_path)) if not os.path.isfile(file_path): logger.info('Creating file %s', file_path) open(file_path, 'w').close() diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index d40c822f..9d1c16d3 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -70,6 +70,18 @@ class GetOrCreateFileTest(unittest.TestCase): self.assert_(os.path.isfile(file_path)) self.assertEqual(created, file_path) + def test_creating_nested_file(self): + level2_dir = os.path.join(self.parent, 'test') + file_path = os.path.join(self.parent, 'test', 'test') + self.assert_(not os.path.exists(level2_dir)) + self.assert_(not os.path.exists(file_path)) + created = path.get_or_create_file(file_path) + self.assert_(os.path.exists(level2_dir)) + self.assert_(os.path.isdir(level2_dir)) + self.assert_(os.path.exists(file_path)) + self.assert_(os.path.isfile(file_path)) + self.assertEqual(created, file_path) + def test_creating_existing_file(self): file_path = os.path.join(self.parent, 'test') path.get_or_create_file(file_path) From 87b526f8d3324236ecd0ca4a1723bef2fac7d094 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:41:07 +0200 Subject: [PATCH 175/403] main: Remove redundant dir creation --- mopidy/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index d5f8c93e..7e1677ef 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -312,7 +312,6 @@ def validate_config(raw_config, schemas, extensions=None): def create_file_structures(): path.get_or_create_dir('$XDG_DATA_DIR/mopidy') - path.get_or_create_dir('$XDG_CONFIG_DIR/mopidy') path.get_or_create_file('$XDG_CONFIG_DIR/mopidy/mopidy.conf') From 172b6f046131e9735ab4310e8d499bea16fe2131 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:44:16 +0200 Subject: [PATCH 176/403] local: Use path helpers to create tag cache --- mopidy/backends/local/library.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index f7a1225a..6b953823 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -1,11 +1,9 @@ from __future__ import unicode_literals import logging -import os - from mopidy.backends import base from mopidy.models import Album, SearchResult -from mopidy.utils.encoding import locale_decode +from mopidy.utils import encoding, path from .translator import parse_mpd_tag_cache @@ -22,14 +20,11 @@ class LocalLibraryProvider(base.BaseLibraryProvider): def refresh(self, uri=None): try: - if not os.path.exists(self._tag_cache_file): - basedir = os.path.dirname(self._tag_cache_file) - if not os.path.exists(basedir): - os.makedirs(basedir) - open(self._tag_cache_file, 'a').close() - except IOError as error: + path.get_or_create_file(self._tag_cache_file) + except EnvironmentError as error: logger.warning( - 'Could not create empty tag cache: %s', locale_decode(error)) + 'Could not create empty tag cache: %s', + encoding.locale_decode(error)) return tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) From bc959e2240f708b6f55b9c51de3b7bd8b7796e54 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:44:35 +0200 Subject: [PATCH 177/403] local: Move data files into an extension specific dir --- mopidy/backends/local/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index ffcf5869..0367cf15 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -9,8 +9,8 @@ default_config = """ [local] enabled = true media_dir = $XDG_MUSIC_DIR -playlists_dir = $XDG_DATA_DIR/mopidy/playlists -tag_cache_file = $XDG_DATA_DIR/mopidy/tag_cache +playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists +tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache """ __doc__ = """A backend for playing music from a local music archive. From bf83a159fedb54d9a12cf24f28ba012d9445f457 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 12:51:34 +0200 Subject: [PATCH 178/403] local: Create all needed dirs/files on startup --- mopidy/backends/local/actor.py | 25 +++++++++++++++++++++++++ mopidy/backends/local/library.py | 11 +++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py index a1655dd9..1817e133 100644 --- a/mopidy/backends/local/actor.py +++ b/mopidy/backends/local/actor.py @@ -5,6 +5,7 @@ import logging import pykka from mopidy.backends import base +from mopidy.utils import encoding, path from .library import LocalLibraryProvider from .playlists import LocalPlaylistsProvider @@ -18,8 +19,32 @@ class LocalBackend(pykka.ThreadingActor, base.Backend): self.config = config + self.create_dirs_and_files() + self.library = LocalLibraryProvider(backend=self) self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = LocalPlaylistsProvider(backend=self) self.uri_schemes = ['file'] + + def create_dirs_and_files(self): + try: + path.get_or_create_dir(self.config['local']['media_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create local media dir: %s', + encoding.locale_decode(error)) + + try: + path.get_or_create_dir(self.config['local']['playlists_dir']) + except EnvironmentError as error: + logger.warning( + 'Could not create local playlists dir: %s', + encoding.locale_decode(error)) + + try: + path.get_or_create_file(self.config['local']['tag_cache_file']) + except EnvironmentError as error: + logger.warning( + 'Could not create empty tag cache file: %s', + encoding.locale_decode(error)) diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 6b953823..669e72d7 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import logging from mopidy.backends import base from mopidy.models import Album, SearchResult -from mopidy.utils import encoding, path from .translator import parse_mpd_tag_cache @@ -19,13 +18,9 @@ class LocalLibraryProvider(base.BaseLibraryProvider): self.refresh() def refresh(self, uri=None): - try: - path.get_or_create_file(self._tag_cache_file) - except EnvironmentError as error: - logger.warning( - 'Could not create empty tag cache: %s', - encoding.locale_decode(error)) - return + logger.debug( + 'Loading local tracks from %s using %s', + self._media_dir, self._tag_cache_file) tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) From 18a2d1770b47160a3aa8848fa5dacffc2a2effcb Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Tue, 9 Apr 2013 19:25:10 +0200 Subject: [PATCH 179/403] mpd: bugfix in MpdContext.create_unique_name --- mopidy/frontends/mpd/dispatcher.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index ed2c82a1..0054a6f1 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -246,10 +246,11 @@ class MpdContext(object): self.playlist_name_from_uri = {} self.refresh_playlists_mapping() - def create_unique_name(self, name): + def create_unique_name(self, playlist_name): + name = playlist_name i = 1 while name in self.playlist_uri_from_name: - name = '%s [%d]' % playlist.name % i + name = '%s [%d]' % (playlist_name, i) i += 1 return name From 5af26a226e439a62b99237718421eb8646c62eba Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Tue, 9 Apr 2013 19:25:52 +0200 Subject: [PATCH 180/403] mpd: added a test to check playlists names uniqueness --- tests/frontends/mpd/protocol/stored_playlists_test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 49da5d0b..a628deae 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -64,6 +64,16 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse('Last-Modified: 2001-03-17T13:41:17Z') self.assertInResponse('OK') + def test_listplaylists_duplicate(self): + playlist1 = Playlist(name='a', uri='dummy:a1') + playlist2 = Playlist(name='a', uri='dummy:a2') + self.backend.playlists.playlists = [ playlist1 , playlist2 ] + + self.sendRequest('listplaylists') + self.assertInResponse('playlist: a') + self.assertInResponse('playlist: a [1]') + self.assertInResponse('OK') + def test_listplaylists_ignores_playlists_without_name(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) self.backend.playlists.playlists = [ From f2000d6e6df362fa9c07379a483d91cf2b67ce88 Mon Sep 17 00:00:00 2001 From: Thomas Refis Date: Tue, 9 Apr 2013 21:31:36 +0200 Subject: [PATCH 181/403] mpd: 'private' fields start with an _ --- mopidy/frontends/mpd/dispatcher.py | 18 +++++++++--------- mopidy/frontends/mpd/protocol/music_db.py | 6 +++--- .../frontends/mpd/protocol/stored_playlists.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 0054a6f1..d269786e 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -232,9 +232,9 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - playlist_uri_from_name = None + _playlist_uri_from_name = None - playlist_name_from_uri = None + _playlist_name_from_uri = None def __init__(self, dispatcher, session=None, core=None): self.dispatcher = dispatcher @@ -242,14 +242,14 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self.playlist_uri_from_name = {} - self.playlist_name_from_uri = {} + self._playlist_uri_from_name = {} + self._playlist_name_from_uri = {} self.refresh_playlists_mapping() def create_unique_name(self, playlist_name): name = playlist_name i = 1 - while name in self.playlist_uri_from_name: + while name in self._playlist_uri_from_name: name = '%s [%d]' % (playlist_name, i) i += 1 return name @@ -260,11 +260,11 @@ class MpdContext(object): MPD """ if self.core is not None: - self.playlist_uri_from_name.clear() - self.playlist_name_from_uri.clear() + self._playlist_uri_from_name.clear() + self._playlist_name_from_uri.clear() for playlist in self.core.playlists.playlists.get(): if not playlist.name: continue name = self.create_unique_name(playlist.name) - self.playlist_uri_from_name[name] = playlist.uri - self.playlist_name_from_uri[playlist.uri] = name + self._playlist_uri_from_name[name] = playlist.uri + self._playlist_name_from_uri[playlist.uri] = name diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 11def309..ac7c860a 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -381,11 +381,11 @@ def searchaddpl(context, playlist_name, mpd_query): return results = context.core.library.search(**query).get() - if len(context.playlist_uri_from_name) == 0: + if len(context._playlist_uri_from_name) == 0: context.refresh_playlists_mapping() - if playlist_name in context.playlist_uri_from_name: - uri = context.playlist_uri_from_name[playlist_name] + if playlist_name in context._playlist_uri_from_name: + uri = context._playlist_uri_from_name[playlist_name] playlist = context.core.playlists.lookup(uri).get() else: playlist = context.core.playlists.create(playlist_name).get() diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index b9fbad06..a01e2775 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -80,10 +80,10 @@ def listplaylists(context): for playlist in context.core.playlists.playlists.get(): if not playlist.name: continue - if playlist.uri not in context.playlist_name_from_uri: + if playlist.uri not in context._playlist_name_from_uri: # the maps are not synced, we refresh them context.refresh_playlists_mapping() - name = context.playlist_name_from_uri[playlist.uri] + name = context._playlist_name_from_uri[playlist.uri] result.append(('playlist', name)) last_modified = ( playlist.last_modified or dt.datetime.utcnow()).isoformat() From 27ff2e5f0ababf6d2a9a4aadb7cf5f3b96942651 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 23:26:23 +0200 Subject: [PATCH 182/403] Revert "mpd: 'private' fields start with an _" This reverts commit f2000d6e6df362fa9c07379a483d91cf2b67ce88. I did not have the code fresh in mind when saying that these should be prefixed with _. As they are accessed from other classes, they are indeed public, and should not be prefixed. My bad. --- mopidy/frontends/mpd/dispatcher.py | 18 +++++++++--------- mopidy/frontends/mpd/protocol/music_db.py | 6 +++--- .../frontends/mpd/protocol/stored_playlists.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index d269786e..0054a6f1 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -232,9 +232,9 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - _playlist_uri_from_name = None + playlist_uri_from_name = None - _playlist_name_from_uri = None + playlist_name_from_uri = None def __init__(self, dispatcher, session=None, core=None): self.dispatcher = dispatcher @@ -242,14 +242,14 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self._playlist_uri_from_name = {} - self._playlist_name_from_uri = {} + self.playlist_uri_from_name = {} + self.playlist_name_from_uri = {} self.refresh_playlists_mapping() def create_unique_name(self, playlist_name): name = playlist_name i = 1 - while name in self._playlist_uri_from_name: + while name in self.playlist_uri_from_name: name = '%s [%d]' % (playlist_name, i) i += 1 return name @@ -260,11 +260,11 @@ class MpdContext(object): MPD """ if self.core is not None: - self._playlist_uri_from_name.clear() - self._playlist_name_from_uri.clear() + self.playlist_uri_from_name.clear() + self.playlist_name_from_uri.clear() for playlist in self.core.playlists.playlists.get(): if not playlist.name: continue name = self.create_unique_name(playlist.name) - self._playlist_uri_from_name[name] = playlist.uri - self._playlist_name_from_uri[playlist.uri] = name + self.playlist_uri_from_name[name] = playlist.uri + self.playlist_name_from_uri[playlist.uri] = name diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index ac7c860a..11def309 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -381,11 +381,11 @@ def searchaddpl(context, playlist_name, mpd_query): return results = context.core.library.search(**query).get() - if len(context._playlist_uri_from_name) == 0: + if len(context.playlist_uri_from_name) == 0: context.refresh_playlists_mapping() - if playlist_name in context._playlist_uri_from_name: - uri = context._playlist_uri_from_name[playlist_name] + if playlist_name in context.playlist_uri_from_name: + uri = context.playlist_uri_from_name[playlist_name] playlist = context.core.playlists.lookup(uri).get() else: playlist = context.core.playlists.create(playlist_name).get() diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index a01e2775..b9fbad06 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -80,10 +80,10 @@ def listplaylists(context): for playlist in context.core.playlists.playlists.get(): if not playlist.name: continue - if playlist.uri not in context._playlist_name_from_uri: + if playlist.uri not in context.playlist_name_from_uri: # the maps are not synced, we refresh them context.refresh_playlists_mapping() - name = context._playlist_name_from_uri[playlist.uri] + name = context.playlist_name_from_uri[playlist.uri] result.append(('playlist', name)) last_modified = ( playlist.last_modified or dt.datetime.utcnow()).isoformat() From 24271681c95c31560c2435afd3f7a2673030dcf6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 23:28:10 +0200 Subject: [PATCH 183/403] mpd: Fix flake8 warnings --- mopidy/frontends/mpd/protocol/stored_playlists.py | 1 + tests/frontends/mpd/protocol/stored_playlists_test.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index b9fbad06..0c9bf050 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -94,6 +94,7 @@ def listplaylists(context): result.append(('Last-Modified', last_modified)) return result + @handle_request(r'^load "(?P[^"]+)"( "(?P\d+):(?P\d+)*")*$') def load(context, name, start=None, end=None): """ diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index a628deae..1936e4c4 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -67,7 +67,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_duplicate(self): playlist1 = Playlist(name='a', uri='dummy:a1') playlist2 = Playlist(name='a', uri='dummy:a2') - self.backend.playlists.playlists = [ playlist1 , playlist2 ] + self.backend.playlists.playlists = [playlist1, playlist2] self.sendRequest('listplaylists') self.assertInResponse('playlist: a') From 016b2db69b1feabc1835b0040725dcb298fe39dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 23:35:00 +0200 Subject: [PATCH 184/403] mpd: Start numbering of duplicate playlist names at 2 --- mopidy/frontends/mpd/dispatcher.py | 2 +- tests/frontends/mpd/protocol/stored_playlists_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 0054a6f1..660e1739 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -248,7 +248,7 @@ class MpdContext(object): def create_unique_name(self, playlist_name): name = playlist_name - i = 1 + i = 2 while name in self.playlist_uri_from_name: name = '%s [%d]' % (playlist_name, i) i += 1 diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 1936e4c4..d837b0fa 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -71,7 +71,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.sendRequest('listplaylists') self.assertInResponse('playlist: a') - self.assertInResponse('playlist: a [1]') + self.assertInResponse('playlist: a [2]') self.assertInResponse('OK') def test_listplaylists_ignores_playlists_without_name(self): From 2b5e2f6e3f9d75ff721cf8bc3ab08479d5596023 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 9 Apr 2013 23:41:52 +0200 Subject: [PATCH 185/403] Update changelog --- docs/changes.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 0c14db72..2bff6c67 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,14 @@ v0.14.0 (UNRELEASED) - Add support for starred playlists, both your own and those owned by other users. (Fixes: :issue:`326`) +**MPD frontend** + +- Playlists with identical names are now handled properly by the MPD frontend + by suffixing the duplicate names with e.g. ``[2]``. This is needed because + MPD identify playlists by name only, while Mopidy and Spotify supports + multiple playlists with the same name, and identify them using an URI. + (Fixes: :issue:`114`) + v0.13.0 (2013-03-31) ==================== From 70068c0f4bfcf6e385349ee601f2705ce44cb86a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Apr 2013 23:57:57 +0200 Subject: [PATCH 186/403] mpris: Only import 'indicate' if $DISPLAY is set This avoids printing of the following error message: ImportError: could not import gtk.gdk Fixes #311 --- mopidy/frontends/mpris/actor.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 92805bd3..fae8618f 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import logging +import os import pykka @@ -10,10 +11,14 @@ from mopidy.frontends.mpris import objects logger = logging.getLogger('mopidy.frontends.mpris') try: - import indicate -except ImportError as import_error: - indicate = None # noqa - logger.debug('Startup notification will not be sent (%s)', import_error) + indicate = None + if 'DISPLAY' in os.environ: + import indicate +except ImportError: + pass + +if indicate is None: + logger.debug('Startup notification will not be sent') class MprisFrontend(pykka.ThreadingActor, CoreListener): From c1b42d97cd1ff72c9f4fa1067e57bae127ab8465 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 10 Apr 2013 23:58:55 +0200 Subject: [PATCH 187/403] mpris: Disable extension if no $DISPLAY set We cannot connect to the D-Bus session bus if there is no X11 $DISPLAY available. --- mopidy/frontends/mpris/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 813e03a2..5be9c6cf 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import os + import mopidy from mopidy import exceptions, ext from mopidy.utils import formatting, config @@ -89,6 +91,10 @@ class Extension(ext.Extension): return schema def validate_environment(self): + if 'DISPLAY' not in os.environ: + raise exceptions.ExtensionError( + 'An X11 $DISPLAY is needed to use D-Bus') + try: import dbus # noqa except ImportError as e: From 8af03262914154a591a3c4ea24822d1a9449031b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 13:13:17 +0200 Subject: [PATCH 188/403] ext: Move ext helper functions from 'main' to 'ext' --- mopidy/__main__.py | 75 ++++------------------------------------------ mopidy/ext.py | 72 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 71 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 7e1677ef..069c883e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -12,7 +12,6 @@ import sys import gobject gobject.threads_init() -import pkg_resources import pykka.debug @@ -28,12 +27,11 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import exceptions +from mopidy import exceptions, ext from mopidy.audio import Audio from mopidy.config import default_config, config_schemas from mopidy.core import Core -from mopidy.utils import ( - config as config_utils, deps, log, path, process, versioning) +from mopidy.utils import deps, log, path, process, versioning logger = logging.getLogger('mopidy.main') @@ -55,9 +53,9 @@ def main(): logging_config = load_config(config_files, config_overrides) log.setup_logging( logging_config, options.verbosity_level, options.save_debug_log) - extensions = load_extensions() + extensions = ext.load_extensions() raw_config = load_config(config_files, config_overrides, extensions) - extensions = filter_enabled_extensions(raw_config, extensions) + extensions = ext.filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, config_schemas, extensions) log.setup_log_levels(config) check_old_locations() @@ -140,9 +138,9 @@ def show_config_callback(option, opt, value, parser): files = getattr(parser.values, 'config', '').split(':') overrides = getattr(parser.values, 'overrides', []) - extensions = load_extensions() + extensions = ext.load_extensions() raw_config = load_config(files, overrides, extensions) - enabled_extensions = filter_enabled_extensions(raw_config, extensions) + enabled_extensions = ext.filter_enabled_extensions(raw_config, extensions) config = validate_config(raw_config, config_schemas, enabled_extensions) output = [] @@ -182,67 +180,6 @@ def check_old_locations(): 'for further instructions.', old_settings_file) -def load_extensions(): - extensions = [] - for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): - logger.debug('Loading entry point: %s', entry_point) - - try: - extension_class = entry_point.load() - except pkg_resources.DistributionNotFound as ex: - logger.info( - 'Disabled extension %s: Dependency %s not found', - entry_point.name, ex) - continue - - extension = extension_class() - - logger.debug( - 'Loaded extension: %s %s', extension.dist_name, extension.version) - - if entry_point.name != extension.ext_name: - logger.warning( - 'Disabled extension %(ep)s: entry point name (%(ep)s) ' - 'does not match extension name (%(ext)s)', - {'ep': entry_point.name, 'ext': extension.ext_name}) - continue - - try: - extension.validate_environment() - except exceptions.ExtensionError as ex: - logger.info( - 'Disabled extension %s: %s', entry_point.name, ex.message) - continue - - extensions.append(extension) - - names = (e.ext_name for e in extensions) - logging.debug('Discovered extensions: %s', ', '.join(names)) - return extensions - - -def filter_enabled_extensions(raw_config, extensions): - boolean = config_utils.Boolean() - enabled_extensions = [] - enabled_names = [] - disabled_names = [] - - for extension in extensions: - # TODO: handle key and value errors. - enabled = raw_config[extension.ext_name]['enabled'] - if boolean.deserialize(enabled): - enabled_extensions.append(extension) - enabled_names.append(extension.ext_name) - else: - disabled_names.append(extension.ext_name) - - logging.info( - 'Enabled extensions: %s', ', '.join(enabled_names) or 'none') - logging.info( - 'Disabled extensions: %s', ', '.join(disabled_names) or 'none') - return enabled_extensions - - def load_config(files, overrides, extensions=None): parser = configparser.RawConfigParser() diff --git a/mopidy/ext.py b/mopidy/ext.py index bc26069c..7fee6014 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -1,6 +1,13 @@ from __future__ import unicode_literals -from mopidy.utils import config +import logging +import pkg_resources + +from mopidy import exceptions +from mopidy.utils import config as config_utils + + +logger = logging.getLogger('mopidy.ext') class Extension(object): @@ -14,7 +21,7 @@ class Extension(object): 'Add at least a config section with "enabled = true"') def get_config_schema(self): - return config.ExtensionConfigSchema() + return config_utils.ExtensionConfigSchema() def validate_environment(self): pass @@ -27,3 +34,64 @@ class Extension(object): def register_gstreamer_elements(self): pass + + +def load_extensions(): + extensions = [] + for entry_point in pkg_resources.iter_entry_points('mopidy.ext'): + logger.debug('Loading entry point: %s', entry_point) + + try: + extension_class = entry_point.load() + except pkg_resources.DistributionNotFound as ex: + logger.info( + 'Disabled extension %s: Dependency %s not found', + entry_point.name, ex) + continue + + extension = extension_class() + + logger.debug( + 'Loaded extension: %s %s', extension.dist_name, extension.version) + + if entry_point.name != extension.ext_name: + logger.warning( + 'Disabled extension %(ep)s: entry point name (%(ep)s) ' + 'does not match extension name (%(ext)s)', + {'ep': entry_point.name, 'ext': extension.ext_name}) + continue + + try: + extension.validate_environment() + except exceptions.ExtensionError as ex: + logger.info( + 'Disabled extension %s: %s', entry_point.name, ex.message) + continue + + extensions.append(extension) + + names = (e.ext_name for e in extensions) + logging.debug('Discovered extensions: %s', ', '.join(names)) + return extensions + + +def filter_enabled_extensions(raw_config, extensions): + boolean = config_utils.Boolean() + enabled_extensions = [] + enabled_names = [] + disabled_names = [] + + for extension in extensions: + # TODO: handle key and value errors. + enabled = raw_config[extension.ext_name]['enabled'] + if boolean.deserialize(enabled): + enabled_extensions.append(extension) + enabled_names.append(extension.ext_name) + else: + disabled_names.append(extension.ext_name) + + logging.info( + 'Enabled extensions: %s', ', '.join(enabled_names) or 'none') + logging.info( + 'Disabled extensions: %s', ', '.join(disabled_names) or 'none') + return enabled_extensions From 5174540096fbf3f0d774dc0b4d377697f3f58abd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 13:27:08 +0200 Subject: [PATCH 189/403] log: Remove system info available in --list-deps --- mopidy/utils/log.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 859289ad..db9a0c7c 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -17,8 +17,6 @@ def setup_logging(config, verbosity_level, save_debug_log): logger = logging.getLogger('mopidy.utils.log') logger.info('Starting Mopidy %s', versioning.get_version()) - logger.info('%(name)s: %(version)s', deps.platform_info()) - logger.info('%(name)s: %(version)s', deps.python_info()) def setup_log_levels(config): From 3a386f05add5e3af025cb320a8d3c4f316a95db0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 21:49:29 +0200 Subject: [PATCH 190/403] docs: Add empty troubleshooting page --- docs/index.rst | 1 + docs/troubleshooting.rst | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 docs/troubleshooting.rst diff --git a/docs/index.rst b/docs/index.rst index aae7e675..bdd50d00 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -45,6 +45,7 @@ User documentation config running clients/index + troubleshooting authors licenses changes diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst new file mode 100644 index 00000000..d29a5d2f --- /dev/null +++ b/docs/troubleshooting.rst @@ -0,0 +1,14 @@ +.. _troubleshooting: + +*************** +Troubleshooting +*************** + +TODO: + +- --show-config +- --list-deps +- Issue tracker +- Reporting bugs +- Mailing list +- IRC channel From 8a1f2ab6087961d3f4cbd022fd09b1c2ffad5a0c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 21:56:39 +0200 Subject: [PATCH 191/403] docs: Rename 'changes' to 'changelog' --- docs/{changes.rst => changelog.rst} | 8 ++++---- docs/index.rst | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename docs/{changes.rst => changelog.rst} (99%) diff --git a/docs/changes.rst b/docs/changelog.rst similarity index 99% rename from docs/changes.rst rename to docs/changelog.rst index 2bff6c67..ae4c5d9d 100644 --- a/docs/changes.rst +++ b/docs/changelog.rst @@ -1,8 +1,8 @@ -******* -Changes -******* +********* +Changelog +********* -This change log is used to track all major changes to Mopidy. +This changelog is used to track all major changes to Mopidy. v0.14.0 (UNRELEASED) ==================== diff --git a/docs/index.rst b/docs/index.rst index bdd50d00..29153155 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,7 +48,7 @@ User documentation troubleshooting authors licenses - changes + changelog Reference documentation From d0f2a77e78d54d5f067baa9d218185b9a4f8ba86 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 22:01:21 +0200 Subject: [PATCH 192/403] docs: Reorganize frontpage sections --- docs/index.rst | 50 +++++++++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 29153155..7a663cd3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,28 +14,18 @@ network which can control UPnP MediaRenderers (see :ref:`upnp-clients`), or any :ref:`MPD client `. MPD clients are available for most platforms, including Windows, Mac OS X, Linux, Android, and iOS. -To install Mopidy, start by reading :ref:`installation`. +To get started with Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net `_ and also got a `mailing list at Google Groups `_. If you stumble into a bug or got a feature request, please create an issue in the `issue -tracker `_. +tracker `_. The `source code +`_ may also be of help. -Project resources -================= - -- `Documentation `_ -- `Source code `_ -- `Issue tracker `_ -- `CI server `_ -- IRC: ``#mopidy`` at `irc.freenode.net `_ -- Mailing list: `mopidy@googlegroups.com `_ - - -User documentation -================== +Introduction +============ .. toctree:: :maxdepth: 2 @@ -46,29 +36,35 @@ User documentation running clients/index troubleshooting + + +Extensions +========== + +TODO + + +About +===== + +.. toctree:: + :maxdepth: 1 + authors licenses changelog -Reference documentation -======================= - -.. toctree:: - :maxdepth: 2 - - api/index - modules/index - - -Development documentation -========================= +Development +=========== .. toctree:: :maxdepth: 2 development extensiondev + api/index + modules/index Indices and tables From 3302e18abe531b93bcb2f378ac85e5982bd00cf2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 22:05:00 +0200 Subject: [PATCH 193/403] docs: Add empty 'contributing' page --- docs/contributing.rst | 13 +++++++++++++ docs/index.rst | 1 + 2 files changed, 14 insertions(+) create mode 100644 docs/contributing.rst diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 00000000..ca013e2f --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,13 @@ +.. _contributing: + +************ +Contributing +************ + +TODO: + +- Getting started +- Making changes +- Testing +- Submitting changes +- Additional resources diff --git a/docs/index.rst b/docs/index.rst index 7a663cd3..9dd9d218 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -61,6 +61,7 @@ Development .. toctree:: :maxdepth: 2 + contributing development extensiondev api/index From 6cee13b5f7f5322607085e5d65f9189e14c15a28 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 22:12:18 +0200 Subject: [PATCH 194/403] docs: Tweak authors page --- docs/authors.rst | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/authors.rst b/docs/authors.rst index 97a2dd2b..c740a160 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -6,11 +6,7 @@ Contributors to Mopidy in the order of appearance: .. include:: ../AUTHORS - -Showing your appreciation -========================= - If you already enjoy Mopidy, or don't enjoy it and want to help us making Mopidy better, the best way to do so is to contribute back to the community. You can contribute code, documentation, tests, bug reports, or help other -users, spreading the word, etc. +users, spreading the word, etc. See :ref:`contributing` for a head start. From 86dc02d794a4629c23cdd59093aa41d0829684f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 22:12:42 +0200 Subject: [PATCH 195/403] docs: Reduce depth of development ToC --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 9dd9d218..6b883b8e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,7 +59,7 @@ Development =========== .. toctree:: - :maxdepth: 2 + :maxdepth: 1 contributing development From 4dd2a56f675675d391aed15a100be3bf99ccec7d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 10 Apr 2013 21:30:47 +0200 Subject: [PATCH 196/403] config: Convert mopidy.config to module --- mopidy/{config.py => config/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mopidy/{config.py => config/__init__.py} (100%) diff --git a/mopidy/config.py b/mopidy/config/__init__.py similarity index 100% rename from mopidy/config.py rename to mopidy/config/__init__.py From 5816b5e099372fe9ff95b1c0becccf92b320148b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 10 Apr 2013 21:42:58 +0200 Subject: [PATCH 197/403] config: Remove register schema as it is not used --- mopidy/config/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 88fc3419..4179d112 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -43,9 +43,3 @@ config_schemas['proxy']['password'] = config.String(optional=True, secret=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() - - -def register_schema(name, schema): - if name in config_schemas: - raise Exception - config_schemas[name] = schema From 7e5ff4bfae90e10d8d9ef43f9ae067f0e06a195c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 22:23:22 +0200 Subject: [PATCH 198/403] docs: Updated intro --- README.rst | 26 ++++++++++++++------------ docs/index.rst | 25 ++++++++++++++----------- docs/modules/backends/stream.rst | 2 ++ 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index b572cdab..f667b7db 100644 --- a/README.rst +++ b/README.rst @@ -2,18 +2,16 @@ Mopidy ****** -.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop +Mopidy is a music server which can play music both from multiple sources, like +your local hard drive, radio streams, and from Spotify and SoundCloud. Searches +combines results from all music sources, and you can mix tracks from all +sources in your play queue. Your playlists from Spotify or SoundCloud are also +available for use. -Mopidy is a music server which can play music both from your local hard drive -and from Spotify. Searches returns results from both your local hard drive and -from Spotify, and you can mix tracks from both sources in your play queue. Your -Spotify playlists are also available for use, though we don't support modifying -them yet. - -To control your music server, you can use the Ubuntu Sound Menu on the machine -running Mopidy, any device on the same network which can control UPnP -MediaRenderers, or any MPD client. MPD clients are available for most -platforms, including Windows, Mac OS X, Linux, Android and iOS. +To control your Mopidy music server, you can use one of Mopidy's web clients, +the Ubuntu Sound Menu, any device on the same network which can control UPnP +MediaRenderers, or any MPD client. MPD clients are available for many +platforms, including Windows, OS X, Linux, Android and iOS. To get started with Mopidy, check out `the docs `_. @@ -21,6 +19,10 @@ To get started with Mopidy, check out `the docs `_. - `Source code `_ - `Issue tracker `_ - `CI server `_ +- `Download development snapshot `_ + - IRC: ``#mopidy`` at `irc.freenode.net `_ - Mailing list: `mopidy@googlegroups.com `_ -- `Download development snapshot `_ +- Twitter: `@mopidy `_ + +.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop diff --git a/docs/index.rst b/docs/index.rst index 6b883b8e..5485f33f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,17 +2,18 @@ Mopidy ****** -Mopidy is a music server which can play music both from your :ref:`local hard -drive ` and from :ref:`Spotify `. Searches -returns results from both your local hard drive and from Spotify, and you can -mix tracks from both sources in your play queue. Your Spotify playlists are -also available for use, though we don't support modifying them yet. +Mopidy is a music server which can play music both from multiple sources, like +your :ref:`local hard drive `, :ref:`radio streams +`, and from :ref:`Spotify ` and SoundCloud. +Searches combines results from all music sources, and you can mix tracks from +all sources in your play queue. Your playlists from Spotify or SoundCloud are +also available for use. -To control your music server, you can use the :ref:`Ubuntu Sound Menu -` on the machine running Mopidy, any device on the same -network which can control UPnP MediaRenderers (see :ref:`upnp-clients`), or any -:ref:`MPD client `. MPD clients are available for most platforms, -including Windows, Mac OS X, Linux, Android, and iOS. +To control your Mopidy music server, you can use one of Mopidy's :ref:`web +clients `, the :ref:`Ubuntu Sound Menu `, any +device on the same network which can control :ref:`UPnP MediaRenderers +`, or any :ref:`MPD client `. MPD clients are +available for many platforms, including Windows, OS X, Linux, Android and iOS. To get started with Mopidy, start by reading :ref:`installation`. @@ -21,7 +22,9 @@ If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net `_. If you stumble into a bug or got a feature request, please create an issue in the `issue tracker `_. The `source code -`_ may also be of help. +`_ may also be of help. If you want to stay +up to date on Mopidy developments, you can follow `@mopidy +`_ on Twitter. Introduction diff --git a/docs/modules/backends/stream.rst b/docs/modules/backends/stream.rst index 73e53048..2843a7e9 100644 --- a/docs/modules/backends/stream.rst +++ b/docs/modules/backends/stream.rst @@ -1,3 +1,5 @@ +.. _stream-backend: + *********************************************** :mod:`mopidy.backends.stream` -- Stream backend *********************************************** From d90a977a3bf78c747f7aec119309dc4d216a62bf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 10 Apr 2013 22:47:37 +0200 Subject: [PATCH 199/403] config: Move everything to mopidy.config sub-modules --- mopidy/__main__.py | 1 + mopidy/backends/local/__init__.py | 4 +- mopidy/backends/spotify/__init__.py | 7 +- mopidy/backends/stream/__init__.py | 4 +- mopidy/config/__init__.py | 29 +- mopidy/config/schemas.py | 137 +++++++ mopidy/config/validators.py | 28 ++ mopidy/config/values.py | 212 ++++++++++ mopidy/ext.py | 2 +- mopidy/frontends/http/__init__.py | 4 +- mopidy/frontends/mpd/__init__.py | 4 +- mopidy/frontends/mpris/__init__.py | 4 +- mopidy/frontends/scrobbler/__init__.py | 4 +- mopidy/utils/config.py | 371 ------------------ tests/config/schemas_test.py | 127 ++++++ tests/config/validator_tests.py | 66 ++++ .../config_test.py => config/values_test.py} | 272 +++---------- tests/ext_test.py | 5 +- 18 files changed, 651 insertions(+), 630 deletions(-) create mode 100644 mopidy/config/schemas.py create mode 100644 mopidy/config/validators.py create mode 100644 mopidy/config/values.py delete mode 100644 mopidy/utils/config.py create mode 100644 tests/config/schemas_test.py create mode 100644 tests/config/validator_tests.py rename tests/{utils/config_test.py => config/values_test.py} (52%) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 069c883e..1bfcfbcf 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -30,6 +30,7 @@ sys.path.insert( from mopidy import exceptions, ext from mopidy.audio import Audio from mopidy.config import default_config, config_schemas +from mopidy import config as config_utils # TODO: cleanup from mopidy.core import Core from mopidy.utils import deps, log, path, process, versioning diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 0367cf15..4c5eb2a2 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.utils import config, formatting +from mopidy import config, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index c26a42e7..ec55888c 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,9 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.exceptions import ExtensionError -from mopidy.utils import config, formatting +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ @@ -96,7 +95,7 @@ class Extension(ext.Extension): try: import spotify # noqa except ImportError as e: - raise ExtensionError('pyspotify library not found', e) + raise exceptions.ExtensionError('pyspotify library not found', e) def get_backend_classes(self): from .actor import SpotifyBackend diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 11918500..60817030 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.utils import config, formatting +from mopidy import config, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 4179d112..82fc839e 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals -from mopidy.utils import config +from mopidy.config.schemas import * +from mopidy.config.values import * default_config = """ @@ -24,22 +25,22 @@ password = """ config_schemas = {} # TODO: use ordered dict? -config_schemas['logging'] = config.ConfigSchema() -config_schemas['logging']['console_format'] = config.String() -config_schemas['logging']['debug_format'] = config.String() -config_schemas['logging']['debug_file'] = config.Path() +config_schemas['logging'] = ConfigSchema() +config_schemas['logging']['console_format'] = String() +config_schemas['logging']['debug_format'] = String() +config_schemas['logging']['debug_file'] = Path() -config_schemas['logging.levels'] = config.LogLevelConfigSchema() +config_schemas['logging.levels'] = LogLevelConfigSchema() -config_schemas['audio'] = config.ConfigSchema() -config_schemas['audio']['mixer'] = config.String() -config_schemas['audio']['mixer_track'] = config.String(optional=True) -config_schemas['audio']['output'] = config.String() +config_schemas['audio'] = ConfigSchema() +config_schemas['audio']['mixer'] = String() +config_schemas['audio']['mixer_track'] = String(optional=True) +config_schemas['audio']['output'] = String() -config_schemas['proxy'] = config.ConfigSchema() -config_schemas['proxy']['hostname'] = config.Hostname(optional=True) -config_schemas['proxy']['username'] = config.String(optional=True) -config_schemas['proxy']['password'] = config.String(optional=True, secret=True) +config_schemas['proxy'] = ConfigSchema() +config_schemas['proxy']['hostname'] = Hostname(optional=True) +config_schemas['proxy']['username'] = String(optional=True) +config_schemas['proxy']['password'] = String(optional=True, secret=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py new file mode 100644 index 00000000..15d0bfe3 --- /dev/null +++ b/mopidy/config/schemas.py @@ -0,0 +1,137 @@ +from mopidy import exceptions + +from mopidy.config import values + + +def _did_you_mean(name, choices): + """Suggest most likely setting based on levenshtein.""" + if not choices: + return None + + name = name.lower() + candidates = [(_levenshtein(name, c), c) for c in choices] + candidates.sort() + + if candidates[0][0] <= 3: + return candidates[0][1] + return None + + +def _levenshtein(a, b): + """Calculates the Levenshtein distance between a and b.""" + n, m = len(a), len(b) + if n > m: + return _levenshtein(b, a) + + current = xrange(n + 1) + for i in xrange(1, m + 1): + previous, current = current, [i] + [0] * n + for j in xrange(1, n + 1): + add, delete = previous[j] + 1, current[j - 1] + 1 + change = previous[j - 1] + if a[j - 1] != b[i - 1]: + change += 1 + current[j] = min(add, delete, change) + return current[n] + + + + +class ConfigSchema(object): + """Logical group of config values that correspond to a config section. + + Schemas are set up by assigning config keys with config values to + instances. Once setup :meth:`convert` can be called with a list of + ``(key, value)`` tuples to process. For convienience we also support + :meth:`format` method that can used for printing out the converted values. + """ + # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) + def __init__(self): + self._schema = {} + self._order = [] + + def __setitem__(self, key, value): + if key not in self._schema: + self._order.append(key) + self._schema[key] = value + + def __getitem__(self, key): + return self._schema[key] + + def format(self, name, values): + # TODO: should the output be encoded utf-8 since we use that in + # serialize for strings? + lines = ['[%s]' % name] + for key in self._order: + value = values.get(key) + if value is not None: + lines.append('%s = %s' % ( + key, self._schema[key].format(value))) + return '\n'.join(lines) + + def convert(self, items): + errors = {} + values = {} + + for key, value in items: + try: + values[key] = self._schema[key].deserialize(value) + except KeyError: # not in our schema + errors[key] = 'unknown config key.' + suggestion = _did_you_mean(key, self._schema.keys()) + if suggestion: + errors[key] += ' Did you mean %s?' % suggestion + except ValueError as e: # deserialization failed + errors[key] = str(e) + + for key in self._schema: + if key not in values and key not in errors: + errors[key] = 'config key not found.' + + if errors: + raise exceptions.ConfigError(errors) + return values + + +class ExtensionConfigSchema(ConfigSchema): + """Sub-classed :class:`ConfigSchema` for use in extensions. + + Ensures that ``enabled`` config value is present. + """ + def __init__(self): + super(ExtensionConfigSchema, self).__init__() + self['enabled'] = values.Boolean() + + +class LogLevelConfigSchema(object): + """Special cased schema for handling a config section with loglevels. + + Expects the config keys to be logger names and the values to be log levels + as understood by the :class:`LogLevel` config value. Does not sub-class + :class:`ConfigSchema`, but implements the same interface. + """ + def __init__(self): + self._config_value = values.LogLevel() + + def format(self, name, values): + lines = ['[%s]' % name] + for key, value in sorted(values.items()): + if value is not None: + lines.append('%s = %s' % ( + key, self._config_value.format(value))) + return '\n'.join(lines) + + def convert(self, items): + errors = {} + values = {} + + for key, value in items: + try: + if value.strip(): + values[key] = self._config_value.deserialize(value) + except ValueError as e: # deserialization failed + errors[key] = str(e) + + if errors: + raise exceptions.ConfigError(errors) + return values diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py new file mode 100644 index 00000000..ab7282be --- /dev/null +++ b/mopidy/config/validators.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +# TODO: add validate regexp? + +def validate_required(value, required): + """Required validation, normally called in config value's validate() on the + raw string, _not_ the converted value.""" + if required and not value.strip(): + raise ValueError('must be set.') + + +def validate_choice(value, choices): + """Choice validation, normally called in config value's validate().""" + if choices is not None and value not in choices: + names = ', '.join(repr(c) for c in choices) + raise ValueError('must be one of %s, not %s.' % (names, value)) + + +def validate_minimum(value, minimum): + """Minimum validation, normally called in config value's validate().""" + if minimum is not None and value < minimum: + raise ValueError('%r must be larger than %r.' % (value, minimum)) + + +def validate_maximum(value, maximum): + """Maximum validation, normally called in config value's validate().""" + if maximum is not None and value > maximum: + raise ValueError('%r must be smaller than %r.' % (value, maximum)) diff --git a/mopidy/config/values.py b/mopidy/config/values.py new file mode 100644 index 00000000..0fe40961 --- /dev/null +++ b/mopidy/config/values.py @@ -0,0 +1,212 @@ +from __future__ import unicode_literals + +import logging +import re +import socket + +from mopidy.utils import path +from mopidy.config import validators + + +class ConfigValue(object): + """Represents a config key's value and how to handle it. + + Normally you will only be interacting with sub-classes for config values + that encode either deserialization behavior and/or validation. + + Each config value should be used for the following actions: + + 1. Deserializing from a raw string and validating, raising ValueError on + failure. + 2. Serializing a value back to a string that can be stored in a config. + 3. Formatting a value to a printable form (useful for masking secrets). + + :class:`None` values should not be deserialized, serialized or formatted, + the code interacting with the config should simply skip None config values. + """ + + #: Collection of valid choices for converted value. Must be combined with + #: :function:`validate_choices` in :method:`validate` do any thing. + choices = None + + #: Minimum of converted value. Must be combined with + #: :function:`validate_minimum` in :method:`validate` do any thing. + minimum = None + + #: Maximum of converted value. Must be combined with + #: :function:`validate_maximum` in :method:`validate` do any thing. + maximum = None + + #: Indicate if this field is required. + optional = None + + #: Indicate if we should mask the when printing for human consumption. + secret = None + + def __init__(self, **kwargs): + self.choices = kwargs.get('choices') + self.minimum = kwargs.get('minimum') + self.maximum = kwargs.get('maximum') + self.optional = kwargs.get('optional') + self.secret = kwargs.get('secret') + + def deserialize(self, value): + """Cast raw string to appropriate type.""" + return value + + def serialize(self, value): + """Convert value back to string for saving.""" + return str(value) + + def format(self, value): + """Format value for display.""" + if self.secret and value is not None: + return '********' + return self.serialize(value) + + +class String(ConfigValue): + """String values. + + Supports: optional, choices and secret. + """ + def deserialize(self, value): + value = value.strip() + validators.validate_required(value, not self.optional) + validators.validate_choice(value, self.choices) + if not value: + return None + return value + + def serialize(self, value): + return value.encode('utf-8').encode('string-escape') + + +class Integer(ConfigValue): + """Integer values. + + Supports: choices, minimum, maximum and secret. + """ + def deserialize(self, value): + value = int(value) + validators.validate_choice(value, self.choices) + validators.validate_minimum(value, self.minimum) + validators.validate_maximum(value, self.maximum) + return value + + +class Boolean(ConfigValue): + """Boolean values. + + Supports: secret. + """ + true_values = ('1', 'yes', 'true', 'on') + false_values = ('0', 'no', 'false', 'off') + + def deserialize(self, value): + if value.lower() in self.true_values: + return True + elif value.lower() in self.false_values: + return False + + raise ValueError('invalid value for boolean: %r' % value) + + def serialize(self, value): + if value: + return 'true' + else: + return 'false' + + +class List(ConfigValue): + """List values split by comma or newline. + + Supports: optional and secret. + """ + def deserialize(self, value): + validators.validate_required(value, not self.optional) + if '\n' in value: + values = re.split(r'\s*\n\s*', value.strip()) + else: + values = re.split(r'\s*,\s*', value.strip()) + return tuple([v for v in values if v]) + + def serialize(self, value): + return '\n ' + '\n '.join(v.encode('utf-8') for v in value) + + +class LogLevel(ConfigValue): + """Log level values. + + Supports: secret. + """ + levels = { + 'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + } + + def deserialize(self, value): + validators.validate_choice(value.lower(), self.levels.keys()) + return self.levels.get(value.lower()) + + def serialize(self, value): + return dict((v, k) for k, v in self.levels.items()).get(value) + + +class Hostname(ConfigValue): + """Hostname values. + + Supports: optional and secret. + """ + def deserialize(self, value): + validators.validate_required(value, not self.optional) + if not value.strip(): + return None + try: + socket.getaddrinfo(value, None) + except socket.error: + raise ValueError('must be a resolveable hostname or valid IP') + return value + + +class Port(Integer): + """Port values limited to 1-65535. + + Supports: choices and secret. + """ + # TODO: consider probing if port is free or not? + def __init__(self, **kwargs): + super(Port, self).__init__(**kwargs) + self.minimum = 1 + self.maximum = 2 ** 16 - 1 + + +class ExpandedPath(bytes): + def __new__(self, value): + expanded = path.expand_path(value) + return super(ExpandedPath, self).__new__(self, expanded) + + def __init__(self, value): + self.original = value + + +class Path(ConfigValue): + """File system path that will be expanded with mopidy.utils.path.expand_path + + Supports: optional, choices and secret. + """ + def deserialize(self, value): + value = value.strip() + validators.validate_required(value, not self.optional) + validators.validate_choice(value, self.choices) + if not value: + return None + return ExpandedPath(value) + + def serialize(self, value): + if isinstance(value, ExpandedPath): + return value.original + return value diff --git a/mopidy/ext.py b/mopidy/ext.py index 7fee6014..1d554e72 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -4,7 +4,7 @@ import logging import pkg_resources from mopidy import exceptions -from mopidy.utils import config as config_utils +from mopidy import config as config_utils logger = logging.getLogger('mopidy.ext') diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 32d55f23..1cdbc1fb 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import exceptions, ext -from mopidy.utils import config, formatting +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 69297374..a5e86de9 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.utils import config, formatting +from mopidy import config, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 5be9c6cf..d41f10fe 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals import os import mopidy -from mopidy import exceptions, ext -from mopidy.utils import formatting, config +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index f3127040..55784c7e 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import exceptions, ext -from mopidy.utils import config, formatting +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py deleted file mode 100644 index 09278535..00000000 --- a/mopidy/utils/config.py +++ /dev/null @@ -1,371 +0,0 @@ -from __future__ import unicode_literals - -import logging -import re -import socket - -from mopidy import exceptions -from mopidy.utils import path - - -def validate_required(value, required): - """Required validation, normally called in config value's validate() on the - raw string, _not_ the converted value.""" - if required and not value.strip(): - raise ValueError('must be set.') - - -def validate_choice(value, choices): - """Choice validation, normally called in config value's validate().""" - if choices is not None and value not in choices: - names = ', '.join(repr(c) for c in choices) - raise ValueError('must be one of %s, not %s.' % (names, value)) - - -def validate_minimum(value, minimum): - """Minimum validation, normally called in config value's validate().""" - if minimum is not None and value < minimum: - raise ValueError('%r must be larger than %r.' % (value, minimum)) - - -def validate_maximum(value, maximum): - """Maximum validation, normally called in config value's validate().""" - if maximum is not None and value > maximum: - raise ValueError('%r must be smaller than %r.' % (value, maximum)) - - -# TODO: move this and levenshtein to a more appropriate class. -def did_you_mean(name, choices): - """Suggest most likely setting based on levenshtein.""" - if not choices: - return None - - name = name.lower() - candidates = [(levenshtein(name, c), c) for c in choices] - candidates.sort() - - if candidates[0][0] <= 3: - return candidates[0][1] - return None - - -def levenshtein(a, b): - """Calculates the Levenshtein distance between a and b.""" - n, m = len(a), len(b) - if n > m: - return levenshtein(b, a) - - current = xrange(n + 1) - for i in xrange(1, m + 1): - previous, current = current, [i] + [0] * n - for j in xrange(1, n + 1): - add, delete = previous[j] + 1, current[j - 1] + 1 - change = previous[j - 1] - if a[j - 1] != b[i - 1]: - change += 1 - current[j] = min(add, delete, change) - return current[n] - - -class ConfigValue(object): - """Represents a config key's value and how to handle it. - - Normally you will only be interacting with sub-classes for config values - that encode either deserialization behavior and/or validation. - - Each config value should be used for the following actions: - - 1. Deserializing from a raw string and validating, raising ValueError on - failure. - 2. Serializing a value back to a string that can be stored in a config. - 3. Formatting a value to a printable form (useful for masking secrets). - - :class:`None` values should not be deserialized, serialized or formatted, - the code interacting with the config should simply skip None config values. - """ - - #: Collection of valid choices for converted value. Must be combined with - #: :function:`validate_choices` in :method:`validate` do any thing. - choices = None - - #: Minimum of converted value. Must be combined with - #: :function:`validate_minimum` in :method:`validate` do any thing. - minimum = None - - #: Maximum of converted value. Must be combined with - #: :function:`validate_maximum` in :method:`validate` do any thing. - maximum = None - - #: Indicate if this field is required. - optional = None - - #: Indicate if we should mask the when printing for human consumption. - secret = None - - def __init__(self, **kwargs): - self.choices = kwargs.get('choices') - self.minimum = kwargs.get('minimum') - self.maximum = kwargs.get('maximum') - self.optional = kwargs.get('optional') - self.secret = kwargs.get('secret') - - def deserialize(self, value): - """Cast raw string to appropriate type.""" - return value - - def serialize(self, value): - """Convert value back to string for saving.""" - return str(value) - - def format(self, value): - """Format value for display.""" - if self.secret and value is not None: - return '********' - return self.serialize(value) - - -class String(ConfigValue): - """String values. - - Supports: optional, choices and secret. - """ - def deserialize(self, value): - value = value.strip() - validate_required(value, not self.optional) - validate_choice(value, self.choices) - if not value: - return None - return value - - def serialize(self, value): - return value.encode('utf-8').encode('string-escape') - - -class Integer(ConfigValue): - """Integer values. - - Supports: choices, minimum, maximum and secret. - """ - def deserialize(self, value): - value = int(value) - validate_choice(value, self.choices) - validate_minimum(value, self.minimum) - validate_maximum(value, self.maximum) - return value - - -class Boolean(ConfigValue): - """Boolean values. - - Supports: secret. - """ - true_values = ('1', 'yes', 'true', 'on') - false_values = ('0', 'no', 'false', 'off') - - def deserialize(self, value): - if value.lower() in self.true_values: - return True - elif value.lower() in self.false_values: - return False - - raise ValueError('invalid value for boolean: %r' % value) - - def serialize(self, value): - if value: - return 'true' - else: - return 'false' - - -class List(ConfigValue): - """List values split by comma or newline. - - Supports: optional and secret. - """ - def deserialize(self, value): - validate_required(value, not self.optional) - if '\n' in value: - values = re.split(r'\s*\n\s*', value.strip()) - else: - values = re.split(r'\s*,\s*', value.strip()) - return tuple([v for v in values if v]) - - def serialize(self, value): - return '\n ' + '\n '.join(v.encode('utf-8') for v in value) - - -class LogLevel(ConfigValue): - """Log level values. - - Supports: secret. - """ - levels = { - 'critical': logging.CRITICAL, - 'error': logging.ERROR, - 'warning': logging.WARNING, - 'info': logging.INFO, - 'debug': logging.DEBUG, - } - - def deserialize(self, value): - validate_choice(value.lower(), self.levels.keys()) - return self.levels.get(value.lower()) - - def serialize(self, value): - return dict((v, k) for k, v in self.levels.items()).get(value) - - -class Hostname(ConfigValue): - """Hostname values. - - Supports: optional and secret. - """ - def deserialize(self, value): - validate_required(value, not self.optional) - if not value.strip(): - return None - try: - socket.getaddrinfo(value, None) - except socket.error: - raise ValueError('must be a resolveable hostname or valid IP') - return value - - -class Port(Integer): - """Port values limited to 1-65535. - - Supports: choices and secret. - """ - # TODO: consider probing if port is free or not? - def __init__(self, **kwargs): - super(Port, self).__init__(**kwargs) - self.minimum = 1 - self.maximum = 2 ** 16 - 1 - - -class ExpandedPath(bytes): - def __new__(self, value): - expanded = path.expand_path(value) - return super(ExpandedPath, self).__new__(self, expanded) - - def __init__(self, value): - self.original = value - - -class Path(ConfigValue): - """File system path that will be expanded with mopidy.utils.path.expand_path - - Supports: optional, choices and secret. - """ - def deserialize(self, value): - value = value.strip() - validate_required(value, not self.optional) - validate_choice(value, self.choices) - if not value: - return None - return ExpandedPath(value) - - def serialize(self, value): - if isinstance(value, ExpandedPath): - return value.original - return value - - -class ConfigSchema(object): - """Logical group of config values that correspond to a config section. - - Schemas are set up by assigning config keys with config values to - instances. Once setup :meth:`convert` can be called with a list of - ``(key, value)`` tuples to process. For convienience we also support - :meth:`format` method that can used for printing out the converted values. - """ - # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) - def __init__(self): - self._schema = {} - self._order = [] - - def __setitem__(self, key, value): - if key not in self._schema: - self._order.append(key) - self._schema[key] = value - - def __getitem__(self, key): - return self._schema[key] - - def format(self, name, values): - # TODO: should the output be encoded utf-8 since we use that in - # serialize for strings? - lines = ['[%s]' % name] - for key in self._order: - value = values.get(key) - if value is not None: - lines.append('%s = %s' % ( - key, self._schema[key].format(value))) - return '\n'.join(lines) - - def convert(self, items): - errors = {} - values = {} - - for key, value in items: - try: - values[key] = self._schema[key].deserialize(value) - except KeyError: # not in our schema - errors[key] = 'unknown config key.' - suggestion = did_you_mean(key, self._schema.keys()) - if suggestion: - errors[key] += ' Did you mean %s?' % suggestion - except ValueError as e: # deserialization failed - errors[key] = str(e) - - for key in self._schema: - if key not in values and key not in errors: - errors[key] = 'config key not found.' - - if errors: - raise exceptions.ConfigError(errors) - return values - - -class ExtensionConfigSchema(ConfigSchema): - """Sub-classed :class:`ConfigSchema` for use in extensions. - - Ensures that ``enabled`` config value is present. - """ - def __init__(self): - super(ExtensionConfigSchema, self).__init__() - self['enabled'] = Boolean() - - -class LogLevelConfigSchema(object): - """Special cased schema for handling a config section with loglevels. - - Expects the config keys to be logger names and the values to be log levels - as understood by the :class:`LogLevel` config value. Does not sub-class - :class:`ConfigSchema`, but implements the same interface. - """ - def __init__(self): - self._config_value = LogLevel() - - def format(self, name, values): - lines = ['[%s]' % name] - for key, value in sorted(values.items()): - if value is not None: - lines.append('%s = %s' % ( - key, self._config_value.format(value))) - return '\n'.join(lines) - - def convert(self, items): - errors = {} - values = {} - - for key, value in items: - try: - if value.strip(): - values[key] = self._config_value.deserialize(value) - except ValueError as e: # deserialization failed - errors[key] = str(e) - - if errors: - raise exceptions.ConfigError(errors) - return values diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py new file mode 100644 index 00000000..4920bbfe --- /dev/null +++ b/tests/config/schemas_test.py @@ -0,0 +1,127 @@ +from __future__ import unicode_literals + +import logging +import mock + +from mopidy import exceptions +from mopidy.config import schemas + +from tests import unittest + + +class ConfigSchemaTest(unittest.TestCase): + def setUp(self): + self.schema = schemas.ConfigSchema() + self.schema['foo'] = mock.Mock() + self.schema['bar'] = mock.Mock() + self.schema['baz'] = mock.Mock() + self.values = {'bar': '123', 'foo': '456', 'baz': '678'} + + def test_format(self): + self.schema['foo'].format.return_value = 'qwe' + self.schema['bar'].format.return_value = 'asd' + self.schema['baz'].format.return_value = 'zxc' + + expected = ['[qwerty]', 'foo = qwe', 'bar = asd', 'baz = zxc'] + result = self.schema.format('qwerty', self.values) + self.assertEqual('\n'.join(expected), result) + + def test_format_unkwown_value(self): + self.schema['foo'].format.return_value = 'qwe' + self.schema['bar'].format.return_value = 'asd' + self.schema['baz'].format.return_value = 'zxc' + self.values['unknown'] = 'rty' + + result = self.schema.format('qwerty', self.values) + self.assertNotIn('unknown = rty', result) + + def test_convert(self): + self.schema.convert(self.values.items()) + + def test_convert_with_missing_value(self): + del self.values['foo'] + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('not found', cm.exception['foo']) + + def test_convert_with_extra_value(self): + self.values['extra'] = '123' + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('unknown', cm.exception['extra']) + + def test_convert_with_deserialization_error(self): + self.schema['foo'].deserialize.side_effect = ValueError('failure') + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('failure', cm.exception['foo']) + + def test_convert_with_multiple_deserialization_errors(self): + self.schema['foo'].deserialize.side_effect = ValueError('failure') + self.schema['bar'].deserialize.side_effect = ValueError('other') + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('failure', cm.exception['foo']) + self.assertIn('other', cm.exception['bar']) + + def test_convert_deserialization_unknown_and_missing_errors(self): + self.values['extra'] = '123' + self.schema['bar'].deserialize.side_effect = ValueError('failure') + del self.values['baz'] + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('unknown', cm.exception['extra']) + self.assertNotIn('foo', cm.exception) + self.assertIn('failure', cm.exception['bar']) + self.assertIn('not found', cm.exception['baz']) + + +class ExtensionConfigSchemaTest(unittest.TestCase): + def test_schema_includes_enabled(self): + schema = schemas.ExtensionConfigSchema() + self.assertIsInstance(schema['enabled'], values.Boolean) + + +class LogLevelConfigSchemaTest(unittest.TestCase): + def test_conversion(self): + schema = schemas.LogLevelConfigSchema() + result = schema.convert([('foo.bar', 'DEBUG'), ('baz', 'INFO')]) + + self.assertEqual(logging.DEBUG, result['foo.bar']) + self.assertEqual(logging.INFO, result['baz']) + + def test_format(self): + schema = schemas.LogLevelConfigSchema() + expected = ['[levels]', 'baz = info', 'foo.bar = debug'] + result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) + self.assertEqual('\n'.join(expected), result) + + +class DidYouMeanTest(unittest.TestCase): + def testSuggestoins(self): + choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') + + suggestion = schemas._did_you_mean('bitrate', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('bitrote', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('Bitrot', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('BTROT', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('btro', choices) + self.assertEqual(suggestion, None) diff --git a/tests/config/validator_tests.py b/tests/config/validator_tests.py new file mode 100644 index 00000000..4c0ff70f --- /dev/null +++ b/tests/config/validator_tests.py @@ -0,0 +1,66 @@ +from __future__ import unicode_literals + +from mopidy.config import validators + +from tests import unittest + + + +class ValidateChoiceTest(unittest.TestCase): + def test_no_choices_passes(self): + validators.validate_choice('foo', None) + + def test_valid_value_passes(self): + validators.validate_choice('foo', ['foo', 'bar', 'baz']) + validators.validate_choice(1, [1, 2, 3]) + + def test_empty_choices_fails(self): + self.assertRaises(ValueError,validators.validate_choice, 'foo', []) + + def test_invalid_value_fails(self): + words = ['foo', 'bar', 'baz'] + self.assertRaises(ValueError, validators.validate_choice, 'foobar', words) + self.assertRaises(ValueError, validators.validate_choice, 5, [1, 2, 3]) + + +class ValidateMinimumTest(unittest.TestCase): + def test_no_minimum_passes(self): + validators.validate_minimum(10, None) + + def test_valid_value_passes(self): + validators.validate_minimum(10, 5) + + def test_to_small_value_fails(self): + self.assertRaises(ValueError, validators.validate_minimum, 10, 20) + + def test_to_small_value_fails_with_zero_as_minimum(self): + self.assertRaises(ValueError, validators.validate_minimum, -1, 0) + + +class ValidateMaximumTest(unittest.TestCase): + def test_no_maximum_passes(self): + validators.validate_maximum(5, None) + + def test_valid_value_passes(self): + validators.validate_maximum(5, 10) + + def test_to_large_value_fails(self): + self.assertRaises(ValueError, validators.validate_maximum, 10, 5) + + def test_to_large_value_fails_with_zero_as_maximum(self): + self.assertRaises(ValueError, validators.validate_maximum, 5, 0) + + +class ValidateRequiredTest(unittest.TestCase): + def test_passes_when_false(self): + validators.validate_required('foo', False) + validators.validate_required('', False) + validators.validate_required(' ', False) + + def test_passes_when_required_and_set(self): + validators.validate_required('foo', True) + validators.validate_required(' foo ', True) + + def test_blocks_when_required_and_emtpy(self): + self.assertRaises(ValueError, validators.validate_required, '', True) + self.assertRaises(ValueError, validators.validate_required, ' ', True) diff --git a/tests/utils/config_test.py b/tests/config/values_test.py similarity index 52% rename from tests/utils/config_test.py rename to tests/config/values_test.py index bf26b2e7..442fffbb 100644 --- a/tests/utils/config_test.py +++ b/tests/config/values_test.py @@ -5,74 +5,14 @@ import mock import socket from mopidy import exceptions -from mopidy.utils import config +from mopidy.config import values from tests import unittest -class ValidateChoiceTest(unittest.TestCase): - def test_no_choices_passes(self): - config.validate_choice('foo', None) - - def test_valid_value_passes(self): - config.validate_choice('foo', ['foo', 'bar', 'baz']) - config.validate_choice(1, [1, 2, 3]) - - def test_empty_choices_fails(self): - self.assertRaises(ValueError, config.validate_choice, 'foo', []) - - def test_invalid_value_fails(self): - words = ['foo', 'bar', 'baz'] - self.assertRaises(ValueError, config.validate_choice, 'foobar', words) - self.assertRaises(ValueError, config.validate_choice, 5, [1, 2, 3]) - - -class ValidateMinimumTest(unittest.TestCase): - def test_no_minimum_passes(self): - config.validate_minimum(10, None) - - def test_valid_value_passes(self): - config.validate_minimum(10, 5) - - def test_to_small_value_fails(self): - self.assertRaises(ValueError, config.validate_minimum, 10, 20) - - def test_to_small_value_fails_with_zero_as_minimum(self): - self.assertRaises(ValueError, config.validate_minimum, -1, 0) - - -class ValidateMaximumTest(unittest.TestCase): - def test_no_maximum_passes(self): - config.validate_maximum(5, None) - - def test_valid_value_passes(self): - config.validate_maximum(5, 10) - - def test_to_large_value_fails(self): - self.assertRaises(ValueError, config.validate_maximum, 10, 5) - - def test_to_large_value_fails_with_zero_as_maximum(self): - self.assertRaises(ValueError, config.validate_maximum, 5, 0) - - -class ValidateRequiredTest(unittest.TestCase): - def test_passes_when_false(self): - config.validate_required('foo', False) - config.validate_required('', False) - config.validate_required(' ', False) - - def test_passes_when_required_and_set(self): - config.validate_required('foo', True) - config.validate_required(' foo ', True) - - def test_blocks_when_required_and_emtpy(self): - self.assertRaises(ValueError, config.validate_required, '', True) - self.assertRaises(ValueError, config.validate_required, ' ', True) - - class ConfigValueTest(unittest.TestCase): def test_init(self): - value = config.ConfigValue() + value = values.ConfigValue() self.assertIsNone(value.choices) self.assertIsNone(value.maximum) self.assertIsNone(value.minimum) @@ -82,7 +22,7 @@ class ConfigValueTest(unittest.TestCase): def test_init_with_params(self): kwargs = {'choices': ['foo'], 'minimum': 0, 'maximum': 10, 'secret': True, 'optional': True} - value = config.ConfigValue(**kwargs) + value = values.ConfigValue(**kwargs) self.assertEqual(['foo'], value.choices) self.assertEqual(0, value.minimum) self.assertEqual(10, value.maximum) @@ -90,90 +30,90 @@ class ConfigValueTest(unittest.TestCase): self.assertEqual(True, value.secret) def test_deserialize_passes_through(self): - value = config.ConfigValue() + value = values.ConfigValue() obj = object() self.assertEqual(obj, value.deserialize(obj)) def test_serialize_conversion_to_string(self): - value = config.ConfigValue() + value = values.ConfigValue() self.assertIsInstance(value.serialize(object()), basestring) def test_format_uses_serialize(self): - value = config.ConfigValue() + value = values.ConfigValue() obj = object() self.assertEqual(value.serialize(obj), value.format(obj)) def test_format_masks_secrets(self): - value = config.ConfigValue(secret=True) + value = values.ConfigValue(secret=True) self.assertEqual('********', value.format(object())) class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.String() + value = values.String() self.assertEqual('foo', value.deserialize(' foo ')) def test_deserialize_enforces_choices(self): - value = config.String(choices=['foo', 'bar', 'baz']) + value = values.String(choices=['foo', 'bar', 'baz']) self.assertEqual('foo', value.deserialize('foo')) self.assertRaises(ValueError, value.deserialize, 'foobar') def test_deserialize_enforces_required(self): - value = config.String() + value = values.String() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = config.String(optional=True) + value = values.String(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) def test_serialize_string_escapes(self): - value = config.String() + value = values.String() self.assertEqual(r'\r\n\t', value.serialize('\r\n\t')) def test_format_masks_secrets(self): - value = config.String(secret=True) + value = values.String(secret=True) self.assertEqual('********', value.format('s3cret')) class IntegerTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.Integer() + value = values.Integer() self.assertEqual(123, value.deserialize('123')) self.assertEqual(0, value.deserialize('0')) self.assertEqual(-10, value.deserialize('-10')) def test_deserialize_conversion_failure(self): - value = config.Integer() + value = values.Integer() self.assertRaises(ValueError, value.deserialize, 'asd') self.assertRaises(ValueError, value.deserialize, '3.14') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_enforces_choices(self): - value = config.Integer(choices=[1, 2, 3]) + value = values.Integer(choices=[1, 2, 3]) self.assertEqual(3, value.deserialize('3')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_minimum(self): - value = config.Integer(minimum=10) + value = values.Integer(minimum=10) self.assertEqual(15, value.deserialize('15')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_maximum(self): - value = config.Integer(maximum=10) + value = values.Integer(maximum=10) self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') def test_format_masks_secrets(self): - value = config.Integer(secret=True) + value = values.Integer(secret=True) self.assertEqual('********', value.format('1337')) class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.Boolean() + value = values.Boolean() for true in ('1', 'yes', 'true', 'on'): self.assertIs(value.deserialize(true), True) self.assertIs(value.deserialize(true.upper()), True) @@ -184,24 +124,24 @@ class BooleanTest(unittest.TestCase): self.assertIs(value.deserialize(false.capitalize()), False) def test_deserialize_conversion_failure(self): - value = config.Boolean() + value = values.Boolean() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') def test_serialize(self): - value = config.Boolean() + value = values.Boolean() self.assertEqual('true', value.serialize(True)) self.assertEqual('false', value.serialize(False)) def test_format_masks_secrets(self): - value = config.Boolean(secret=True) + value = values.Boolean(secret=True) self.assertEqual('********', value.format('true')) class ListTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.List() + value = values.List() expected = ('foo', 'bar', 'baz') self.assertEqual(expected, value.deserialize('foo, bar ,baz ')) @@ -210,17 +150,17 @@ class ListTest(unittest.TestCase): self.assertEqual(expected, value.deserialize(' foo,bar\nbar\nbaz')) def test_deserialize_enforces_required(self): - value = config.List() + value = values.List() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = config.List(optional=True) + value = values.List(optional=True) self.assertEqual(tuple(), value.deserialize('')) self.assertEqual(tuple(), value.deserialize(' ')) def test_serialize(self): - value = config.List() + value = values.List() result = value.serialize(('foo', 'bar', 'baz')) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') @@ -233,21 +173,21 @@ class BooleanTest(unittest.TestCase): 'debug': logging.DEBUG} def test_deserialize_conversion_success(self): - value = config.LogLevel() + value = values.LogLevel() for name, level in self.levels.items(): self.assertEqual(level, value.deserialize(name)) self.assertEqual(level, value.deserialize(name.upper())) self.assertEqual(level, value.deserialize(name.capitalize())) def test_deserialize_conversion_failure(self): - value = config.LogLevel() + value = values.LogLevel() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_serialize(self): - value = config.LogLevel() + value = values.LogLevel() for name, level in self.levels.items(): self.assertEqual(name, value.serialize(level)) self.assertIsNone(value.serialize(1337)) @@ -256,26 +196,26 @@ class BooleanTest(unittest.TestCase): class HostnameTest(unittest.TestCase): @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_success(self, getaddrinfo_mock): - value = config.Hostname() + value = values.Hostname() value.deserialize('example.com') getaddrinfo_mock.assert_called_once_with('example.com', None) @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_failure(self, getaddrinfo_mock): - value = config.Hostname() + value = values.Hostname() getaddrinfo_mock.side_effect = socket.error self.assertRaises(ValueError, value.deserialize, 'example.com') @mock.patch('socket.getaddrinfo') def test_deserialize_enforces_required(self, getaddrinfo_mock): - value = config.Hostname() + value = values.Hostname() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') self.assertEqual(0, getaddrinfo_mock.call_count) @mock.patch('socket.getaddrinfo') def test_deserialize_respects_optional(self, getaddrinfo_mock): - value = config.Hostname(optional=True) + value = values.Hostname(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) self.assertEqual(0, getaddrinfo_mock.call_count) @@ -283,14 +223,14 @@ class HostnameTest(unittest.TestCase): class PortTest(unittest.TestCase): def test_valid_ports(self): - value = config.Port() + value = values.Port() self.assertEqual(1, value.deserialize('1')) self.assertEqual(80, value.deserialize('80')) self.assertEqual(6600, value.deserialize('6600')) self.assertEqual(65535, value.deserialize('65535')) def test_invalid_ports(self): - value = config.Port() + value = values.Port() self.assertRaises(ValueError, value.deserialize, '65536') self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '0') @@ -300,166 +240,48 @@ class PortTest(unittest.TestCase): class ExpandedPathTest(unittest.TestCase): def test_is_bytes(self): - self.assertIsInstance(config.ExpandedPath('/tmp'), bytes) + self.assertIsInstance(values.ExpandedPath('/tmp'), bytes) @mock.patch('mopidy.utils.path.expand_path') def test_defaults_to_expanded(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - self.assertEqual('expanded_path', config.ExpandedPath('~')) + self.assertEqual('expanded_path', values.ExpandedPath('~')) @mock.patch('mopidy.utils.path.expand_path') def test_orginal_stores_unexpanded(self, expand_path_mock): - self.assertEqual('~', config.ExpandedPath('~').original) + self.assertEqual('~', values.ExpandedPath('~').original) class PathTest(unittest.TestCase): def test_deserialize_conversion_success(self): - result = config.Path().deserialize('/foo') + result = values.Path().deserialize('/foo') self.assertEqual('/foo', result) - self.assertIsInstance(result, config.ExpandedPath) + self.assertIsInstance(result, values.ExpandedPath) self.assertIsInstance(result, bytes) def test_deserialize_enforces_choices(self): - value = config.Path(choices=['/foo', '/bar', '/baz']) + value = values.Path(choices=['/foo', '/bar', '/baz']) self.assertEqual('/foo', value.deserialize('/foo')) self.assertRaises(ValueError, value.deserialize, '/foobar') def test_deserialize_enforces_required(self): - value = config.Path() + value = values.Path() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = config.Path(optional=True) + value = values.Path(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) @mock.patch('mopidy.utils.path.expand_path') def test_serialize_uses_original(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - path = config.ExpandedPath('original_path') - value = config.Path() + path = values.ExpandedPath('original_path') + value = values.Path() self.assertEqual('expanded_path', path) self.assertEqual('original_path', value.serialize(path)) def test_serialize_plain_string(self): - value = config.Path() + value = values.Path() self.assertEqual('path', value.serialize('path')) - - -class ConfigSchemaTest(unittest.TestCase): - def setUp(self): - self.schema = config.ConfigSchema() - self.schema['foo'] = mock.Mock() - self.schema['bar'] = mock.Mock() - self.schema['baz'] = mock.Mock() - self.values = {'bar': '123', 'foo': '456', 'baz': '678'} - - def test_format(self): - self.schema['foo'].format.return_value = 'qwe' - self.schema['bar'].format.return_value = 'asd' - self.schema['baz'].format.return_value = 'zxc' - - expected = ['[qwerty]', 'foo = qwe', 'bar = asd', 'baz = zxc'] - result = self.schema.format('qwerty', self.values) - self.assertEqual('\n'.join(expected), result) - - def test_format_unkwown_value(self): - self.schema['foo'].format.return_value = 'qwe' - self.schema['bar'].format.return_value = 'asd' - self.schema['baz'].format.return_value = 'zxc' - self.values['unknown'] = 'rty' - - result = self.schema.format('qwerty', self.values) - self.assertNotIn('unknown = rty', result) - - def test_convert(self): - self.schema.convert(self.values.items()) - - def test_convert_with_missing_value(self): - del self.values['foo'] - - with self.assertRaises(exceptions.ConfigError) as cm: - self.schema.convert(self.values.items()) - - self.assertIn('not found', cm.exception['foo']) - - def test_convert_with_extra_value(self): - self.values['extra'] = '123' - - with self.assertRaises(exceptions.ConfigError) as cm: - self.schema.convert(self.values.items()) - - self.assertIn('unknown', cm.exception['extra']) - - def test_convert_with_deserialization_error(self): - self.schema['foo'].deserialize.side_effect = ValueError('failure') - - with self.assertRaises(exceptions.ConfigError) as cm: - self.schema.convert(self.values.items()) - - self.assertIn('failure', cm.exception['foo']) - - def test_convert_with_multiple_deserialization_errors(self): - self.schema['foo'].deserialize.side_effect = ValueError('failure') - self.schema['bar'].deserialize.side_effect = ValueError('other') - - with self.assertRaises(exceptions.ConfigError) as cm: - self.schema.convert(self.values.items()) - - self.assertIn('failure', cm.exception['foo']) - self.assertIn('other', cm.exception['bar']) - - def test_convert_deserialization_unknown_and_missing_errors(self): - self.values['extra'] = '123' - self.schema['bar'].deserialize.side_effect = ValueError('failure') - del self.values['baz'] - - with self.assertRaises(exceptions.ConfigError) as cm: - self.schema.convert(self.values.items()) - - self.assertIn('unknown', cm.exception['extra']) - self.assertNotIn('foo', cm.exception) - self.assertIn('failure', cm.exception['bar']) - self.assertIn('not found', cm.exception['baz']) - - -class ExtensionConfigSchemaTest(unittest.TestCase): - def test_schema_includes_enabled(self): - schema = config.ExtensionConfigSchema() - self.assertIsInstance(schema['enabled'], config.Boolean) - - -class LogLevelConfigSchemaTest(unittest.TestCase): - def test_conversion(self): - schema = config.LogLevelConfigSchema() - result = schema.convert([('foo.bar', 'DEBUG'), ('baz', 'INFO')]) - - self.assertEqual(logging.DEBUG, result['foo.bar']) - self.assertEqual(logging.INFO, result['baz']) - - def test_format(self): - schema = config.LogLevelConfigSchema() - expected = ['[levels]', 'baz = info', 'foo.bar = debug'] - result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) - self.assertEqual('\n'.join(expected), result) - - -class DidYouMeanTest(unittest.TestCase): - def testSuggestoins(self): - choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') - - suggestion = config.did_you_mean('bitrate', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('bitrote', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('Bitrot', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('BTROT', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('btro', choices) - self.assertEqual(suggestion, None) diff --git a/tests/ext_test.py b/tests/ext_test.py index b58333c2..04f52866 100644 --- a/tests/ext_test.py +++ b/tests/ext_test.py @@ -1,14 +1,13 @@ from __future__ import unicode_literals -from mopidy.ext import Extension -from mopidy.utils import config +from mopidy import config, ext from tests import unittest class ExtensionTest(unittest.TestCase): def setUp(self): - self.ext = Extension() + self.ext = ext.Extension() def test_dist_name_is_none(self): self.assertIsNone(self.ext.dist_name) From a5f2dc924cd189441e90ec8fe94dac80fdcc327f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 11 Apr 2013 22:26:22 +0200 Subject: [PATCH 200/403] config: Review fixes --- mopidy/config/schemas.py | 3 ++- tests/config/validator_tests.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 15d0bfe3..5c90c474 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -1,5 +1,6 @@ -from mopidy import exceptions +from __future__ import unicode_literals +from mopidy import exceptions from mopidy.config import values diff --git a/tests/config/validator_tests.py b/tests/config/validator_tests.py index 4c0ff70f..3993168d 100644 --- a/tests/config/validator_tests.py +++ b/tests/config/validator_tests.py @@ -5,7 +5,6 @@ from mopidy.config import validators from tests import unittest - class ValidateChoiceTest(unittest.TestCase): def test_no_choices_passes(self): validators.validate_choice('foo', None) From d2b9eda335fc173e8f224f7cfb42deef5cd4d43f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 22:33:39 +0200 Subject: [PATCH 201/403] docs: Fix wording --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 5485f33f..214ddd6c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,7 +18,7 @@ available for many platforms, including Windows, OS X, Linux, Android and iOS. To get started with Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net -`_ and also got a `mailing list at Google Groups +`_ and also have a `mailing list at Google Groups `_. If you stumble into a bug or got a feature request, please create an issue in the `issue tracker `_. The `source code From 5fb16eb1ea75652fc46edabf8a3812ebe6cb0939 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 22:54:55 +0200 Subject: [PATCH 202/403] docs: Add extension registry --- docs/ext/index.rst | 33 +++++++++++++++++++++++++++++++++ docs/index.rst | 9 ++------- 2 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 docs/ext/index.rst diff --git a/docs/ext/index.rst b/docs/ext/index.rst new file mode 100644 index 00000000..a94295ec --- /dev/null +++ b/docs/ext/index.rst @@ -0,0 +1,33 @@ +********** +Extensions +********** + +Here you can find a list of packages that extend Mopidy with additional +functionality. This list is moderated and updated on a regular basis. If you +want your package to show up here, follow the :ref:`guide on creating +extensions `. + + +Bundled with Mopidy +=================== + +These extensions are created and maintained by Mopidy's core developers. They +are installed together with Mopidy and are enabled by default. + +.. toctree:: + :glob: + + ** + + +External extensions +=================== + +These extensions are created and maintained by other developers. + + +Mopidy-SoundCloud +----------------- + +To play music from `SoundCloud `_ check out +`Mopidy-SoundCloud `_. diff --git a/docs/index.rst b/docs/index.rst index 214ddd6c..dd89c679 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,22 +31,17 @@ Introduction ============ .. toctree:: - :maxdepth: 2 + :maxdepth: 3 installation/index installation/raspberrypi config running + ext/index clients/index troubleshooting -Extensions -========== - -TODO - - About ===== From 6072bbe0b8e9012eb0f4a68b8a2502db196521d6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 23:01:56 +0200 Subject: [PATCH 203/403] docs: Move scrobbler docs to new ext registry --- docs/ext/index.rst | 1 + docs/ext/scrobbler.rst | 46 +++++++++++++++++++++++ docs/modules/frontends/scrobbler.rst | 6 --- mopidy/frontends/scrobbler/__init__.py | 52 +++----------------------- mopidy/frontends/scrobbler/ext.conf | 4 ++ 5 files changed, 56 insertions(+), 53 deletions(-) create mode 100644 docs/ext/scrobbler.rst delete mode 100644 docs/modules/frontends/scrobbler.rst create mode 100644 mopidy/frontends/scrobbler/ext.conf diff --git a/docs/ext/index.rst b/docs/ext/index.rst index a94295ec..bfa3237f 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -15,6 +15,7 @@ These extensions are created and maintained by Mopidy's core developers. They are installed together with Mopidy and are enabled by default. .. toctree:: + :maxdepth: 2 :glob: ** diff --git a/docs/ext/scrobbler.rst b/docs/ext/scrobbler.rst new file mode 100644 index 00000000..1d7b3d4b --- /dev/null +++ b/docs/ext/scrobbler.rst @@ -0,0 +1,46 @@ +**************** +Mopidy-Scrobbler +**************** + +This extension scrobbles the music you play to your `Last.fm +`_ profile. + +.. note:: + + This extension requires a free user account at Last.fm. + + +Dependencies +============ + +.. literalinclude:: ../../requirements/scrobbler.txt + + +Configuration values +==================== + +.. confval:: scrobbler/enabled + + If the scrobbler extension should be enabled or not. + +.. confval:: scrobbler/username + + Your Last.fm username. + +.. confval:: scrobbler/password + + Your Last.fm password. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/scrobbler/ext.conf + :language: ini + + +Usage +===== + +The extension is enabled by default if all dependencies are available. You just +need to add your Last.fm username and password to the config file. diff --git a/docs/modules/frontends/scrobbler.rst b/docs/modules/frontends/scrobbler.rst deleted file mode 100644 index 2af9fcff..00000000 --- a/docs/modules/frontends/scrobbler.rst +++ /dev/null @@ -1,6 +0,0 @@ -*********************************************** -:mod:`mopidy.frontends.scrobbler` -- Scrobbler -*********************************************** - -.. automodule:: mopidy.frontends.scrobbler - :synopsis: Music scrobbler frontend diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index f3127040..a60823d7 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -1,53 +1,10 @@ from __future__ import unicode_literals +import os + import mopidy from mopidy import exceptions, ext -from mopidy.utils import config, formatting - - -default_config = """ -[scrobbler] -enabled = true -username = -password = -""" - -__doc__ = """ -Frontend which scrobbles the music you play to your -`Last.fm `_ profile. - -.. note:: - - This frontend requires a free user account at Last.fm. - -**Dependencies** - -.. literalinclude:: ../../../requirements/scrobbler.txt - -**Configuration** - -.. confval:: scrobbler/enabled - - If the scrobbler extension should be enabled or not. - -.. confval:: scrobbler/username - - Your Last.fm username. - -.. confval:: scrobbler/password - - Your Last.fm password. - -**Default config** - -.. code-block:: ini - -%(config)s - -**Usage** - -The frontend is enabled by default if all dependencies are available. -""" % {'config': formatting.indent(default_config)} +from mopidy.utils import config class Extension(ext.Extension): @@ -57,7 +14,8 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return default_config + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return open(conf_file).read() def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/scrobbler/ext.conf b/mopidy/frontends/scrobbler/ext.conf new file mode 100644 index 00000000..4fded92f --- /dev/null +++ b/mopidy/frontends/scrobbler/ext.conf @@ -0,0 +1,4 @@ +[scrobbler] +enabled = true +username = +password = From 467c8b34dc6fa0f52ed04ac9bb97e029200eddbc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 11 Apr 2013 23:18:17 +0200 Subject: [PATCH 204/403] config: Move load, validate and overrides code into mopidy.config --- mopidy/__main__.py | 87 ++++--------------------------------- mopidy/config/__init__.py | 90 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 79 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1bfcfbcf..30fcd7f9 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,12 +1,9 @@ from __future__ import unicode_literals -import codecs -import ConfigParser as configparser import logging import optparse import os import signal -import StringIO import sys import gobject @@ -29,12 +26,10 @@ sys.path.insert( from mopidy import exceptions, ext from mopidy.audio import Audio -from mopidy.config import default_config, config_schemas -from mopidy import config as config_utils # TODO: cleanup +from mopidy import config as config_lib from mopidy.core import Core from mopidy.utils import deps, log, path, process, versioning - logger = logging.getLogger('mopidy.main') @@ -51,16 +46,19 @@ def main(): try: create_file_structures() - logging_config = load_config(config_files, config_overrides) + logging_config = config_lib.load(config_files, config_overrides) log.setup_logging( logging_config, options.verbosity_level, options.save_debug_log) extensions = ext.load_extensions() - raw_config = load_config(config_files, config_overrides, extensions) + raw_config = config_lib.load(config_files, config_overrides, extensions) extensions = ext.filter_enabled_extensions(raw_config, extensions) - config = validate_config(raw_config, config_schemas, extensions) + config = config_lib.validate( + raw_config, config_lib.config_schemas, extensions) log.setup_log_levels(config) check_old_locations() + # TODO: wrap config in RO proxy. + # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors have been started. audio = setup_audio(config) @@ -83,9 +81,7 @@ def main(): def check_config_override(option, opt, override): try: - section, remainder = override.split('/', 1) - key, value = remainder.split('=', 1) - return (section, key, value) + return config_lib.parse_override(override) except ValueError: raise optparse.OptionValueError( 'option %s: must have the format section/key=value' % opt) @@ -181,73 +177,6 @@ def check_old_locations(): 'for further instructions.', old_settings_file) -def load_config(files, overrides, extensions=None): - parser = configparser.RawConfigParser() - - files = [path.expand_path(f) for f in files] - sources = ['builtin-defaults'] + files + ['command-line'] - logger.info('Loading config from: %s', ', '.join(sources)) - - # Read default core config - parser.readfp(StringIO.StringIO(default_config)) - - # Read default extension config - for extension in extensions or []: - parser.readfp(StringIO.StringIO(extension.get_default_config())) - - # Load config from a series of config files - for filename in files: - # TODO: if this is the initial load of logging config we might not have - # a logger at this point, we might want to handle this better. - try: - filehandle = codecs.open(filename, encoding='utf-8') - parser.readfp(filehandle) - except IOError: - logger.debug('Config file %s not found; skipping', filename) - continue - except UnicodeDecodeError: - logger.error('Config file %s is not UTF-8 encoded', filename) - sys.exit(1) - - raw_config = {} - for section in parser.sections(): - raw_config[section] = dict(parser.items(section)) - - for section, key, value in overrides or []: - raw_config.setdefault(section, {})[key] = value - - return raw_config - - -def validate_config(raw_config, schemas, extensions=None): - # Collect config schemas to validate against - sections_and_schemas = schemas.items() - for extension in extensions or []: - sections_and_schemas.append( - (extension.ext_name, extension.get_config_schema())) - - # Get validated config - config = {} - errors = {} - for section_name, schema in sections_and_schemas: - if section_name not in raw_config: - errors[section_name] = {section_name: 'section not found'} - try: - items = raw_config[section_name].items() - config[section_name] = schema.convert(items) - except exceptions.ConfigError as error: - errors[section_name] = error - - if errors: - for section_name, error in errors.items(): - logger.error('[%s] config errors:', section_name) - for key in error: - logger.error('%s %s', key, error[key]) - sys.exit(1) - - return config - - def create_file_structures(): path.get_or_create_dir('$XDG_DATA_DIR/mopidy') path.get_or_create_file('$XDG_CONFIG_DIR/mopidy/mopidy.conf') diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 82fc839e..5ac492d4 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,7 +1,16 @@ from __future__ import unicode_literals +import codecs +import ConfigParser as configparser +import io +import logging +import sys + from mopidy.config.schemas import * from mopidy.config.values import * +from mopidy.utils import path + +logger = logging.getLogger('mopdiy.config') default_config = """ @@ -44,3 +53,84 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() + + +# TODO: update API to load(files, defaults, overrides) this should not need to +# know about extensions +def load(files, overrides, extensions=None): + parser = configparser.RawConfigParser() + + files = [path.expand_path(f) for f in files] + sources = ['builtin-defaults'] + files + ['command-line'] + logger.info('Loading config from: %s', ', '.join(sources)) + + # Read default core config + parser.readfp(io.StringIO(default_config)) + + # Read default extension config + for extension in extensions or []: + parser.readfp(io.StringIO(extension.get_default_config())) + + # Load config from a series of config files + for filename in files: + # TODO: if this is the initial load of logging config we might not have + # a logger at this point, we might want to handle this better. + try: + filehandle = codecs.open(filename, encoding='utf-8') + parser.readfp(filehandle) + except IOError: + logger.debug('Config file %s not found; skipping', filename) + continue + except UnicodeDecodeError: + logger.error('Config file %s is not UTF-8 encoded', filename) + sys.exit(1) + + raw_config = {} + for section in parser.sections(): + raw_config[section] = dict(parser.items(section)) + + # TODO: move out of file loading code? + for section, key, value in overrides or []: + raw_config.setdefault(section, {})[key] = value + + return raw_config + + +# TODO: switch API to validate(raw_config, schemas) this should not need to +# know about extensions +def validate(raw_config, schemas, extensions=None): + # Collect config schemas to validate against + sections_and_schemas = schemas.items() + for extension in extensions or []: + sections_and_schemas.append( + (extension.ext_name, extension.get_config_schema())) + + # Get validated config + config = {} + errors = {} + for section_name, schema in sections_and_schemas: + if section_name not in raw_config: + errors[section_name] = {section_name: 'section not found'} + try: + items = raw_config[section_name].items() + config[section_name] = schema.convert(items) + except exceptions.ConfigError as error: + errors[section_name] = error + + if errors: + # TODO: raise error instead. + #raise exceptions.ConfigError(errors) + for section_name, error in errors.items(): + logger.error('[%s] config errors:', section_name) + for key in error: + logger.error('%s %s', key, error[key]) + sys.exit(1) + + return config + + +def parse_override(override): + """Parse section/key=value override.""" + section, remainder = override.split('/', 1) + key, value = remainder.split('=', 1) + return (section, key, value) From 0e9da33147b0cd4a7566e6010a1916052937c9bc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 23:25:57 +0200 Subject: [PATCH 205/403] docs: Move HTTP to ext registry and API section --- docs/api/http.rst | 439 ++++++++++++++++++++++++ docs/api/index.rst | 1 + docs/ext/http.rst | 101 ++++++ docs/modules/frontends/http.rst | 8 - mopidy/frontends/http/__init__.py | 532 +----------------------------- mopidy/frontends/http/ext.conf | 8 + 6 files changed, 554 insertions(+), 535 deletions(-) create mode 100644 docs/api/http.rst create mode 100644 docs/ext/http.rst delete mode 100644 docs/modules/frontends/http.rst create mode 100644 mopidy/frontends/http/ext.conf diff --git a/docs/api/http.rst b/docs/api/http.rst new file mode 100644 index 00000000..16546683 --- /dev/null +++ b/docs/api/http.rst @@ -0,0 +1,439 @@ +.. _http-api: + +******** +HTTP API +******** + +The :ref:`ext-http` extension makes Mopidy's :ref:`core-api` available over +HTTP using WebSockets. We also provide a JavaScript wrapper, called +:ref:`Mopidy.js ` around the HTTP API for use both from browsers and +Node.js. + +.. warning:: API stability + + Since the HTTP API exposes our internal core API directly it is to be + regarded as **experimental**. We cannot promise to keep any form of + backwards compatibility between releases as we will need to change the core + API while working out how to support new use cases. Thus, if you use this + API, you must expect to do small adjustments to your client for every + release of Mopidy. + + From Mopidy 1.0 and onwards, we intend to keep the core API far more + stable. + + +.. _websocket-api: + +WebSocket API +============= + +The web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you +access to Mopidy's full API and enables Mopidy to instantly push events to the +client, as they happen. + +On the WebSocket we send two different kind of messages: The client can send +JSON-RPC 2.0 requests, and the server will respond with JSON-RPC 2.0 responses. +In addition, the server will send event messages when something happens on the +server. Both message types are encoded as JSON objects. + + +Event messages +-------------- + +Event objects will always have a key named ``event`` whose value is the event +type. Depending on the event type, the event may include additional fields for +related data. The events maps directly to the :class:`mopidy.core.CoreListener` +API. Refer to the ``CoreListener`` method names is the available event types. +The ``CoreListener`` method's keyword arguments are all included as extra +fields on the event objects. Example event message:: + + {"event": "track_playback_started", "track": {...}} + + +JSON-RPC 2.0 messaging +---------------------- + +JSON-RPC 2.0 messages can be recognized by checking for the key named +``jsonrpc`` with the string value ``2.0``. For details on the messaging format, +please refer to the `JSON-RPC 2.0 spec +`_. + +All methods (not attributes) in the :ref:`core-api` is made available through +JSON-RPC calls over the WebSocket. For example, +:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method +``core.playback.play``. + +The core API's attributes is made available through setters and getters. For +example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is +available as the JSON-RPC method ``core.playback.get_current_track``. + +Example JSON-RPC request:: + + {"jsonrpc": "2.0", "id": 1, "method": "core.playback.get_current_track"} + +Example JSON-RPC response:: + + {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", "...": "..."}} + +The JSON-RPC method ``core.describe`` returns a data structure describing all +available methods. If you're unsure how the core API maps to JSON-RPC, having a +look at the ``core.describe`` response can be helpful. + + +.. _mopidy-js: + +Mopidy.js JavaScript library +============================ + +We've made a JavaScript library, Mopidy.js, which wraps the WebSocket and gets +you quickly started with working on your client instead of figuring out how to +communicate with Mopidy. + + +Getting the library for browser use +----------------------------------- + +Regular and minified versions of Mopidy.js, ready for use, is installed +together with Mopidy. When the HTTP extension is enabled, the files are +available at: + +- http://localhost:6680/mopidy/mopidy.js +- http://localhost:6680/mopidy/mopidy.min.js + +You may need to adjust hostname and port for your local setup. + +Thus, if you use Mopidy to host your web client, like described above, you can +load the latest version of Mopidy.js by adding the following script tag to your +HTML file: + +.. code-block:: html + + + +If you don't use Mopidy to host your web client, you can find the JS files in +the Git repo at: + +- ``mopidy/frontends/http/data/mopidy.js`` +- ``mopidy/frontends/http/data/mopidy.min.js`` + + +Getting the library for Node.js use +----------------------------------- + +If you want to use Mopidy.js from Node.js instead of a browser, you can install +Mopidy.js using npm:: + + npm install mopidy + +After npm completes, you can import Mopidy.js using ``require()``: + +.. code-block:: js + + var Mopidy = require("mopidy").Mopidy; + + +Getting the library for development on the library +-------------------------------------------------- + +If you want to work on the Mopidy.js library itself, you'll find a complete +development setup in the ``js/`` dir in our repo. The instructions in +``js/README.md`` will guide you on your way. + + +Creating an instance +-------------------- + +Once you got Mopidy.js loaded, you need to create an instance of the wrapper: + +.. code-block:: js + + var mopidy = new Mopidy(); + +When you instantiate ``Mopidy()`` without arguments, it will connect to +the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host +your web client using Mopidy's web server, or if you use Mopidy.js from a +Node.js environment, you'll need to pass the URL to the WebSocket end point: + +.. code-block:: js + + var mopidy = new Mopidy({ + webSocketUrl: "ws://localhost:6680/mopidy/ws/" + }); + +It is also possible to create an instance first and connect to the WebSocket +later: + +.. code-block:: js + + var mopidy = new Mopidy({autoConnect: false}); + // ... do other stuff, like hooking up events ... + mopidy.connect(); + + +Hooking up to events +-------------------- + +Once you have a Mopidy.js object, you can hook up to the events it emits. To +explore your possibilities, it can be useful to subscribe to all events and log +them: + +.. code-block:: js + + mopidy.on(console.log.bind(console)); + +Several types of events are emitted: + +- You can get notified about when the Mopidy.js object is connected to the + server and ready for method calls, when it's offline, and when it's trying to + reconnect to the server by looking at the events ``state:online``, + ``state:offline``, ``reconnectionPending``, and ``reconnecting``. + +- You can get events sent from the Mopidy server by looking at the events with + the name prefix ``event:``, like ``event:trackPlaybackStarted``. + +- You can introspect what happens internally on the WebSocket by looking at the + events emitted with the name prefix ``websocket:``. + +Mopidy.js uses the event emitter library `BANE +`_, so you should refer to BANE's +short API documentation to see how you can hook up your listeners to the +different events. + + +Calling core API methods +------------------------ + +Once your Mopidy.js object has connected to the Mopidy server and emits the +``state:online`` event, it is ready to accept core API method calls: + +.. code-block:: js + + mopidy.on("state:online", function () { + mopidy.playback.next(); + }); + +Any calls you make before the ``state:online`` event is emitted will fail. If +you've hooked up an errback (more on that a bit later) to the promise returned +from the call, the errback will be called with an error message. + +All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core +API attributes is *not* available, but that shouldn't be a problem as we've +added (undocumented) getters and setters for all of them, so you can access the +attributes as well from JavaScript. + +Both the WebSocket API and the JavaScript API are based on introspection of the +core Python API. Thus, they will always be up to date and immediately reflect +any changes we do to the core API. + +The best way to explore the JavaScript API, is probably by opening your +browser's console, and using its tab completion to navigate the API. You'll +find the Mopidy core API exposed under ``mopidy.playback``, +``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. + +All methods in the JavaScript API have an associated data structure describing +the Python params it expects, and most methods also have the Python API +documentation available. This is available right there in the browser console, +by looking at the method's ``description`` and ``params`` attributes: + +.. code-block:: js + + console.log(mopidy.playback.next.params); + console.log(mopidy.playback.next.description); + +JSON-RPC 2.0 limits method parameters to be sent *either* by-position or +by-name. Combinations of both, like we're used to from Python, isn't supported +by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports +passing parameters by-position. + +Obviously, you'll want to get a return value from many of your method calls. +Since everything is happening across the WebSocket and maybe even across the +network, you'll get the results asynchronously. Instead of having to pass +callbacks and errbacks to every method you call, the methods return "promise" +objects, which you can use to pipe the future result as input to another +method, or to hook up callback and errback functions. + +.. code-block:: js + + var track = mopidy.playback.getCurrentTrack(); + // => ``track`` isn't a track, but a "promise" object + +Instead, typical usage will look like this: + +.. code-block:: js + + var printCurrentTrack = function (track) { + if (track) { + console.log("Currently playing:", track.name, "by", + track.artists[0].name, "from", track.album.name); + } else { + console.log("No current track"); + } + }; + + mopidy.playback.getCurrentTrack().then( + printCurrentTrack, console.error.bind(console)); + +The first function passed to ``then()``, ``printCurrentTrack``, is the callback +that will be called if the method call succeeds. The second function, +``console.error``, is the errback that will be called if anything goes wrong. +If you don't hook up an errback, debugging will be hard as errors will silently +go missing. + +For debugging, you may be interested in errors from function without +interesting return values as well. In that case, you can pass ``null`` as the +callback: + +.. code-block:: js + + mopidy.playback.next().then(null, console.error.bind(console)); + +The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A +`_ standard. We use the +implementation known as `when.js `_. Please +refer to when.js' documentation or the standard for further details on how to +work with promise objects. + + +Cleaning up +----------- + +If you for some reason want to clean up after Mopidy.js before the web page is +closed or navigated away from, you can close the WebSocket, unregister all +event listeners, and delete the object like this: + +.. code-block:: js + + // Close the WebSocket without reconnecting. Letting the object be garbage + // collected will have the same effect, so this isn't strictly necessary. + mopidy.close(); + + // Unregister all event listeners. If you don't do this, you may have + // lingering references to the object causing the garbage collector to not + // clean up after it. + mopidy.off(); + + // Delete your reference to the object, so it can be garbage collected. + mopidy = null; + + +Example to get started with +--------------------------- + +1. Make sure that you've installed all dependencies required by + :ref:`ext-http`. + +2. Create an empty directory for your web client. + +3. Change the :confval:`http/static_dir` config value to point to your new + directory. + +4. Start/restart Mopidy. + +5. Create a file in the directory named ``index.html`` containing e.g. "Hello, + world!". + +6. Visit http://localhost:6680/ to confirm that you can view your new HTML file + there. + +7. Include Mopidy.js in your web page: + + .. code-block:: html + + + +8. Add one of the following Mopidy.js examples of how to queue and start + playback of your first playlist either to your web page or a JavaScript file + that you include in your web page. + + "Imperative" style: + + .. code-block:: js + + var consoleError = console.error.bind(console); + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var queueAndPlayFirstPlaylist = function () { + mopidy.playlists.getPlaylists().then(function (playlists) { + var playlist = playlists[0]; + console.log("Loading playlist:", playlist.name); + mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) { + mopidy.playback.play(tlTracks[0]).then(function () { + mopidy.playback.getCurrentTrack().then(function (track) { + console.log("Now playing:", trackDesc(track)); + }, consoleError); + }, consoleError); + }, consoleError); + }, consoleError); + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log.bind(console)); // Log all events + mopidy.on("state:online", queueAndPlayFirstPlaylist); + + Approximately the same behavior in a more functional style, using chaining + of promisies. + + .. code-block:: js + + var consoleError = console.error.bind(console); + + var getFirst = function (list) { + return list[0]; + }; + + var extractTracks = function (playlist) { + return playlist.tracks; + }; + + var printTypeAndName = function (model) { + console.log(model.__model__ + ": " + model.name); + // By returning the playlist, this function can be inserted + // anywhere a model with a name is piped in the chain. + return model; + }; + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var printNowPlaying = function () { + // By returning any arguments we get, the function can be inserted + // anywhere in the chain. + var args = arguments; + return mopidy.playback.getCurrentTrack().then(function (track) { + console.log("Now playing:", trackDesc(track)); + return args; + }); + }; + + var queueAndPlayFirstPlaylist = function () { + mopidy.playlists.getPlaylists() + // => list of Playlists + .then(getFirst, consoleError) + // => Playlist + .then(printTypeAndName, consoleError) + // => Playlist + .then(extractTracks, consoleError) + // => list of Tracks + .then(mopidy.tracklist.add, consoleError) + // => list of TlTracks + .then(getFirst, consoleError) + // => TlTrack + .then(mopidy.playback.play, consoleError) + // => null + .then(printNowPlaying, consoleError); + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log.bind(console)); // Log all events + mopidy.on("state:online", queueAndPlayFirstPlaylist); + +9. The web page should now queue and play your first playlist every time your + load it. See the browser's console for output from the function, any errors, + and all events that are emitted. + diff --git a/docs/api/index.rst b/docs/api/index.rst index 5a210812..6ba44999 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,3 +11,4 @@ API reference core audio frontends + http diff --git a/docs/ext/http.rst b/docs/ext/http.rst new file mode 100644 index 00000000..5c7ba79b --- /dev/null +++ b/docs/ext/http.rst @@ -0,0 +1,101 @@ +.. _ext-http: + +*********** +Mopidy-HTTP +*********** + +The HTTP extension lets you control Mopidy through HTTP and WebSockets, e.g. +from a web based client. See :ref:`http-api` for details on how to integrate +with Mopidy over HTTP. + + +Known issues +============ + +https://github.com/mopidy/mopidy/issues?labels=HTTP+frontend + + +Dependencies +============ + +.. literalinclude:: ../../requirements/http.txt + + +Configuration values +==================== + +.. confval:: http/enabled + + If the HTTP extension should be enabled or not. + +.. confval:: http/hostname + + Which address the HTTP server should bind to. + + ``127.0.0.1`` + Listens only on the IPv4 loopback interface + ``::1`` + Listens only on the IPv6 loopback interface + ``0.0.0.0`` + Listens on all IPv4 interfaces + ``::`` + Listens on all interfaces, both IPv4 and IPv6 + +.. confval:: http/port + + Which TCP port the HTTP server should listen to. + +.. confval:: http/static_dir + + Which directory the HTTP server should serve at "/" + + Change this to have Mopidy serve e.g. files for your JavaScript client. + "/mopidy" will continue to work as usual even if you change this setting. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/http/ext.conf + :language: ini + + +Setup +===== + +The extension is enabled by default if all dependencies are available. + +When it is enabled it starts a web server at the port specified by the +:confval:`http/port` config value. + +.. warning:: Security + + As a simple security measure, the web server is by default only available + from localhost. To make it available from other computers, change the + :confval:`http/hostname` config value. Before you do so, note that the HTTP + extension does not feature any form of user authentication or + authorization. Anyone able to access the web server can use the full core + API of Mopidy. Thus, you probably only want to make the web server + available from your local network or place it behind a web proxy which + takes care or user authentication. You have been warned. + + +Using a web based Mopidy client +=============================== + +The web server can also host any static files, for example the HTML, CSS, +JavaScript, and images needed for a web based Mopidy client. To host static +files, change the ``http/static_dir`` to point to the root directory of your +web client, e.g.:: + + [http] + static_dir = /home/alice/dev/the-client + +If the directory includes a file named ``index.html``, it will be served on the +root of Mopidy's web server. + +If you're making a web based client and wants to do server side development as +well, you are of course free to run your own web server and just use Mopidy's +web server for the APIs. But, for clients implemented purely in JavaScript, +letting Mopidy host the files is a simpler solution. + diff --git a/docs/modules/frontends/http.rst b/docs/modules/frontends/http.rst deleted file mode 100644 index 31366bd1..00000000 --- a/docs/modules/frontends/http.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _http-frontend: - -********************************************* -:mod:`mopidy.frontends.http` -- HTTP frontend -********************************************* - -.. automodule:: mopidy.frontends.http - :synopsis: HTTP and WebSockets frontend diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 32d55f23..99399e11 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,533 +1,10 @@ from __future__ import unicode_literals +import os + import mopidy from mopidy import exceptions, ext -from mopidy.utils import config, formatting - - -default_config = """ -[http] -enabled = true -hostname = 127.0.0.1 -port = 6680 -static_dir = - -[logging.levels] -cherrypy = warning -""" - -__doc__ = """ -The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g. -from a web based client. - -**Issues** - -https://github.com/mopidy/mopidy/issues?labels=HTTP+frontend - -**Dependencies** - -.. literalinclude:: ../../../requirements/http.txt - -**Configuration** - -.. confval:: http/enabled - - If the HTTP extension should be enabled or not. - -.. confval:: http/hostname - - Which address the HTTP server should bind to. - - ``127.0.0.1`` - Listens only on the IPv4 loopback interface - ``::1`` - Listens only on the IPv6 loopback interface - ``0.0.0.0`` - Listens on all IPv4 interfaces - ``::`` - Listens on all interfaces, both IPv4 and IPv6 - -.. confval:: http/port - - Which TCP port the HTTP server should listen to. - -.. confval:: http/static_dir - - Which directory the HTTP server should serve at "/" - - Change this to have Mopidy serve e.g. files for your JavaScript client. - "/mopidy" will continue to work as usual even if you change this setting. - -**Default config** - -.. code-block:: ini - -%(config)s - - -Setup -===== - -The frontend is enabled by default if all dependencies are available. - -When it is enabled it starts a web server at the port specified by the -:confval:`http/port` config value. - -.. warning:: Security - - As a simple security measure, the web server is by default only available - from localhost. To make it available from other computers, change the - :confval:`http/hostname` config value. Before you do so, note that the HTTP - frontend does not feature any form of user authentication or authorization. - Anyone able to access the web server can use the full core API of Mopidy. - Thus, you probably only want to make the web server available from your - local network or place it behind a web proxy which takes care or user - authentication. You have been warned. - - -Using a web based Mopidy client -=============================== - -The web server can also host any static files, for example the HTML, CSS, -JavaScript, and images needed for a web based Mopidy client. To host static -files, change the ``http/static_dir`` to point to the root directory of your -web client, e.g.:: - - [http] - static_dir = /home/alice/dev/the-client - -If the directory includes a file named ``index.html``, it will be served on the -root of Mopidy's web server. - -If you're making a web based client and wants to do server side development as -well, you are of course free to run your own web server and just use Mopidy's -web server for the APIs. But, for clients implemented purely in JavaScript, -letting Mopidy host the files is a simpler solution. - - -WebSocket API -============= - -.. warning:: API stability - - Since this frontend exposes our internal core API directly it is to be - regarded as **experimental**. We cannot promise to keep any form of - backwards compatibility between releases as we will need to change the core - API while working out how to support new use cases. Thus, if you use this - API, you must expect to do small adjustments to your client for every - release of Mopidy. - - From Mopidy 1.0 and onwards, we intend to keep the core API far more - stable. - -The web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you -access to Mopidy's full API and enables Mopidy to instantly push events to the -client, as they happen. - -On the WebSocket we send two different kind of messages: The client can send -JSON-RPC 2.0 requests, and the server will respond with JSON-RPC 2.0 responses. -In addition, the server will send event messages when something happens on the -server. Both message types are encoded as JSON objects. - - -Event messages --------------- - -Event objects will always have a key named ``event`` whose value is the event -type. Depending on the event type, the event may include additional fields for -related data. The events maps directly to the :class:`mopidy.core.CoreListener` -API. Refer to the ``CoreListener`` method names is the available event types. -The ``CoreListener`` method's keyword arguments are all included as extra -fields on the event objects. Example event message:: - - {"event": "track_playback_started", "track": {...}} - - -JSON-RPC 2.0 messaging ----------------------- - -JSON-RPC 2.0 messages can be recognized by checking for the key named -``jsonrpc`` with the string value ``2.0``. For details on the messaging format, -please refer to the `JSON-RPC 2.0 spec -`_. - -All methods (not attributes) in the :ref:`core-api` is made available through -JSON-RPC calls over the WebSocket. For example, -:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method -``core.playback.play``. - -The core API's attributes is made available through setters and getters. For -example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is -available as the JSON-RPC method ``core.playback.get_current_track``. - -Example JSON-RPC request:: - - {"jsonrpc": "2.0", "id": 1, "method": "core.playback.get_current_track"} - -Example JSON-RPC response:: - - {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", "...": "..."}} - -The JSON-RPC method ``core.describe`` returns a data structure describing all -available methods. If you're unsure how the core API maps to JSON-RPC, having a -look at the ``core.describe`` response can be helpful. - - -Mopidy.js JavaScript library -============================ - -We've made a JavaScript library, Mopidy.js, which wraps the WebSocket and gets -you quickly started with working on your client instead of figuring out how to -communicate with Mopidy. - - -Getting the library for browser use ------------------------------------ - -Regular and minified versions of Mopidy.js, ready for use, is installed -together with Mopidy. When the HTTP frontend is running, the files are -available at: - -- http://localhost:6680/mopidy/mopidy.js -- http://localhost:6680/mopidy/mopidy.min.js - -You may need to adjust hostname and port for your local setup. - -Thus, if you use Mopidy to host your web client, like described above, you can -load the latest version of Mopidy.js by adding the following script tag to your -HTML file: - -.. code-block:: html - - - -If you don't use Mopidy to host your web client, you can find the JS files in -the Git repo at: - -- ``mopidy/frontends/http/data/mopidy.js`` -- ``mopidy/frontends/http/data/mopidy.min.js`` - - -Getting the library for Node.js use ------------------------------------ - -If you want to use Mopidy.js from Node.js instead of a browser, you can install -Mopidy.js using npm:: - - npm install mopidy - -After npm completes, you can import Mopidy.js using ``require()``: - -.. code-block:: js - - var Mopidy = require("mopidy").Mopidy; - - -Getting the library for development on the library --------------------------------------------------- - -If you want to work on the Mopidy.js library itself, you'll find a complete -development setup in the ``js/`` dir in our repo. The instructions in -``js/README.md`` will guide you on your way. - - -Creating an instance --------------------- - -Once you got Mopidy.js loaded, you need to create an instance of the wrapper: - -.. code-block:: js - - var mopidy = new Mopidy(); - -When you instantiate ``Mopidy()`` without arguments, it will connect to -the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host -your web client using Mopidy's web server, or if you use Mopidy.js from a -Node.js environment, you'll need to pass the URL to the WebSocket end point: - -.. code-block:: js - - var mopidy = new Mopidy({ - webSocketUrl: "ws://localhost:6680/mopidy/ws/" - }); - -It is also possible to create an instance first and connect to the WebSocket -later: - -.. code-block:: js - - var mopidy = new Mopidy({autoConnect: false}); - // ... do other stuff, like hooking up events ... - mopidy.connect(); - - -Hooking up to events --------------------- - -Once you have a Mopidy.js object, you can hook up to the events it emits. To -explore your possibilities, it can be useful to subscribe to all events and log -them: - -.. code-block:: js - - mopidy.on(console.log.bind(console)); - -Several types of events are emitted: - -- You can get notified about when the Mopidy.js object is connected to the - server and ready for method calls, when it's offline, and when it's trying to - reconnect to the server by looking at the events ``state:online``, - ``state:offline``, ``reconnectionPending``, and ``reconnecting``. - -- You can get events sent from the Mopidy server by looking at the events with - the name prefix ``event:``, like ``event:trackPlaybackStarted``. - -- You can introspect what happens internally on the WebSocket by looking at the - events emitted with the name prefix ``websocket:``. - -Mopidy.js uses the event emitter library `BANE -`_, so you should refer to BANE's -short API documentation to see how you can hook up your listeners to the -different events. - - -Calling core API methods ------------------------- - -Once your Mopidy.js object has connected to the Mopidy server and emits the -``state:online`` event, it is ready to accept core API method calls: - -.. code-block:: js - - mopidy.on("state:online", function () { - mopidy.playback.next(); - }); - -Any calls you make before the ``state:online`` event is emitted will fail. If -you've hooked up an errback (more on that a bit later) to the promise returned -from the call, the errback will be called with an error message. - -All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core -API attributes is *not* available, but that shouldn't be a problem as we've -added (undocumented) getters and setters for all of them, so you can access the -attributes as well from JavaScript. - -Both the WebSocket API and the JavaScript API are based on introspection of the -core Python API. Thus, they will always be up to date and immediately reflect -any changes we do to the core API. - -The best way to explore the JavaScript API, is probably by opening your -browser's console, and using its tab completion to navigate the API. You'll -find the Mopidy core API exposed under ``mopidy.playback``, -``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. - -All methods in the JavaScript API have an associated data structure describing -the Python params it expects, and most methods also have the Python API -documentation available. This is available right there in the browser console, -by looking at the method's ``description`` and ``params`` attributes: - -.. code-block:: js - - console.log(mopidy.playback.next.params); - console.log(mopidy.playback.next.description); - -JSON-RPC 2.0 limits method parameters to be sent *either* by-position or -by-name. Combinations of both, like we're used to from Python, isn't supported -by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports -passing parameters by-position. - -Obviously, you'll want to get a return value from many of your method calls. -Since everything is happening across the WebSocket and maybe even across the -network, you'll get the results asynchronously. Instead of having to pass -callbacks and errbacks to every method you call, the methods return "promise" -objects, which you can use to pipe the future result as input to another -method, or to hook up callback and errback functions. - -.. code-block:: js - - var track = mopidy.playback.getCurrentTrack(); - // => ``track`` isn't a track, but a "promise" object - -Instead, typical usage will look like this: - -.. code-block:: js - - var printCurrentTrack = function (track) { - if (track) { - console.log("Currently playing:", track.name, "by", - track.artists[0].name, "from", track.album.name); - } else { - console.log("No current track"); - } - }; - - mopidy.playback.getCurrentTrack().then( - printCurrentTrack, console.error.bind(console)); - -The first function passed to ``then()``, ``printCurrentTrack``, is the callback -that will be called if the method call succeeds. The second function, -``console.error``, is the errback that will be called if anything goes wrong. -If you don't hook up an errback, debugging will be hard as errors will silently -go missing. - -For debugging, you may be interested in errors from function without -interesting return values as well. In that case, you can pass ``null`` as the -callback: - -.. code-block:: js - - mopidy.playback.next().then(null, console.error.bind(console)); - -The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A -`_ standard. We use the -implementation known as `when.js `_. Please -refer to when.js' documentation or the standard for further details on how to -work with promise objects. - - -Cleaning up ------------ - -If you for some reason want to clean up after Mopidy.js before the web page is -closed or navigated away from, you can close the WebSocket, unregister all -event listeners, and delete the object like this: - -.. code-block:: js - - // Close the WebSocket without reconnecting. Letting the object be garbage - // collected will have the same effect, so this isn't strictly necessary. - mopidy.close(); - - // Unregister all event listeners. If you don't do this, you may have - // lingering references to the object causing the garbage collector to not - // clean up after it. - mopidy.off(); - - // Delete your reference to the object, so it can be garbage collected. - mopidy = null; - - -Example to get started with ---------------------------- - -1. Make sure that you've installed all dependencies required by the HTTP - frontend. - -2. Create an empty directory for your web client. - -3. Change the :confval:`http/static_dir` config value to point to your new - directory. - -4. Start/restart Mopidy. - -5. Create a file in the directory named ``index.html`` containing e.g. "Hello, - world!". - -6. Visit http://localhost:6680/ to confirm that you can view your new HTML file - there. - -7. Include Mopidy.js in your web page: - - .. code-block:: html - - - -8. Add one of the following Mopidy.js examples of how to queue and start - playback of your first playlist either to your web page or a JavaScript file - that you include in your web page. - - "Imperative" style: - - .. code-block:: js - - var consoleError = console.error.bind(console); - - var trackDesc = function (track) { - return track.name + " by " + track.artists[0].name + - " from " + track.album.name; - }; - - var queueAndPlayFirstPlaylist = function () { - mopidy.playlists.getPlaylists().then(function (playlists) { - var playlist = playlists[0]; - console.log("Loading playlist:", playlist.name); - mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) { - mopidy.playback.play(tlTracks[0]).then(function () { - mopidy.playback.getCurrentTrack().then(function (track) { - console.log("Now playing:", trackDesc(track)); - }, consoleError); - }, consoleError); - }, consoleError); - }, consoleError); - }; - - var mopidy = new Mopidy(); // Connect to server - mopidy.on(console.log.bind(console)); // Log all events - mopidy.on("state:online", queueAndPlayFirstPlaylist); - - Approximately the same behavior in a more functional style, using chaining - of promisies. - - .. code-block:: js - - var consoleError = console.error.bind(console); - - var getFirst = function (list) { - return list[0]; - }; - - var extractTracks = function (playlist) { - return playlist.tracks; - }; - - var printTypeAndName = function (model) { - console.log(model.__model__ + ": " + model.name); - // By returning the playlist, this function can be inserted - // anywhere a model with a name is piped in the chain. - return model; - }; - - var trackDesc = function (track) { - return track.name + " by " + track.artists[0].name + - " from " + track.album.name; - }; - - var printNowPlaying = function () { - // By returning any arguments we get, the function can be inserted - // anywhere in the chain. - var args = arguments; - return mopidy.playback.getCurrentTrack().then(function (track) { - console.log("Now playing:", trackDesc(track)); - return args; - }); - }; - - var queueAndPlayFirstPlaylist = function () { - mopidy.playlists.getPlaylists() - // => list of Playlists - .then(getFirst, consoleError) - // => Playlist - .then(printTypeAndName, consoleError) - // => Playlist - .then(extractTracks, consoleError) - // => list of Tracks - .then(mopidy.tracklist.add, consoleError) - // => list of TlTracks - .then(getFirst, consoleError) - // => TlTrack - .then(mopidy.playback.play, consoleError) - // => null - .then(printNowPlaying, consoleError); - }; - - var mopidy = new Mopidy(); // Connect to server - mopidy.on(console.log.bind(console)); // Log all events - mopidy.on("state:online", queueAndPlayFirstPlaylist); - -9. The web page should now queue and play your first playlist every time your - load it. See the browser's console for output from the function, any errors, - and all events that are emitted. -""" % {'config': formatting.indent(default_config)} +from mopidy.utils import config class Extension(ext.Extension): @@ -537,7 +14,8 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return default_config + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return open(conf_file).read() def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/http/ext.conf b/mopidy/frontends/http/ext.conf new file mode 100644 index 00000000..8f8f2a1e --- /dev/null +++ b/mopidy/frontends/http/ext.conf @@ -0,0 +1,8 @@ +[http] +enabled = true +hostname = 127.0.0.1 +port = 6680 +static_dir = + +[logging.levels] +cherrypy = warning From 7a4475b3d655a0dcad8d4dffa54eb15188dd6a41 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 23:27:16 +0200 Subject: [PATCH 206/403] docs: Unbreak link from changelog --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ae4c5d9d..ab97bf08 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -333,7 +333,7 @@ We've added an HTTP frontend for those wanting to build web clients for Mopidy! **HTTP frontend** - Added new optional HTTP frontend which exposes Mopidy's core API through - JSON-RPC 2.0 messages over a WebSocket. See :ref:`http-frontend` for further + JSON-RPC 2.0 messages over a WebSocket. See :ref:`http-api` for further details. - Added a JavaScript library, Mopidy.js, to make it easier to develop web based From 66be2dc551371f45da4561cdd8bfac15895bfbc8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 11 Apr 2013 23:30:29 +0200 Subject: [PATCH 207/403] config: Rename mopidy.config.values to types --- mopidy/config/__init__.py | 2 +- mopidy/config/schemas.py | 6 +- mopidy/config/{values.py => types.py} | 0 .../config/{values_test.py => types_test.py} | 94 +++++++++---------- 4 files changed, 51 insertions(+), 51 deletions(-) rename mopidy/config/{values.py => types.py} (100%) rename tests/config/{values_test.py => types_test.py} (83%) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 5ac492d4..715027cc 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -7,7 +7,7 @@ import logging import sys from mopidy.config.schemas import * -from mopidy.config.values import * +from mopidy.config.types import * from mopidy.utils import path logger = logging.getLogger('mopdiy.config') diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 5c90c474..13928054 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import exceptions -from mopidy.config import values +from mopidy.config import types def _did_you_mean(name, choices): @@ -101,7 +101,7 @@ class ExtensionConfigSchema(ConfigSchema): """ def __init__(self): super(ExtensionConfigSchema, self).__init__() - self['enabled'] = values.Boolean() + self['enabled'] = types.Boolean() class LogLevelConfigSchema(object): @@ -112,7 +112,7 @@ class LogLevelConfigSchema(object): :class:`ConfigSchema`, but implements the same interface. """ def __init__(self): - self._config_value = values.LogLevel() + self._config_value = types.LogLevel() def format(self, name, values): lines = ['[%s]' % name] diff --git a/mopidy/config/values.py b/mopidy/config/types.py similarity index 100% rename from mopidy/config/values.py rename to mopidy/config/types.py diff --git a/tests/config/values_test.py b/tests/config/types_test.py similarity index 83% rename from tests/config/values_test.py rename to tests/config/types_test.py index 442fffbb..89fb3ac1 100644 --- a/tests/config/values_test.py +++ b/tests/config/types_test.py @@ -5,14 +5,14 @@ import mock import socket from mopidy import exceptions -from mopidy.config import values +from mopidy.config import types from tests import unittest class ConfigValueTest(unittest.TestCase): def test_init(self): - value = values.ConfigValue() + value = types.ConfigValue() self.assertIsNone(value.choices) self.assertIsNone(value.maximum) self.assertIsNone(value.minimum) @@ -22,7 +22,7 @@ class ConfigValueTest(unittest.TestCase): def test_init_with_params(self): kwargs = {'choices': ['foo'], 'minimum': 0, 'maximum': 10, 'secret': True, 'optional': True} - value = values.ConfigValue(**kwargs) + value = types.ConfigValue(**kwargs) self.assertEqual(['foo'], value.choices) self.assertEqual(0, value.minimum) self.assertEqual(10, value.maximum) @@ -30,90 +30,90 @@ class ConfigValueTest(unittest.TestCase): self.assertEqual(True, value.secret) def test_deserialize_passes_through(self): - value = values.ConfigValue() + value = types.ConfigValue() obj = object() self.assertEqual(obj, value.deserialize(obj)) def test_serialize_conversion_to_string(self): - value = values.ConfigValue() + value = types.ConfigValue() self.assertIsInstance(value.serialize(object()), basestring) def test_format_uses_serialize(self): - value = values.ConfigValue() + value = types.ConfigValue() obj = object() self.assertEqual(value.serialize(obj), value.format(obj)) def test_format_masks_secrets(self): - value = values.ConfigValue(secret=True) + value = types.ConfigValue(secret=True) self.assertEqual('********', value.format(object())) class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.String() + value = types.String() self.assertEqual('foo', value.deserialize(' foo ')) def test_deserialize_enforces_choices(self): - value = values.String(choices=['foo', 'bar', 'baz']) + value = types.String(choices=['foo', 'bar', 'baz']) self.assertEqual('foo', value.deserialize('foo')) self.assertRaises(ValueError, value.deserialize, 'foobar') def test_deserialize_enforces_required(self): - value = values.String() + value = types.String() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = values.String(optional=True) + value = types.String(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) def test_serialize_string_escapes(self): - value = values.String() + value = types.String() self.assertEqual(r'\r\n\t', value.serialize('\r\n\t')) def test_format_masks_secrets(self): - value = values.String(secret=True) + value = types.String(secret=True) self.assertEqual('********', value.format('s3cret')) class IntegerTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.Integer() + value = types.Integer() self.assertEqual(123, value.deserialize('123')) self.assertEqual(0, value.deserialize('0')) self.assertEqual(-10, value.deserialize('-10')) def test_deserialize_conversion_failure(self): - value = values.Integer() + value = types.Integer() self.assertRaises(ValueError, value.deserialize, 'asd') self.assertRaises(ValueError, value.deserialize, '3.14') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_enforces_choices(self): - value = values.Integer(choices=[1, 2, 3]) + value = types.Integer(choices=[1, 2, 3]) self.assertEqual(3, value.deserialize('3')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_minimum(self): - value = values.Integer(minimum=10) + value = types.Integer(minimum=10) self.assertEqual(15, value.deserialize('15')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_maximum(self): - value = values.Integer(maximum=10) + value = types.Integer(maximum=10) self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') def test_format_masks_secrets(self): - value = values.Integer(secret=True) + value = types.Integer(secret=True) self.assertEqual('********', value.format('1337')) class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.Boolean() + value = types.Boolean() for true in ('1', 'yes', 'true', 'on'): self.assertIs(value.deserialize(true), True) self.assertIs(value.deserialize(true.upper()), True) @@ -124,24 +124,24 @@ class BooleanTest(unittest.TestCase): self.assertIs(value.deserialize(false.capitalize()), False) def test_deserialize_conversion_failure(self): - value = values.Boolean() + value = types.Boolean() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') def test_serialize(self): - value = values.Boolean() + value = types.Boolean() self.assertEqual('true', value.serialize(True)) self.assertEqual('false', value.serialize(False)) def test_format_masks_secrets(self): - value = values.Boolean(secret=True) + value = types.Boolean(secret=True) self.assertEqual('********', value.format('true')) class ListTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.List() + value = types.List() expected = ('foo', 'bar', 'baz') self.assertEqual(expected, value.deserialize('foo, bar ,baz ')) @@ -150,17 +150,17 @@ class ListTest(unittest.TestCase): self.assertEqual(expected, value.deserialize(' foo,bar\nbar\nbaz')) def test_deserialize_enforces_required(self): - value = values.List() + value = types.List() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = values.List(optional=True) + value = types.List(optional=True) self.assertEqual(tuple(), value.deserialize('')) self.assertEqual(tuple(), value.deserialize(' ')) def test_serialize(self): - value = values.List() + value = types.List() result = value.serialize(('foo', 'bar', 'baz')) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') @@ -173,21 +173,21 @@ class BooleanTest(unittest.TestCase): 'debug': logging.DEBUG} def test_deserialize_conversion_success(self): - value = values.LogLevel() + value = types.LogLevel() for name, level in self.levels.items(): self.assertEqual(level, value.deserialize(name)) self.assertEqual(level, value.deserialize(name.upper())) self.assertEqual(level, value.deserialize(name.capitalize())) def test_deserialize_conversion_failure(self): - value = values.LogLevel() + value = types.LogLevel() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_serialize(self): - value = values.LogLevel() + value = types.LogLevel() for name, level in self.levels.items(): self.assertEqual(name, value.serialize(level)) self.assertIsNone(value.serialize(1337)) @@ -196,26 +196,26 @@ class BooleanTest(unittest.TestCase): class HostnameTest(unittest.TestCase): @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_success(self, getaddrinfo_mock): - value = values.Hostname() + value = types.Hostname() value.deserialize('example.com') getaddrinfo_mock.assert_called_once_with('example.com', None) @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_failure(self, getaddrinfo_mock): - value = values.Hostname() + value = types.Hostname() getaddrinfo_mock.side_effect = socket.error self.assertRaises(ValueError, value.deserialize, 'example.com') @mock.patch('socket.getaddrinfo') def test_deserialize_enforces_required(self, getaddrinfo_mock): - value = values.Hostname() + value = types.Hostname() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') self.assertEqual(0, getaddrinfo_mock.call_count) @mock.patch('socket.getaddrinfo') def test_deserialize_respects_optional(self, getaddrinfo_mock): - value = values.Hostname(optional=True) + value = types.Hostname(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) self.assertEqual(0, getaddrinfo_mock.call_count) @@ -223,14 +223,14 @@ class HostnameTest(unittest.TestCase): class PortTest(unittest.TestCase): def test_valid_ports(self): - value = values.Port() + value = types.Port() self.assertEqual(1, value.deserialize('1')) self.assertEqual(80, value.deserialize('80')) self.assertEqual(6600, value.deserialize('6600')) self.assertEqual(65535, value.deserialize('65535')) def test_invalid_ports(self): - value = values.Port() + value = types.Port() self.assertRaises(ValueError, value.deserialize, '65536') self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '0') @@ -240,48 +240,48 @@ class PortTest(unittest.TestCase): class ExpandedPathTest(unittest.TestCase): def test_is_bytes(self): - self.assertIsInstance(values.ExpandedPath('/tmp'), bytes) + self.assertIsInstance(types.ExpandedPath('/tmp'), bytes) @mock.patch('mopidy.utils.path.expand_path') def test_defaults_to_expanded(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - self.assertEqual('expanded_path', values.ExpandedPath('~')) + self.assertEqual('expanded_path', types.ExpandedPath('~')) @mock.patch('mopidy.utils.path.expand_path') def test_orginal_stores_unexpanded(self, expand_path_mock): - self.assertEqual('~', values.ExpandedPath('~').original) + self.assertEqual('~', types.ExpandedPath('~').original) class PathTest(unittest.TestCase): def test_deserialize_conversion_success(self): - result = values.Path().deserialize('/foo') + result = types.Path().deserialize('/foo') self.assertEqual('/foo', result) - self.assertIsInstance(result, values.ExpandedPath) + self.assertIsInstance(result, types.ExpandedPath) self.assertIsInstance(result, bytes) def test_deserialize_enforces_choices(self): - value = values.Path(choices=['/foo', '/bar', '/baz']) + value = types.Path(choices=['/foo', '/bar', '/baz']) self.assertEqual('/foo', value.deserialize('/foo')) self.assertRaises(ValueError, value.deserialize, '/foobar') def test_deserialize_enforces_required(self): - value = values.Path() + value = types.Path() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = values.Path(optional=True) + value = types.Path(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) @mock.patch('mopidy.utils.path.expand_path') def test_serialize_uses_original(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - path = values.ExpandedPath('original_path') - value = values.Path() + path = types.ExpandedPath('original_path') + value = types.Path() self.assertEqual('expanded_path', path) self.assertEqual('original_path', value.serialize(path)) def test_serialize_plain_string(self): - value = values.Path() + value = types.Path() self.assertEqual('path', value.serialize('path')) From 21f2e5c8b09e6d426daae82509c0a009374f91f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 23:34:12 +0200 Subject: [PATCH 208/403] docs: Unbreak links from HTTP client page --- docs/clients/http.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/clients/http.rst b/docs/clients/http.rst index 5381eaff..d67dbb7b 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -4,14 +4,14 @@ HTTP clients ************ -Mopidy added an :ref:`HTTP frontend ` in 0.10 which provides the +Mopidy added an :ref:`HTTP frontend ` in 0.10 which provides the building blocks needed for creating web clients for Mopidy with the help of a WebSocket and a JavaScript library provided by Mopidy. This page will list any HTTP/web Mopidy clients. If you've created one, please notify us so we can include your client on this page. -See :ref:`http-frontend` for details on how to build your own web client. +See :ref:`http-api` for details on how to build your own web client. woutervanwijk/Mopidy-Webclient From 82d2ecd41e376039a93d0ae995cf7ce3e1547f33 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 23:38:45 +0200 Subject: [PATCH 209/403] docs: Move MPRIS to ext registry --- docs/ext/mpris.rst | 70 ++++++++++++++++++++++++++++ docs/modules/frontends/mpris.rst | 8 ---- mopidy/frontends/mpris/__init__.py | 74 ++---------------------------- mopidy/frontends/mpris/ext.conf | 3 ++ 4 files changed, 76 insertions(+), 79 deletions(-) create mode 100644 docs/ext/mpris.rst delete mode 100644 docs/modules/frontends/mpris.rst create mode 100644 mopidy/frontends/mpris/ext.conf diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst new file mode 100644 index 00000000..b1c23278 --- /dev/null +++ b/docs/ext/mpris.rst @@ -0,0 +1,70 @@ +************ +Mopidy-MPRIS +************ + +This extension lets you control Mopidy through the Media Player Remote +Interfacing Specification (`MPRIS `_) D-Bus interface. + +An example of an MPRIS client is the :ref:`ubuntu-sound-menu`. + + +Dependencies +============ + +- D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. + +- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. + +- An ``.desktop`` file for Mopidy installed at the path set in the + :confval:`mpris/desktop_file` config value. See :ref:`install-desktop-file` + for details. + + +Configuration values +==================== + +.. confval:: mpris/enabled + + If the MPRIS extension should be enabled or not. + +.. confval:: mpris/desktop_file + + Location of the Mopidy ``.desktop`` file. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/mpris/ext.conf + :language: ini + + +Usage +===== + +The extension is enabled by default if all dependencies are available. + + +Testing the MPRIS API +===================== + +To test, start Mopidy, and then run the following in a Python shell:: + + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') + +Now you can control Mopidy through the player object. Examples: + +- To get some properties from Mopidy, run:: + + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') + +- To quit Mopidy through D-Bus, run:: + + player.Quit(dbus_interface='org.mpris.MediaPlayer2') diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst deleted file mode 100644 index e0ec63da..00000000 --- a/docs/modules/frontends/mpris.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _mpris-frontend: - -*********************************************** -:mod:`mopidy.frontends.mpris` -- MPRIS frontend -*********************************************** - -.. automodule:: mopidy.frontends.mpris - :synopsis: MPRIS frontend diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 5be9c6cf..7601d81f 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -4,76 +4,7 @@ import os import mopidy from mopidy import exceptions, ext -from mopidy.utils import formatting, config - - -default_config = """ -[mpris] -enabled = true -desktop_file = /usr/share/applications/mopidy.desktop -""" - -__doc__ = """ -Frontend which lets you control Mopidy through the Media Player Remote -Interfacing Specification (`MPRIS `_) D-Bus -interface. - -An example of an MPRIS client is the `Ubuntu Sound Menu -`_. - -**Dependencies** - -- D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - -- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - -- An ``.desktop`` file for Mopidy installed at the path set in the - :confval:`mpris/desktop_file` config value. See :ref:`install-desktop-file` - for details. - -**Configuration** - -.. confval:: mpris/enabled - - If the MPRIS extension should be enabled or not. - -.. confval:: mpris/desktop_file - - Location of the Mopidy ``.desktop`` file. - -**Default config** - -.. code-block:: ini - -%(config)s - -**Usage** - -The frontend is enabled by default if all dependencies are available. - -**Testing the frontend** - -To test, start Mopidy, and then run the following in a Python shell:: - - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') - -Now you can control Mopidy through the player object. Examples: - -- To get some properties from Mopidy, run:: - - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') - -- To quit Mopidy through D-Bus, run:: - - player.Quit(dbus_interface='org.mpris.MediaPlayer2') -""" % {'config': formatting.indent(default_config)} +from mopidy.utils import config class Extension(ext.Extension): @@ -83,7 +14,8 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return default_config + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return open(conf_file).read() def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/mpris/ext.conf b/mopidy/frontends/mpris/ext.conf new file mode 100644 index 00000000..b83411c2 --- /dev/null +++ b/mopidy/frontends/mpris/ext.conf @@ -0,0 +1,3 @@ +[mpris] +enabled = true +desktop_file = /usr/share/applications/mopidy.desktop From b7546eed0b24af8bdf18116e175bad927fe75211 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 11 Apr 2013 23:58:42 +0200 Subject: [PATCH 210/403] docs: Move MPD to ext registry --- docs/ext/mpd.rst | 107 +++++++++++++++++++++++++++++++ mopidy/frontends/mpd/__init__.py | 103 ++--------------------------- mopidy/frontends/mpd/ext.conf | 7 ++ 3 files changed, 119 insertions(+), 98 deletions(-) create mode 100644 docs/ext/mpd.rst create mode 100644 mopidy/frontends/mpd/ext.conf diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst new file mode 100644 index 00000000..fc5a3082 --- /dev/null +++ b/docs/ext/mpd.rst @@ -0,0 +1,107 @@ +.. _ext-mpd: + +********** +Mopidy-MPD +********** + +This extension implements an MPD server to make Mopidy available to :ref:`MPD +clients `. + +MPD stands for Music Player Daemon, which is also the name of the `original MPD +server project `_. Mopidy does not depend on the +original MPD server, but implements the MPD protocol itself, and is thus +compatible with clients for the original MPD server. + +For more details on our MPD server implementation, see +:mod:`mopidy.frontends.mpd`. + + +Known issues +============ + +https://github.com/mopidy/mopidy/issues?labels=MPD+frontend + + +Limitations +=========== + +This is a non exhaustive list of MPD features that Mopidy doesn't support. +Items on this list will probably not be supported in the near future. + +- Toggling of audio outputs is not supported +- Channels for client-to-client communication are not supported +- Stickers are not supported +- Crossfade is not supported +- Replay gain is not supported +- ``count`` does not provide any statistics +- ``stats`` does not provide any statistics +- ``list`` does not support listing tracks by genre +- ``decoders`` does not provide information about available decoders + +The following items are currently not supported, but should be added in the +near future: + +- Modifying stored playlists is not supported +- ``tagtypes`` is not supported +- Browsing the file system is not supported +- Live update of the music database is not supported + + +Dependencies +============ + +None. The extension just needs Mopidy. + + +Configuration values +==================== + +.. confval:: mpd/enabled + + If the MPD extension should be enabled or not. + +.. confval:: mpd/hostname + + Which address the MPD server should bind to. + + ``127.0.0.1`` + Listens only on the IPv4 loopback interface + ``::1`` + Listens only on the IPv6 loopback interface + ``0.0.0.0`` + Listens on all IPv4 interfaces + ``::`` + Listens on all interfaces, both IPv4 and IPv6 + +.. confval:: mpd/port + + Which TCP port the MPD server should listen to. + +.. confval:: mpd/password + + The password required for connecting to the MPD server. If blank, no + password is required. + +.. confval:: mpd/max_connections + + The maximum number of concurrent connections the MPD server will accept. + +.. confval:: mpd/connection_timeout + + Number of seconds an MPD client can stay inactive before the connection is + closed by the server. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/mpd/ext.conf + :language: ini + + +Usage +===== + +The extension is enabled by default. To connect to the server, use an :ref:`MPD +client `. If you want to connect to the server from another host, +you'll need to adjust the value of :confval:`mpd/hostname`. diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 69297374..d0f082ae 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,104 +1,10 @@ from __future__ import unicode_literals +import os + import mopidy from mopidy import ext -from mopidy.utils import config, formatting - - -default_config = """ -[mpd] -enabled = true -hostname = 127.0.0.1 -port = 6600 -password = -max_connections = 20 -connection_timeout = 60 -""" - -__doc__ = """The MPD server frontend. - -MPD stands for Music Player Daemon. MPD is an independent project and server. -Mopidy implements the MPD protocol, and is thus compatible with clients for the -original MPD server. - -**Issues** - -https://github.com/mopidy/mopidy/issues?labels=MPD+frontend - -**Dependencies** - -None - -**Configuration** - -.. confval:: mpd/enabled - - If the MPD extension should be enabled or not. - -.. confval:: mpd/hostname - - Which address the MPD server should bind to. - - ``127.0.0.1`` - Listens only on the IPv4 loopback interface - ``::1`` - Listens only on the IPv6 loopback interface - ``0.0.0.0`` - Listens on all IPv4 interfaces - ``::`` - Listens on all interfaces, both IPv4 and IPv6 - -.. confval:: mpd/port - - Which TCP port the MPD server should listen to. - -.. confval:: mpd/password - - The password required for connecting to the MPD server. If blank, no - password is required. - -.. confval:: mpd/max_connections - - The maximum number of concurrent connections the MPD server will accept. - -.. confval:: mpd/connection_timeout - - Number of seconds an MPD client can stay inactive before the connection is - closed by the server. - -**Default config** - -.. code-block:: ini - -%(config)s - -**Usage:** - -The frontend is enabled by default. - -**Limitations:** - -This is a non exhaustive list of MPD features that Mopidy doesn't support. -Items on this list will probably not be supported in the near future. - -- Toggling of audio outputs is not supported -- Channels for client-to-client communication are not supported -- Stickers are not supported -- Crossfade is not supported -- Replay gain is not supported -- ``count`` does not provide any statistics -- ``stats`` does not provide any statistics -- ``list`` does not support listing tracks by genre -- ``decoders`` does not provide information about available decoders - -The following items are currently not supported, but should be added in the -near future: - -- Modifying stored playlists is not supported -- ``tagtypes`` is not supported -- Browsing the file system is not supported -- Live update of the music database is not supported -""" % {'config': formatting.indent(default_config)} +from mopidy.utils import config class Extension(ext.Extension): @@ -108,7 +14,8 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return default_config + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return open(conf_file).read() def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/mpd/ext.conf b/mopidy/frontends/mpd/ext.conf new file mode 100644 index 00000000..bf77100c --- /dev/null +++ b/mopidy/frontends/mpd/ext.conf @@ -0,0 +1,7 @@ +[mpd] +enabled = true +hostname = 127.0.0.1 +port = 6600 +password = +max_connections = 20 +connection_timeout = 60 From 12938df3a1d374a421b961f8e321aa851c940004 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:04:55 +0200 Subject: [PATCH 211/403] docs: Unbreak links to MPRIS docs --- docs/clients/mpris.rst | 6 +++--- docs/clients/upnp.rst | 22 +++++++++++----------- docs/ext/mpris.rst | 2 ++ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index 28da63ed..141a2371 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -8,9 +8,9 @@ MPRIS clients Specification. It's a spec that describes a standard D-Bus interface for making media players available to other applications on the same system. -Mopidy's :ref:`MPRIS frontend ` currently implements all -required parts of the MPRIS spec, plus the optional playlist interface. It does -not implement the optional tracklist interface. +Mopidy's :ref:`MPRIS frontend ` currently implements all required +parts of the MPRIS spec, plus the optional playlist interface. It does not +implement the optional tracklist interface. .. _ubuntu-sound-menu: diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst index 567fb04f..c90227e2 100644 --- a/docs/clients/upnp.rst +++ b/docs/clients/upnp.rst @@ -37,18 +37,18 @@ How to make Mopidy available as an UPnP MediaRenderer With the help of `the Rygel project `_ Mopidy can be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's -:ref:`MPRIS frontend `, and make Mopidy available as a -MediaRenderer on the local network. Since this depends on the MPRIS frontend, -which again depends on D-Bus being available, this will only work on Linux, and -not OS X. MPRIS/D-Bus is only available to other applications on the same host, -so Rygel must be running on the same machine as Mopidy. +:ref:`MPRIS frontend `, and make Mopidy available as a MediaRenderer +on the local network. Since this depends on the MPRIS frontend, which again +depends on D-Bus being available, this will only work on Linux, and not OS X. +MPRIS/D-Bus is only available to other applications on the same host, so Rygel +must be running on the same machine as Mopidy. -1. Start Mopidy and make sure the :ref:`MPRIS frontend ` is - working. It is activated by default, but you may miss dependencies or be - using OS X, in which case it will not work. Check the console output when - Mopidy is started for any errors related to the MPRIS frontend. If you're - unsure it is working, there are instructions for how to test it on the - :ref:`MPRIS frontend ` page. +1. Start Mopidy and make sure the :ref:`MPRIS frontend ` is working. + It is activated by default, but you may miss dependencies or be using OS X, + in which case it will not work. Check the console output when Mopidy is + started for any errors related to the MPRIS frontend. If you're unsure it is + working, there are instructions for how to test it on the :ref:`MPRIS + frontend ` page. 2. Install Rygel. On Debian/Ubuntu:: diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst index b1c23278..88510d98 100644 --- a/docs/ext/mpris.rst +++ b/docs/ext/mpris.rst @@ -1,3 +1,5 @@ +.. _ext-mpris: + ************ Mopidy-MPRIS ************ From 08117841c1c71f9fdd3e75b738820bbd1d2068ba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:05:56 +0200 Subject: [PATCH 212/403] docs: Move Spotify to ext registry --- docs/ext/spotify.rst | 68 ++++++++++++++++++++++++++ docs/index.rst | 2 +- docs/modules/backends/spotify.rst | 8 --- mopidy/backends/spotify/__init__.py | 75 ++--------------------------- mopidy/backends/spotify/ext.conf | 7 +++ 5 files changed, 81 insertions(+), 79 deletions(-) create mode 100644 docs/ext/spotify.rst delete mode 100644 docs/modules/backends/spotify.rst create mode 100644 mopidy/backends/spotify/ext.conf diff --git a/docs/ext/spotify.rst b/docs/ext/spotify.rst new file mode 100644 index 00000000..0d3729b2 --- /dev/null +++ b/docs/ext/spotify.rst @@ -0,0 +1,68 @@ +.. _ext-spotify: + +************** +Mopidy-Spotify +************** + +An extension for playing music from Spotify. + +`Spotify `_ is a music streaming service. The backend +uses the official `libspotify +`_ library and the +`pyspotify `_ Python bindings for +libspotify. This backend handles URIs starting with ``spotify:``. + +See :ref:`music-from-spotify` for further instructions on using this backend. + +.. note:: + + This product uses SPOTIFY(R) CORE but is not endorsed, certified or + otherwise approved in any way by Spotify. Spotify is the registered + trade mark of the Spotify Group. + + +Known issues +============ + +https://github.com/mopidy/mopidy/issues?labels=Spotify+backend + + +Dependencies +============ + +.. literalinclude:: ../../requirements/spotify.txt + + +Configuration values +==================== + +.. confval:: spotify/enabled + + If the Spotify extension should be enabled or not. + +.. confval:: spotify/username + + Your Spotify Premium username. + +.. confval:: spotify/password + + Your Spotify Premium password. + +.. confval:: spotify/bitrate + + The preferred audio bitrate. Valid values are 96, 160, 320. + +.. confval:: spotify/timeout + + Max number of seconds to wait for Spotify operations to complete. + +.. confval:: spotify/cache_dir + + Path to the Spotify data cache. Cannot be shared with other Spotify apps. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/backends/spotify/ext.conf + :language: ini diff --git a/docs/index.rst b/docs/index.rst index dd89c679..e5d21385 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ Mopidy Mopidy is a music server which can play music both from multiple sources, like your :ref:`local hard drive `, :ref:`radio streams -`, and from :ref:`Spotify ` and SoundCloud. +`, and from :ref:`Spotify ` and SoundCloud. Searches combines results from all music sources, and you can mix tracks from all sources in your play queue. Your playlists from Spotify or SoundCloud are also available for use. diff --git a/docs/modules/backends/spotify.rst b/docs/modules/backends/spotify.rst deleted file mode 100644 index b410f272..00000000 --- a/docs/modules/backends/spotify.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _spotify-backend: - -************************************************* -:mod:`mopidy.backends.spotify` -- Spotify backend -************************************************* - -.. automodule:: mopidy.backends.spotify - :synopsis: Backend for the Spotify music streaming service diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index c26a42e7..d26c9cd3 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,77 +1,11 @@ from __future__ import unicode_literals +import os + import mopidy from mopidy import ext from mopidy.exceptions import ExtensionError -from mopidy.utils import config, formatting - - -default_config = """ -[spotify] -enabled = true -username = -password = -bitrate = 160 -timeout = 10 -cache_dir = $XDG_CACHE_DIR/mopidy/spotify -""" - -__doc__ = """A backend for playing music from Spotify - -`Spotify `_ is a music streaming service. The backend -uses the official `libspotify -`_ library and the -`pyspotify `_ Python bindings for -libspotify. This backend handles URIs starting with ``spotify:``. - -See :ref:`music-from-spotify` for further instructions on using this backend. - -.. note:: - - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. - -**Issues** - -https://github.com/mopidy/mopidy/issues?labels=Spotify+backend - -**Dependencies** - -.. literalinclude:: ../../../requirements/spotify.txt - -**Configuration** - -.. confval:: spotify/enabled - - If the Spotify extension should be enabled or not. - -.. confval:: spotify/username - - Your Spotify Premium username. - -.. confval:: spotify/password - - Your Spotify Premium password. - -.. confval:: spotify/bitrate - - The preferred audio bitrate. Valid values are 96, 160, 320. - -.. confval:: spotify/timeout - - Max number of seconds to wait for Spotify operations to complete. - -.. confval:: spotify/cache_dir - - Path to the Spotify data cache. Cannot be shared with other Spotify apps. - -**Default config** - -.. code-block:: ini - -%(config)s -""" % {'config': formatting.indent(default_config)} +from mopidy.utils import config class Extension(ext.Extension): @@ -81,7 +15,8 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return default_config + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return open(conf_file).read() def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/backends/spotify/ext.conf b/mopidy/backends/spotify/ext.conf new file mode 100644 index 00000000..83bf191a --- /dev/null +++ b/mopidy/backends/spotify/ext.conf @@ -0,0 +1,7 @@ +[spotify] +enabled = true +username = +password = +bitrate = 160 +timeout = 10 +cache_dir = $XDG_CACHE_DIR/mopidy/spotify From 7c01db842d62133391c60642297a5ed20d714996 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:07:59 +0200 Subject: [PATCH 213/403] docs: Remove empty dummy backend docs --- docs/modules/backends/dummy.rst | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 docs/modules/backends/dummy.rst diff --git a/docs/modules/backends/dummy.rst b/docs/modules/backends/dummy.rst deleted file mode 100644 index 03b2e6ce..00000000 --- a/docs/modules/backends/dummy.rst +++ /dev/null @@ -1,7 +0,0 @@ -********************************************************* -:mod:`mopidy.backends.dummy` -- Dummy backend for testing -********************************************************* - -.. automodule:: mopidy.backends.dummy - :synopsis: Dummy backend used for testing - :members: From 8bcf1de21c51ec0bdc24409e848244e2fdd0d302 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:13:22 +0200 Subject: [PATCH 214/403] docs: Move stream to ext registry --- docs/ext/stream.rst | 42 ++++++++++++++++++++++++ docs/index.rst | 8 ++--- docs/modules/backends/stream.rst | 9 ------ mopidy/backends/stream/__init__.py | 52 +++--------------------------- mopidy/backends/stream/ext.conf | 9 ++++++ 5 files changed, 60 insertions(+), 60 deletions(-) create mode 100644 docs/ext/stream.rst delete mode 100644 docs/modules/backends/stream.rst create mode 100644 mopidy/backends/stream/ext.conf diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst new file mode 100644 index 00000000..3d7ea7d3 --- /dev/null +++ b/docs/ext/stream.rst @@ -0,0 +1,42 @@ +.. _ext-stream: + +************* +Mopidy-Stream +************* + +Extension for playing streaming music. + +The stream backend will handle streaming of URIs matching the +:confval:`stream/protocols` config value, assuming the needed GStreamer plugins +are installed. + + +Known issues +============ + +https://github.com/mopidy/mopidy/issues?labels=Stream+backend + + +Dependencies +============ + +None. The extension just needs Mopidy. + + +Configuration values +==================== + +.. confval:: stream/enabled + + If the stream extension should be enabled or not. + +.. confval:: stream/protocols + + Whitelist of URI schemas to allow streaming from. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/backends/stream/ext.conf + :language: ini diff --git a/docs/index.rst b/docs/index.rst index e5d21385..16518f53 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,10 +4,10 @@ Mopidy Mopidy is a music server which can play music both from multiple sources, like your :ref:`local hard drive `, :ref:`radio streams -`, and from :ref:`Spotify ` and SoundCloud. -Searches combines results from all music sources, and you can mix tracks from -all sources in your play queue. Your playlists from Spotify or SoundCloud are -also available for use. +`, and from :ref:`Spotify ` and SoundCloud. Searches +combines results from all music sources, and you can mix tracks from all +sources in your play queue. Your playlists from Spotify or SoundCloud are also +available for use. To control your Mopidy music server, you can use one of Mopidy's :ref:`web clients `, the :ref:`Ubuntu Sound Menu `, any diff --git a/docs/modules/backends/stream.rst b/docs/modules/backends/stream.rst deleted file mode 100644 index 2843a7e9..00000000 --- a/docs/modules/backends/stream.rst +++ /dev/null @@ -1,9 +0,0 @@ -.. _stream-backend: - -*********************************************** -:mod:`mopidy.backends.stream` -- Stream backend -*********************************************** - -.. automodule:: mopidy.backends.stream - :synopsis: Backend for playing audio streams - :members: diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 11918500..904c9a7f 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -1,53 +1,10 @@ from __future__ import unicode_literals +import os + import mopidy from mopidy import ext -from mopidy.utils import config, formatting - - -default_config = """ -[stream] -enabled = true -protocols = - http - https - mms - rtmp - rtmps - rtsp -""" - -__doc__ = """ -A backend for playing music for streaming music. - -This backend will handle streaming of URIs matching the -:confval:`stream/protocols` config value, assuming the needed GStreamer plugins -are installed. - -**Issues** - -https://github.com/mopidy/mopidy/issues?labels=Stream+backend - -**Dependencies** - -None - -**Configuration** - -.. confval:: stream/enabled - - If the stream extension should be enabled or not. - -.. confval:: stream/protocols - - Whitelist of URI schemas to allow streaming from. - -**Default config** - -.. code-block:: ini - -%(config)s -""" % {'config': formatting.indent(default_config)} +from mopidy.utils import config class Extension(ext.Extension): @@ -57,7 +14,8 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return default_config + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return open(conf_file).read() def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/backends/stream/ext.conf b/mopidy/backends/stream/ext.conf new file mode 100644 index 00000000..9caafac1 --- /dev/null +++ b/mopidy/backends/stream/ext.conf @@ -0,0 +1,9 @@ +[stream] +enabled = true +protocols = + http + https + mms + rtmp + rtmps + rtsp From ae7543a985dd1864948ef2b0d866b87fe9014473 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:17:48 +0200 Subject: [PATCH 215/403] docs: Move local to ext registry --- docs/config.rst | 2 +- docs/ext/local.rst | 49 +++++++++++++++++++++++++++ docs/index.rst | 9 +++-- docs/modules/backends/local.rst | 8 ----- mopidy/backends/local/__init__.py | 56 +++---------------------------- mopidy/backends/local/ext.conf | 5 +++ 6 files changed, 64 insertions(+), 65 deletions(-) create mode 100644 docs/ext/local.rst delete mode 100644 docs/modules/backends/local.rst create mode 100644 mopidy/backends/local/ext.conf diff --git a/docs/config.rst b/docs/config.rst index cd75e6dc..097695d1 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -60,7 +60,7 @@ Music from local storage If you want use Mopidy to play music you have locally at your machine instead of or in addition to using Spotify, you need to review and maybe change some of -the local backend config values. See :ref:`local-backend`, for a complete list. +the local backend config values. See :ref:`ext-local`, for a complete list. Then you need to generate a tag cache for your local music... diff --git a/docs/ext/local.rst b/docs/ext/local.rst new file mode 100644 index 00000000..aa18cd5c --- /dev/null +++ b/docs/ext/local.rst @@ -0,0 +1,49 @@ +.. _ext-local: + +************ +Mopidy-Local +************ + +Extension for playing music from a local music archive. + +This backend handles URIs starting with ``file:``. See +:ref:`music-from-local-storage` for further instructions on using this backend. + + +Known issues +============ + +https://github.com/mopidy/mopidy/issues?labels=Local+backend + + +Dependencies +============ + +None. The extension just needs Mopidy. + + +Configuration values +==================== + +.. confval:: local/enabled + + If the local extension should be enabled or not. + +.. confval:: local/media_dir + + Path to directory with local media files. + +.. confval:: local/playlists_dir + + Path to playlists directory with m3u files for local media. + +.. confval:: local/tag_cache_file + + Path to tag cache for local media. + + +Default configuration +===================== + +.. literalinclude:: ../../mopidy/backends/local/ext.conf + :language: ini diff --git a/docs/index.rst b/docs/index.rst index 16518f53..bbf57fb5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,11 +3,10 @@ Mopidy ****** Mopidy is a music server which can play music both from multiple sources, like -your :ref:`local hard drive `, :ref:`radio streams -`, and from :ref:`Spotify ` and SoundCloud. Searches -combines results from all music sources, and you can mix tracks from all -sources in your play queue. Your playlists from Spotify or SoundCloud are also -available for use. +your :ref:`local hard drive `, :ref:`radio streams `, +and from :ref:`Spotify ` and SoundCloud. Searches combines results +from all music sources, and you can mix tracks from all sources in your play +queue. Your playlists from Spotify or SoundCloud are also available for use. To control your Mopidy music server, you can use one of Mopidy's :ref:`web clients `, the :ref:`Ubuntu Sound Menu `, any diff --git a/docs/modules/backends/local.rst b/docs/modules/backends/local.rst deleted file mode 100644 index 9ac93bc8..00000000 --- a/docs/modules/backends/local.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. _local-backend: - -********************************************* -:mod:`mopidy.backends.local` -- Local backend -********************************************* - -.. automodule:: mopidy.backends.local - :synopsis: Backend for playing music files on local storage diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 0367cf15..1a676e4a 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,57 +1,10 @@ from __future__ import unicode_literals +import os + import mopidy from mopidy import ext -from mopidy.utils import config, formatting - - -default_config = """ -[local] -enabled = true -media_dir = $XDG_MUSIC_DIR -playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists -tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache -""" - -__doc__ = """A backend for playing music from a local music archive. - -This backend handles URIs starting with ``file:``. - -See :ref:`music-from-local-storage` for further instructions on using this -backend. - -**Issues** - -https://github.com/mopidy/mopidy/issues?labels=Local+backend - -**Dependencies** - -None - -**Configuration** - -.. confval:: local/enabled - - If the local extension should be enabled or not. - -.. confval:: local/media_dir - - Path to directory with local media files. - -.. confval:: local/playlists_dir - - Path to playlists directory with m3u files for local media. - -.. confval:: local/tag_cache_file - - Path to tag cache for local media. - -**Default config** - -.. code-block:: ini - -%(config)s -""" % {'config': formatting.indent(default_config)} +from mopidy.utils import config class Extension(ext.Extension): @@ -61,7 +14,8 @@ class Extension(ext.Extension): version = mopidy.__version__ def get_default_config(self): - return default_config + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return open(conf_file).read() def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/backends/local/ext.conf b/mopidy/backends/local/ext.conf new file mode 100644 index 00000000..54c3ab78 --- /dev/null +++ b/mopidy/backends/local/ext.conf @@ -0,0 +1,5 @@ +[local] +enabled = true +media_dir = $XDG_MUSIC_DIR +playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists +tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache From ee021b7cf0b6fb11c7b67e6e7f62895beb466962 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:27:00 +0200 Subject: [PATCH 216/403] docs: Use proper headers in mixer docs --- mopidy/audio/mixers/auto.py | 8 ++++++-- mopidy/audio/mixers/fake.py | 7 +++++-- mopidy/audio/mixers/nad.py | 8 ++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index f1dde3f9..6b76e1a1 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -2,11 +2,15 @@ This is Mopidy's default mixer. -**Dependencies** + +Dependencies +============ None -**Configuration** + +Configuration +============= If this wasn't the default, you would set the :confval:`audio/mixer` config value to ``autoaudiomixer`` to use this mixer. diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index 98afca2a..589610ce 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -1,10 +1,13 @@ """Fake mixer for use in tests. -**Dependencies** +Dependencies +============ None -**Configuration** + +Configuration +============= Set the :confval:`audio/mixer:` config value to ``fakemixer`` to use this mixer. diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 9259d291..bbbaee71 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -3,11 +3,15 @@ The NAD amplifier must be connected to the machine running Mopidy using a serial cable. -**Dependencies** + +Dependencies +============ .. literalinclude:: ../../../../requirements/external_mixers.txt -**Configuration** + +Configuration +============= Set the :confval:`audio/mixer` config value to ``nadmixer`` to use it. You probably also needs to add some properties to the :confval:`audio/mixer` config From 079f5847331181e8403b92f427daa3323fda959d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:28:18 +0200 Subject: [PATCH 217/403] docs: Link from MPD module docs to ext docs --- docs/modules/frontends/mpd.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index f25b90f2..750d19bb 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -2,6 +2,8 @@ :mod:`mopidy.frontends.mpd` -- MPD server ***************************************** +For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`. + .. automodule:: mopidy.frontends.mpd :synopsis: MPD server frontend From f01ac1c5501b8c44ea340fe4a308acd6ad7f184e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:30:16 +0200 Subject: [PATCH 218/403] docs: Rename docs section --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index bbf57fb5..a50acd4e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,8 +26,8 @@ up to date on Mopidy developments, you can follow `@mopidy `_ on Twitter. -Introduction -============ +Usage +===== .. toctree:: :maxdepth: 3 From ad21236155ba081e01c2fcdf646be6c29154fd95 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 00:54:47 +0200 Subject: [PATCH 219/403] docs: Move usage instructions from config to ext pages --- docs/config.rst | 152 ++++++----------------------------------- docs/ext/local.rst | 41 ++++++++++- docs/ext/mpd.rst | 20 +++++- docs/ext/mpris.rst | 39 ++++++++++- docs/ext/scrobbler.rst | 9 ++- docs/ext/spotify.rst | 19 +++++- 6 files changed, 138 insertions(+), 142 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 097695d1..6f4877b2 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -7,10 +7,6 @@ a few, and stay ignorant of the rest. Below you can find guides for typical configuration changes you may want to do, and a listing of the available config values. - -Changing configuration -====================== - Mopidy primarily reads config from the file ``~/.config/mopidy/mopidy.conf``, where ``~`` means your *home directory*. If your username is ``alice`` and you are running Linux, the settings file should probably be at @@ -35,126 +31,24 @@ A complete ``~/.config/mopidy/mopidy.conf`` may look as simple as this: password = mysecret -.. _music-from-spotify: - -Music from Spotify -================== - -If you are using the Spotify backend, which is the default, enter your Spotify -Premium account's username and password into the file, like this: - -.. code-block:: ini - - [spotify] - username = myusername - password = mysecret - -This will only work if you have the Spotify Premium subscription. Spotify -Unlimited will not work. - - -.. _music-from-local-storage: - -Music from local storage -======================== - -If you want use Mopidy to play music you have locally at your machine instead -of or in addition to using Spotify, you need to review and maybe change some of -the local backend config values. See :ref:`ext-local`, for a complete list. -Then you need to generate a tag cache for your local music... - - -.. _generating-a-tag-cache: - -Generating a tag cache ----------------------- - -The program :command:`mopidy-scan` will scan the path set in the -:confval:`local/media_dir` config value for any media files and build a MPD -compatible ``tag_cache``. - -To make a ``tag_cache`` of your local music available for Mopidy: - -#. Ensure that the :confval:`local/media_dir` config value points to where your - music is located. Check the current setting by running:: - - mopidy --show-config - -#. Scan your media library. The command outputs the ``tag_cache`` to - standard output, which means that you will need to redirect the output to a - file yourself:: - - mopidy-scan > tag_cache - -#. Move the ``tag_cache`` file to the location - set in the :confval:`local/tag_cache_file` config value, or change the - config value to point to where your ``tag_cache`` file is. - -#. Start Mopidy, find the music library in a client, and play some local music! - - -.. _use-mpd-on-a-network: - -Connecting from other machines on the network -============================================= - -As a secure default, Mopidy only accepts connections from ``localhost``. If you -want to open it for connections from other machines on your network, see -the documentation for the :confval:`mpd/hostname` config value. - -If you open up Mopidy for your local network, you should consider turning on -MPD password authentication by setting the :confval:`mpd/password` config value -to the password you want to use. If the password is set, Mopidy will require -MPD clients to provide the password before they can do anything else. Mopidy -only supports a single password, and do not support different permission -schemes like the original MPD server. - - -Scrobbling tracks to Last.fm -============================ - -If you want to submit the tracks you are playing to your `Last.fm -`_ profile, make sure you've installed the dependencies -found at :mod:`mopidy.frontends.scrobbler` and add the following to your -settings file: - -.. code-block:: ini - - [scrobbler] - username = myusername - password = mysecret - - -.. _install-desktop-file: - -Controlling Mopidy through the Ubuntu Sound Menu -================================================ - -If you are running Ubuntu and installed Mopidy using the Debian package from -APT you should be able to control Mopidy through the `Ubuntu Sound Menu -`_ without any changes. - -If you installed Mopidy in any other way and want to control Mopidy through the -Ubuntu Sound Menu, you must install the ``mopidy.desktop`` file which can be -found in the ``data/`` dir of the Mopidy source into the -``/usr/share/applications`` dir by hand:: - - cd /path/to/mopidy/source - sudo cp data/mopidy.desktop /usr/share/applications/ - -After you have installed the file, start Mopidy in any way, and Mopidy should -appear in the Ubuntu Sound Menu. When you quit Mopidy, it will still be listed -in the Ubuntu Sound Menu, and may be restarted by selecting it there. - -The Ubuntu Sound Menu interacts with Mopidy's MPRIS frontend, -:mod:`mopidy.frontends.mpris`. The MPRIS frontend supports the minimum -requirements of the `MPRIS specification `_. The -``TrackList`` interface of the spec is not supported. - - -Using a custom audio sink +Core configuration values ========================= +TODO + + +Default core configuration +========================== + +TODO + + +Advanced configurations +======================= + +Custom audio sink +----------------- + If you have successfully installed GStreamer, and then run the ``gst-inspect`` or ``gst-inspect-0.10`` command, you should see a long listing of installed plugins, ending in a summary line:: @@ -190,8 +84,8 @@ this work first:: gst-launch-0.10 audiotestsrc ! audioresample ! oss4sink -Streaming audio through a SHOUTcast/Icecast server -================================================== +Streaming through SHOUTcast/Icecast +----------------------------------- If you want to play the audio on another computer than the one running Mopidy, you can stream the audio from Mopidy through an SHOUTcast or Icecast audio @@ -219,8 +113,8 @@ can use with the ``gst-launch-0.10`` command can be plugged into :confval:`audio/output`. -Custom configuration values -=========================== +New configuration values +------------------------ Mopidy's settings validator will stop you from defining any config values in your settings file that Mopidy doesn't know about. This may sound obnoxious, @@ -232,9 +126,3 @@ system, you can add new sections to the config without triggering the config validator. We recommend that you choose a good and unique name for the config section so that multiple extensions to Mopidy can be used at the same time without any danger of naming collisions. - - -Available settings -================== - -.. note:: TODO: Document config values of the new config system diff --git a/docs/ext/local.rst b/docs/ext/local.rst index aa18cd5c..a446ffc7 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -6,8 +6,7 @@ Mopidy-Local Extension for playing music from a local music archive. -This backend handles URIs starting with ``file:``. See -:ref:`music-from-local-storage` for further instructions on using this backend. +This backend handles URIs starting with ``file:``. Known issues @@ -47,3 +46,41 @@ Default configuration .. literalinclude:: ../../mopidy/backends/local/ext.conf :language: ini + + +Usage +===== + +If you want use Mopidy to play music you have locally at your machine, you need +to review and maybe change some of the local extension config values. See above +for a complete list. Then you need to generate a tag cache for your local +music... + + +.. _generating-a-tag-cache: + +Generating a tag cache +---------------------- + +The program :command:`mopidy-scan` will scan the path set in the +:confval:`local/media_dir` config value for any media files and build a MPD +compatible ``tag_cache``. + +To make a ``tag_cache`` of your local music available for Mopidy: + +#. Ensure that the :confval:`local/media_dir` config value points to where your + music is located. Check the current setting by running:: + + mopidy --show-config + +#. Scan your media library. The command outputs the ``tag_cache`` to + standard output, which means that you will need to redirect the output to a + file yourself:: + + mopidy-scan > tag_cache + +#. Move the ``tag_cache`` file to the location + set in the :confval:`local/tag_cache_file` config value, or change the + config value to point to where your ``tag_cache`` file is. + +#. Start Mopidy, find the music library in a client, and play some local music! diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index fc5a3082..0d669d79 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -103,5 +103,21 @@ Usage ===== The extension is enabled by default. To connect to the server, use an :ref:`MPD -client `. If you want to connect to the server from another host, -you'll need to adjust the value of :confval:`mpd/hostname`. +client `. + + +.. _use-mpd-on-a-network: + +Connecting from other machines on the network +--------------------------------------------- + +As a secure default, Mopidy only accepts connections from ``localhost``. If you +want to open it for connections from other machines on your network, see +the documentation for the :confval:`mpd/hostname` config value. + +If you open up Mopidy for your local network, you should consider turning on +MPD password authentication by setting the :confval:`mpd/password` config value +to the password you want to use. If the password is set, Mopidy will require +MPD clients to provide the password before they can do anything else. Mopidy +only supports a single password, and do not support different permission +schemes like the original MPD server. diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst index 88510d98..bf8963c1 100644 --- a/docs/ext/mpris.rst +++ b/docs/ext/mpris.rst @@ -50,10 +50,40 @@ Usage The extension is enabled by default if all dependencies are available. -Testing the MPRIS API -===================== +Controlling Mopidy through the Ubuntu Sound Menu +------------------------------------------------ -To test, start Mopidy, and then run the following in a Python shell:: +If you are running Ubuntu and installed Mopidy using the Debian package from +APT you should be able to control Mopidy through the :ref:`ubuntu-sound-menu` +without any changes. + +If you installed Mopidy in any other way and want to control Mopidy through the +Ubuntu Sound Menu, you must install the ``mopidy.desktop`` file which can be +found in the ``data/`` dir of the Mopidy source repo into the +``/usr/share/applications`` dir by hand:: + + cd /path/to/mopidy/source + sudo cp data/mopidy.desktop /usr/share/applications/ + +If the correct path to the installed ``mopidy.desktop`` file on your system +isn't ``/usr/share/applications/mopidy.conf``, you'll need to set the +:confval:`mpris/desktop_file` config value. + +After you have installed the file, start Mopidy in any way, and Mopidy should +appear in the Ubuntu Sound Menu. When you quit Mopidy, it will still be listed +in the Ubuntu Sound Menu, and may be restarted by selecting it there. + +The Ubuntu Sound Menu interacts with Mopidy's MPRIS frontend. The MPRIS +frontend supports the minimum requirements of the `MPRIS specification +`_. The ``TrackList`` interface of the spec is not +supported. + + +Testing the MPRIS API directly +------------------------------ + +To use the MPRIS API directly, start Mopidy, and then run the following in a +Python shell:: import dbus bus = dbus.SessionBus() @@ -70,3 +100,6 @@ Now you can control Mopidy through the player object. Examples: - To quit Mopidy through D-Bus, run:: player.Quit(dbus_interface='org.mpris.MediaPlayer2') + +For details on the API, please refer to the `MPRIS specification +`_. diff --git a/docs/ext/scrobbler.rst b/docs/ext/scrobbler.rst index 1d7b3d4b..af9f9e5b 100644 --- a/docs/ext/scrobbler.rst +++ b/docs/ext/scrobbler.rst @@ -43,4 +43,11 @@ Usage ===== The extension is enabled by default if all dependencies are available. You just -need to add your Last.fm username and password to the config file. +need to add your Last.fm username and password to the +``~/.config/mopidy/mopidy.conf`` file: + +.. code-block:: ini + + [scrobbler] + username = myusername + password = mysecret diff --git a/docs/ext/spotify.rst b/docs/ext/spotify.rst index 0d3729b2..63ccefec 100644 --- a/docs/ext/spotify.rst +++ b/docs/ext/spotify.rst @@ -12,8 +12,6 @@ uses the official `libspotify `pyspotify `_ Python bindings for libspotify. This backend handles URIs starting with ``spotify:``. -See :ref:`music-from-spotify` for further instructions on using this backend. - .. note:: This product uses SPOTIFY(R) CORE but is not endorsed, certified or @@ -66,3 +64,20 @@ Default configuration .. literalinclude:: ../../mopidy/backends/spotify/ext.conf :language: ini + + +Usage +===== + +If you are using the Spotify backend, which is the default, enter your Spotify +Premium account's username and password into ``~/.config/mopidy/mopidy.conf``, +like this: + +.. code-block:: ini + + [spotify] + username = myusername + password = mysecret + +This will only work if you have the Spotify Premium subscription. Spotify +Unlimited will not work. From 1550d553d37e299dae7bb64ef0b710e1f9c775f6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 01:06:11 +0200 Subject: [PATCH 220/403] docs: Fix broken reference in MPRIS ext docs --- docs/ext/mpris.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst index bf8963c1..eb55ef54 100644 --- a/docs/ext/mpris.rst +++ b/docs/ext/mpris.rst @@ -21,8 +21,8 @@ Dependencies Ubuntu/Debian. - An ``.desktop`` file for Mopidy installed at the path set in the - :confval:`mpris/desktop_file` config value. See :ref:`install-desktop-file` - for details. + :confval:`mpris/desktop_file` config value. See usage section below for + details. Configuration values From 3de39b5a4c9d90d9ece51ce56c5647e8fd8272c5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 01:06:21 +0200 Subject: [PATCH 221/403] docs: Document core config values --- docs/config.rst | 74 +++++++++++++++++++++++++++++++++++++++++++-- mopidy/config.py | 22 +++----------- mopidy/default.conf | 17 +++++++++++ 3 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 mopidy/default.conf diff --git a/docs/config.rst b/docs/config.rst index 6f4877b2..b6cf8402 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -34,13 +34,83 @@ A complete ``~/.config/mopidy/mopidy.conf`` may look as simple as this: Core configuration values ========================= -TODO +.. confval:: audio/mixer + + Audio mixer to use. + + Expects a GStreamer mixer to use, typical values are: ``alsamixer``, + ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. + + Setting this to blank turns off volume control. ``software`` can be used to + force software mixing in the application. + +.. confval:: audio/mixer_track + + Audio mixer track to use. + + Name of the mixer track to use. If this is not set we will try to find the + master output track. As an example, using ``alsamixer`` you would typically + set this to ``Master`` or ``PCM``. + +.. confval:: audio/output + + Audio output to use. + + Expects a GStreamer sink. Typical values are ``autoaudiosink``, + ``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``, + and additional arguments specific to each sink. You can use the command + ``gst-inspect-0.10`` to see what output properties can be set on the sink. + For example: ``gst-inspect-0.10 shout2send`` + +.. confval:: logging/console_format + + The log format used for informational logging. + + See `the Python logging docs + `_ for + details on the format. + +.. confval:: logging/debug_format + + The log format used for debug logging. + + See `the Python logging docs + `_ for + details on the format. + +.. confval:: logging/debug_file + + The file to dump debug log data to when Mopidy is run with the + :option:`--save-debug-log` option. + +.. confval:: logging.levels/* + + The ``logging.levels`` config section can be used to change the log level + for specific parts of Mopidy during development or debugging. Each key in + the config section should match the name of a logger. The value is the log + level to use for that logger, one of ``debug``, ``info``, ``warning``, + ``error``, or ``critical``. + +.. confval:: proxy/hostname + + Proxy server to use for communication with the Internet. + + Currently only used by the Spotify extension. + +.. confval:: proxy/username + + Username for the proxy server, if required. + +.. confval:: proxy/password + + Password for the proxy server, if required. Default core configuration ========================== -TODO +.. literalinclude:: ../mopidy/default.conf + :language: ini Advanced configurations diff --git a/mopidy/config.py b/mopidy/config.py index 88fc3419..04c7c16e 100644 --- a/mopidy/config.py +++ b/mopidy/config.py @@ -1,27 +1,13 @@ from __future__ import unicode_literals +import os + from mopidy.utils import config -default_config = """ -[logging] -console_format = %(levelname)-8s %(message)s -debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s -debug_file = mopidy.log +default_config_file = os.path.join(os.path.dirname(__file__), 'default.conf') +default_config = open(default_config_file).read() -[logging.levels] -pykka = info - -[audio] -mixer = autoaudiomixer -mixer_track = -output = autoaudiosink - -[proxy] -hostname = -username = -password = -""" config_schemas = {} # TODO: use ordered dict? config_schemas['logging'] = config.ConfigSchema() diff --git a/mopidy/default.conf b/mopidy/default.conf new file mode 100644 index 00000000..1d7d7338 --- /dev/null +++ b/mopidy/default.conf @@ -0,0 +1,17 @@ +[logging] +console_format = %(levelname)-8s %(message)s +debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s +debug_file = mopidy.log + +[logging.levels] +pykka = info + +[audio] +mixer = autoaudiomixer +mixer_track = +output = autoaudiosink + +[proxy] +hostname = +username = +password = From 3280f0413220d8ea1ab26eb51a8e25ffc57ec437 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 01:23:05 +0200 Subject: [PATCH 222/403] docs: Update config system introduction --- docs/config.rst | 43 +++++++++++++++++++++++++------------------ docs/ext/index.rst | 2 ++ docs/index.rst | 2 +- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index b6cf8402..ad1d8ba7 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -2,24 +2,9 @@ Configuration ************* -Mopidy has quite a few config values to tweak. Luckily, you only need to change -a few, and stay ignorant of the rest. Below you can find guides for typical -configuration changes you may want to do, and a listing of the available config -values. - -Mopidy primarily reads config from the file ``~/.config/mopidy/mopidy.conf``, -where ``~`` means your *home directory*. If your username is ``alice`` and you -are running Linux, the settings file should probably be at -``/home/alice/.config/mopidy/mopidy.conf``. - -You can either create the configuration file yourself, or run the ``mopidy`` -command, and it will create an empty settings file for you. - -When you have created the configuration file, open it in a text editor, and add -settings you want to change. If you want to keep the default value for a -setting, you should *not* redefine it in your own settings file. - -A complete ``~/.config/mopidy/mopidy.conf`` may look as simple as this: +Mopidy has a lot of config values you can tweak, but you only need to change a +few to get up and running. A complete ``~/.config/mopidy/mopidy.conf`` may be +as simple as this: .. code-block:: ini @@ -30,6 +15,28 @@ A complete ``~/.config/mopidy/mopidy.conf`` may look as simple as this: username = alice password = mysecret +Mopidy primarily reads config from the file ``~/.config/mopidy/mopidy.conf``, +where ``~`` means your *home directory*. If your username is ``alice`` and you +are running Linux, the settings file should probably be at +``/home/alice/.config/mopidy/mopidy.conf``. You can either create the +configuration file yourself, or run the ``mopidy`` command, and it will create +an empty settings file for you and print what config values must be set +to successfully start Mopidy. + +When you have created the configuration file, open it in a text editor, and add +the config values you want to change. If you want to keep the default for a +config value, you **should not** add it to ``~/.config/mopidy/mopidy.conf``. + +To see what's the effective configuration for your Mopidy installation, you can +run ``mopidy --show-config``. It will print your full effective config with +passwords masked out so that you safely can share the output with others for +debugging. + +You can find a description of all config values belonging to Mopidy's core +below, together with their default values. In addition, all :ref:`extensions +` got additional config values. The extension's config values and config +defaults are documented on the :ref:`extension pages `. + Core configuration values ========================= diff --git a/docs/ext/index.rst b/docs/ext/index.rst index bfa3237f..b11821d4 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -1,3 +1,5 @@ +.. _ext: + ********** Extensions ********** diff --git a/docs/index.rst b/docs/index.rst index a50acd4e..47778bc8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,8 +35,8 @@ Usage installation/index installation/raspberrypi config - running ext/index + running clients/index troubleshooting From bfceb45609a5bc7ba5f86eab767b0a22f86433a3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 01:31:52 +0200 Subject: [PATCH 223/403] docs: Remove default config from audio class --- mopidy/audio/actor.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 5d92f3c4..af0a0c68 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -26,15 +26,6 @@ MB = 1 << 20 class Audio(pykka.ThreadingActor): """ Audio output through `GStreamer `_. - - **Default config:** - - .. code-block:: ini - - [audio] - mixer = autoaudiomixer - mixer_track = - output = autoaudiosink """ #: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState` From 0c090499c58f1525712cf2d3f0de61a42395e657 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 01:34:21 +0200 Subject: [PATCH 224/403] docs: Document 'config' arg to frontend actors --- docs/api/frontends.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 58436c03..6da5d337 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -13,9 +13,12 @@ The following requirements applies to any frontend implementation: `_ actor, called the "main actor" from here on. -- The main actor MUST accept a constructor argument ``core``, which will be an - :class:`ActorProxy ` for the core actor. This object - gives access to the full :ref:`core-api`. +- The main actor MUST accept two constructor arguments: + + - ``config``, which is a dict structure with the entire Mopidy configuration. + + - ``core``, which will be an :class:`ActorProxy ` for + the core actor. This object gives access to the full :ref:`core-api`. - It MAY use additional actors to implement whatever it does, and using actors in frontend implementations is encouraged. From 2281b6256656cb07ab872f1e265bcdc8078ca26e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 01:36:28 +0200 Subject: [PATCH 225/403] docs: Show additional ToC level for API and modules --- docs/index.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 47778bc8..ff63bece 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -61,6 +61,14 @@ Development contributing development extensiondev + + +Reference +========= + +.. toctree:: + :maxdepth: 2 + api/index modules/index From 11d01124c8fd92cfe089a045cb3b445be1644a96 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 01:37:11 +0200 Subject: [PATCH 226/403] docs: Update troubleshooting todo --- docs/troubleshooting.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index d29a5d2f..9bfc3400 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -8,6 +8,7 @@ TODO: - --show-config - --list-deps +- SIGUSR1 for thread traceback logging - Issue tracker - Reporting bugs - Mailing list From 29ac5f0cc75ee34ca075a81ef6c0da25e5582ed2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 11:30:36 +0200 Subject: [PATCH 227/403] js: Update link to HTTP API docs --- js/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/README.md b/js/README.md index 9601b64a..27803f25 100644 --- a/js/README.md +++ b/js/README.md @@ -41,8 +41,8 @@ After npm completes, you can import Mopidy.js using ``require()``: Using the library ----------------- -See Mopidy's [HTTP frontend -documentation](http://docs.mopidy.com/en/latest/modules/frontends/http/). +See Mopidy's [HTTP API +documentation](http://docs.mopidy.com/en/latest/api/http/). Building from source From 7b9095adba96201752b0149b50e58eef0482069c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 21:24:31 +0200 Subject: [PATCH 228/403] docs: More structure to the ext page --- docs/ext/index.rst | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/ext/index.rst b/docs/ext/index.rst index b11821d4..af51e9bb 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -13,11 +13,11 @@ extensions `. Bundled with Mopidy =================== -These extensions are created and maintained by Mopidy's core developers. They -are installed together with Mopidy and are enabled by default. +These extensions are maintained by Mopidy's core developers. They are installed +together with Mopidy and are enabled by default. .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :glob: ** @@ -26,11 +26,19 @@ are installed together with Mopidy and are enabled by default. External extensions =================== -These extensions are created and maintained by other developers. +These extensions are maintained outside Mopidy's core, often by other +developers. Mopidy-SoundCloud ----------------- -To play music from `SoundCloud `_ check out -`Mopidy-SoundCloud `_. +Provides a backend for playing music from the `SoundCloud +`_ service. + +Author: + Janez Troha +PyPI: + `Mopidy-SoundCloud `_ +GitHub: + `dz0ny/mopidy-soundcloud `_ From 69c2a2d32c798758d28e34885920319853111028 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 21:35:33 +0200 Subject: [PATCH 229/403] docs: Reorder default config and confval descriptions --- docs/config.rst | 14 +++++++------- docs/ext/http.rst | 18 +++++++++--------- docs/ext/local.rst | 14 +++++++------- docs/ext/mpd.rst | 14 +++++++------- docs/ext/mpris.rst | 14 +++++++------- docs/ext/scrobbler.rst | 14 +++++++------- docs/ext/spotify.rst | 14 +++++++------- docs/ext/stream.rst | 14 +++++++------- 8 files changed, 58 insertions(+), 58 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index ad1d8ba7..07ebffc2 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -38,6 +38,13 @@ below, together with their default values. In addition, all :ref:`extensions defaults are documented on the :ref:`extension pages `. +Default core configuration +========================== + +.. literalinclude:: ../mopidy/default.conf + :language: ini + + Core configuration values ========================= @@ -113,13 +120,6 @@ Core configuration values Password for the proxy server, if required. -Default core configuration -========================== - -.. literalinclude:: ../mopidy/default.conf - :language: ini - - Advanced configurations ======================= diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 5c7ba79b..0ca1046c 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -21,6 +21,13 @@ Dependencies .. literalinclude:: ../../requirements/http.txt +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/http/ext.conf + :language: ini + + Configuration values ==================== @@ -53,14 +60,7 @@ Configuration values "/mopidy" will continue to work as usual even if you change this setting. -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/http/ext.conf - :language: ini - - -Setup +Usage ===== The extension is enabled by default if all dependencies are available. @@ -81,7 +81,7 @@ When it is enabled it starts a web server at the port specified by the Using a web based Mopidy client -=============================== +------------------------------- The web server can also host any static files, for example the HTML, CSS, JavaScript, and images needed for a web based Mopidy client. To host static diff --git a/docs/ext/local.rst b/docs/ext/local.rst index a446ffc7..fc89e69a 100644 --- a/docs/ext/local.rst +++ b/docs/ext/local.rst @@ -21,6 +21,13 @@ Dependencies None. The extension just needs Mopidy. +Default configuration +===================== + +.. literalinclude:: ../../mopidy/backends/local/ext.conf + :language: ini + + Configuration values ==================== @@ -41,13 +48,6 @@ Configuration values Path to tag cache for local media. -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/local/ext.conf - :language: ini - - Usage ===== diff --git a/docs/ext/mpd.rst b/docs/ext/mpd.rst index 0d669d79..b4d0e1c8 100644 --- a/docs/ext/mpd.rst +++ b/docs/ext/mpd.rst @@ -53,6 +53,13 @@ Dependencies None. The extension just needs Mopidy. +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/mpd/ext.conf + :language: ini + + Configuration values ==================== @@ -92,13 +99,6 @@ Configuration values closed by the server. -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/mpd/ext.conf - :language: ini - - Usage ===== diff --git a/docs/ext/mpris.rst b/docs/ext/mpris.rst index eb55ef54..125f8fec 100644 --- a/docs/ext/mpris.rst +++ b/docs/ext/mpris.rst @@ -25,6 +25,13 @@ Dependencies details. +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/mpris/ext.conf + :language: ini + + Configuration values ==================== @@ -37,13 +44,6 @@ Configuration values Location of the Mopidy ``.desktop`` file. -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/mpris/ext.conf - :language: ini - - Usage ===== diff --git a/docs/ext/scrobbler.rst b/docs/ext/scrobbler.rst index af9f9e5b..a0496b37 100644 --- a/docs/ext/scrobbler.rst +++ b/docs/ext/scrobbler.rst @@ -16,6 +16,13 @@ Dependencies .. literalinclude:: ../../requirements/scrobbler.txt +Default configuration +===================== + +.. literalinclude:: ../../mopidy/frontends/scrobbler/ext.conf + :language: ini + + Configuration values ==================== @@ -32,13 +39,6 @@ Configuration values Your Last.fm password. -Default configuration -===================== - -.. literalinclude:: ../../mopidy/frontends/scrobbler/ext.conf - :language: ini - - Usage ===== diff --git a/docs/ext/spotify.rst b/docs/ext/spotify.rst index 63ccefec..4bb5b7a3 100644 --- a/docs/ext/spotify.rst +++ b/docs/ext/spotify.rst @@ -31,6 +31,13 @@ Dependencies .. literalinclude:: ../../requirements/spotify.txt +Default configuration +===================== + +.. literalinclude:: ../../mopidy/backends/spotify/ext.conf + :language: ini + + Configuration values ==================== @@ -59,13 +66,6 @@ Configuration values Path to the Spotify data cache. Cannot be shared with other Spotify apps. -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/spotify/ext.conf - :language: ini - - Usage ===== diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index 3d7ea7d3..5669640c 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -23,6 +23,13 @@ Dependencies None. The extension just needs Mopidy. +Default configuration +===================== + +.. literalinclude:: ../../mopidy/backends/stream/ext.conf + :language: ini + + Configuration values ==================== @@ -33,10 +40,3 @@ Configuration values .. confval:: stream/protocols Whitelist of URI schemas to allow streaming from. - - -Default configuration -===================== - -.. literalinclude:: ../../mopidy/backends/stream/ext.conf - :language: ini From 7387acd017dd85e9ef776902a94473633704a7c4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 21:44:14 +0200 Subject: [PATCH 230/403] docs: A note about stream backend usage --- docs/ext/stream.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ext/stream.rst b/docs/ext/stream.rst index 5669640c..bb30e924 100644 --- a/docs/ext/stream.rst +++ b/docs/ext/stream.rst @@ -40,3 +40,17 @@ Configuration values .. confval:: stream/protocols Whitelist of URI schemas to allow streaming from. + + +Usage +===== + +This backend does not provide a library or similar. It simply takes any URI +added to Mopidy's tracklist that matches any of the protocols in the +:confval:`stream/protocols` setting and tries to play back the URI using +GStreamer. E.g. if you're using an MPD client, you'll just have to find your +clients "add URI" interface, and provide it with the direct URI of the stream. + +Currently the stream backend can only work with URIs pointing direcly at +streams, and not intermediate playlists which is often used. See :issue:`303` +to track the development of playlist expansion support. From b300924e2bbbe7ec8921ba4fccf08e0787fba273 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 21:46:09 +0200 Subject: [PATCH 231/403] docs: Link from HTTP ext to HTTP clients page --- docs/ext/http.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ext/http.rst b/docs/ext/http.rst index 0ca1046c..65bddb73 100644 --- a/docs/ext/http.rst +++ b/docs/ext/http.rst @@ -99,3 +99,5 @@ well, you are of course free to run your own web server and just use Mopidy's web server for the APIs. But, for clients implemented purely in JavaScript, letting Mopidy host the files is a simpler solution. +If you're looking for a web based client for Mopidy, go check out +:ref:`http-clients`. From 2730dccecd44f4c4933a82333112bf0ed23c72cc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Apr 2013 23:47:58 +0200 Subject: [PATCH 232/403] docs: Add troubleshooting tips --- docs/troubleshooting.rst | 59 ++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 9bfc3400..4a634a99 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -4,12 +4,55 @@ Troubleshooting *************** -TODO: +If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at +`irc.freenode.net `_ and also have a `mailing list at +Google Groups `_. +If you stumble into a bug or got a feature request, please create an issue in +the `issue tracker `_. -- --show-config -- --list-deps -- SIGUSR1 for thread traceback logging -- Issue tracker -- Reporting bugs -- Mailing list -- IRC channel +When you're debugging yourself or asking for help, there are some tools built +into Mopidy that you should know about. + + +Effective configuration +======================= + +The command :option:`mopidy --show-config` will print your full effective +configuration the way Mopidy sees it after all defaults and all config files +have been merged into a single config document. Any secret values like +passwords are masked out, so the output of the command should be safe to share +with others for debugging. + + +Installed dependencies +====================== + +The command :option:`mopidy --list-deps` will list the paths to and versions of +any dependency Mopidy or the extensions might need to work. This is very useful +data for checking that you're using the right versions, and that you're using +the right installation if you have multiple installations of a dependency on +your system. + + +Debug logging +============= + +If you run :option:`mopidy -v`, Mopidy will output debug log to stdout. If you +run :option:`mopidy --save-debug-log`, it will save the debug log to the file +``mopidy.log`` in the directory you ran the command from. + +If you want to turn on more or less logging for some component, see the +docs for the :confval:`logging.levels/*` config section. + + +Debugging deadlocks +=================== + +If Mopidy hangs without and obvious explanation, you can send the ``SIGUSR1`` +signal to the Mopidy process. If Mopidy's main thread is still responsive, it +will log a traceback for each running thread, showing what the threads are +currently doing. This is a very useful tool for understanding exactly how the +system is deadlocking. If you have the ``pkill`` command installed, you can use +this by simply running:: + + pkill -SIGUSR1 mopidy From 6076a0f117bad2132627bc240e331a5b041ce9e6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 00:28:45 +0200 Subject: [PATCH 233/403] Fix flake8 warnings --- mopidy/scanner.py | 4 ++-- mopidy/utils/config.py | 9 +++++---- mopidy/utils/log.py | 2 +- tests/exceptions_test.py | 3 ++- tests/utils/config_test.py | 3 ++- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 0c78839b..d28d328a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -154,8 +154,8 @@ class Scanner(object): self.fakesink.connect('handoff', self.process_handoff) self.uribin = gst.element_factory_make('uridecodebin') - self.uribin.set_property('caps', - gst.Caps(b'audio/x-raw-int; audio/x-raw-float')) + self.uribin.set_property( + 'caps', gst.Caps(b'audio/x-raw-int; audio/x-raw-float')) self.uribin.connect('pad-added', self.process_new_pad) self.pipe = gst.element_factory_make('pipeline') diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 09278535..21846fbb 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -253,7 +253,8 @@ class ExpandedPath(bytes): class Path(ConfigValue): - """File system path that will be expanded with mopidy.utils.path.expand_path + """File system path that will be expanded with + mopidy.utils.path.expand_path Supports: optional, choices and secret. """ @@ -275,9 +276,9 @@ class ConfigSchema(object): """Logical group of config values that correspond to a config section. Schemas are set up by assigning config keys with config values to - instances. Once setup :meth:`convert` can be called with a list of - ``(key, value)`` tuples to process. For convienience we also support - :meth:`format` method that can used for printing out the converted values. + instances. Once setup :meth:`convert` can be called with a list of ``(key, + value)`` tuples to process. For convienience we also support :meth:`format` + method that can used for printing out the converted values. """ # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) def __init__(self): diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index db9a0c7c..ff593e09 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import logging import logging.handlers -from . import deps, versioning +from . import versioning def setup_logging(config, verbosity_level, save_debug_log): diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index 12a18338..c1dd7634 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -29,7 +29,8 @@ class ExceptionsTest(unittest.TestCase): exceptions.ConfigError, exceptions.MopidyException)) def test_config_error_provides_getitem(self): - exception = exceptions.ConfigError({'field1': 'msg1', 'field2': 'msg2'}) + exception = exceptions.ConfigError( + {'field1': 'msg1', 'field2': 'msg2'}) self.assertEqual('msg1', exception['field1']) self.assertEqual('msg2', exception['field2']) self.assertItemsEqual(['field1', 'field2'], exception) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index bf26b2e7..c823427b 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -441,7 +441,8 @@ class LogLevelConfigSchemaTest(unittest.TestCase): def test_format(self): schema = config.LogLevelConfigSchema() expected = ['[levels]', 'baz = info', 'foo.bar = debug'] - result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) + result = schema.format( + 'levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) self.assertEqual('\n'.join(expected), result) From fb6a91f23eac41cf2091e50a4000c238b0d4ea39 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 00:32:31 +0200 Subject: [PATCH 234/403] docs: Remove duplicate description of pkill -SIGUSR1 --- docs/development.rst | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 776b004d..283600b4 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -294,23 +294,6 @@ Using this setup you can now run Mopidy with ``PROFILE=silent mopidy`` if you for instance want to test Spotify without any actual audio output. -Debugging deadlocks -=================== - -Between the numerous Pykka threads and GStreamer interactions there can -sometimes be a potential for deadlocks. In an effort to make these slightly -simpler to debug Mopidy registers a ``SIGUSR1`` signal handler which logs the -traceback of all alive threads. - -To trigger the signal handler, you can use the ``pkill`` command to -send the ``SIGUSR1`` signal to any Mopidy processes:: - - pkill -SIGUSR1 mopidy - -If you check the log, you should now find one log record with a full traceback -for each of the currently alive threads in Mopidy. - - Writing documentation ===================== From e737edf5ec90f1739c53c0b584fd10357dade8b7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 00:32:54 +0200 Subject: [PATCH 235/403] docs: Started on new contribution docs --- docs/contributing.rst | 88 ++++++++++++++++++++++++++++++++++++++++--- docs/development.rst | 44 ---------------------- 2 files changed, 82 insertions(+), 50 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index ca013e2f..7d768a4d 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -4,10 +4,86 @@ Contributing ************ -TODO: +If you are thinking about making Mopidy better, or you just want to hack on it, +that’s great. Here are some tips to get you started. -- Getting started -- Making changes -- Testing -- Submitting changes -- Additional resources + +Getting started +=============== + +1. Make sure you have a `GitHub account `_. + +2. `Submit `_ a ticket for your + issue, assuming one does not already exist. Clearly describe the issue + including steps to reproduce when it is a bug. + +3. Fork the repository on GitHub. + + +Making changes +============== + +1. Clone your fork on GitHub to your computer. + +2. Install dependencies as described in the :ref:`installation` section. + +3. Checkout a new branch (usually based on develop) and name it accordingly to + what you intend to do. + + - Features get the prefix ``feature/`` + + - Bug fixes get the prefix ``fix/`` + + - Improvements to the documentation get the prefix ``docs/`` + + +Testing +======= + +Mopidy got quite good test coverage, and we would like all new code going into +Mopidy to come with tests. + +1. To run tests, you need a couple of dependencies. They can be installed using + ``pip``:: + + pip install -r requirements/tests.txt + +2. Then, to run all tests, go to the project directory and run:: + + nosetests + + To run tests with test coverage statistics, remember to specify the tests + dir:: + + nosetests --with-coverage tests/ + +3. Check the code for errors and style issues using flake8:: + + flake8 . + +For more documentation on testing, check out the `nose documentation +`_. + + +Submitting changes +================== + +- One branch per feature or fix. + +- Follow the style guide, especially make sure ``flake8`` does not complain + about anything. + +- Send a pull request to the ``develop`` branch. + + +Additional resources +==================== + +- `Issue tracker `_ + +- `Mailing List `_ + +- `General GitHub documentation `_ + +- `GitHub pull request documentation + `_ diff --git a/docs/development.rst b/docs/development.rst index 283600b4..193f6ae8 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -161,50 +161,6 @@ Commit guidelines - Merge feature branches with ``--no-ff`` to keep track of the merge. -Running tests -============= - -To run tests, you need a couple of dependencies. They can be installed through -Debian/Ubuntu package management:: - - sudo apt-get install python-coverage python-mock python-nose - -Or, they can be installed using ``pip``:: - - sudo pip install -r requirements/tests.txt - -Then, to run all tests, go to the project directory and run:: - - nosetests - -For example:: - - $ nosetests - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................................. - ............................................................. - ----------------------------------------------------------------------------- - 1062 tests run in 7.4 seconds (1062 tests passed) - -To run tests with test coverage statistics, remember to specify the tests dir:: - - nosetests --with-coverage tests/ - -For more documentation on testing, check out the `nose documentation -`_. - - Continuous integration ====================== From 02518b17df20151a47dda077e3c9446f96403378 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 00:38:22 +0200 Subject: [PATCH 236/403] config: Refactor to internal API that is closer to end goal for load and validate --- mopidy/config/__init__.py | 46 +++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 715027cc..a4e7a364 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -55,29 +55,31 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() -# TODO: update API to load(files, defaults, overrides) this should not need to -# know about extensions def load(files, overrides, extensions=None): + defaults = [default_config] + if extensions: + defaults.extend(e.get_default_config() for e in extensions) + return _load(files, defaults, extensions) + + +# TODO: replace load() with this version of API. +def _load(files, defaults, overrides): parser = configparser.RawConfigParser() files = [path.expand_path(f) for f in files] sources = ['builtin-defaults'] + files + ['command-line'] logger.info('Loading config from: %s', ', '.join(sources)) - # Read default core config - parser.readfp(io.StringIO(default_config)) - - # Read default extension config - for extension in extensions or []: - parser.readfp(io.StringIO(extension.get_default_config())) + for default in defaults: + parser.readfp(io.StringIO(default)) # Load config from a series of config files for filename in files: # TODO: if this is the initial load of logging config we might not have # a logger at this point, we might want to handle this better. try: - filehandle = codecs.open(filename, encoding='utf-8') - parser.readfp(filehandle) + with codecs.open(filename, encoding='utf-8') as filehandle: + parser.readfp(filehandle) except IOError: logger.debug('Config file %s not found; skipping', filename) continue @@ -89,39 +91,41 @@ def load(files, overrides, extensions=None): for section in parser.sections(): raw_config[section] = dict(parser.items(section)) - # TODO: move out of file loading code? for section, key, value in overrides or []: raw_config.setdefault(section, {})[key] = value return raw_config -# TODO: switch API to validate(raw_config, schemas) this should not need to -# know about extensions def validate(raw_config, schemas, extensions=None): # Collect config schemas to validate against sections_and_schemas = schemas.items() for extension in extensions or []: sections_and_schemas.append( (extension.ext_name, extension.get_config_schema())) + return _validate(raw_config, sections_and_schemas) + +# TODO: replace validate() with this version of API. +def _validate(raw_config, schemas): # Get validated config config = {} errors = {} - for section_name, schema in sections_and_schemas: - if section_name not in raw_config: - errors[section_name] = {section_name: 'section not found'} + for name, schema in schemas: + if name not in raw_config: + errors[name] = {name: 'section not found'} try: - items = raw_config[section_name].items() - config[section_name] = schema.convert(items) + items = raw_config[name].items() + config[name] = schema.convert(items) + # TODO: convert to ConfigSchemaError except exceptions.ConfigError as error: - errors[section_name] = error + errors[name] = error if errors: # TODO: raise error instead. #raise exceptions.ConfigError(errors) - for section_name, error in errors.items(): - logger.error('[%s] config errors:', section_name) + for name, error in errors.items(): + logger.error('[%s] config errors:', name) for key in error: logger.error('%s %s', key, error[key]) sys.exit(1) From 8362fbcdc53413067d8feb56040bf38e729e73cf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 00:38:58 +0200 Subject: [PATCH 237/403] docs: Add page on versioning --- docs/development.rst | 17 ----------------- docs/index.rst | 1 + docs/versioning.rst | 23 +++++++++++++++++++++++ 3 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 docs/versioning.rst diff --git a/docs/development.rst b/docs/development.rst index 193f6ae8..c53fbd2a 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -6,23 +6,6 @@ Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at ``irc.freenode.net`` and through `GitHub `_. -Release schedule -================ - -We intend to have about one timeboxed feature release every month -in periods of active development. The feature releases are numbered 0.x.0. The -features added is a mix of what we feel is most important/requested of the -missing features, and features we develop just because we find them fun to -make, even though they may be useful for very few users or for a limited use -case. - -Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs -that are too serious to wait for the next feature release. We will only release -bugfix releases for the last feature release. E.g. when 0.3.0 is released, we -will no longer provide bugfix releases for the 0.2 series. In other words, -there will be just a single supported release at any point in time. - - Feature wishlist ================ diff --git a/docs/index.rst b/docs/index.rst index ff63bece..439850a4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,6 +50,7 @@ About authors licenses changelog + versioning Development diff --git a/docs/versioning.rst b/docs/versioning.rst new file mode 100644 index 00000000..cc7f58bc --- /dev/null +++ b/docs/versioning.rst @@ -0,0 +1,23 @@ +********** +Versioning +********** + +Mopidy uses `Semantic Versioning `_, but since we're still +pre-1.0 that doesn't mean much yet. + + +Release schedule +================ + +We intend to have about one feature release every month in periods of active +development. The feature releases are numbered 0.x.0. The features added is a +mix of what we feel is most important/requested of the missing features, and +features we develop just because we find them fun to make, even though they may +be useful for very few users or for a limited use case. + +Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs +that are too serious to wait for the next feature release. We will only release +bugfix releases for the last feature release. E.g. when 0.14.0 is released, we +will no longer provide bugfix releases for the 0.13 series. In other words, +there will be just a single supported release at any point in time. This is to +not spread our limited resources too thin. From 51afbe19e12ea338a1034999863a83ff872699f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 00:39:00 +0200 Subject: [PATCH 238/403] config: Start adding basic data loading test --- tests/config/config_test.py | 49 +++++++++++++++++++++++++++++++++++++ tests/data/file1.conf | 2 ++ tests/data/file2.conf | 2 ++ 3 files changed, 53 insertions(+) create mode 100644 tests/config/config_test.py create mode 100644 tests/data/file1.conf create mode 100644 tests/data/file2.conf diff --git a/tests/config/config_test.py b/tests/config/config_test.py new file mode 100644 index 00000000..bb161a8f --- /dev/null +++ b/tests/config/config_test.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals + +from mopidy import config + +from tests import unittest, path_to_data_dir + + +class LoadConfigTest(unittest.TestCase): + def test_load_nothing(self): + self.assertEqual({}, config._load([], [], [])) + + def test_load_single_default(self): + default = '[foo]\nbar = baz' + expected = {'foo': {'bar': 'baz'}} + result = config._load([], [default], []) + self.assertEqual(expected, result) + + def test_load_defaults(self): + default1 = '[foo]\nbar = baz' + default2 = '[foo2]\n' + expected = {'foo': {'bar': 'baz'}, 'foo2': {}} + result = config._load([], [default1, default2], []) + self.assertEqual(expected, result) + + def test_load_single_override(self): + override = ('foo', 'bar', 'baz') + expected = {'foo': {'bar': 'baz'}} + result = config._load([], [], [override]) + self.assertEqual(expected, result) + + def test_load_overrides(self): + override1 = ('foo', 'bar', 'baz') + override2 = ('foo2', 'bar', 'baz') + expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} + result = config._load([], [], [override1, override2]) + self.assertEqual(expected, result) + + def test_load_single_file(self): + file1 = path_to_data_dir('file1.conf') + expected = {'foo': {'bar': 'baz'}} + result = config._load([file1], [], []) + self.assertEqual(expected, result) + + def test_load_files(self): + file1 = path_to_data_dir('file1.conf') + file2 = path_to_data_dir('file2.conf') + expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} + result = config._load([file1, file2], [], []) + self.assertEqual(expected, result) diff --git a/tests/data/file1.conf b/tests/data/file1.conf new file mode 100644 index 00000000..e6396bff --- /dev/null +++ b/tests/data/file1.conf @@ -0,0 +1,2 @@ +[foo] +bar = baz diff --git a/tests/data/file2.conf b/tests/data/file2.conf new file mode 100644 index 00000000..ef189703 --- /dev/null +++ b/tests/data/file2.conf @@ -0,0 +1,2 @@ +[foo2] +bar = baz From c5f8e1da19cabd725d314af6285360061173007d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 00:53:19 +0200 Subject: [PATCH 239/403] config: Add parse_override test --- mopidy/config/__init__.py | 2 +- tests/config/config_test.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index a4e7a364..935f8743 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -137,4 +137,4 @@ def parse_override(override): """Parse section/key=value override.""" section, remainder = override.split('/', 1) key, value = remainder.split('=', 1) - return (section, key, value) + return (section.strip(), key.strip(), value.strip()) diff --git a/tests/config/config_test.py b/tests/config/config_test.py index bb161a8f..bb24e3a2 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -47,3 +47,22 @@ class LoadConfigTest(unittest.TestCase): expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} result = config._load([file1, file2], [], []) self.assertEqual(expected, result) + + +class ParseOverrideTest(unittest.TestCase): + def test_valid_override(self): + expected = ('section', 'key', 'value') + self.assertEqual(expected, config.parse_override('section/key=value')) + self.assertEqual(expected, config.parse_override('section/key=value ')) + self.assertEqual(expected, config.parse_override('section/key =value')) + self.assertEqual(expected, config.parse_override('section /key=value')) + + def test_empty_override(self): + expected = ('section', 'key', '') + self.assertEqual(expected, config.parse_override('section/key=')) + self.assertEqual(expected, config.parse_override('section/key= ')) + + def test_invalid_override(self): + self.assertRaises(ValueError, config.parse_override, 'section/key') + self.assertRaises(ValueError, config.parse_override, 'section=') + self.assertRaises(ValueError, config.parse_override, 'section') From cf42161e1e2a2f7d41f5a910f56bfe48268ff215 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 00:53:32 +0200 Subject: [PATCH 240/403] docs: Move how to run from Git instructions --- docs/contributing.rst | 29 +++++++++++++++++++- docs/development.rst | 62 ------------------------------------------- 2 files changed, 28 insertions(+), 63 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 7d768a4d..52194702 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -37,10 +37,35 @@ Making changes - Improvements to the documentation get the prefix ``docs/`` +.. _run-from-git: + +Running Mopidy from Git +======================= + +If you want to hack on Mopidy, you should run Mopidy directly from the Git +repo. + +1. Go to the Git repo root:: + + cd mopidy/ + +2. To get a ``mopidy`` executable, run:: + + python setup.py develop + +3. Now you can run the Mopidy command, and it will run using the code + in the Git repo:: + + mopidy + + If you do any changes to the code, you'll just need to restart ``mopidy`` + to see the changes take effect. + + Testing ======= -Mopidy got quite good test coverage, and we would like all new code going into +Mopidy has quite good test coverage, and we would like all new code going into Mopidy to come with tests. 1. To run tests, you need a couple of dependencies. They can be installed using @@ -79,6 +104,8 @@ Submitting changes Additional resources ==================== +- IRC channel: ``#mopidy`` at `irc.freenode.net `_ + - `Issue tracker `_ - `Mailing List `_ diff --git a/docs/development.rst b/docs/development.rst index c53fbd2a..af451ef7 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -2,68 +2,6 @@ Development *********** -Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at -``irc.freenode.net`` and through `GitHub `_. - - -Feature wishlist -================ - -We maintain our collection of sane or less sane ideas for future Mopidy -features as `issues `_ at GitHub -labeled with `the "wishlist" label -`_. Feel free to vote -up any feature you would love to see in Mopidy, but please refrain from adding -a comment just to say "I want this too!". You are of course free to add -comments if you have suggestions for how the feature should work or be -implemented, and you may add new wishlist issues if your ideas are not already -represented. - - -.. _run-from-git: - -Run Mopidy from Git repo -======================== - -If you want to contribute to the development of Mopidy, you should run Mopidy -directly from the Git repo. - -#. First of all, install Mopidy in the recommended way for your OS and/or - distribution, like described at :ref:`installation`. You can have a - system-wide installation of the last Mopidy release in addition to the Git - repo which you run from when you code on Mopidy. - -#. Then install Git, if haven't already. For Ubuntu/Debian:: - - sudo apt-get install git-core - - On OS X using Homebrew:: - - sudo brew install git - -#. Clone the official Mopidy repository:: - - git clone git://github.com/mopidy/mopidy.git - - or your own fork of it:: - - git clone git@github.com:mygithubuser/mopidy.git - -#. You can then run Mopidy directly from the Git repository:: - - cd mopidy/ # Move into the Git repo dir - python mopidy # Run python on the mopidy source code dir - -How you update your clone depends on whether you cloned the official Mopidy -repository or your own fork, whether you have made any changes to the clone -or not, and whether you are currently working on a feature branch or not. In -other words, you'll need to learn Git. - -For an introduction to Git, please visit `git-scm.com `_. -Also, please read the rest of our developer documentation before you start -contributing. - - Code style ========== From b0e281b90a48713aed138f867e35a60e2ccf8eba Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 00:53:49 +0200 Subject: [PATCH 241/403] docs: Extract codestyle to own page --- docs/codestyle.rst | 63 ++++++++++++++++++++++++++++++++++++++++++ docs/development.rst | 66 -------------------------------------------- docs/index.rst | 1 + 3 files changed, 64 insertions(+), 66 deletions(-) create mode 100644 docs/codestyle.rst diff --git a/docs/codestyle.rst b/docs/codestyle.rst new file mode 100644 index 00000000..ae58d2d8 --- /dev/null +++ b/docs/codestyle.rst @@ -0,0 +1,63 @@ +********** +Code style +********** + +- Always import ``unicode_literals`` and use unicode literals for everything + except where you're explicitly working with bytes, which are marked with the + ``b`` prefix. + + Do this:: + + from __future__ import unicode_literals + + foo = 'I am a unicode string, which is a sane default' + bar = b'I am a bytestring' + + Not this:: + + foo = u'I am a unicode string' + bar = 'I am a bytestring, but was it intentional?' + +- Follow :pep:`8` unless otherwise noted. `flake8 + `_ should be used to check your code + against the guidelines. + +- Use four spaces for indentation, *never* tabs. + +- Use CamelCase with initial caps for class names:: + + ClassNameWithCamelCase + +- Use underscore to split variable, function and method names for + readability. Don't use CamelCase. + + :: + + lower_case_with_underscores + +- Use the fact that empty strings, lists and tuples are :class:`False` and + don't compare boolean values using ``==`` and ``!=``. + +- Follow whitespace rules as described in :pep:`8`. Good examples:: + + spam(ham[1], {eggs: 2}) + spam(1) + dict['key'] = list[index] + +- Limit lines to 80 characters and avoid trailing whitespace. However note that + wrapped lines should be *one* indentation level in from level above, except + for ``if``, ``for``, ``with``, and ``while`` lines which should have two + levels of indentation:: + + if (foo and bar ... + baz and foobar): + a = 1 + + from foobar import (foo, bar, ... + baz) + +- For consistency, prefer ``'`` over ``"`` for strings, unless the string + contains ``'``. + +- Take a look at :pep:`20` for a nice peek into a general mindset useful for + Python coding. diff --git a/docs/development.rst b/docs/development.rst index af451ef7..580178f1 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -2,72 +2,6 @@ Development *********** -Code style -========== - -- Always import ``unicode_literals`` and use unicode literals for everything - except where you're explicitly working with bytes, which are marked with the - ``b`` prefix. - - Do this:: - - from __future__ import unicode_literals - - foo = 'I am a unicode string, which is a sane default' - bar = b'I am a bytestring' - - Not this:: - - foo = u'I am a unicode string' - bar = 'I am a bytestring, but was it intentional?' - -- Follow :pep:`8` unless otherwise noted. `pep8.py - `_ or `flake8 - `_ can be used to check your code - against the guidelines, however remember that matching the style of the - surrounding code is also important. - -- Use four spaces for indentation, *never* tabs. - -- Use CamelCase with initial caps for class names:: - - ClassNameWithCamelCase - -- Use underscore to split variable, function and method names for - readability. Don't use CamelCase. - - :: - - lower_case_with_underscores - -- Use the fact that empty strings, lists and tuples are :class:`False` and - don't compare boolean values using ``==`` and ``!=``. - -- Follow whitespace rules as described in :pep:`8`. Good examples:: - - spam(ham[1], {eggs: 2}) - spam(1) - dict['key'] = list[index] - -- Limit lines to 80 characters and avoid trailing whitespace. However note that - wrapped lines should be *one* indentation level in from level above, except - for ``if``, ``for``, ``with``, and ``while`` lines which should have two - levels of indentation:: - - if (foo and bar ... - baz and foobar): - a = 1 - - from foobar import (foo, bar, ... - baz) - -- For consistency, prefer ``'`` over ``"`` for strings, unless the string - contains ``'``. - -- Take a look at :pep:`20` for a nice peek into a general mindset useful for - Python coding. - - Commit guidelines ================= diff --git a/docs/index.rst b/docs/index.rst index 439850a4..6f5ef95f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -61,6 +61,7 @@ Development contributing development + codestyle extensiondev From 2ac1cc45824e0b7b7271c31d8b1f595805ae4f20 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 00:56:08 +0200 Subject: [PATCH 242/403] docs: Link to code style from contributions docs --- docs/codestyle.rst | 2 ++ docs/contributing.rst | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/codestyle.rst b/docs/codestyle.rst index ae58d2d8..4b6e7448 100644 --- a/docs/codestyle.rst +++ b/docs/codestyle.rst @@ -1,3 +1,5 @@ +.. _codestyle: + ********** Code style ********** diff --git a/docs/contributing.rst b/docs/contributing.rst index 52194702..70326145 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -95,8 +95,8 @@ Submitting changes - One branch per feature or fix. -- Follow the style guide, especially make sure ``flake8`` does not complain - about anything. +- Follow the :ref:`style guide `_, especially make sure ``flake8`` + does not complain about anything. - Send a pull request to the ``develop`` branch. From ad1904023e7a876562efe283aa52d5f53705adab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 01:01:25 +0200 Subject: [PATCH 243/403] docs: Rename dev docs to devtools --- docs/contributing.rst | 2 +- docs/{development.rst => devtools.rst} | 52 +++++--------------------- docs/index.rst | 2 +- 3 files changed, 11 insertions(+), 45 deletions(-) rename docs/{development.rst => devtools.rst} (73%) diff --git a/docs/contributing.rst b/docs/contributing.rst index 70326145..215aea65 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -93,7 +93,7 @@ For more documentation on testing, check out the `nose documentation Submitting changes ================== -- One branch per feature or fix. +- One branch per feature or fix. Keep branches small and on topic. - Follow the :ref:`style guide `_, especially make sure ``flake8`` does not complain about anything. diff --git a/docs/development.rst b/docs/devtools.rst similarity index 73% rename from docs/development.rst rename to docs/devtools.rst index 580178f1..7b5b2f81 100644 --- a/docs/development.rst +++ b/docs/devtools.rst @@ -1,19 +1,8 @@ -*********** -Development -*********** +***************** +Development tools +***************** -Commit guidelines -================= - -- We follow the development process described at - `nvie.com `_. - -- Keep commits small and on topic. - -- If a commit looks too big you should be working in a feature branch not a - single commit. - -- Merge feature branches with ``--no-ff`` to keep track of the merge. +Here you'll find description of the development tools we use. Continuous integration @@ -38,8 +27,8 @@ code. So, if you're out of work, the code coverage and pylint data at the CI server should give you a place to start. -Protocol debugging -================== +Protocol debugger +================= Since the main interface provided to Mopidy is through the MPD protocol, it is crucial that we try and stay in sync with protocol developments. In an attempt @@ -82,35 +71,12 @@ both to use ``tests/data/advanced_tag_cache`` for their tag cache and playlists. -Setting profiles during development -=================================== - -While developing Mopidy switching settings back and forth can become an all too -frequent occurrence. As a quick hack to get around this you can structure your -settings file in the following way:: - - import os - profile = os.environ.get('PROFILE', '').split(',') - - if 'shoutcast' in profile: - OUTPUT = u'lame ! shout2send mount="/stream"' - elif 'silent' in profile: - OUTPUT = u'fakesink' - MIXER = None - - SPOTIFY_USERNAME = u'xxxxx' - SPOTIFY_PASSWORD = u'xxxxx' - -Using this setup you can now run Mopidy with ``PROFILE=silent mopidy`` -if you for instance want to test Spotify without any actual audio output. - - -Writing documentation +Documentation writing ===================== To write documentation, we use `Sphinx `_. See their -site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX -from the documentation files, you need some additional dependencies. +site for lots of documentation on how to use Sphinx. To generate HTML from the +documentation files, you need some additional dependencies. You can install them through Debian/Ubuntu package management:: diff --git a/docs/index.rst b/docs/index.rst index 6f5ef95f..199ba31c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,7 +60,7 @@ Development :maxdepth: 1 contributing - development + devtools codestyle extensiondev From 067cc4c112d02dd3085aa2ae99406adca9b803ce Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 01:18:26 +0200 Subject: [PATCH 244/403] config: Add basic validate tests --- mopidy/config/__init__.py | 35 +++++++++++++++++--------------- tests/config/config_test.py | 40 ++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 935f8743..38db4c42 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -103,23 +103,8 @@ def validate(raw_config, schemas, extensions=None): for extension in extensions or []: sections_and_schemas.append( (extension.ext_name, extension.get_config_schema())) - return _validate(raw_config, sections_and_schemas) - -# TODO: replace validate() with this version of API. -def _validate(raw_config, schemas): - # Get validated config - config = {} - errors = {} - for name, schema in schemas: - if name not in raw_config: - errors[name] = {name: 'section not found'} - try: - items = raw_config[name].items() - config[name] = schema.convert(items) - # TODO: convert to ConfigSchemaError - except exceptions.ConfigError as error: - errors[name] = error + config, errors = _validate(raw_config, sections_and_schemas) if errors: # TODO: raise error instead. @@ -133,6 +118,24 @@ def _validate(raw_config, schemas): return config +# TODO: replace validate() with this version of API. +def _validate(raw_config, schemas): + # Get validated config + config = {} + errors = [] + for name, schema in schemas: + try: + items = raw_config[name].items() + config[name] = schema.convert(items) + except KeyError: + errors.append('%s: section not found.' % name) + except exceptions.ConfigError as error: + for key in error: + errors.append('%s/%s: %s' % (name, key, error[key])) + # TODO: raise errors instead of return + return config, errors + + def parse_override(override): """Parse section/key=value override.""" section, remainder = override.split('/', 1) diff --git a/tests/config/config_test.py b/tests/config/config_test.py index bb24e3a2..00cb4e83 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals -from mopidy import config +import mock + +from mopidy import config, exceptions from tests import unittest, path_to_data_dir @@ -49,6 +51,42 @@ class LoadConfigTest(unittest.TestCase): self.assertEqual(expected, result) +class ValidateTest(unittest.TestCase): + def test_empty_config_no_schemas(self): + conf, errors = config._validate({}, []) + self.assertEqual({}, conf) + self.assertEqual([], errors) + + def test_config_no_schemas(self): + raw_config = {'foo': {'bar': 'baz'}} + conf, errors = config._validate(raw_config, []) + self.assertEqual({}, conf) + self.assertEqual([], errors) + + def test_empty_config_single_schema(self): + conf, errors = config._validate({}, [('foo', mock.Mock())]) + self.assertEqual({}, conf) + self.assertEqual(['foo: section not found.'], errors) + + def test_config_single_schema(self): + raw_config = {'foo': {'bar': 'baz'}} + schema = mock.Mock() + schema.convert.return_value = {'baz': 'bar'} + conf, errors = config._validate(raw_config, [('foo', schema)]) + self.assertEqual({'foo': {'baz': 'bar'}}, conf) + self.assertEqual([], errors) + + def test_config_single_schema_config_error(self): + raw_config = {'foo': {'bar': 'baz'}} + schema = mock.Mock() + schema.convert.side_effect = exceptions.ConfigError({'bar': 'bad'}) + conf, errors = config._validate(raw_config, [('foo', schema)]) + self.assertEqual(['foo/bar: bad'], errors) + self.assertEqual({}, conf) + + # TODO: add more tests + + class ParseOverrideTest(unittest.TestCase): def test_valid_override(self): expected = ('section', 'key', 'value') From e98ca4c94caebb6b46611f7f6c5d38f2bc30f096 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 01:35:55 +0200 Subject: [PATCH 245/403] config: Handle encoding and other minor refactoring mistakes --- mopidy/config/__init__.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 38db4c42..cca217c3 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -59,7 +59,7 @@ def load(files, overrides, extensions=None): defaults = [default_config] if extensions: defaults.extend(e.get_default_config() for e in extensions) - return _load(files, defaults, extensions) + return _load(files, defaults, overrides) # TODO: replace load() with this version of API. @@ -70,8 +70,8 @@ def _load(files, defaults, overrides): sources = ['builtin-defaults'] + files + ['command-line'] logger.info('Loading config from: %s', ', '.join(sources)) - for default in defaults: - parser.readfp(io.StringIO(default)) + for default in defaults: # TODO: remove decoding + parser.readfp(io.StringIO(default.decode('utf-8'))) # Load config from a series of config files for filename in files: @@ -109,10 +109,8 @@ def validate(raw_config, schemas, extensions=None): if errors: # TODO: raise error instead. #raise exceptions.ConfigError(errors) - for name, error in errors.items(): - logger.error('[%s] config errors:', name) - for key in error: - logger.error('%s %s', key, error[key]) + for error in errors: + logger.error(error) sys.exit(1) return config From 028c56b003c22150f674001982efaadac1ea2106 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 13:25:50 +0200 Subject: [PATCH 246/403] docs: Add links to blog posts on commit messages --- docs/contributing.rst | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 215aea65..b8d2a6f3 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -27,8 +27,8 @@ Making changes 2. Install dependencies as described in the :ref:`installation` section. -3. Checkout a new branch (usually based on develop) and name it accordingly to - what you intend to do. +3. Checkout a new branch (usually based on ``develop``) and name it accordingly + to what you intend to do. - Features get the prefix ``feature/`` @@ -95,10 +95,22 @@ Submitting changes - One branch per feature or fix. Keep branches small and on topic. -- Follow the :ref:`style guide `_, especially make sure ``flake8`` +- Follow the :ref:`code style `, especially make sure ``flake8`` does not complain about anything. -- Send a pull request to the ``develop`` branch. +- Write good commit messages. Here's three blog posts on how to do it right: + + - `Writing Git commit messages + `_ + + - `A Note About Git Commit Messages + `_ + + - `On commit messages + `_ + +- Send a pull request to the ``develop`` branch. See the `GitHub pull request + docs `_ for help. Additional resources @@ -110,7 +122,4 @@ Additional resources - `Mailing List `_ -- `General GitHub documentation `_ - -- `GitHub pull request documentation - `_ +- `GitHub documentation `_ From e1cf8fbd2c848713c6141f2ccca3a1126901a8c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 13:40:14 +0200 Subject: [PATCH 247/403] docs: Remove extensiondev draft warning --- docs/extensiondev.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index b64ac3b6..8d0ace16 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -4,13 +4,6 @@ Extension development ********************* -.. warning:: Draft - - This document is a draft open for discussion. It shows how we imagine that - development of Mopidy extensions should become in the future, not how to - currently develop an extension for Mopidy. - - Mopidy started as simply an MPD server that could play music from Spotify. Early on Mopidy got multiple "frontends" to expose Mopidy to more than just MPD clients: for example the Last.fm frontend what scrobbles what you've listened From dbbe0fd16e73a429fecaf974ba8233b5f1e60923 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 13:49:01 +0200 Subject: [PATCH 248/403] docs: Remove link to removed document --- docs/changelog.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index ab97bf08..8e7fbdd1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1493,8 +1493,7 @@ In the last two months, Mopidy's MPD frontend has gotten lots of stability fixes and error handling improvements, proper support for having the same track multiple times in a playlist, and support for IPv6. We have also fixed the choppy playback on the libspotify backend. For the road ahead of us, we got an -updated :doc:`release roadmap ` with our goals for the 0.1 to 0.3 -releases. +updated release roadmap with our goals for the 0.1 to 0.3 releases. Enjoy the best alpha relase of Mopidy ever :-) From 00075979616de803bf2e7e7e355a236ac2dd3e8e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 13:51:02 +0200 Subject: [PATCH 249/403] docs: Add mopidy.ext to API docs --- docs/api/ext.rst | 9 +++++++++ docs/api/index.rst | 1 + mopidy/ext.py | 7 +++++++ 3 files changed, 17 insertions(+) create mode 100644 docs/api/ext.rst diff --git a/docs/api/ext.rst b/docs/api/ext.rst new file mode 100644 index 00000000..594f5200 --- /dev/null +++ b/docs/api/ext.rst @@ -0,0 +1,9 @@ +.. _ext-api: + +************* +Extension API +************* + +.. automodule:: mopidy.ext + :synopsis: Extension API for extending Mopidy + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 6ba44999..6313b7a5 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,4 +11,5 @@ API reference core audio frontends + ext http diff --git a/mopidy/ext.py b/mopidy/ext.py index 7fee6014..6e97844e 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -11,28 +11,35 @@ logger = logging.getLogger('mopidy.ext') class Extension(object): + """Base class for Mopidy extensions""" dist_name = None ext_name = None version = None def get_default_config(self): + """TODO""" raise NotImplementedError( 'Add at least a config section with "enabled = true"') def get_config_schema(self): + """TODO""" return config_utils.ExtensionConfigSchema() def validate_environment(self): + """TODO""" pass def get_frontend_classes(self): + """TODO""" return [] def get_backend_classes(self): + """TODO""" return [] def register_gstreamer_elements(self): + """TODO""" pass From a543a0167583eb508e67201fe597bad1c41ba594 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 13:55:56 +0200 Subject: [PATCH 250/403] docs: Minor updates to extensiondev docs --- docs/extensiondev.rst | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 8d0ace16..d78c8c4d 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -6,7 +6,7 @@ Extension development Mopidy started as simply an MPD server that could play music from Spotify. Early on Mopidy got multiple "frontends" to expose Mopidy to more than just MPD -clients: for example the Last.fm frontend what scrobbles what you've listened +clients: for example the scrobbler frontend what scrobbles what you've listened to to your Last.fm account, the MPRIS frontend that integrates Mopidy into the Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web based Mopidy clients possible. In Mopidy 0.9 we added support for multiple @@ -113,9 +113,9 @@ register themselves as available Mopidy extensions when they are installed on your system. The example below also includes a couple of convenient tricks for reading the -package version from the source code so that it it's just defined in a single -place, and to reuse the README file as the long description of the package for -the PyPI registration. +package version from the source code so that it is defined in a single place, +and to reuse the README file as the long description of the package for the +PyPI registration. The package must have ``install_requires`` on ``setuptools`` and ``Mopidy``, in addition to any other dependencies required by your extension. The @@ -182,17 +182,18 @@ Python package. The root of your Python package should have an ``__version__`` attribute with a :pep:`386` compliant version number, for example "0.1". Next, it should have a -class named ``Extension`` which inherits from Mopidy's extension base class. -This is the class referred to in the ``entry_points`` part of ``setup.py``. Any -imports of other files in your extension should be kept inside methods. This -ensures that this file can be imported without raising :exc:`ImportError` -exceptions for missing dependencies, etc. +class named ``Extension`` which inherits from Mopidy's extension base class, +:class:`mopidy.ext.Extension`. This is the class referred to in the +``entry_points`` part of ``setup.py``. Any imports of other files in your +extension should be kept inside methods. This ensures that this file can be +imported without raising :exc:`ImportError` exceptions for missing +dependencies, etc. The default configuration for the extension is defined by the ``get_default_config()`` method in the ``Extension`` class which returns a -:mod:`ConfigParser` compatible config section. The config section's name should +:mod:`ConfigParser` compatible config section. The config section's name must be the same as the extension's short name, as defined in the ``entry_points`` -part of ``setup.py``, for example ``soundspot``. All extensions should include +part of ``setup.py``, for example ``soundspot``. All extensions must include an ``enabled`` config which should default to ``true``. Provide good defaults for all config values so that as few users as possible will need to change them. The exception is if the config value has security implications; in that @@ -204,8 +205,6 @@ and ``password``. from __future__ import unicode_literals - import os - import pygst pygst.require('0.10') import gst @@ -325,8 +324,8 @@ If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer elements, you'll need to register them in GStreamer before they can be used. Basically, you just implement your GStreamer element in Python and then make -your :meth:`Extension.register_gstreamer_elements` method register all your -custom GStreamer elements. +your :meth:`~mopidy.ext.Extension.register_gstreamer_elements` method register +all your custom GStreamer elements. For examples of custom GStreamer elements implemented in Python, see :mod:`mopidy.audio.mixers`. From 0052dbeb0d041da29f0ea60e100355cf20ff2234 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 14:01:07 +0200 Subject: [PATCH 251/403] docs: Remove extension support implementation plan --- docs/extensiondev.rst | 59 ------------------------------------------- 1 file changed, 59 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index d78c8c4d..5530d2f2 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -329,62 +329,3 @@ all your custom GStreamer elements. For examples of custom GStreamer elements implemented in Python, see :mod:`mopidy.audio.mixers`. - - -Implementation steps -==================== - -A rough plan of how to make the above document the reality of how Mopidy -extensions work. - -1. Implement :class:`mopidy.utils.ext.Extension` base class and the - :exc:`mopidy.exceptions.ExtensionError` exception class. - -2. Switch from using distutils to setuptools to package and install Mopidy so - that we can register entry points for the bundled extensions and get - information about all extensions available on the system from - :mod:`pkg_resources`. - -3. Add :class:`Extension` classes for all existing frontends and backends. Skip - any default config and config validation for now. - -4. Add entry points for the existing extensions in the ``setup.py`` file. - -5. Rewrite the startup procedure to find extensions and thus frontends and - backends via :mod:`pkg_resouces` instead of the ``FRONTENDS`` and - ``BACKENDS`` settings. - -6. Remove the ``FRONTENDS`` and ``BACKENDS`` settings. - -7. Add default config files and config validation to all existing extensions. - -8. Switch to ini file based configuration, using :mod:`ConfigParser`. The - default config is the combination of a core config file plus the config from - each installed extension. To find the effective config for the system, the - following config sources are added together, with the later ones overriding - the earlier ones: - - - the default config built from Mopidy core and all installed extensions, - - - ``/etc/mopidy/mopidy.conf``, - - - ``~/.config/mopidy/mopidy.conf``, - - - any config file provided via command line arguments, and - - - any config values provided via command line arguments. - -9. Replace all use of ``mopidy.settings`` with the new config object. - -10. Add command line options for: - - - loading an additional config file for this execution of Mopidy, - - - setting a config value for this execution of Mopidy, - - - printing the effective config and exit, and - - - write a config value permanently to ``~/.config/mopidy/mopidy.conf``, or - ``/etc/mopidy/mopidy.conf`` if root, and exit. - -11. Reimplement ``--list-deps`` based upon information provided by extensions. From a2e08c6d4e4a638aea2d4be2e9c039558bbae76c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 14:04:54 +0200 Subject: [PATCH 252/403] docs: Add note about extension's API usage --- docs/api/index.rst | 2 ++ docs/extensiondev.rst | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/api/index.rst b/docs/api/index.rst index 6313b7a5..cb7014f1 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -1,3 +1,5 @@ +.. _api-ref: + ************* API reference ************* diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 5530d2f2..34b26e57 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -329,3 +329,11 @@ all your custom GStreamer elements. For examples of custom GStreamer elements implemented in Python, see :mod:`mopidy.audio.mixers`. + + +Use of Mopidy APIs +================== + +When writing an extension, you should only use APIs documented at +:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils` may change at +any time, and is not something extensions should rely on being stable. From 73e3a774844960e79d06f01f9e435103880b8d7c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 14:06:20 +0200 Subject: [PATCH 253/403] docs: Link from ext API to extensiondev --- docs/api/ext.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api/ext.rst b/docs/api/ext.rst index 594f5200..11908920 100644 --- a/docs/api/ext.rst +++ b/docs/api/ext.rst @@ -4,6 +4,8 @@ Extension API ************* +If you want to learn how to make Mopidy extensions, read :ref:`extensiondev`. + .. automodule:: mopidy.ext :synopsis: Extension API for extending Mopidy :members: From 9d94e815ce2aab3f989e3c33d6ad6ce78324ac67 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 14:22:37 +0200 Subject: [PATCH 254/403] docs: Note about logging in extensions --- docs/extensiondev.rst | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 34b26e57..446e950b 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -335,5 +335,37 @@ Use of Mopidy APIs ================== When writing an extension, you should only use APIs documented at -:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils` may change at +:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils`, may change at any time, and is not something extensions should rely on being stable. + + +Logging in extensions +===================== + +When making servers like Mopidy, logging is essential for understanding what's +going on. We use the :mod:`logging` module from Python's standard library. When +creating a logger, always namespace the logger using your Python package name +as this will be visible in Mopidy's debug log:: + + import logging + + logger = logging.getLogger('mopidy_soundspot') + +When logging at logging level ``info`` or higher (i.e. ``warning``, ``error``, +and ``critical``, but not ``debug``) the log message will be displayed to all +Mopidy users. Thus, the log messages at those levels should be well written and +easy to understand. + +As the logger name is not included in Mopidy's default logging format, you +should make it obvious from the log message who is the source of the log +message. For example:: + + Loaded 17 Soundspot playlists + +Is much better than:: + + Loaded 17 playlists + +If you want to turn on debug logging for your own extension, but not for +everything else due to the amount of noise, see the docs for the +:confval:`logging.levels/*` config section. From d232b729c9d673bbb2f6d38452d6f50d277987c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 14:24:20 +0200 Subject: [PATCH 255/403] docs: Add TODOs for documenting command line options --- docs/running.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/running.rst b/docs/running.rst index 6c8d0ede..b81dbef7 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -18,3 +18,19 @@ using ``kill``:: kill `ps ax | grep mopidy | grep -v grep | cut -d' ' -f1` This can be useful e.g. if you create init script for managing Mopidy. + + +mopidy command +============== + +.. program:: mopidy + +TODO: Document all command line options + + +mopidy-scan command +=================== + +.. program:: mopidy-scan + +TODO: Document all command line options From 63003abb2ecbadcb5ae2ec86414b190dd5ab3783 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 20:40:13 +0200 Subject: [PATCH 256/403] config: Flake8 fixes --- mopidy/__main__.py | 10 ++++++---- mopidy/config/__init__.py | 3 +-- mopidy/config/schemas.py | 4 +--- mopidy/config/types.py | 2 +- mopidy/config/validators.py | 1 + tests/config/schemas_test.py | 7 ++++--- tests/config/types_test.py | 3 +-- tests/config/validator_tests.py | 8 +++++--- 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 30fcd7f9..a9649bd1 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -24,7 +24,7 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import exceptions, ext +from mopidy import ext from mopidy.audio import Audio from mopidy import config as config_lib from mopidy.core import Core @@ -136,12 +136,14 @@ def show_config_callback(option, opt, value, parser): overrides = getattr(parser.values, 'overrides', []) extensions = ext.load_extensions() - raw_config = load_config(files, overrides, extensions) + raw_config = config_lib.load(files, overrides, extensions) enabled_extensions = ext.filter_enabled_extensions(raw_config, extensions) - config = validate_config(raw_config, config_schemas, enabled_extensions) + config = config_lib.validate( + raw_config, config_lib.config_schemas, enabled_extensions) + # TODO: create mopidy.config.format? output = [] - for section_name, schema in config_schemas.items(): + for section_name, schema in config_lib.config_schemas.items(): options = config.get(section_name, {}) if not options: continue diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index cca217c3..6cf352f5 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -69,8 +69,7 @@ def _load(files, defaults, overrides): files = [path.expand_path(f) for f in files] sources = ['builtin-defaults'] + files + ['command-line'] logger.info('Loading config from: %s', ', '.join(sources)) - - for default in defaults: # TODO: remove decoding + for default in defaults: # TODO: remove decoding parser.readfp(io.StringIO(default.decode('utf-8'))) # Load config from a series of config files diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 13928054..b074e79a 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -36,13 +36,11 @@ def _levenshtein(a, b): return current[n] - - class ConfigSchema(object): """Logical group of config values that correspond to a config section. Schemas are set up by assigning config keys with config values to - instances. Once setup :meth:`convert` can be called with a list of + instances. Once setup :meth:`convert` can be called with a list of ``(key, value)`` tuples to process. For convienience we also support :meth:`format` method that can used for printing out the converted values. """ diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 0fe40961..43878e87 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -194,7 +194,7 @@ class ExpandedPath(bytes): class Path(ConfigValue): - """File system path that will be expanded with mopidy.utils.path.expand_path + """File system path that will be expanded. Supports: optional, choices and secret. """ diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py index ab7282be..0fda118d 100644 --- a/mopidy/config/validators.py +++ b/mopidy/config/validators.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals # TODO: add validate regexp? + def validate_required(value, required): """Required validation, normally called in config value's validate() on the raw string, _not_ the converted value.""" diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py index 4920bbfe..e7d6dde1 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/schemas_test.py @@ -4,7 +4,7 @@ import logging import mock from mopidy import exceptions -from mopidy.config import schemas +from mopidy.config import schemas, types from tests import unittest @@ -89,7 +89,7 @@ class ConfigSchemaTest(unittest.TestCase): class ExtensionConfigSchemaTest(unittest.TestCase): def test_schema_includes_enabled(self): schema = schemas.ExtensionConfigSchema() - self.assertIsInstance(schema['enabled'], values.Boolean) + self.assertIsInstance(schema['enabled'], types.Boolean) class LogLevelConfigSchemaTest(unittest.TestCase): @@ -102,8 +102,9 @@ class LogLevelConfigSchemaTest(unittest.TestCase): def test_format(self): schema = schemas.LogLevelConfigSchema() + values = {'foo.bar': logging.DEBUG, 'baz': logging.INFO} expected = ['[levels]', 'baz = info', 'foo.bar = debug'] - result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) + result = schema.format('levels', values) self.assertEqual('\n'.join(expected), result) diff --git a/tests/config/types_test.py b/tests/config/types_test.py index 89fb3ac1..448283b1 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -4,7 +4,6 @@ import logging import mock import socket -from mopidy import exceptions from mopidy.config import types from tests import unittest @@ -165,7 +164,7 @@ class ListTest(unittest.TestCase): self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') -class BooleanTest(unittest.TestCase): +class LogLevelTest(unittest.TestCase): levels = {'critical': logging.CRITICAL, 'error': logging.ERROR, 'warning': logging.WARNING, diff --git a/tests/config/validator_tests.py b/tests/config/validator_tests.py index 3993168d..57489b6b 100644 --- a/tests/config/validator_tests.py +++ b/tests/config/validator_tests.py @@ -14,12 +14,14 @@ class ValidateChoiceTest(unittest.TestCase): validators.validate_choice(1, [1, 2, 3]) def test_empty_choices_fails(self): - self.assertRaises(ValueError,validators.validate_choice, 'foo', []) + self.assertRaises(ValueError, validators.validate_choice, 'foo', []) def test_invalid_value_fails(self): words = ['foo', 'bar', 'baz'] - self.assertRaises(ValueError, validators.validate_choice, 'foobar', words) - self.assertRaises(ValueError, validators.validate_choice, 5, [1, 2, 3]) + self.assertRaises( + ValueError, validators.validate_choice, 'foobar', words) + self.assertRaises( + ValueError, validators.validate_choice, 5, [1, 2, 3]) class ValidateMinimumTest(unittest.TestCase): From 917c1e4c9d88137d649b6a00aef6f16183f662d8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 20:51:09 +0200 Subject: [PATCH 257/403] config: Use default config file --- mopidy/config/__init__.py | 28 +++++----------------------- mopidy/{ => config}/default.conf | 0 2 files changed, 5 insertions(+), 23 deletions(-) rename mopidy/{ => config}/default.conf (100%) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 6cf352f5..b04bcc44 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -4,6 +4,7 @@ import codecs import ConfigParser as configparser import io import logging +import os.path import sys from mopidy.config.schemas import * @@ -12,28 +13,7 @@ from mopidy.utils import path logger = logging.getLogger('mopdiy.config') - -default_config = """ -[logging] -console_format = %(levelname)-8s %(message)s -debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s -debug_file = mopidy.log - -[logging.levels] -pykka = info - -[audio] -mixer = autoaudiomixer -mixer_track = -output = autoaudiosink - -[proxy] -hostname = -username = -password = -""" - -config_schemas = {} # TODO: use ordered dict? +config_schemas = {} # TODO: use ordered dict or list? config_schemas['logging'] = ConfigSchema() config_schemas['logging']['console_format'] = String() config_schemas['logging']['debug_format'] = String() @@ -56,7 +36,9 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) def load(files, overrides, extensions=None): - defaults = [default_config] + default_config_file = os.path.join( + os.path.dirname(__file__), 'default.conf') + defaults = [open(default_config_file).read()] if extensions: defaults.extend(e.get_default_config() for e in extensions) return _load(files, defaults, overrides) diff --git a/mopidy/default.conf b/mopidy/config/default.conf similarity index 100% rename from mopidy/default.conf rename to mopidy/config/default.conf From b655e846b1767ce737d6838eb333ffbf2b665f0b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 21:06:09 +0200 Subject: [PATCH 258/403] config: Add read helper --- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/__init__.py | 2 +- mopidy/backends/stream/__init__.py | 2 +- mopidy/config/__init__.py | 11 ++++++++--- mopidy/frontends/http/__init__.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- mopidy/frontends/scrobbler/__init__.py | 2 +- 8 files changed, 15 insertions(+), 10 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index a6c96a3a..161506e3 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 2860b593..2833c4c4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 732fd3f2..3f116eed 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index b04bcc44..48334942 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -35,10 +35,15 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() +def read(config_file): + """Helper to load defaults in same way across core and extensions.""" + with io.open(config_file, 'rb') as filehandle: + return filehandle.read() + + def load(files, overrides, extensions=None): - default_config_file = os.path.join( - os.path.dirname(__file__), 'default.conf') - defaults = [open(default_config_file).read()] + config_dir = os.path.dirname(__file__) + defaults = [read(os.path.join(config_dir, 'default.conf'))] if extensions: defaults.extend(e.get_default_config() for e in extensions) return _load(files, defaults, overrides) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 34fa065a..07b9285d 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index f108aec5..5b45a9c1 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index a2a6edf3..fcb9a634 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index 0aa0bdc6..f4208824 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() From d143ec6e369bff65a032be165c464e358b203e3b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 21:07:59 +0200 Subject: [PATCH 259/403] ext: Rename config_utils import to config_lib --- mopidy/ext.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 1d554e72..03491f57 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -4,7 +4,7 @@ import logging import pkg_resources from mopidy import exceptions -from mopidy import config as config_utils +from mopidy import config as config_lib logger = logging.getLogger('mopidy.ext') @@ -21,7 +21,7 @@ class Extension(object): 'Add at least a config section with "enabled = true"') def get_config_schema(self): - return config_utils.ExtensionConfigSchema() + return config_lib.ExtensionConfigSchema() def validate_environment(self): pass @@ -76,7 +76,7 @@ def load_extensions(): def filter_enabled_extensions(raw_config, extensions): - boolean = config_utils.Boolean() + boolean = config_lib.Boolean() enabled_extensions = [] enabled_names = [] disabled_names = [] From 5fd4c187929a4b70152ee41453b0b5797635f54a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 21:12:48 +0200 Subject: [PATCH 260/403] ext: Get config schema from super --- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/__init__.py | 2 +- mopidy/backends/stream/__init__.py | 2 +- mopidy/frontends/http/__init__.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- mopidy/frontends/scrobbler/__init__.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 161506e3..f718eeb5 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['media_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Path() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 2833c4c4..55d0e3d7 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['username'] = config.String() schema['password'] = config.String(secret=True) schema['bitrate'] = config.Integer(choices=(96, 160, 320)) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 3f116eed..061ac5d0 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['protocols'] = config.List() return schema diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 07b9285d..6d84b25b 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() schema['static_dir'] = config.Path(optional=True) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 5b45a9c1..04c00c2b 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() schema['password'] = config.String(optional=True, secret=True) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index fcb9a634..1fd258b5 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['desktop_file'] = config.Path() return schema diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index f4208824..dcc6f195 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['username'] = config.String() schema['password'] = config.String(secret=True) return schema From 6e42a381cdb1884eb17e7433301b37fa7f243822 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 21:46:15 +0200 Subject: [PATCH 261/403] config: Add name to schemas --- mopidy/config/__init__.py | 8 ++++---- mopidy/config/schemas.py | 18 ++++++++++-------- mopidy/ext.py | 2 +- tests/config/schemas_test.py | 16 ++++++++-------- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 48334942..450ec626 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -14,19 +14,19 @@ from mopidy.utils import path logger = logging.getLogger('mopdiy.config') config_schemas = {} # TODO: use ordered dict or list? -config_schemas['logging'] = ConfigSchema() +config_schemas['logging'] = ConfigSchema('logging') config_schemas['logging']['console_format'] = String() config_schemas['logging']['debug_format'] = String() config_schemas['logging']['debug_file'] = Path() -config_schemas['logging.levels'] = LogLevelConfigSchema() +config_schemas['logging.levels'] = LogLevelConfigSchema('loglevels') -config_schemas['audio'] = ConfigSchema() +config_schemas['audio'] = ConfigSchema('audio') config_schemas['audio']['mixer'] = String() config_schemas['audio']['mixer_track'] = String(optional=True) config_schemas['audio']['output'] = String() -config_schemas['proxy'] = ConfigSchema() +config_schemas['proxy'] = ConfigSchema('proxy') config_schemas['proxy']['hostname'] = Hostname(optional=True) config_schemas['proxy']['username'] = String(optional=True) config_schemas['proxy']['password'] = String(optional=True, secret=True) diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index b074e79a..19b5d665 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -45,7 +45,8 @@ class ConfigSchema(object): :meth:`format` method that can used for printing out the converted values. """ # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) - def __init__(self): + def __init__(self, name): + self.name = name self._schema = {} self._order = [] @@ -57,10 +58,10 @@ class ConfigSchema(object): def __getitem__(self, key): return self._schema[key] - def format(self, name, values): + def format(self, values): # TODO: should the output be encoded utf-8 since we use that in # serialize for strings? - lines = ['[%s]' % name] + lines = ['[%s]' % self.name] for key in self._order: value = values.get(key) if value is not None: @@ -97,8 +98,8 @@ class ExtensionConfigSchema(ConfigSchema): Ensures that ``enabled`` config value is present. """ - def __init__(self): - super(ExtensionConfigSchema, self).__init__() + def __init__(self, name): + super(ExtensionConfigSchema, self).__init__(name) self['enabled'] = types.Boolean() @@ -109,11 +110,12 @@ class LogLevelConfigSchema(object): as understood by the :class:`LogLevel` config value. Does not sub-class :class:`ConfigSchema`, but implements the same interface. """ - def __init__(self): + def __init__(self, name): + self.name = name self._config_value = types.LogLevel() - def format(self, name, values): - lines = ['[%s]' % name] + def format(self, values): + lines = ['[%s]' % self.name] for key, value in sorted(values.items()): if value is not None: lines.append('%s = %s' % ( diff --git a/mopidy/ext.py b/mopidy/ext.py index c90e75e3..aa8ec5e0 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -24,7 +24,7 @@ class Extension(object): def get_config_schema(self): """TODO""" - return config_lib.ExtensionConfigSchema() + return config_lib.ExtensionConfigSchema(self.ext_name) def validate_environment(self): """TODO""" diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py index e7d6dde1..487136c2 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/schemas_test.py @@ -11,7 +11,7 @@ from tests import unittest class ConfigSchemaTest(unittest.TestCase): def setUp(self): - self.schema = schemas.ConfigSchema() + self.schema = schemas.ConfigSchema('test') self.schema['foo'] = mock.Mock() self.schema['bar'] = mock.Mock() self.schema['baz'] = mock.Mock() @@ -22,8 +22,8 @@ class ConfigSchemaTest(unittest.TestCase): self.schema['bar'].format.return_value = 'asd' self.schema['baz'].format.return_value = 'zxc' - expected = ['[qwerty]', 'foo = qwe', 'bar = asd', 'baz = zxc'] - result = self.schema.format('qwerty', self.values) + expected = ['[test]', 'foo = qwe', 'bar = asd', 'baz = zxc'] + result = self.schema.format(self.values) self.assertEqual('\n'.join(expected), result) def test_format_unkwown_value(self): @@ -32,7 +32,7 @@ class ConfigSchemaTest(unittest.TestCase): self.schema['baz'].format.return_value = 'zxc' self.values['unknown'] = 'rty' - result = self.schema.format('qwerty', self.values) + result = self.schema.format(self.values) self.assertNotIn('unknown = rty', result) def test_convert(self): @@ -88,7 +88,7 @@ class ConfigSchemaTest(unittest.TestCase): class ExtensionConfigSchemaTest(unittest.TestCase): def test_schema_includes_enabled(self): - schema = schemas.ExtensionConfigSchema() + schema = schemas.ExtensionConfigSchema('test') self.assertIsInstance(schema['enabled'], types.Boolean) @@ -101,10 +101,10 @@ class LogLevelConfigSchemaTest(unittest.TestCase): self.assertEqual(logging.INFO, result['baz']) def test_format(self): - schema = schemas.LogLevelConfigSchema() + schema = schemas.LogLevelConfigSchema('test') values = {'foo.bar': logging.DEBUG, 'baz': logging.INFO} - expected = ['[levels]', 'baz = info', 'foo.bar = debug'] - result = schema.format('levels', values) + expected = ['[test]', 'baz = info', 'foo.bar = debug'] + result = schema.format(values) self.assertEqual('\n'.join(expected), result) From 943a3097a99a111cd03ce023e57277ac2143de78 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 22:36:55 +0200 Subject: [PATCH 262/403] config: Update validate to use schema.name --- mopidy/config/__init__.py | 16 ++++++---------- tests/config/config_test.py | 16 +++++++++------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 450ec626..50f53290 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -19,7 +19,7 @@ config_schemas['logging']['console_format'] = String() config_schemas['logging']['debug_format'] = String() config_schemas['logging']['debug_file'] = Path() -config_schemas['logging.levels'] = LogLevelConfigSchema('loglevels') +config_schemas['logging.levels'] = LogLevelConfigSchema('logging.levels') config_schemas['audio'] = ConfigSchema('audio') config_schemas['audio']['mixer'] = String() @@ -85,12 +85,8 @@ def _load(files, defaults, overrides): def validate(raw_config, schemas, extensions=None): # Collect config schemas to validate against - sections_and_schemas = schemas.items() - for extension in extensions or []: - sections_and_schemas.append( - (extension.ext_name, extension.get_config_schema())) - - config, errors = _validate(raw_config, sections_and_schemas) + extension_schemas = [e.get_config_schema() for e in extensions or []] + config, errors = _validate(raw_config, schemas.values() + extension_schemas) if errors: # TODO: raise error instead. @@ -107,10 +103,10 @@ def _validate(raw_config, schemas): # Get validated config config = {} errors = [] - for name, schema in schemas: + for schema in schemas: try: - items = raw_config[name].items() - config[name] = schema.convert(items) + items = raw_config[schema.name].items() + config[schema.name] = schema.convert(items) except KeyError: errors.append('%s: section not found.' % name) except exceptions.ConfigError as error: diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 00cb4e83..dc1b1c59 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -52,6 +52,10 @@ class LoadConfigTest(unittest.TestCase): class ValidateTest(unittest.TestCase): + def setUp(self): + self.schema = mock.Mock() + self.schema.name = 'foo' + def test_empty_config_no_schemas(self): conf, errors = config._validate({}, []) self.assertEqual({}, conf) @@ -64,23 +68,21 @@ class ValidateTest(unittest.TestCase): self.assertEqual([], errors) def test_empty_config_single_schema(self): - conf, errors = config._validate({}, [('foo', mock.Mock())]) + conf, errors = config._validate({}, [self.schema]) self.assertEqual({}, conf) self.assertEqual(['foo: section not found.'], errors) def test_config_single_schema(self): raw_config = {'foo': {'bar': 'baz'}} - schema = mock.Mock() - schema.convert.return_value = {'baz': 'bar'} - conf, errors = config._validate(raw_config, [('foo', schema)]) + self.schema.convert.return_value = {'baz': 'bar'} + conf, errors = config._validate(raw_config, [self.schema]) self.assertEqual({'foo': {'baz': 'bar'}}, conf) self.assertEqual([], errors) def test_config_single_schema_config_error(self): raw_config = {'foo': {'bar': 'baz'}} - schema = mock.Mock() - schema.convert.side_effect = exceptions.ConfigError({'bar': 'bad'}) - conf, errors = config._validate(raw_config, [('foo', schema)]) + self.schema.convert.side_effect = exceptions.ConfigError({'bar': 'bad'}) + conf, errors = config._validate(raw_config, [self.schema]) self.assertEqual(['foo/bar: bad'], errors) self.assertEqual({}, conf) From 10d7c157922b341bb924977ef82887066521e48e Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 22:44:36 +0200 Subject: [PATCH 263/403] config: Switch to list of core_schemas --- mopidy/__main__.py | 4 ++-- mopidy/config/__init__.py | 33 +++++++++++++++++---------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index a9649bd1..56953d50 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -53,7 +53,7 @@ def main(): raw_config = config_lib.load(config_files, config_overrides, extensions) extensions = ext.filter_enabled_extensions(raw_config, extensions) config = config_lib.validate( - raw_config, config_lib.config_schemas, extensions) + raw_config, config_lib.core_schemas, extensions) log.setup_log_levels(config) check_old_locations() @@ -139,7 +139,7 @@ def show_config_callback(option, opt, value, parser): raw_config = config_lib.load(files, overrides, extensions) enabled_extensions = ext.filter_enabled_extensions(raw_config, extensions) config = config_lib.validate( - raw_config, config_lib.config_schemas, enabled_extensions) + raw_config, config_lib.core_schemas, enabled_extensions) # TODO: create mopidy.config.format? output = [] diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 50f53290..a8ece9c1 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -13,26 +13,27 @@ from mopidy.utils import path logger = logging.getLogger('mopdiy.config') -config_schemas = {} # TODO: use ordered dict or list? -config_schemas['logging'] = ConfigSchema('logging') -config_schemas['logging']['console_format'] = String() -config_schemas['logging']['debug_format'] = String() -config_schemas['logging']['debug_file'] = Path() +_logging_schema = ConfigSchema('logging') +_logging_schema['console_format'] = String() +_logging_schema['debug_format'] = String() +_logging_schema['debug_file'] = Path() -config_schemas['logging.levels'] = LogLevelConfigSchema('logging.levels') +_loglevels_schema = LogLevelConfigSchema('logging.levels') -config_schemas['audio'] = ConfigSchema('audio') -config_schemas['audio']['mixer'] = String() -config_schemas['audio']['mixer_track'] = String(optional=True) -config_schemas['audio']['output'] = String() +_audio_schema = ConfigSchema('audio') +_audio_schema['mixer'] = String() +_audio_schema['mixer_track'] = String(optional=True) +_audio_schema['output'] = String() -config_schemas['proxy'] = ConfigSchema('proxy') -config_schemas['proxy']['hostname'] = Hostname(optional=True) -config_schemas['proxy']['username'] = String(optional=True) -config_schemas['proxy']['password'] = String(optional=True, secret=True) +_proxy_schema = ConfigSchema('proxy') +_proxy_schema['hostname'] = Hostname(optional=True) +_proxy_schema['username'] = String(optional=True) +_proxy_schema['password'] = String(optional=True, secret=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema -#config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() +#_outputs_schema = config.AudioOutputConfigSchema() + +core_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema] def read(config_file): @@ -86,7 +87,7 @@ def _load(files, defaults, overrides): def validate(raw_config, schemas, extensions=None): # Collect config schemas to validate against extension_schemas = [e.get_config_schema() for e in extensions or []] - config, errors = _validate(raw_config, schemas.values() + extension_schemas) + config, errors = _validate(raw_config, schemas + extension_schemas) if errors: # TODO: raise error instead. From d8a0cb880d9efddc431dedbdc259b3fb8a63ceaf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 22:51:59 +0200 Subject: [PATCH 264/403] config: Make --show-config use schema name --- mopidy/__main__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 56953d50..8f98de5c 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -143,19 +143,20 @@ def show_config_callback(option, opt, value, parser): # TODO: create mopidy.config.format? output = [] - for section_name, schema in config_lib.config_schemas.items(): - options = config.get(section_name, {}) + for schema in config_lib.core_schemas: + options = config.get(schema.name, {}) if not options: continue - output.append(schema.format(section_name, options)) + output.append(schema.format(options)) for extension in extensions: + schema = extension.get_config_schema() + if extension in enabled_extensions: - schema = extension.get_config_schema() - options = config.get(extension.ext_name, {}) - output.append(schema.format(extension.ext_name, options)) + options = config.get(schema.name, {}) + output.append(schema.format(options)) else: - lines = ['[%s]' % extension.ext_name, 'enabled = false', + lines = ['[%s]' % schema.name, 'enabled = false', '# Config hidden as extension is disabled'] output.append('\n'.join(lines)) From 06534a8dc2f89f39965ea0e51581740102f539f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 13 Apr 2013 22:56:23 +0200 Subject: [PATCH 265/403] docs: Update Extension example --- docs/extensiondev.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 446e950b..7214aae8 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -210,8 +210,7 @@ and ``password``. import gst import gobject - from mopidy import exceptions, ext - from mopidy.utils import config + from mopidy import config, exceptions, ext __version__ = '0.1' @@ -234,7 +233,7 @@ and ``password``. return default_config def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['username'] = config.String(required=True) schema['password'] = config.String(required=True, secret=True) return schema From 1163d12306e45b493e3046fa336b8500fe9dced8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 23:15:47 +0200 Subject: [PATCH 266/403] scanner/config: Convert to new config system (fixes #405) --- mopidy/config/__init__.py | 2 +- mopidy/scanner.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index a8ece9c1..6cf9a802 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -112,7 +112,7 @@ def _validate(raw_config, schemas): errors.append('%s: section not found.' % name) except exceptions.ConfigError as error: for key in error: - errors.append('%s/%s: %s' % (name, key, error[key])) + errors.append('%s/%s: %s' % (schema.name, key, error[key])) # TODO: raise errors instead of return return config, errors diff --git a/mopidy/scanner.py b/mopidy/scanner.py index d28d328a..226fd410 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -34,6 +34,7 @@ import pygst pygst.require('0.10') import gst +from mopidy import config as config_lib, ext from mopidy.frontends.mpd import translator as mpd_translator from mopidy.models import Track, Artist, Album from mopidy.utils import log, path, versioning @@ -41,10 +42,22 @@ from mopidy.utils import log, path, versioning def main(): options = parse_options() - config = {} # TODO Read config from new config system + # TODO: support config files and overrides (shared from main?) + config_files = ['/etc/mopidy/mopidy.conf', + '$XDG_CONFIG_DIR/mopidy/mopidy.conf'] + config_overrides = [] + # TODO: decide if we want to avoid this boilerplate some how. + logging_config = config_lib.load(config_files, config_overrides) log.setup_root_logger() - log.setup_console_logging(options.verbosity_level) + log.setup_console_logging(logging_config, options.verbosity_level) + + extensions = ext.load_extensions() + raw_config = config_lib.load(config_files, config_overrides, extensions) + extensions = ext.filter_enabled_extensions(raw_config, extensions) + config = config_lib.validate( + raw_config, config_lib.core_schemas, extensions) + log.setup_log_levels(config) tracks = [] @@ -68,7 +81,7 @@ def main(): logging.info('Done scanning; writing tag cache...') for row in mpd_translator.tracks_to_tag_cache_format( - tracks, config['mpd']['media_dir']): + tracks, config['local']['media_dir']): if len(row) == 1: print ('%s' % row).encode('utf-8') else: From f38b806133231fa9a31f97d0f946a8e558b396f9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 10:54:26 +0200 Subject: [PATCH 267/403] utils: Remove unused importing utils --- mopidy/utils/importing.py | 26 -------------------------- tests/utils/importing_test.py | 29 ----------------------------- 2 files changed, 55 deletions(-) delete mode 100644 mopidy/utils/importing.py delete mode 100644 tests/utils/importing_test.py diff --git a/mopidy/utils/importing.py b/mopidy/utils/importing.py deleted file mode 100644 index 591071a1..00000000 --- a/mopidy/utils/importing.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import unicode_literals - -import logging -import sys - - -logger = logging.getLogger('mopidy.utils') - - -def import_module(name): - __import__(name) - return sys.modules[name] - - -def get_class(name): - logger.debug('Loading: %s', name) - if '.' not in name: - raise ImportError("Couldn't load: %s" % name) - module_name = name[:name.rindex('.')] - cls_name = name[name.rindex('.') + 1:] - try: - module = import_module(module_name) - cls = getattr(module, cls_name) - except (ImportError, AttributeError): - raise ImportError("Couldn't load: %s" % name) - return cls diff --git a/tests/utils/importing_test.py b/tests/utils/importing_test.py deleted file mode 100644 index 5be4078b..00000000 --- a/tests/utils/importing_test.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import unicode_literals - -from mopidy.utils import importing - -from tests import unittest - - -class GetClassTest(unittest.TestCase): - def test_loading_module_that_does_not_exist(self): - with self.assertRaises(ImportError): - importing.get_class('foo.bar.Baz') - - def test_loading_class_that_does_not_exist(self): - with self.assertRaises(ImportError): - importing.get_class('unittest.FooBarBaz') - - def test_loading_incorrect_class_path(self): - with self.assertRaises(ImportError): - importing.get_class('foobarbaz') - - def test_import_error_message_contains_complete_class_path(self): - try: - importing.get_class('foo.bar.Baz') - except ImportError as e: - self.assertIn('foo.bar.Baz', str(e)) - - def test_loading_existing_class(self): - cls = importing.get_class('unittest.TestCase') - self.assertEqual(cls.__name__, 'TestCase') From 809b1659669e9807da05d6a464e5da2fc75380f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 11:06:45 +0200 Subject: [PATCH 268/403] exc: Remove unused SettingsError --- mopidy/exceptions.py | 4 ---- mopidy/utils/process.py | 2 -- tests/exceptions_test.py | 4 ---- 3 files changed, 10 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 23aa3fb8..2b2b4ef8 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -16,10 +16,6 @@ class MopidyException(Exception): self._message = message -class SettingsError(MopidyException): - pass - - class ConfigError(MopidyException): def __init__(self, errors): self._errors = errors diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index c8c85f59..24a2e773 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -65,8 +65,6 @@ class BaseThread(threading.Thread): self.run_inside_try() except KeyboardInterrupt: logger.info('Interrupted by user') - except exceptions.SettingsError as e: - logger.error(e.message) except ImportError as e: logger.error(e) except ActorDeadError as e: diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index c1dd7634..8dd71076 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -12,10 +12,6 @@ class ExceptionsTest(unittest.TestCase): self.assertEqual(exc.message, 'foo') self.assertEqual(str(exc), 'foo') - def test_settings_error_is_a_mopidy_exception(self): - self.assert_(issubclass( - exceptions.SettingsError, exceptions.MopidyException)) - def test_optional_dependency_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.OptionalDependencyError, exceptions.MopidyException)) From 65f14a9cc4eb21bb39d330fb554863a227f78c40 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 11:26:16 +0200 Subject: [PATCH 269/403] exc: Remove OptionalDependencyError It is no longer needed as the extension loading mechanisms verifies the environment and presence of dependencies before it tries to import modules that would cause ImportErrors if optional dependencies are missing. --- mopidy/exceptions.py | 4 ---- mopidy/frontends/http/actor.py | 13 ++++--------- mopidy/frontends/http/ws.py | 11 ++++------- mopidy/frontends/mpris/objects.py | 12 ++++-------- mopidy/frontends/scrobbler/actor.py | 6 +----- tests/exceptions_test.py | 4 ---- tests/frontends/http/events_test.py | 8 +++----- tests/frontends/mpris/events_test.py | 14 +++++++------- tests/frontends/mpris/player_interface_test.py | 15 ++++++++------- tests/frontends/mpris/playlists_interface_test.py | 14 ++++++++------ tests/frontends/mpris/root_interface_test.py | 15 ++++++++------- 11 files changed, 47 insertions(+), 69 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 2b2b4ef8..702eab9f 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -34,9 +34,5 @@ class ConfigError(MopidyException): return '\n'.join(lines) -class OptionalDependencyError(MopidyException): - pass - - class ExtensionError(MopidyException): pass diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 149cbc7f..b6fb26d4 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -4,18 +4,13 @@ import logging import json import os +import cherrypy import pykka +from ws4py.messaging import TextMessage +from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool -from mopidy import exceptions, models +from mopidy import models from mopidy.core import CoreListener - -try: - import cherrypy - from ws4py.messaging import TextMessage - from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool -except ImportError as import_error: - raise exceptions.OptionalDependencyError(import_error) - from . import ws diff --git a/mopidy/frontends/http/ws.py b/mopidy/frontends/http/ws.py index 7f24850f..b46b450e 100644 --- a/mopidy/frontends/http/ws.py +++ b/mopidy/frontends/http/ws.py @@ -2,14 +2,11 @@ from __future__ import unicode_literals import logging -from mopidy import core, exceptions, models -from mopidy.utils import jsonrpc +import cherrypy +from ws4py.websocket import WebSocket -try: - import cherrypy - from ws4py.websocket import WebSocket -except ImportError as import_error: - raise exceptions.OptionalDependencyError(import_error) +from mopidy import core, models +from mopidy.utils import jsonrpc logger = logging.getLogger('mopidy.frontends.http') diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index a7f049d2..15be1eea 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -4,14 +4,10 @@ import base64 import logging import os -try: - import dbus - import dbus.mainloop.glib - import dbus.service - import gobject -except ImportError as import_error: - from mopidy.exceptions import OptionalDependencyError - raise OptionalDependencyError(import_error) +import dbus +import dbus.mainloop.glib +import dbus.service +import gobject from mopidy.core import PlaybackState from mopidy.utils.process import exit_process diff --git a/mopidy/frontends/scrobbler/actor.py b/mopidy/frontends/scrobbler/actor.py index 74a11f82..2343e0cb 100644 --- a/mopidy/frontends/scrobbler/actor.py +++ b/mopidy/frontends/scrobbler/actor.py @@ -4,14 +4,10 @@ import logging import time import pykka +import pylast -from mopidy import exceptions from mopidy.core import CoreListener -try: - import pylast -except ImportError as import_error: - raise exceptions.OptionalDependencyError(import_error) logger = logging.getLogger('mopidy.frontends.scrobbler') diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index 8dd71076..daf51a0d 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -12,10 +12,6 @@ class ExceptionsTest(unittest.TestCase): self.assertEqual(exc.message, 'foo') self.assertEqual(str(exc), 'foo') - def test_optional_dependency_error_is_a_mopidy_exception(self): - self.assert_(issubclass( - exceptions.OptionalDependencyError, exceptions.MopidyException)) - def test_extension_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ExtensionError, exceptions.MopidyException)) diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index c334eefa..ee08a60a 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -6,18 +6,16 @@ try: import cherrypy except ImportError: cherrypy = False + try: import ws4py except ImportError: ws4py = False -import mock -from mopidy.exceptions import OptionalDependencyError -try: +if cherrypy and ws4py: from mopidy.frontends.http import actor -except OptionalDependencyError: - pass +import mock from tests import unittest diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index f79202c0..b336cbc7 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -1,21 +1,21 @@ from __future__ import unicode_literals -import sys - import mock -from mopidy.exceptions import OptionalDependencyError +try: + import dbus +except ImportError: + dbus = False + from mopidy.models import Playlist, TlTrack -try: +if dbus: from mopidy.frontends.mpris import actor, objects -except OptionalDependencyError: - pass from tests import unittest -@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') +@unittest.skipUnless(dbus, 'dbus not found') class BackendEventsTest(unittest.TestCase): def setUp(self): # As a plain class, not an actor: diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index e1e13084..2353d831 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -1,19 +1,20 @@ from __future__ import unicode_literals -import sys - import mock import pykka -from mopidy import core, exceptions +try: + import dbus +except ImportError: + dbus = False + +from mopidy import core from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track -try: +if dbus: from mopidy.frontends.mpris import objects -except exceptions.OptionalDependencyError: - pass from tests import unittest @@ -22,7 +23,7 @@ PAUSED = PlaybackState.PAUSED STOPPED = PlaybackState.STOPPED -@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') +@unittest.skipUnless(dbus, 'dbus not found') class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py index 67f9e9be..06f7f45e 100644 --- a/tests/frontends/mpris/playlists_interface_test.py +++ b/tests/frontends/mpris/playlists_interface_test.py @@ -1,25 +1,27 @@ from __future__ import unicode_literals import datetime -import sys import mock import pykka -from mopidy import core, exceptions +try: + import dbus +except ImportError: + dbus = False + +from mopidy import core from mopidy.audio import PlaybackState from mopidy.backends import dummy from mopidy.models import Track -try: +if dbus: from mopidy.frontends.mpris import objects -except exceptions.OptionalDependencyError: - pass from tests import unittest -@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') +@unittest.skipUnless(dbus, 'dbus not found') class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 806b2162..85c72c97 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -1,22 +1,23 @@ from __future__ import unicode_literals -import sys - import mock import pykka -from mopidy import core, exceptions +try: + import dbus +except ImportError: + dbus = False + +from mopidy import core from mopidy.backends import dummy -try: +if dbus: from mopidy.frontends.mpris import objects -except exceptions.OptionalDependencyError: - pass from tests import unittest -@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') +@unittest.skipUnless(dbus, 'dbus not found') class RootInterfaceTest(unittest.TestCase): def setUp(self): config = { From f001142b7d231191224e5970aa2d7398134b5b29 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 11:29:47 +0200 Subject: [PATCH 270/403] docs: Update default.conf path --- docs/config.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.rst b/docs/config.rst index 07ebffc2..3b31f0dd 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -41,7 +41,7 @@ defaults are documented on the :ref:`extension pages `. Default core configuration ========================== -.. literalinclude:: ../mopidy/default.conf +.. literalinclude:: ../mopidy/config/default.conf :language: ini From 526bc837128cdbbf1950bf82bf8f2e5a06699dbf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 11:45:26 +0200 Subject: [PATCH 271/403] Make setup.py Python 3 compatible --- setup.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 2f4d98d6..58c043f8 100644 --- a/setup.py +++ b/setup.py @@ -28,10 +28,10 @@ setup( 'Pykka >= 1.1', ], extras_require={ - b'spotify': ['pyspotify >= 1.9, < 1.11'], - b'scrobbler': ['pylast >= 0.5.7'], - b'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], - b'external_mixers': ['pyserial'], + 'spotify': ['pyspotify >= 1.9, < 1.11'], + 'scrobbler': ['pylast >= 0.5.7'], + 'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'], + 'external_mixers': ['pyserial'], }, test_suite='nose.collector', tests_require=[ @@ -40,11 +40,11 @@ setup( 'unittest2', ], entry_points={ - b'console_scripts': [ + 'console_scripts': [ 'mopidy = mopidy.__main__:main', 'mopidy-scan = mopidy.scanner:main', ], - b'mopidy.ext': [ + 'mopidy.ext': [ 'http = mopidy.frontends.http:Extension [http]', 'scrobbler = mopidy.frontends.scrobbler:Extension [scrobbler]', 'local = mopidy.backends.local:Extension', From eeba15e4ccbcce2afd863ca363995b3cd3bcd4b9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 11:45:38 +0200 Subject: [PATCH 272/403] Remove tox.ini Testing across Python 2.6 and 2.7 with tox is no longer working on systems which doesn't ship both Python versions by default. I can install Python 2.6 on a newer Ubuntu system using the deadsnakes PPA, but dependencies installed from APT, like gobject and gstreamer are still only installed for Python 2.7, thus making tox useless. --- tox.ini | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 tox.ini diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 48676e46..00000000 --- a/tox.ini +++ /dev/null @@ -1,14 +0,0 @@ -[tox] -envlist = py26,py27,docs - -[testenv] -deps = nose -commands = nosetests [] -sitepackages = True - -[testenv:docs] -basepython = python -changedir = docs -deps = sphinx -commands = - sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html From 209c8d979a0f8d06b01476ccd221e57603acbd3f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 12:42:35 +0200 Subject: [PATCH 273/403] docs: Document API of Extension class --- docs/extensiondev.rst | 6 ++--- mopidy/ext.py | 59 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 7214aae8..ad8a3aff 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -239,10 +239,6 @@ and ``password``. return schema def validate_environment(self): - # This method can validate anything it wants about the environment - # the extension is running in. Examples include checking if all - # dependencies are installed. - try: import pysoundspot except ImportError as e: @@ -265,6 +261,8 @@ and ``password``. gst.element_register( SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) +For more detailed documentation, see the :ref:`ext-api`. + Example frontend ================ diff --git a/mopidy/ext.py b/mopidy/ext.py index aa8ec5e0..db75a16d 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -14,32 +14,79 @@ class Extension(object): """Base class for Mopidy extensions""" dist_name = None + """The extension's distribution name, as registered on PyPI + + Example: ``Mopidy-Soundspot`` + """ + ext_name = None + """The extension's short name, as used in setup.py and as config section + name + + Example: ``soundspot`` + """ + version = None + """The extension's version + + Should match the :attr:`__version__` attribute on the extension's main + Python module and the version registered on PyPI. + """ def get_default_config(self): - """TODO""" + """The extension's default config as a string + + :returns: string + """ raise NotImplementedError( 'Add at least a config section with "enabled = true"') def get_config_schema(self): - """TODO""" + """The extension's config validation schema + + :returns: :class:`mopidy.config.ExtensionConfigSchema` + """ return config_lib.ExtensionConfigSchema(self.ext_name) def validate_environment(self): - """TODO""" + """Checks if the extension can run in the current environment + + For example, this method can be used to check if all dependencies that + are needed are installed. + + :raises: :class:`mopidy.exceptions.ExtensionsError` + :returns: :class:`None` + """ pass def get_frontend_classes(self): - """TODO""" + """List of frontend actor classes to start + + :returns: list of :class:`pykka.Actor` subclasses + """ return [] def get_backend_classes(self): - """TODO""" + """List of backend actor classes to start + + :returns: list of :class:`mopidy.backends.base.Backend` subclasses + """ return [] def register_gstreamer_elements(self): - """TODO""" + """Hook for registering custom GStreamer elements + + Register custom GStreamer elements by implementing this method. + Example:: + + def register_gstreamer_elements(self): + from .mixer import SoundspotMixer + gobject.type_register(SoundspotMixer) + gst.element_register( + SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL) + + :returns: :class:`None` + """ pass From c481c2fe525eac693ba139003800ccd5561841f3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 13:12:03 +0200 Subject: [PATCH 274/403] docs: Update changelog --- docs/changelog.rst | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8e7fbdd1..1d1b26ac 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,12 +7,63 @@ This changelog is used to track all major changes to Mopidy. v0.14.0 (UNRELEASED) ==================== +The 0.14 release has a clear focus on two things: the new configuration system +and extension support. Mopidy's documentation has also been greatly extended +and improved. + **Dependencies** - setuptools or distribute is now required. We've introduced this dependency to use setuptools' entry points functionality to find installed Mopidy extensions. +**New configuration system** + +- Mopidy got a new configuration system based on ini-style files instead of a + Python file. This makes configuration easier for users, and also makes it + possible for Mopidy extensions to have their own config sections. + +- Many config values got slightly modified names. We've made a configuration + conversion tool to convert the old ``settings.py`` to a new ``mopidy.conf`` + file. This tool takes care of all the renamed config values as well. + + TODO: Describe how to use the configuration conversion tool. + +- A long wanted feature: You can now enable or disable specific frontends or + backends without having to redefine :attr:`~mopidy.settings.FRONTENDS` or + :attr:`~mopidy.settings.BACKENDS` in your config. Those config values are + gone completely. + +**Extension support** + +- Mopidy now supports extensions. This means that any developer now easily can + create a Mopidy extension to add new control interfaces or music backends. + This helps spread the maintenance burden across more developers, and also + makes it possible to create Mopidy features not necessarily wanted by the + core developers. If you're interested in creating an extension for Mopidy, + read up on :ref:`extensiondev`. + +- All of Mopidy's existing frontends and backends are now plugged into Mopidy + as extensions, but they are still distributed together with Mopidy and are + enabled by default. + +**Command line options** + +- The command option :option:`mopody --list-settings` is now named + :option:`mopidy --show-config`. + +- What configuration files to use can now be specified through the command + option :option:`mopidy --config`. + +- Configuration values can now be overridden through the command option + :option:`mopidy --option`. For example: ``mopidy --option + spotify/enabled=false``. + +- The GStreamer command line options, :option:`mopidy --gst-*` and + :option:`mopidy --help-gst` are no longer supported. To set GStreamer debug + flags, you can use environment variables, like :envvar:`GST_DEBUG`. Refer to + GStreamer's documentation for details. + **Spotify backend** - Add support for starred playlists, both your own and those owned by other @@ -26,6 +77,12 @@ v0.14.0 (UNRELEASED) multiple playlists with the same name, and identify them using an URI. (Fixes: :issue:`114`) +**MPRIS frontend** + +- The frontend is now disabled if the :envvar:`DISPLAY` environment variable is + unset. This prevents some harmless error messages to appear, and thus + to confuse users debugging other problems. + v0.13.0 (2013-03-31) ==================== From b42fa02195debe28ed3db6d6dfb83ff293b1bd6e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 13:14:15 +0200 Subject: [PATCH 275/403] docs: Fix typo --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1d1b26ac..894b5883 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -49,7 +49,7 @@ and improved. **Command line options** -- The command option :option:`mopody --list-settings` is now named +- The command option :option:`mopidy --list-settings` is now named :option:`mopidy --show-config`. - What configuration files to use can now be specified through the command From 6df42299b4c8be28620d4b61e81f374239d4d907 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 13:15:42 +0200 Subject: [PATCH 276/403] docs: Remove b'' usage in setup.py example --- docs/extensiondev.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index ad8a3aff..d705cd60 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -159,7 +159,7 @@ class that will connect the rest of the dots. 'pysoundspot', ], entry_points={ - b'mopidy.ext': [ + 'mopidy.ext': [ 'soundspot = mopidy_soundspot:Extension', ], }, From 2d0e5ac117d38bd9687765386639d82b02060cdb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 13:58:37 +0200 Subject: [PATCH 277/403] docs: Document config API --- docs/api/config.rst | 37 +++++++++++++++++ docs/api/index.rst | 1 + docs/conf.py | 1 + mopidy/config/__init__.py | 4 +- mopidy/config/schemas.py | 4 ++ mopidy/config/types.py | 82 ++++++++++++++++++++++++++----------- mopidy/config/validators.py | 22 +++++++--- 7 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 docs/api/config.rst diff --git a/docs/api/config.rst b/docs/api/config.rst new file mode 100644 index 00000000..240040eb --- /dev/null +++ b/docs/api/config.rst @@ -0,0 +1,37 @@ +.. _config-api: + +********** +Config API +********** + +.. automodule:: mopidy.config + :synopsis: Config API for config loading and validation + :members: + + +Config section schemas +====================== + +.. inheritance-diagram:: mopidy.config.schemas + +.. automodule:: mopidy.config.schemas + :synopsis: Config section validation schemas + :members: + + +Config value types +================== + +.. inheritance-diagram:: mopidy.config.types + +.. automodule:: mopidy.config.types + :synopsis: Config value validation types + :members: + + +Config value validators +======================= + +.. automodule:: mopidy.config.validators + :synopsis: Config value validators + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index cb7014f1..bb29890b 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -14,4 +14,5 @@ API reference audio frontends ext + config http diff --git a/docs/conf.py b/docs/conf.py index 1e9f9cb2..fa13c3bd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -81,6 +81,7 @@ on_rtd = os.environ.get('READTHEDOCS', None) == 'True' extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.extlinks', + 'sphinx.ext.inheritance_diagram', 'sphinx.ext.graphviz', 'sphinx.ext.viewcode', ] diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 6cf9a802..97a073f9 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -37,7 +37,7 @@ core_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema def read(config_file): - """Helper to load defaults in same way across core and extensions.""" + """Helper to load config defaults in same way across core and extensions""" with io.open(config_file, 'rb') as filehandle: return filehandle.read() @@ -118,7 +118,7 @@ def _validate(raw_config, schemas): def parse_override(override): - """Parse section/key=value override.""" + """Parse ``section/key=value`` command line overrides""" section, remainder = override.split('/', 1) key, value = remainder.split('=', 1) return (section.strip(), key.strip(), value.strip()) diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 19b5d665..9e117d67 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -59,6 +59,8 @@ class ConfigSchema(object): return self._schema[key] def format(self, values): + """Returns the schema as a config section with the given ``values`` + filled in""" # TODO: should the output be encoded utf-8 since we use that in # serialize for strings? lines = ['[%s]' % self.name] @@ -70,6 +72,8 @@ class ConfigSchema(object): return '\n'.join(lines) def convert(self, items): + """Validates the given ``items`` using the config schema and returns + clean values""" errors = {} values = {} diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 43878e87..35ec0a44 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -25,23 +25,32 @@ class ConfigValue(object): the code interacting with the config should simply skip None config values. """ - #: Collection of valid choices for converted value. Must be combined with - #: :function:`validate_choices` in :method:`validate` do any thing. choices = None + """ + Collection of valid choices for converted value. Must be combined with + :func:`~mopidy.config.validators.validate_choice` in :meth:`deserialize` + do any thing. + """ - #: Minimum of converted value. Must be combined with - #: :function:`validate_minimum` in :method:`validate` do any thing. minimum = None + """ + Minimum of converted value. Must be combined with + :func:`~mopidy.config.validators.validate_minimum` in :meth:`deserialize` + do any thing. + """ - #: Maximum of converted value. Must be combined with - #: :function:`validate_maximum` in :method:`validate` do any thing. maximum = None + """ + Maximum of converted value. Must be combined with + :func:`~mopidy.config.validators.validate_maximum` in :meth:`deserialize` + do any thing. + """ - #: Indicate if this field is required. optional = None + """Indicate if this field is required.""" - #: Indicate if we should mask the when printing for human consumption. secret = None + """Indicate if we should mask the when printing for human consumption.""" def __init__(self, **kwargs): self.choices = kwargs.get('choices') @@ -66,9 +75,9 @@ class ConfigValue(object): class String(ConfigValue): - """String values. + """String value - Supports: optional, choices and secret. + Supported kwargs: ``optional``, ``choices``, and ``secret``. """ def deserialize(self, value): value = value.strip() @@ -83,9 +92,9 @@ class String(ConfigValue): class Integer(ConfigValue): - """Integer values. + """Integer value - Supports: choices, minimum, maximum and secret. + Supported kwargs: ``choices``, ``minimum``, ``maximum``, and ``secret`` """ def deserialize(self, value): value = int(value) @@ -96,9 +105,15 @@ class Integer(ConfigValue): class Boolean(ConfigValue): - """Boolean values. + """Boolean value - Supports: secret. + Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as + :class:`True`. + + Accepts ``0``, ``no``, ``false``, and ``off`` with any casing as + :class:`False`. + + Supported kwargs: ``secret`` """ true_values = ('1', 'yes', 'true', 'on') false_values = ('0', 'no', 'false', 'off') @@ -119,9 +134,11 @@ class Boolean(ConfigValue): class List(ConfigValue): - """List values split by comma or newline. + """List value - Supports: optional and secret. + Supports elements split by commas or newlines. + + Supported kwargs: ``optional`` and ``secret`` """ def deserialize(self, value): validators.validate_required(value, not self.optional) @@ -136,9 +153,12 @@ class List(ConfigValue): class LogLevel(ConfigValue): - """Log level values. + """Log level value - Supports: secret. + Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug`` + with any casing. + + Supported kwargs: ``secret`` """ levels = { 'critical': logging.CRITICAL, @@ -157,9 +177,9 @@ class LogLevel(ConfigValue): class Hostname(ConfigValue): - """Hostname values. + """Hostname value - Supports: optional and secret. + Supported kwargs: ``optional`` and ``secret`` """ def deserialize(self, value): validators.validate_required(value, not self.optional) @@ -173,9 +193,11 @@ class Hostname(ConfigValue): class Port(Integer): - """Port values limited to 1-65535. + """Port value - Supports: choices and secret. + Expects integer in the range 1-65535 + + Supported kwargs: ``choices`` and ``secret`` """ # TODO: consider probing if port is free or not? def __init__(self, **kwargs): @@ -194,9 +216,21 @@ class ExpandedPath(bytes): class Path(ConfigValue): - """File system path that will be expanded. + """File system path - Supports: optional, choices and secret. + The following expansions of the path will be done: + + - ``~`` to the current user's home directory + + - ``$XDG_CACHE_DIR`` according to the XDG spec + + - ``$XDG_CONFIG_DIR`` according to the XDG spec + + - ``$XDG_DATA_DIR`` according to the XDG spec + + - ``$XDG_MUSIC_DIR`` according to the XDG spec + + Supported kwargs: ``optional``, ``choices``, and ``secret`` """ def deserialize(self, value): value = value.strip() diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py index 0fda118d..9e374ce5 100644 --- a/mopidy/config/validators.py +++ b/mopidy/config/validators.py @@ -4,26 +4,38 @@ from __future__ import unicode_literals def validate_required(value, required): - """Required validation, normally called in config value's validate() on the - raw string, _not_ the converted value.""" + """Validate that ``value`` is set if ``required`` + + Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize` on + the raw string, _not_ the converted value. + """ if required and not value.strip(): raise ValueError('must be set.') def validate_choice(value, choices): - """Choice validation, normally called in config value's validate().""" + """Validate that ``value`` is one of the ``choices`` + + Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize`. + """ if choices is not None and value not in choices: names = ', '.join(repr(c) for c in choices) raise ValueError('must be one of %s, not %s.' % (names, value)) def validate_minimum(value, minimum): - """Minimum validation, normally called in config value's validate().""" + """Validate that ``value`` is at least ``minimum`` + + Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize`. + """ if minimum is not None and value < minimum: raise ValueError('%r must be larger than %r.' % (value, minimum)) def validate_maximum(value, maximum): - """Maximum validation, normally called in config value's validate().""" + """Validate that ``value`` is at most ``maximum`` + + Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize`. + """ if maximum is not None and value > maximum: raise ValueError('%r must be smaller than %r.' % (value, maximum)) From 96d6badfd7f502a91256e5075830b6a3e3561225 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 14:05:19 +0200 Subject: [PATCH 278/403] docs: More reasons for running 'python setup.py develop' --- docs/contributing.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index b8d2a6f3..97bc46bf 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -49,10 +49,16 @@ repo. cd mopidy/ -2. To get a ``mopidy`` executable, run:: +2. To get a ``mopidy`` executable and register all bundled extensions with + setuptools, run:: python setup.py develop + It still works to run ``python mopidy`` directly on the ``mopidy`` Python + package directory, but if you have never run ``python setup.py develop`` the + extensions bundled with Mopidy isn't registered with setuptools, so Mopidy + will start without any frontends or backends, making it quite useless. + 3. Now you can run the Mopidy command, and it will run using the code in the Git repo:: From 61e0f941e0180f403a62b645acf0e1d26ab58f22 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 14:19:20 +0200 Subject: [PATCH 279/403] docs: Don't number lists explicitly in rST --- docs/contributing.rst | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 97bc46bf..b7d60d91 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -11,23 +11,24 @@ that’s great. Here are some tips to get you started. Getting started =============== -1. Make sure you have a `GitHub account `_. +#. Make sure you have a `GitHub account `_. -2. `Submit `_ a ticket for your +#. `Submit `_ a ticket for your issue, assuming one does not already exist. Clearly describe the issue including steps to reproduce when it is a bug. -3. Fork the repository on GitHub. +#. Fork the repository on GitHub. Making changes ============== -1. Clone your fork on GitHub to your computer. +#. Clone your fork on GitHub to your computer. -2. Install dependencies as described in the :ref:`installation` section. -3. Checkout a new branch (usually based on ``develop``) and name it accordingly +#. Install dependencies as described in the :ref:`installation` section. + +#. Checkout a new branch (usually based on ``develop``) and name it accordingly to what you intend to do. - Features get the prefix ``feature/`` @@ -45,11 +46,11 @@ Running Mopidy from Git If you want to hack on Mopidy, you should run Mopidy directly from the Git repo. -1. Go to the Git repo root:: +#. Go to the Git repo root:: cd mopidy/ -2. To get a ``mopidy`` executable and register all bundled extensions with +#. To get a ``mopidy`` executable and register all bundled extensions with setuptools, run:: python setup.py develop @@ -59,7 +60,7 @@ repo. extensions bundled with Mopidy isn't registered with setuptools, so Mopidy will start without any frontends or backends, making it quite useless. -3. Now you can run the Mopidy command, and it will run using the code +#. Now you can run the Mopidy command, and it will run using the code in the Git repo:: mopidy @@ -74,12 +75,12 @@ Testing Mopidy has quite good test coverage, and we would like all new code going into Mopidy to come with tests. -1. To run tests, you need a couple of dependencies. They can be installed using +#. To run tests, you need a couple of dependencies. They can be installed using ``pip``:: pip install -r requirements/tests.txt -2. Then, to run all tests, go to the project directory and run:: +#. Then, to run all tests, go to the project directory and run:: nosetests @@ -88,7 +89,7 @@ Mopidy to come with tests. nosetests --with-coverage tests/ -3. Check the code for errors and style issues using flake8:: +#. Check the code for errors and style issues using flake8:: flake8 . From 0ae356f5814008ee910625503ed05fdee9a28b01 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 14:19:29 +0200 Subject: [PATCH 280/403] docs: Add note about using virtualenv --- docs/contributing.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/contributing.rst b/docs/contributing.rst index b7d60d91..22df8ced 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -25,6 +25,13 @@ Making changes #. Clone your fork on GitHub to your computer. +#. Consider making a Python `virtualenv `_ for + Mopidy development to wall of Mopidy and it's dependencies from the rest of + your system. If you do so, create the virtualenv with the + ``--system-site-packages`` flag so that Mopidy can use globally installed + dependencies like GStreamer. If you don't use a virtualenv, you may need to + run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to + install stuff globally on your computer. #. Install dependencies as described in the :ref:`installation` section. From be58c380830dd9b703e6fd4e8279b793e78df8ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 14:23:12 +0200 Subject: [PATCH 281/403] docs: Fix link from Ext API to Config API --- mopidy/ext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index db75a16d..677086cd 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -44,7 +44,7 @@ class Extension(object): def get_config_schema(self): """The extension's config validation schema - :returns: :class:`mopidy.config.ExtensionConfigSchema` + :returns: :class:`~mopidy.config.schema.ExtensionConfigSchema` """ return config_lib.ExtensionConfigSchema(self.ext_name) From 4c08a5dfab33f6537bd585caca9e92379a654ce5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 14:27:28 +0200 Subject: [PATCH 282/403] docs: Include Backend class in API docs --- docs/api/backends.rst | 7 +++++++ mopidy/backends/base.py | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 32c04d37..45315b27 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -12,6 +12,13 @@ backend. If you are working on a frontend and need to access the backend, see the :ref:`core-api`. +Backend class +============= + +.. autoclass:: mopidy.backends.base.Backend + :members: + + Playback provider ================= diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 415ef2a5..c45eb771 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -11,17 +11,17 @@ class Backend(object): audio = None #: The library provider. An instance of - #: :class:`mopidy.backends.base.BaseLibraryProvider`, or :class:`None` if + #: :class:`~mopidy.backends.base.BaseLibraryProvider`, or :class:`None` if #: the backend doesn't provide a library. library = None #: The playback provider. An instance of - #: :class:`mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if + #: :class:`~mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if #: the backend doesn't provide playback. playback = None #: The playlists provider. An instance of - #: :class:`mopidy.backends.base.BasePlaylistsProvider`, or class:`None` if + #: :class:`~mopidy.backends.base.BasePlaylistsProvider`, or class:`None` if #: the backend doesn't provide playlists. playlists = None From 1debaf327631811569a2eb6976eb8cddd910cdee Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 14 Apr 2013 14:28:09 +0200 Subject: [PATCH 283/403] docs: Fix class links in Ext API docs --- mopidy/ext.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 677086cd..13df48c2 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -54,7 +54,7 @@ class Extension(object): For example, this method can be used to check if all dependencies that are needed are installed. - :raises: :class:`mopidy.exceptions.ExtensionsError` + :raises: :class:`~mopidy.exceptions.ExtensionError` :returns: :class:`None` """ pass @@ -69,7 +69,7 @@ class Extension(object): def get_backend_classes(self): """List of backend actor classes to start - :returns: list of :class:`mopidy.backends.base.Backend` subclasses + :returns: list of :class:`~mopidy.backends.base.Backend` subclasses """ return [] From bc41d55a88f420ebd3b3b4ce284ac773b33f5a8d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Apr 2013 15:35:00 +0200 Subject: [PATCH 284/403] config: Read configs in binary mode --- mopidy/config/__init__.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 97a073f9..98ab9055 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import codecs import ConfigParser as configparser import io import logging @@ -57,22 +56,21 @@ def _load(files, defaults, overrides): files = [path.expand_path(f) for f in files] sources = ['builtin-defaults'] + files + ['command-line'] logger.info('Loading config from: %s', ', '.join(sources)) - for default in defaults: # TODO: remove decoding - parser.readfp(io.StringIO(default.decode('utf-8'))) + + # TODO: simply return path to config file for defaults so we can load it + # all in the same way? + for default in defaults: + parser.readfp(io.BytesIO(default)) # Load config from a series of config files for filename in files: - # TODO: if this is the initial load of logging config we might not have - # a logger at this point, we might want to handle this better. try: - with codecs.open(filename, encoding='utf-8') as filehandle: + with io.open(filename, 'rb') as filehandle: parser.readfp(filehandle) except IOError: + # TODO: if this is the initial load of logging config we might not + # have a logger at this point, we might want to handle this better. logger.debug('Config file %s not found; skipping', filename) - continue - except UnicodeDecodeError: - logger.error('Config file %s is not UTF-8 encoded', filename) - sys.exit(1) raw_config = {} for section in parser.sections(): From 6b89051b5e86b9d1978fc543514e3685bc4350ac Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Apr 2013 16:14:41 +0200 Subject: [PATCH 285/403] config: Add encoding support to strings --- mopidy/config/types.py | 2 ++ tests/config/types_test.py | 41 ++++++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 35ec0a44..1f5c0946 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -80,6 +80,8 @@ class String(ConfigValue): Supported kwargs: ``optional``, ``choices``, and ``secret``. """ def deserialize(self, value): + if not isinstance(value, unicode): + value = value.decode('utf-8') value = value.strip() validators.validate_required(value, not self.optional) validators.validate_choice(value, self.choices) diff --git a/tests/config/types_test.py b/tests/config/types_test.py index 448283b1..9c12b951 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -1,3 +1,5 @@ +# encoding: utf-8 + from __future__ import unicode_literals import logging @@ -30,12 +32,12 @@ class ConfigValueTest(unittest.TestCase): def test_deserialize_passes_through(self): value = types.ConfigValue() - obj = object() - self.assertEqual(obj, value.deserialize(obj)) + sentinel = object() + self.assertEqual(sentinel, value.deserialize(sentinel)) def test_serialize_conversion_to_string(self): value = types.ConfigValue() - self.assertIsInstance(value.serialize(object()), basestring) + self.assertIsInstance(value.serialize(object()), bytes) def test_format_uses_serialize(self): value = types.ConfigValue() @@ -50,22 +52,41 @@ class ConfigValueTest(unittest.TestCase): class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): value = types.String() - self.assertEqual('foo', value.deserialize(' foo ')) + self.assertEqual('foo', value.deserialize(b' foo ')) + self.assertIsInstance(value.deserialize(b'foo'), unicode) + + def test_deserialize_decodes_utf8(self): + value = types.String() + result = value.deserialize('æøå'.encode('utf-8')) + self.assertEqual('æøå', result) + + def test_deserialize_does_not_double_encode_unicode(self): + value = types.String() + result = value.deserialize('æøå') + self.assertEqual('æøå', result) + + # TODO: add test_deserialize_decodes_string_escapes def test_deserialize_enforces_choices(self): value = types.String(choices=['foo', 'bar', 'baz']) - self.assertEqual('foo', value.deserialize('foo')) - self.assertRaises(ValueError, value.deserialize, 'foobar') + self.assertEqual('foo', value.deserialize(b'foo')) + self.assertRaises(ValueError, value.deserialize, b'foobar') def test_deserialize_enforces_required(self): value = types.String() - self.assertRaises(ValueError, value.deserialize, '') - self.assertRaises(ValueError, value.deserialize, ' ') + self.assertRaises(ValueError, value.deserialize, b'') + self.assertRaises(ValueError, value.deserialize, b' ') def test_deserialize_respects_optional(self): value = types.String(optional=True) - self.assertIsNone(value.deserialize('')) - self.assertIsNone(value.deserialize(' ')) + self.assertIsNone(value.deserialize(b'')) + self.assertIsNone(value.deserialize(b' ')) + + def test_deserialize_decode_failure(self): + value = types.String() + incorrectly_encoded_bytes = u'æøå'.encode('iso-8859-1') + self.assertRaises( + ValueError, value.deserialize, incorrectly_encoded_bytes) def test_serialize_string_escapes(self): value = types.String() From 9f18d50ab04d1c67beeca72be9b6b3afaefbb35a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Apr 2013 17:16:17 +0200 Subject: [PATCH 286/403] config: Fix escapes in string handling --- mopidy/config/types.py | 9 +++++++-- tests/config/types_test.py | 23 ++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 1f5c0946..1f57f62c 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -81,7 +81,8 @@ class String(ConfigValue): """ def deserialize(self, value): if not isinstance(value, unicode): - value = value.decode('utf-8') + # TODO: only unescape \n \t and \\? + value = value.decode('string-escape').decode('utf-8') value = value.strip() validators.validate_required(value, not self.optional) validators.validate_choice(value, self.choices) @@ -90,7 +91,11 @@ class String(ConfigValue): return value def serialize(self, value): - return value.encode('utf-8').encode('string-escape') + if isinstance(value, unicode): + for char in ('\\', '\n', '\t'): # TODO: more escapes? + value = value.replace(char, char.encode('unicode-escape')) + value = value.encode('utf-8') + return value class Integer(ConfigValue): diff --git a/tests/config/types_test.py b/tests/config/types_test.py index 9c12b951..d78db461 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -65,7 +65,10 @@ class StringTest(unittest.TestCase): result = value.deserialize('æøå') self.assertEqual('æøå', result) - # TODO: add test_deserialize_decodes_string_escapes + def test_deserialize_handles_escapes(self): + value = types.String(optional=True) + result = value.deserialize(b'a\\t\\nb') + self.assertEqual('a\t\nb', result) def test_deserialize_enforces_choices(self): value = types.String(choices=['foo', 'bar', 'baz']) @@ -88,9 +91,23 @@ class StringTest(unittest.TestCase): self.assertRaises( ValueError, value.deserialize, incorrectly_encoded_bytes) - def test_serialize_string_escapes(self): + def test_serialize_encodes_utf8(self): value = types.String() - self.assertEqual(r'\r\n\t', value.serialize('\r\n\t')) + result = value.serialize('æøå') + self.assertIsInstance(result, bytes) + self.assertEqual('æøå'.encode('utf-8'), result) + + def test_serialize_does_not_encode_bytes(self): + value = types.String() + result = value.serialize('æøå'.encode('utf-8')) + self.assertIsInstance(result, bytes) + self.assertEqual('æøå'.encode('utf-8'), result) + + def test_serialize_handles_escapes(self): + value = types.String() + result = value.serialize('a\n\tb') + self.assertIsInstance(result, bytes) + self.assertEqual(r'a\n\tb'.encode('utf-8'), result) def test_format_masks_secrets(self): value = types.String(secret=True) From 7ed9b8adab24a499353cb36f8459fa2afe6ee632 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Apr 2013 17:34:54 +0200 Subject: [PATCH 287/403] config: Extract encode and decode helpers from String --- mopidy/__main__.py | 4 ++++ mopidy/config/types.py | 26 +++++++++++++++++--------- tests/config/types_test.py | 2 ++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 8f98de5c..163f67ae 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -44,8 +44,12 @@ def main(): extensions = [] # Make sure it is defined before the finally block + # TODO: figure out a way to make the boilerplate in this file reusable in + # scanner and other places we need it. + try: create_file_structures() + # TODO: run raw logging config trough escape code etc, or just validate? logging_config = config_lib.load(config_files, config_overrides) log.setup_logging( logging_config, options.verbosity_level, options.save_debug_log) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 1f57f62c..3fed89b2 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -8,6 +8,21 @@ from mopidy.utils import path from mopidy.config import validators +def decode(value): + if isinstance(value, unicode): + return value + # TODO: only unescape \n \t and \\? + return value.decode('string-escape').decode('utf-8') + + +def encode(value): + if not isinstance(value, unicode): + return value + for char in ('\\', '\n', '\t'): # TODO: more escapes? + value = value.replace(char, char.encode('unicode-escape')) + return value.encode('utf-8') + + class ConfigValue(object): """Represents a config key's value and how to handle it. @@ -80,10 +95,7 @@ class String(ConfigValue): Supported kwargs: ``optional``, ``choices``, and ``secret``. """ def deserialize(self, value): - if not isinstance(value, unicode): - # TODO: only unescape \n \t and \\? - value = value.decode('string-escape').decode('utf-8') - value = value.strip() + value = decode(value).strip() validators.validate_required(value, not self.optional) validators.validate_choice(value, self.choices) if not value: @@ -91,11 +103,7 @@ class String(ConfigValue): return value def serialize(self, value): - if isinstance(value, unicode): - for char in ('\\', '\n', '\t'): # TODO: more escapes? - value = value.replace(char, char.encode('unicode-escape')) - value = value.encode('utf-8') - return value + return encode(value) class Integer(ConfigValue): diff --git a/tests/config/types_test.py b/tests/config/types_test.py index d78db461..fb23051c 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -10,6 +10,8 @@ from mopidy.config import types from tests import unittest +# TODO: DecodeTest and EncodeTest + class ConfigValueTest(unittest.TestCase): def test_init(self): From d5b8f2ab02715fde32deb1b36bc253d424f90831 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Apr 2013 17:50:16 +0200 Subject: [PATCH 288/403] config: Make List use proper encode/decode helpers --- mopidy/config/types.py | 11 ++++++----- tests/config/types_test.py | 40 ++++++++++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 3fed89b2..8a4a4a52 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -157,14 +157,15 @@ class List(ConfigValue): """ def deserialize(self, value): validators.validate_required(value, not self.optional) - if '\n' in value: - values = re.split(r'\s*\n\s*', value.strip()) + if b'\n' in value: + values = re.split(r'\s*\n\s*', value) else: - values = re.split(r'\s*,\s*', value.strip()) - return tuple([v for v in values if v]) + values = re.split(r'\s*,\s*', value) + values = (decode(v).strip() for v in values) + return tuple(v for v in values if v) def serialize(self, value): - return '\n ' + '\n '.join(v.encode('utf-8') for v in value) + return b'\n ' + b'\n '.join(encode(v) for v in value if v) class LogLevel(ConfigValue): diff --git a/tests/config/types_test.py b/tests/config/types_test.py index fb23051c..ddfc06a0 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -179,28 +179,56 @@ class BooleanTest(unittest.TestCase): class ListTest(unittest.TestCase): + # TODO: add test_deserialize_ignores_blank + # TODO: add test_serialize_ignores_blank + # TODO: add test_deserialize_handles_escapes + def test_deserialize_conversion_success(self): value = types.List() expected = ('foo', 'bar', 'baz') - self.assertEqual(expected, value.deserialize('foo, bar ,baz ')) + self.assertEqual(expected, value.deserialize(b'foo, bar ,baz ')) expected = ('foo,bar', 'bar', 'baz') - self.assertEqual(expected, value.deserialize(' foo,bar\nbar\nbaz')) + self.assertEqual(expected, value.deserialize(b' foo,bar\nbar\nbaz')) + + def test_deserialize_creates_tuples(self): + value = types.List(optional=True) + self.assertIsInstance(value.deserialize(b'foo,bar,baz'), tuple) + self.assertIsInstance(value.deserialize(b''), tuple) + + def test_deserialize_decodes_utf8(self): + value = types.List() + + result = value.deserialize('æ, ø, å'.encode('utf-8')) + self.assertEqual(('æ', 'ø', 'å'), result) + + result = value.deserialize('æ\nø\nå'.encode('utf-8')) + self.assertEqual(('æ', 'ø', 'å'), result) + + def test_deserialize_does_not_double_encode_unicode(self): + value = types.List() + + result = value.deserialize('æ, ø, å') + self.assertEqual(('æ', 'ø', 'å'), result) + + result = value.deserialize('æ\nø\nå') + self.assertEqual(('æ', 'ø', 'å'), result) def test_deserialize_enforces_required(self): value = types.List() - self.assertRaises(ValueError, value.deserialize, '') - self.assertRaises(ValueError, value.deserialize, ' ') + self.assertRaises(ValueError, value.deserialize, b'') + self.assertRaises(ValueError, value.deserialize, b' ') def test_deserialize_respects_optional(self): value = types.List(optional=True) - self.assertEqual(tuple(), value.deserialize('')) - self.assertEqual(tuple(), value.deserialize(' ')) + self.assertEqual(tuple(), value.deserialize(b'')) + self.assertEqual(tuple(), value.deserialize(b' ')) def test_serialize(self): value = types.List() result = value.serialize(('foo', 'bar', 'baz')) + self.assertIsInstance(result, bytes) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') From 805733a2aa601f93b5b57a01cacaad0a1d1b3bbd Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 14 Apr 2013 23:52:39 +0200 Subject: [PATCH 289/403] config: Make tests discoverable and fix broken ones --- mopidy/config/__init__.py | 2 +- tests/config/__init__.py | 0 tests/config/config_test.py | 6 +++--- tests/config/schemas_test.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 tests/config/__init__.py diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 98ab9055..0267aff5 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -107,7 +107,7 @@ def _validate(raw_config, schemas): items = raw_config[schema.name].items() config[schema.name] = schema.convert(items) except KeyError: - errors.append('%s: section not found.' % name) + errors.append('%s: section not found.' % schema.name) except exceptions.ConfigError as error: for key in error: errors.append('%s/%s: %s' % (schema.name, key, error[key])) diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/config/config_test.py b/tests/config/config_test.py index dc1b1c59..c90486c7 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -12,14 +12,14 @@ class LoadConfigTest(unittest.TestCase): self.assertEqual({}, config._load([], [], [])) def test_load_single_default(self): - default = '[foo]\nbar = baz' + default = b'[foo]\nbar = baz' expected = {'foo': {'bar': 'baz'}} result = config._load([], [default], []) self.assertEqual(expected, result) def test_load_defaults(self): - default1 = '[foo]\nbar = baz' - default2 = '[foo2]\n' + default1 = b'[foo]\nbar = baz' + default2 = b'[foo2]\n' expected = {'foo': {'bar': 'baz'}, 'foo2': {}} result = config._load([], [default1, default2], []) self.assertEqual(expected, result) diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py index 487136c2..6274f66c 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/schemas_test.py @@ -94,7 +94,7 @@ class ExtensionConfigSchemaTest(unittest.TestCase): class LogLevelConfigSchemaTest(unittest.TestCase): def test_conversion(self): - schema = schemas.LogLevelConfigSchema() + schema = schemas.LogLevelConfigSchema('test') result = schema.convert([('foo.bar', 'DEBUG'), ('baz', 'INFO')]) self.assertEqual(logging.DEBUG, result['foo.bar']) From 15875b092c34e30ca16c8cfae263763549dc3112 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 14 Apr 2013 22:51:54 +0100 Subject: [PATCH 290/403] Use helper function lookup_playlist_from_name() to resolve uniquified MPD playlist names to mopidy playlists in all MPD playlist handling commands. Also make playlist_uri_from_name map private. --- mopidy/frontends/mpd/dispatcher.py | 22 ++++++++++++++----- mopidy/frontends/mpd/protocol/music_db.py | 9 ++------ .../mpd/protocol/stored_playlists.py | 18 +++++++-------- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index e39c140b..4a1a4c04 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -236,7 +236,7 @@ class MpdContext(object): #: The subsytems that we want to be notified about in idle mode. subscriptions = None - playlist_uri_from_name = None + _playlist_uri_from_name = None playlist_name_from_uri = None def __init__(self, dispatcher, session=None, config=None, core=None): @@ -246,14 +246,14 @@ class MpdContext(object): self.core = core self.events = set() self.subscriptions = set() - self.playlist_uri_from_name = {} + self._playlist_uri_from_name = {} self.playlist_name_from_uri = {} self.refresh_playlists_mapping() def create_unique_name(self, playlist_name): name = playlist_name i = 2 - while name in self.playlist_uri_from_name: + while name in self._playlist_uri_from_name: name = '%s [%d]' % (playlist_name, i) i += 1 return name @@ -264,11 +264,23 @@ class MpdContext(object): MPD """ if self.core is not None: - self.playlist_uri_from_name.clear() + self._playlist_uri_from_name.clear() self.playlist_name_from_uri.clear() for playlist in self.core.playlists.playlists.get(): if not playlist.name: continue name = self.create_unique_name(playlist.name) - self.playlist_uri_from_name[name] = playlist.uri + self._playlist_uri_from_name[name] = playlist.uri self.playlist_name_from_uri[playlist.uri] = name + logger.info("Refreshed name mappings for %u playlists" % len(self.playlist_name_from_uri)) + + def lookup_playlist_from_name(self, name): + """ + Helper function to retrieve a playlist from it's unique MPD name. + """ + if len(self._playlist_uri_from_name) == 0: + self.refresh_playlists_mapping() + if name not in self._playlist_uri_from_name: + return None; + uri = self._playlist_uri_from_name[name] + return self.core.playlists.lookup(uri).get() diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 11def309..ff79c33a 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -381,13 +381,8 @@ def searchaddpl(context, playlist_name, mpd_query): return results = context.core.library.search(**query).get() - if len(context.playlist_uri_from_name) == 0: - context.refresh_playlists_mapping() - - if playlist_name in context.playlist_uri_from_name: - uri = context.playlist_uri_from_name[playlist_name] - playlist = context.core.playlists.lookup(uri).get() - else: + playlist = context.lookup_playlist_from_name(playlist_name) + if not playlist: playlist = context.core.playlists.create(playlist_name).get() tracks = list(playlist.tracks) + _get_tracks(results) playlist = playlist.copy(tracks=tracks) diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index 0c9bf050..bf9e44fb 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -23,10 +23,10 @@ def listplaylist(context, name): file: relative/path/to/file2.ogg file: relative/path/to/file3.mp3 """ - playlists = context.core.playlists.filter(name=name).get() - if not playlists: + playlist = context.lookup_playlist_from_name(name) + if not playlist: raise MpdNoExistError('No such playlist', command='listplaylist') - return ['file: %s' % t.uri for t in playlists[0].tracks] + return ['file: %s' % t.uri for t in playlist.tracks] @handle_request(r'^listplaylistinfo (?P\w+)$') @@ -44,10 +44,10 @@ def listplaylistinfo(context, name): Standard track listing, with fields: file, Time, Title, Date, Album, Artist, Track """ - playlists = context.core.playlists.filter(name=name).get() - if not playlists: + playlist = context.lookup_playlist_from_name(name) + if not playlist: raise MpdNoExistError('No such playlist', command='listplaylistinfo') - return playlist_to_mpd_format(playlists[0]) + return playlist_to_mpd_format(playlist) @handle_request(r'^listplaylists$') @@ -117,14 +117,14 @@ def load(context, name, start=None, end=None): - MPD 0.17.1 does not fail if the specified range is outside the playlist, in either or both ends. """ - playlists = context.core.playlists.filter(name=name).get() - if not playlists: + playlist = context.lookup_playlist_from_name(name) + if not playlist: raise MpdNoExistError('No such playlist', command='load') if start is not None: start = int(start) if end is not None: end = int(end) - context.core.tracklist.add(playlists[0].tracks[start:end]) + context.core.tracklist.add(playlist.tracks[start:end]) @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') From 3d5894fa3b8b7fbda8db4506d28d638d51c05bda Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 14 Apr 2013 23:03:00 +0100 Subject: [PATCH 291/403] Define a uri for all playlists used in the MPD stored_playlists tests. --- .../mpd/protocol/stored_playlists_test.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index d837b0fa..43096857 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -10,7 +10,8 @@ from tests.frontends.mpd import protocol class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='dummy:a')])] + Playlist(name='name', uri='dummy:name', + tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylist "name"') self.assertInResponse('file: dummy:a') @@ -18,7 +19,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylist_without_quotes(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='dummy:a')])] + Playlist(name='name', uri='dummy:name', + tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylist name') self.assertInResponse('file: dummy:a') @@ -30,7 +32,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='dummy:a')])] + Playlist(name='name', uri='dummy:name', + tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylistinfo "name"') self.assertInResponse('file: dummy:a') @@ -40,7 +43,8 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylistinfo_without_quotes(self): self.backend.playlists.playlists = [ - Playlist(name='name', tracks=[Track(uri='dummy:a')])] + Playlist(name='name', uri='dummy:name', + tracks=[Track(uri='dummy:a')])] self.sendRequest('listplaylistinfo name') self.assertInResponse('file: dummy:a') @@ -56,7 +60,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) self.backend.playlists.playlists = [ - Playlist(name='a', last_modified=last_modified)] + Playlist(name='a', uri='dummy:a', last_modified=last_modified)] self.sendRequest('listplaylists') self.assertInResponse('playlist: a') @@ -77,7 +81,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists_ignores_playlists_without_name(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) self.backend.playlists.playlists = [ - Playlist(name='', last_modified=last_modified)] + Playlist(name='', uri='dummy:', last_modified=last_modified)] self.sendRequest('listplaylists') self.assertNotInResponse('playlist: ') @@ -87,7 +91,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.playlists = [ - Playlist(name='A-list', tracks=[ + Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest('load "A-list"') @@ -105,7 +109,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.playlists = [ - Playlist(name='A-list', tracks=[ + Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest('load "A-list" "1:2"') @@ -121,7 +125,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.core.tracklist.add([Track(uri='a'), Track(uri='b')]) self.assertEqual(len(self.core.tracklist.tracks.get()), 2) self.backend.playlists.playlists = [ - Playlist(name='A-list', tracks=[ + Playlist(name='A-list', uri='dummy:A-list', tracks=[ Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest('load "A-list" "1:"') From 4826dc7cace647764c0123407f6d1787ade53141 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Apr 2013 00:07:07 +0200 Subject: [PATCH 292/403] config: Support lists in required validator --- mopidy/config/validators.py | 2 +- tests/config/validator_tests.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py index 9e374ce5..a0ca25d9 100644 --- a/mopidy/config/validators.py +++ b/mopidy/config/validators.py @@ -9,7 +9,7 @@ def validate_required(value, required): Normally called in :meth:`~mopidy.config.types.ConfigValue.deserialize` on the raw string, _not_ the converted value. """ - if required and not value.strip(): + if required and not value: raise ValueError('must be set.') diff --git a/tests/config/validator_tests.py b/tests/config/validator_tests.py index 57489b6b..63ef8ca6 100644 --- a/tests/config/validator_tests.py +++ b/tests/config/validator_tests.py @@ -57,11 +57,13 @@ class ValidateRequiredTest(unittest.TestCase): validators.validate_required('foo', False) validators.validate_required('', False) validators.validate_required(' ', False) + validators.validate_required([], False) def test_passes_when_required_and_set(self): validators.validate_required('foo', True) validators.validate_required(' foo ', True) + validators.validate_required([1], True) def test_blocks_when_required_and_emtpy(self): self.assertRaises(ValueError, validators.validate_required, '', True) - self.assertRaises(ValueError, validators.validate_required, ' ', True) + self.assertRaises(ValueError, validators.validate_required, [], True) From ee57eb58a32e54b337374ac4499ddcae6e0fde6f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Apr 2013 00:07:31 +0200 Subject: [PATCH 293/403] config: Strict config value init kwargs, also adds Secret --- mopidy/config/__init__.py | 2 +- mopidy/config/types.py | 153 +++++++++++++++++-------------------- tests/config/types_test.py | 57 +++++--------- 3 files changed, 90 insertions(+), 122 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 0267aff5..73b384c2 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -27,7 +27,7 @@ _audio_schema['output'] = String() _proxy_schema = ConfigSchema('proxy') _proxy_schema['hostname'] = Hostname(optional=True) _proxy_schema['username'] = String(optional=True) -_proxy_schema['password'] = String(optional=True, secret=True) +_proxy_schema['password'] = Secret(optional=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #_outputs_schema = config.AudioOutputConfigSchema() diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 8a4a4a52..6543b62e 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -23,6 +23,15 @@ def encode(value): return value.encode('utf-8') +class ExpandedPath(bytes): + def __new__(self, value): + expanded = path.expand_path(value) + return super(ExpandedPath, self).__new__(self, expanded) + + def __init__(self, value): + self.original = value + + class ConfigValue(object): """Represents a config key's value and how to handle it. @@ -40,64 +49,32 @@ class ConfigValue(object): the code interacting with the config should simply skip None config values. """ - choices = None - """ - Collection of valid choices for converted value. Must be combined with - :func:`~mopidy.config.validators.validate_choice` in :meth:`deserialize` - do any thing. - """ - - minimum = None - """ - Minimum of converted value. Must be combined with - :func:`~mopidy.config.validators.validate_minimum` in :meth:`deserialize` - do any thing. - """ - - maximum = None - """ - Maximum of converted value. Must be combined with - :func:`~mopidy.config.validators.validate_maximum` in :meth:`deserialize` - do any thing. - """ - - optional = None - """Indicate if this field is required.""" - - secret = None - """Indicate if we should mask the when printing for human consumption.""" - - def __init__(self, **kwargs): - self.choices = kwargs.get('choices') - self.minimum = kwargs.get('minimum') - self.maximum = kwargs.get('maximum') - self.optional = kwargs.get('optional') - self.secret = kwargs.get('secret') - def deserialize(self, value): """Cast raw string to appropriate type.""" return value def serialize(self, value): """Convert value back to string for saving.""" - return str(value) + return bytes(value) def format(self, value): """Format value for display.""" - if self.secret and value is not None: - return '********' return self.serialize(value) class String(ConfigValue): - """String value + """String value. - Supported kwargs: ``optional``, ``choices``, and ``secret``. + Is decoded as utf-8 and \\n \\t escapes should work and be preserved. """ + def __init__(self, optional=False, choices=None): + self._required = not optional + self._choices = choices + def deserialize(self, value): value = decode(value).strip() - validators.validate_required(value, not self.optional) - validators.validate_choice(value, self.choices) + validators.validate_required(value, self._required) + validators.validate_choice(value, self._choices) if not value: return None return value @@ -106,29 +83,46 @@ class String(ConfigValue): return encode(value) -class Integer(ConfigValue): - """Integer value +class Secret(ConfigValue): + """String value. - Supported kwargs: ``choices``, ``minimum``, ``maximum``, and ``secret`` + Masked when being displayed, and is not decoded. """ + def __init__(self, optional=False, choices=None): + self._required = not optional + + def deserialize(self, value): + validators.validate_required(value, self._required) + return value + + def format(self, value): + return '********' + + +class Integer(ConfigValue): + """Integer value.""" + + def __init__(self, minimum=None, maximum=None, choices=None): + self._minimum = minimum + self._maximum = maximum + self._choices = choices + def deserialize(self, value): value = int(value) - validators.validate_choice(value, self.choices) - validators.validate_minimum(value, self.minimum) - validators.validate_maximum(value, self.maximum) + validators.validate_choice(value, self._choices) + validators.validate_minimum(value, self._minimum) + validators.validate_maximum(value, self._maximum) return value class Boolean(ConfigValue): - """Boolean value + """Boolean value. Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as :class:`True`. Accepts ``0``, ``no``, ``false``, and ``off`` with any casing as :class:`False`. - - Supported kwargs: ``secret`` """ true_values = ('1', 'yes', 'true', 'on') false_values = ('0', 'no', 'false', 'off') @@ -138,7 +132,6 @@ class Boolean(ConfigValue): return True elif value.lower() in self.false_values: return False - raise ValueError('invalid value for boolean: %r' % value) def serialize(self, value): @@ -149,32 +142,33 @@ class Boolean(ConfigValue): class List(ConfigValue): - """List value + """List value. - Supports elements split by commas or newlines. - - Supported kwargs: ``optional`` and ``secret`` + Supports elements split by commas or newlines. Newlines take presedence and + empty list items will be filtered out. """ + def __init__(self, optional=False): + self._required = not optional + def deserialize(self, value): - validators.validate_required(value, not self.optional) if b'\n' in value: values = re.split(r'\s*\n\s*', value) else: values = re.split(r'\s*,\s*', value) values = (decode(v).strip() for v in values) - return tuple(v for v in values if v) + values = filter(None, values) + validators.validate_required(values, self._required) + return tuple(values) def serialize(self, value): return b'\n ' + b'\n '.join(encode(v) for v in value if v) class LogLevel(ConfigValue): - """Log level value + """Log level value. Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug`` with any casing. - - Supported kwargs: ``secret`` """ levels = { 'critical': logging.CRITICAL, @@ -193,12 +187,13 @@ class LogLevel(ConfigValue): class Hostname(ConfigValue): - """Hostname value + """Network hostname value.""" + + def __init__(self, optional=False): + self._required = not optional - Supported kwargs: ``optional`` and ``secret`` - """ def deserialize(self, value): - validators.validate_required(value, not self.optional) + validators.validate_required(value, self._required) if not value.strip(): return None try: @@ -209,26 +204,14 @@ class Hostname(ConfigValue): class Port(Integer): - """Port value + """Network port value. - Expects integer in the range 1-65535 - - Supported kwargs: ``choices`` and ``secret`` + Expects integer in the range 0-65535, zero tells the kernel to simply + allocate a port for us. """ # TODO: consider probing if port is free or not? - def __init__(self, **kwargs): - super(Port, self).__init__(**kwargs) - self.minimum = 1 - self.maximum = 2 ** 16 - 1 - - -class ExpandedPath(bytes): - def __new__(self, value): - expanded = path.expand_path(value) - return super(ExpandedPath, self).__new__(self, expanded) - - def __init__(self, value): - self.original = value + def __init__(self, choices=None): + super(Port, self).__init__(minimum=0, maximum=2**16-1, choices=choices) class Path(ConfigValue): @@ -248,10 +231,14 @@ class Path(ConfigValue): Supported kwargs: ``optional``, ``choices``, and ``secret`` """ + def __init__(self, optional=False, choices=None): + self._required = not optional + self._choices = choices + def deserialize(self, value): value = value.strip() - validators.validate_required(value, not self.optional) - validators.validate_choice(value, self.choices) + validators.validate_required(value, self._required) + validators.validate_choice(value, self._choices) if not value: return None return ExpandedPath(value) diff --git a/tests/config/types_test.py b/tests/config/types_test.py index ddfc06a0..fe616b77 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -14,24 +14,6 @@ from tests import unittest class ConfigValueTest(unittest.TestCase): - def test_init(self): - value = types.ConfigValue() - self.assertIsNone(value.choices) - self.assertIsNone(value.maximum) - self.assertIsNone(value.minimum) - self.assertIsNone(value.optional) - self.assertIsNone(value.secret) - - def test_init_with_params(self): - kwargs = {'choices': ['foo'], 'minimum': 0, 'maximum': 10, - 'secret': True, 'optional': True} - value = types.ConfigValue(**kwargs) - self.assertEqual(['foo'], value.choices) - self.assertEqual(0, value.minimum) - self.assertEqual(10, value.maximum) - self.assertEqual(True, value.optional) - self.assertEqual(True, value.secret) - def test_deserialize_passes_through(self): value = types.ConfigValue() sentinel = object() @@ -46,10 +28,6 @@ class ConfigValueTest(unittest.TestCase): obj = object() self.assertEqual(value.serialize(obj), value.format(obj)) - def test_format_masks_secrets(self): - value = types.ConfigValue(secret=True) - self.assertEqual('********', value.format(object())) - class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): @@ -80,7 +58,6 @@ class StringTest(unittest.TestCase): def test_deserialize_enforces_required(self): value = types.String() self.assertRaises(ValueError, value.deserialize, b'') - self.assertRaises(ValueError, value.deserialize, b' ') def test_deserialize_respects_optional(self): value = types.String(optional=True) @@ -111,8 +88,24 @@ class StringTest(unittest.TestCase): self.assertIsInstance(result, bytes) self.assertEqual(r'a\n\tb'.encode('utf-8'), result) - def test_format_masks_secrets(self): - value = types.String(secret=True) + +class SecretTest(unittest.TestCase): + def test_deserialize_passes_through(self): + value = types.Secret() + result = value.deserialize(b'foo') + self.assertIsInstance(result, bytes) + self.assertEqual(b'foo', result) + + def test_deserialize_enforces_required(self): + value = types.Secret() + self.assertRaises(ValueError, value.deserialize, b'') + + def test_serialize_conversion_to_string(self): + value = types.Secret() + self.assertIsInstance(value.serialize(object()), bytes) + + def test_format_masks_value(self): + value = types.Secret() self.assertEqual('********', value.format('s3cret')) @@ -145,10 +138,6 @@ class IntegerTest(unittest.TestCase): self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') - def test_format_masks_secrets(self): - value = types.Integer(secret=True) - self.assertEqual('********', value.format('1337')) - class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): @@ -173,10 +162,6 @@ class BooleanTest(unittest.TestCase): self.assertEqual('true', value.serialize(True)) self.assertEqual('false', value.serialize(False)) - def test_format_masks_secrets(self): - value = types.Boolean(secret=True) - self.assertEqual('********', value.format('true')) - class ListTest(unittest.TestCase): # TODO: add test_deserialize_ignores_blank @@ -218,12 +203,10 @@ class ListTest(unittest.TestCase): def test_deserialize_enforces_required(self): value = types.List() self.assertRaises(ValueError, value.deserialize, b'') - self.assertRaises(ValueError, value.deserialize, b' ') def test_deserialize_respects_optional(self): value = types.List(optional=True) self.assertEqual(tuple(), value.deserialize(b'')) - self.assertEqual(tuple(), value.deserialize(b' ')) def test_serialize(self): value = types.List() @@ -277,7 +260,6 @@ class HostnameTest(unittest.TestCase): def test_deserialize_enforces_required(self, getaddrinfo_mock): value = types.Hostname() self.assertRaises(ValueError, value.deserialize, '') - self.assertRaises(ValueError, value.deserialize, ' ') self.assertEqual(0, getaddrinfo_mock.call_count) @mock.patch('socket.getaddrinfo') @@ -291,6 +273,7 @@ class HostnameTest(unittest.TestCase): class PortTest(unittest.TestCase): def test_valid_ports(self): value = types.Port() + self.assertEqual(0, value.deserialize('0')) self.assertEqual(1, value.deserialize('1')) self.assertEqual(80, value.deserialize('80')) self.assertEqual(6600, value.deserialize('6600')) @@ -300,7 +283,6 @@ class PortTest(unittest.TestCase): value = types.Port() self.assertRaises(ValueError, value.deserialize, '65536') self.assertRaises(ValueError, value.deserialize, '100000') - self.assertRaises(ValueError, value.deserialize, '0') self.assertRaises(ValueError, value.deserialize, '-1') self.assertRaises(ValueError, value.deserialize, '') @@ -334,7 +316,6 @@ class PathTest(unittest.TestCase): def test_deserialize_enforces_required(self): value = types.Path() self.assertRaises(ValueError, value.deserialize, '') - self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): value = types.Path(optional=True) From ad25a60ba53e6b1540f2cbb5b232e660e3a789b7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Apr 2013 00:08:23 +0200 Subject: [PATCH 294/403] config: Update extensions with respect to config changes --- mopidy/backends/spotify/__init__.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/scrobbler/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 55d0e3d7..3cee609a 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -19,7 +19,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['username'] = config.String() - schema['password'] = config.String(secret=True) + schema['password'] = config.Secret() schema['bitrate'] = config.Integer(choices=(96, 160, 320)) schema['timeout'] = config.Integer(minimum=0) schema['cache_dir'] = config.Path() diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 04c00c2b..276be450 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -20,7 +20,7 @@ class Extension(ext.Extension): schema = super(Extension, self).get_config_schema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() - schema['password'] = config.String(optional=True, secret=True) + schema['password'] = config.Secret(optional=True) schema['max_connections'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1) return schema diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index dcc6f195..c08bc15e 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -19,7 +19,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['username'] = config.String() - schema['password'] = config.String(secret=True) + schema['password'] = config.Secret() return schema def validate_environment(self): From 0ede12f0506b197dbe0993faed8deb2b5788f98c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Apr 2013 00:08:48 +0200 Subject: [PATCH 295/403] ext: Update and fix extensiondev docs with respect to config --- docs/extensiondev.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index d705cd60..106ae219 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -230,12 +230,12 @@ and ``password``. version = __version__ def get_default_config(self): - return default_config + return bytes(default_config) def get_config_schema(self): schema = super(Extension, self).get_config_schema() - schema['username'] = config.String(required=True) - schema['password'] = config.String(required=True, secret=True) + schema['username'] = config.String() + schema['password'] = config.Secret() return schema def validate_environment(self): From cecc0f621f95467ec8bea6e0feaacffda9873777 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 14 Apr 2013 23:18:53 +0100 Subject: [PATCH 296/403] Extra MPD stored_playlist tests for duplicate names --- .../mpd/protocol/stored_playlists_test.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 43096857..8199be2b 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -30,6 +30,15 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.sendRequest('listplaylist "name"') self.assertEqualResponse('ACK [50@0] {listplaylist} No such playlist') + def test_listplaylist_duplicate(self): + playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) + playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) + self.backend.playlists.playlists = [playlist1, playlist2] + + self.sendRequest('listplaylist "a [2]"') + self.assertInResponse('file: c') + self.assertInResponse('OK') + def test_listplaylistinfo(self): self.backend.playlists.playlists = [ Playlist(name='name', uri='dummy:name', @@ -57,6 +66,17 @@ class PlaylistsHandlerTest(protocol.BaseTestCase): self.assertEqualResponse( 'ACK [50@0] {listplaylistinfo} No such playlist') + def test_listplaylistinfo_duplicate(self): + playlist1 = Playlist(name='a', uri='dummy:a1', tracks=[Track(uri='b')]) + playlist2 = Playlist(name='a', uri='dummy:a2', tracks=[Track(uri='c')]) + self.backend.playlists.playlists = [playlist1, playlist2] + + self.sendRequest('listplaylistinfo "a [2]"') + self.assertInResponse('file: c') + self.assertInResponse('Track: 0') + self.assertNotInResponse('Pos: 0') + self.assertInResponse('OK') + def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) self.backend.playlists.playlists = [ From a7035063c28f8606ca3403b4cb2c373114b4f918 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Apr 2013 11:58:54 +0200 Subject: [PATCH 297/403] docs: Note on debugging GStreamer --- docs/troubleshooting.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 4a634a99..e590c86c 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -56,3 +56,21 @@ system is deadlocking. If you have the ``pkill`` command installed, you can use this by simply running:: pkill -SIGUSR1 mopidy + + +Debugging GStreamer +=================== + +If you really want to dig in and debug GStreamer behaviour, then check out the +`Debugging section +`_ +of GStreamer's documentation for your options. Note that Mopidy does not +support the GStreamer command line options, like ``--gst-debug-level=3``, but +setting GStreamer environment variables, like :envvar:`GST_DEBUG`, works with +Mopidy. For example, to run Mopidy with debug logging and GStreamer logging at +level 3, you can run:: + + GST_DEBUG=3 mopidy -v + +This will produce a lot of output, but given some GStreamer knowledge this is +very useful for debugging GStreamer pipeline issues. From fb0810bf9edff591341972d20edebbc141196f8a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 15 Apr 2013 17:43:14 +0200 Subject: [PATCH 298/403] config: Specify we want bytes for default configs --- mopidy/ext.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 13df48c2..9cea1bd5 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -34,9 +34,9 @@ class Extension(object): """ def get_default_config(self): - """The extension's default config as a string + """The extension's default config as a bytestring - :returns: string + :returns: bytes """ raise NotImplementedError( 'Add at least a config section with "enabled = true"') From 90067a2128d15c6803479d219567e81912c73d6d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Apr 2013 18:28:12 +0200 Subject: [PATCH 299/403] docs: Mark command line options with :option: --- docs/config.rst | 8 ++++---- docs/devtools.rst | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 3b31f0dd..44e65875 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -28,9 +28,9 @@ the config values you want to change. If you want to keep the default for a config value, you **should not** add it to ``~/.config/mopidy/mopidy.conf``. To see what's the effective configuration for your Mopidy installation, you can -run ``mopidy --show-config``. It will print your full effective config with -passwords masked out so that you safely can share the output with others for -debugging. +run :option:`mopidy --show-config`. It will print your full effective config +with passwords masked out so that you safely can share the output with others +for debugging. You can find a description of all config values belonging to Mopidy's core below, together with their default values. In addition, all :ref:`extensions @@ -95,7 +95,7 @@ Core configuration values .. confval:: logging/debug_file The file to dump debug log data to when Mopidy is run with the - :option:`--save-debug-log` option. + :option:`mopidy --save-debug-log` option. .. confval:: logging.levels/* diff --git a/docs/devtools.rst b/docs/devtools.rst index 7b5b2f81..bc066cd0 100644 --- a/docs/devtools.rst +++ b/docs/devtools.rst @@ -40,7 +40,8 @@ sends all requests to both, returning the primary response to the client and then printing any diff in the two responses. Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time -of writing. See ``--help`` for available options. Sample session:: +of writing. See :option:`tools/debug-proxy.py --help` for available options. +Sample session:: [127.0.0.1]:59714 listallinfo From ee915fbf7a1ecdbbb49cb193d331e8df3bcdd62c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Apr 2013 18:51:58 +0200 Subject: [PATCH 300/403] docs: Add command line options --- docs/running.rst | 59 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/running.rst b/docs/running.rst index b81dbef7..58f7d591 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -25,7 +25,48 @@ mopidy command .. program:: mopidy -TODO: Document all command line options +.. cmdoption:: --version + + Show Mopidy's version number and exit. + +.. cmdoption:: -h, --help + + Show help message and exit. + +.. cmdoption:: -q, --quite + + Show less output: warning level and higher. + +.. cmdoption:: -v, --verbose + + Show more output: debug level and higher. + +.. cmdoption:: --save-debug-log + + Save debug log to the file specified in the :confval:`logging/debug_file` + config value, typically ``./mopidy.conf``. + +.. cmdoption:: --show-config + + Show the current effective config. All configuration sources are merged + together to show the effective document. Secret values like passwords are + masked out. Config for disabled extensions are not included. + +.. cmdoption:: --list-deps + + List dependencies and their versions. + +.. cmdoption:: --config + + Specify config file to use. To use multiple config files, separate them + with colon. The later files override the earlier ones if there's a + conflict. + +.. cmdoption:: -o