Repository: DakaraProject/dakara-feeder

Issue:
Set custom Song class with file path
The parameters could be:

```yaml
custom_song_module = mymodule
# or
custom_song_file = path/to/mymodule.py

custom_song_class = Song
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87259a7..0f0d16a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@
 
 - Name of the command changed from `dakara-feed` to `dakara-feeder`.
 - Feed command for songs changed from `dakara-feed` to `dakara-feeder feed songs`.
+- Custom song class can be indicated in configuration file with a file name: `custom_song_class: path/to/file.py::MySong`.
 
 ## 1.7.0 - 2021-06-20
 
diff --git a/README.md b/README.md
index 9f23573..c2b1178 100644
--- a/README.md
+++ b/README.md
@@ -107,17 +107,12 @@ class Song(BaseSong):
         return [{"name": self.video_path.stem.split(" - ")[1]}]
 ```
 
-The file must be in the same directory you are calling `dakara-feeder`, or in any directory reachable by Python.
-To register your customized `Song` class, you simply enable it in the config file:
+To register your customized `Song` class, you simply indicate it in the configuration file.
+You can either indicate an importable module or a file:
 
 ```yaml
-# Custom song class to use
-# If you want to extract additional data when parsing files (video, subtitle or
-# other), you can write your own Song class, derived from
-# `dakara_feeder.song.BaseSong`. See documentation of BaseSong for more details
-# on how to proceed.
-# Indicate the module name of the class to use.
-# Default is BaseSong, which is pretty basic.
+custom_song_class: path/to/my_song.py::Song
+# or
 custom_song_class: my_song.Song
 ```
 
diff --git a/src/dakara_feeder/customization.py b/src/dakara_feeder/customization.py
index 79393ec..1e61a7c 100644
--- a/src/dakara_feeder/customization.py
+++ b/src/dakara_feeder/customization.py
@@ -3,24 +3,28 @@
 import importlib
 import inspect
 import logging
-import os
 import sys
 from contextlib import contextmanager
 
 from dakara_base.exceptions import DakaraError
+from path import Path
 
 from dakara_feeder.song import BaseSong
 
 logger = logging.getLogger(__name__)
 
 
-def get_custom_song(class_module_name):
+def get_custom_song(string, default_class_name="Song"):
     """Get the customized Song class.
 
+    See also:
+        `split_path_object` for the syntax of `string`.
+
     Args:
-        class_module_name (str): Python name of the custom `Song` class to
-            import. It can designate a class in a module, or a module. In this
-            case, the guessed class name is "Song".
+        string (str): Either name of a module or path to a file, containing a
+            subclass of `dakara_feeder.song.BaseSong`. If the name of the class
+            is not provided, then fallback to `default_class_name`.
+        default_class_name (str): Default song class name to use.
 
     Returns:
         class: Customized Song class.
@@ -29,55 +33,112 @@ def get_custom_song(class_module_name):
         InvalidObjectTypeError: If the designated object is not a class, or if
             it does not inherit from Song.
     """
-    custom_object = import_custom_object(class_module_name)
+    # import a file or a module
+    file_path, module_name = split_path_object(string)
+    if file_path is not None:
+        custom_module = import_from_file(file_path)
+        class_name = module_name or default_class_name
+        separator = "::"
+
+    elif module_name is not None:
+        custom_module = import_from_module(module_name)
+        class_name = default_class_name
+        separator = "."
+
+    else:
+        raise InvalidSongClassConfigError("No song class file or module provided")
 
-    # if the custom object is a module, get default "Song" class
-    if inspect.ismodule(custom_object):
+    # if the custom object is a module, get the song class
+    if inspect.ismodule(custom_module):
         try:
-            custom_class = getattr(custom_object, "Song")
+            custom_class = getattr(custom_module, class_name)
 
         except AttributeError as error:
             raise InvalidObjectModuleNameError(
-                "Cannot find default class Song in module {}".format(
-                    custom_object.__name__
+                "Cannot find class {} in module {}".format(
+                    class_name, custom_module.__name__
                 )
             ) from error
 
-        # append ".Song" to make the class module name equal to the actual
-        # module name of the class
-        class_module_name += ".Song"
+        # append song class name to the string
+        string += separator + class_name
 
     else:
-        custom_class = custom_object
+        custom_class = custom_module
 
     # check the custom class is a class
     if not inspect.isclass(custom_class):
-        raise InvalidObjectTypeError("{} is not a class".format(class_module_name))
+        raise InvalidObjectTypeError("{} is not a class".format(string))
 
-    # check the custom Song inherits from default Song
+    # check the custom Song inherits from BaseSong
     if not issubclass(custom_class, BaseSong):
-        raise InvalidObjectTypeError(
-            "{} is not a Song subclass".format(class_module_name)
-        )
+        raise InvalidObjectTypeError("{} is not a BaseSong subclass".format(string))
 
-    logger.info("Using custom Song class: {}".format(class_module_name))
+    logger.info("Using custom Song class: {}".format(string))
 
     return custom_class
 
 
+def split_path_object(string):
+    """Split string containnig a path and an object.
+
+    The string is in the form `path::object`, each part is optional. The
+    function splits the path from the object:
+
+    >>> split_path_object("path/to/file.py::my.object")
+    ... Path("path/to/file.py"), "my.object"
+    >>> split_path_object("path/to/file.py")
+    ... Path("path/to/file.py"), None
+    >>> split_path_object("my.object")
+    ... None, "my.object"
+
+    Args:
+        string (str): Path and object separated by `::`.
+
+    Returns:
+        tuple: Contains:
+
+        1. path.Path: The path of the file;
+        2. str: The object name.
+    """
+    # if nothing is given
+    if not string:
+        return None, None
+
+    # both path and object given
+    if "::" in string:
+        path, obj = string.split("::")
+
+        # if no object is provided
+        if not obj:
+            obj = None
+
+        return Path(path), obj
+
+    # only path given
+    if ".py" in string:
+        return Path(string), None
+
+    # assume only object given
+    return None, string
+
+
 @contextmanager
-def current_dir_in_path():
-    """Temporarily add current directory to top of the Python path.
+def dir_in_path(directory):
+    """Temporarily add a directory to the top of the Python path.
 
     Python path is reseted to its initial state when leaving the context
     manager.
+
+    Args:
+        directory (path.Path): Directory to add.
     """
     # get copy of system path
     old_path_list = sys.path.copy()
 
     try:
-        # prepend the current dir in path
-        sys.path.insert(0, os.getcwd())
+        # prepend the directory in path
+        sys.path.insert(0, str(directory.expand()))
         yield None
 
     finally:
@@ -85,7 +146,32 @@ def current_dir_in_path():
         sys.path = old_path_list
 
 
-def import_custom_object(object_module_name):
+def import_from_file(file_path):
+    """Import a custom file as a module.
+
+    Args:
+        file_path (path.Path): Path to a Python file to import.
+
+    Returns:
+        type: Imported module.
+
+    Raises:
+        InvalidObjectModuleNameError: If the given module name cannot be found.
+    """
+    directory = file_path.parent
+    module_name = file_path.stem
+
+    try:
+        with dir_in_path(directory):
+            return importlib.import_module(module_name)
+
+    except ImportError as error:
+        raise InvalidObjectModuleNameError(
+            "No module found from file {}".format(file_path)
+        ) from error
+
+
+def import_from_module(object_module_name):
     """Import a custom object from a given module name.
 
     Args:
@@ -106,8 +192,7 @@ def import_custom_object(object_module_name):
 
         # try to import the module
         try:
-            with current_dir_in_path():
-                module = importlib.import_module(module_name)
+            module = importlib.import_module(module_name)
 
         # if not continue with parent module
         except ImportError:
@@ -141,3 +226,7 @@ class InvalidObjectModuleNameError(DakaraError, ImportError, AttributeError):
 
 class InvalidObjectTypeError(DakaraError):
     """Error when the object type is unexpected."""
+
+
+class InvalidSongClassConfigError(DakaraError):
+    """Error when the config to get the Song file is wrong."""
diff --git a/src/dakara_feeder/resources/feeder.yaml b/src/dakara_feeder/resources/feeder.yaml
index 4238a9d..8b2d6f7 100644
--- a/src/dakara_feeder/resources/feeder.yaml
+++ b/src/dakara_feeder/resources/feeder.yaml
@@ -34,7 +34,15 @@ kara_folder: /path/to/folder
 # other), you can write your own Song class, derived from
 # `dakara_feeder.song.BaseSong`. See documentation of BaseSong for more details
 # on how to proceed.
-# Indicate the module name of the class to use.
+# You can indicate it in various ways.
+# Module only, in that case the default class name Song will be used:
+# custom_song_class: my.module
+# Module and a custom class name:
+# custom_song_class: my.module.MySong
+# File only, in that case the default class name Song will be used:
+# custom_song_class: path/to/file.py
+# File and a custom class name:
+# custom_song_class: path/to/file.py::MySong
 # Default is BaseSong, which is pretty basic.
 # custom_song_class: module_name.Song
 


Test patch:
diff --git a/tests/unit/test_customization.py b/tests/unit/test_customization.py
index ea2cf74..d9c944b 100644
--- a/tests/unit/test_customization.py
+++ b/tests/unit/test_customization.py
@@ -1,23 +1,26 @@
 import inspect
+import re
+from importlib import resources
 from types import ModuleType
 from unittest import TestCase
 from unittest.mock import patch
 
+from path import Path
+
 from dakara_feeder import customization
 from dakara_feeder.song import BaseSong
 
 
+@patch("dakara_feeder.customization.import_from_file", autospec=True)
+@patch("dakara_feeder.customization.import_from_module", autospec=True)
 class GetCustomSongTestCase(TestCase):
-    """Test the getter of customized song class."""
-
-    @patch("dakara_feeder.customization.import_custom_object", autospec=True)
-    def test_get_from_class(self, mocked_import_custom_object):
+    def test_get_from_class(self, mocked_import_from_module, mocked_import_from_file):
         """Test to get a valid song class from class module name."""
         # mock the returned class
         class MySong(BaseSong):
             pass
 
-        mocked_import_custom_object.return_value = MySong
+        mocked_import_from_module.return_value = MySong
 
         # call the method
         with self.assertLogs("dakara_feeder.customization") as logger:
@@ -27,7 +30,8 @@ class GetCustomSongTestCase(TestCase):
         self.assertIs(CustomSong, MySong)
 
         # assert the call
-        mocked_import_custom_object.assert_called_with("song.MySong")
+        mocked_import_from_module.assert_called_with("song.MySong")
+        mocked_import_from_file.assert_not_called()
 
         # assert logs
         self.assertListEqual(
@@ -35,8 +39,7 @@ class GetCustomSongTestCase(TestCase):
             ["INFO:dakara_feeder.customization:Using custom Song class: song.MySong"],
         )
 
-    @patch("dakara_feeder.customization.import_custom_object", autospec=True)
-    def test_get_from_module(self, mocked_import_custom_object):
+    def test_get_from_module(self, mocked_import_from_module, mocked_import_from_file):
         """Test to get a valid default song class from module name."""
         # mock the returned class
         my_module = ModuleType("my_module")
@@ -45,7 +48,7 @@ class GetCustomSongTestCase(TestCase):
             pass
 
         my_module.Song = Song
-        mocked_import_custom_object.return_value = my_module
+        mocked_import_from_module.return_value = my_module
 
         # call the method
         CustomSong = customization.get_custom_song("song")
@@ -53,25 +56,75 @@ class GetCustomSongTestCase(TestCase):
         # assert the result
         self.assertIs(CustomSong, Song)
 
-    @patch("dakara_feeder.customization.import_custom_object", autospec=True)
-    def test_get_from_module_error_no_default(self, mocked_import_custom_object):
+        # assert the call
+        mocked_import_from_file.assert_not_called()
+
+    def test_get_from_file_module(
+        self, mocked_import_from_module, mocked_import_from_file
+    ):
+        """Test to get a valid song class from file."""
+        # mock the returned class
+        my_module = ModuleType("my_module")
+
+        class Song(BaseSong):
+            pass
+
+        my_module.song = Song
+        mocked_import_from_file.return_value = my_module
+
+        # call the method
+        CustomSong = customization.get_custom_song("file.py::song")
+
+        # assert the result
+        self.assertIs(CustomSong, Song)
+
+        # assert the call
+        mocked_import_from_module.assert_not_called()
+
+    def test_get_from_file(self, mocked_import_from_module, mocked_import_from_file):
+        """Test to get a valid default song class from file."""
+        # mock the returned class
+        my_module = ModuleType("my_module")
+
+        class Song(BaseSong):
+            pass
+
+        my_module.Song = Song
+        mocked_import_from_file.return_value = my_module
+
+        # call the method
+        CustomSong = customization.get_custom_song("file.py")
+
+        # assert the result
+        self.assertIs(CustomSong, Song)
+
+        # assert the call
+        mocked_import_from_module.assert_not_called()
+
+    def test_get_from_module_error_no_default(
+        self, mocked_import_from_module, mocked_import_from_file
+    ):
         """Test to get a default song class that does not exist."""
         # mock the returned class
         my_module = ModuleType("my_module")
-        mocked_import_custom_object.return_value = my_module
+        mocked_import_from_module.return_value = my_module
 
         # call the method
         with self.assertRaisesRegex(
             customization.InvalidObjectModuleNameError,
-            "Cannot find default class Song in module my_module",
+            "Cannot find class Song in module my_module",
         ):
             customization.get_custom_song("song")
 
-    @patch("dakara_feeder.customization.import_custom_object", autospec=True)
-    def test_get_from_class_error_not_class(self, mocked_import_custom_object):
+        # assert the call
+        mocked_import_from_file.assert_not_called()
+
+    def test_get_from_class_error_not_class(
+        self, mocked_import_from_module, mocked_import_from_file
+    ):
         """Test to get a song class that is not a class."""
         # mock the returned class
-        mocked_import_custom_object.return_value = "str"
+        mocked_import_from_module.return_value = "str"
 
         # call the method
         with self.assertRaisesRegex(
@@ -79,13 +132,17 @@ class GetCustomSongTestCase(TestCase):
         ):
             customization.get_custom_song("song.MySong")
 
-    @patch("dakara_feeder.customization.import_custom_object", autospec=True)
-    def test_get_from_module_error_not_class(self, mocked_import_custom_object):
+        # assert the call
+        mocked_import_from_file.assert_not_called()
+
+    def test_get_from_module_error_not_class(
+        self, mocked_import_from_module, mocked_import_from_file
+    ):
         """Test to get a default song class that is not a class."""
         # mock the returned class
         my_module = ModuleType("my_module")
         my_module.Song = 42
-        mocked_import_custom_object.return_value = my_module
+        mocked_import_from_module.return_value = my_module
 
         # call the method
         with self.assertRaisesRegex(
@@ -93,82 +150,148 @@ class GetCustomSongTestCase(TestCase):
         ):
             customization.get_custom_song("song")
 
-    @patch("dakara_feeder.customization.import_custom_object", autospec=True)
-    def test_get_from_class_error_not_song_subclass(self, mocked_import_custom_object):
+        # assert the call
+        mocked_import_from_file.assert_not_called()
+
+    def test_get_from_class_error_not_song_subclass(
+        self, mocked_import_from_module, mocked_import_from_file
+    ):
         """Test to get a song class that is not a subclass of Song."""
         # mock the returned class
         class MySong:
             pass
 
-        mocked_import_custom_object.return_value = MySong
+        mocked_import_from_module.return_value = MySong
 
         # call the method
         with self.assertRaisesRegex(
-            customization.InvalidObjectTypeError, "song.MySong is not a Song subclass"
+            customization.InvalidObjectTypeError,
+            "song.MySong is not a BaseSong subclass",
         ):
             customization.get_custom_song("song.MySong")
 
+        # assert the call
+        mocked_import_from_file.assert_not_called()
+
+    def test_get_from_nothing_error(
+        self, mocked_import_from_module, mocked_import_from_file
+    ):
+        """Test to get a song class from nothing."""
+        # call the method
+        with self.assertRaises(customization.InvalidSongClassConfigError):
+            customization.get_custom_song("")
+
+        # assert the call
+        mocked_import_from_file.assert_not_called()
+
+
+class SplitPathObjectTestCase(TestCase):
+    def test_split_path_and_module(self):
+        self.assertTupleEqual(
+            customization.split_path_object(
+                str(Path("path") / "to" / "file.py") + "::object.CustomSong"
+            ),
+            (Path("path") / "to" / "file.py", "object.CustomSong"),
+        )
+
+    def test_split_path(self):
+        self.assertTupleEqual(
+            customization.split_path_object(str(Path("path") / "to" / "file.py")),
+            (Path("path") / "to" / "file.py", None),
+        )
+        self.assertTupleEqual(
+            customization.split_path_object(
+                str(Path("path") / "to" / "file.py") + "::"
+            ),
+            (Path("path") / "to" / "file.py", None),
+        )
+
+    def test_split_module(self):
+        self.assertTupleEqual(
+            customization.split_path_object("object.CustomSong"),
+            (None, "object.CustomSong"),
+        )
+
+    def test_split_nothing(self):
+        self.assertTupleEqual(
+            customization.split_path_object(""),
+            (None, None),
+        )
 
-class CurrentDirInPathTestCase(TestCase):
-    """Test the helper to put current directory in Python path."""
 
-    @patch("dakara_feeder.customization.os.getcwd")
+class DirInPathTestCase(TestCase):
     @patch("dakara_feeder.customization.sys")
-    def test_normal(self, mocked_sys, mocked_getcwd):
+    def test_normal(self, mocked_sys):
         """Test the helper with no alteration of the path."""
         # setup mocks
-        mocked_getcwd.return_value = "current/directory"
         mocked_sys.path = ["some/directory"]
 
         # use the context manager
-        with customization.current_dir_in_path():
+        with customization.dir_in_path(Path("path") / "to" / "directory"):
             self.assertListEqual(
-                mocked_sys.path, ["current/directory", "some/directory"]
+                mocked_sys.path,
+                [str(Path("path") / "to" / "directory"), "some/directory"],
             )
 
         # assert the mock
         self.assertListEqual(mocked_sys.path, ["some/directory"])
 
-    @patch("dakara_feeder.customization.os.getcwd")
     @patch("dakara_feeder.customization.sys")
-    def test_alteration(self, mocked_sys, mocked_getcwd):
+    def test_alteration(self, mocked_sys):
         """Test the helper with alteration of the path."""
         # setup mocks
-        mocked_getcwd.return_value = "current/directory"
         mocked_sys.path = []
 
         # use the context manager
-        with customization.current_dir_in_path():
+        with customization.dir_in_path(Path("path") / "to" / "directory"):
             mocked_sys.path.append("other/directory")
             self.assertListEqual(
-                mocked_sys.path, ["current/directory", "other/directory"]
+                mocked_sys.path,
+                [str(Path("path") / "to" / "directory"), "other/directory"],
             )
 
         # assert the mock
         self.assertListEqual(mocked_sys.path, [])
 
 
-class ImportCustomObjectTestCase(TestCase):
-    """Test the importer for custom objects."""
+class ImportFromFileTestCase(TestCase):
+    def test_import_file(self):
+        """Test to import a file."""
+        with resources.path("tests.resources", "my_module.py") as file:
+            module = customization.import_from_file(Path(file))
+
+        self.assertTrue(inspect.ismodule(module))
+
+    def test_import_error(self):
+        """Test to import a non existing file."""
+        with self.assertRaisesRegex(
+            customization.InvalidObjectModuleNameError,
+            re.escape(
+                "No module found from file " + Path("path") / "to" / "nowhere.py"
+            ),
+        ):
+            customization.import_from_file(Path("path") / "to" / "nowhere.py")
+
 
+class ImportFromModuleTestCase(TestCase):
     def test_import_module(self):
         """Test to import a module."""
-        module = customization.import_custom_object("tests.resources.my_module")
+        module = customization.import_from_module("tests.resources.my_module")
         self.assertTrue(inspect.ismodule(module))
 
     def test_import_parent_module(self):
         """Test to import a parent module."""
-        module = customization.import_custom_object("tests.resources")
+        module = customization.import_from_module("tests.resources")
         self.assertTrue(inspect.ismodule(module))
 
     def test_import_class(self):
         """Test to import a class."""
-        klass = customization.import_custom_object("tests.resources.my_module.MyClass")
+        klass = customization.import_from_module("tests.resources.my_module.MyClass")
         self.assertTrue(inspect.isclass(klass))
 
     def test_import_static_attribute(self):
         """Test to import a class static attribute."""
-        attribute = customization.import_custom_object(
+        attribute = customization.import_from_module(
             "tests.resources.my_module.MyClass.my_attribute"
         )
         self.assertEqual(attribute, 42)
@@ -179,7 +302,7 @@ class ImportCustomObjectTestCase(TestCase):
             customization.InvalidObjectModuleNameError,
             "No module notexistingmodule found",
         ):
-            customization.import_custom_object("notexistingmodule.sub")
+            customization.import_from_module("notexistingmodule.sub")
 
     def test_error_module(self):
         """Test to import a non-existing module."""
@@ -187,7 +310,7 @@ class ImportCustomObjectTestCase(TestCase):
             customization.InvalidObjectModuleNameError,
             "No module or object notexistingmodule found in tests.resources",
         ):
-            customization.import_custom_object("tests.resources.notexistingmodule")
+            customization.import_from_module("tests.resources.notexistingmodule")
 
     def test_error_object(self):
         """Test to import a non-existing object."""
@@ -196,7 +319,7 @@ class ImportCustomObjectTestCase(TestCase):
             "No module or object notexistingattribute found in "
             "tests.resources.my_module",
         ):
-            customization.import_custom_object(
+            customization.import_from_module(
                 "tests.resources.my_module.notexistingattribute"
             )
 
@@ -207,6 +330,6 @@ class ImportCustomObjectTestCase(TestCase):
             "No module or object notexistingattribute found in "
             "tests.resources.my_module.MyClass",
         ):
-            customization.import_custom_object(
+            customization.import_from_module(
                 "tests.resources.my_module.MyClass.notexistingattribute"
             )