Repository: springload/draftjs_exporter

Issue:
Use Django-style loading via path for custom backing engines
> It would be handy to allow custom engines to be loaded via path (and not only by passing the class) with `django.utils.module_loading.import_string`.

So we don't tie this project to Django, here is the [`import_string` source code](https://docs.djangoproject.com/en/1.11/_modules/django/utils/module_loading/#import_string) which only relies on `six`.

Comments:

Patch:
diff --git a/benchmark.py b/benchmark.py
index 40ae663..0d0855e 100644
--- a/benchmark.py
+++ b/benchmark.py
@@ -69,7 +69,7 @@ config = {
             'component': br,
         }
     ],
-    'engine': 'string',
+    'engine': DOM.STRING,
 }
 
 exporter = HTML(config)
@@ -78,8 +78,7 @@ content_states = get_content_sample()
 
 BENCHMARK_RUNS = int(os.environ.get('BENCHMARK_RUNS', 1))
 
-print('Exporting %s ContentStates %s times' %
-      (len(content_states), BENCHMARK_RUNS))
+print('Exporting %s ContentStates %s times' % (len(content_states), BENCHMARK_RUNS))
 
 pr = cProfile.Profile()
 pr.enable()
diff --git a/draftjs_exporter/dom.py b/draftjs_exporter/dom.py
index e3b9424..967253b 100644
--- a/draftjs_exporter/dom.py
+++ b/draftjs_exporter/dom.py
@@ -1,10 +1,8 @@
 from __future__ import absolute_import, unicode_literals
 
-import inspect
 import re
 
-from draftjs_exporter.engines.html5lib import DOM_HTML5LIB
-from draftjs_exporter.error import ConfigException
+from draftjs_exporter.utils.module_loading import import_string
 
 # Python 2/3 unicode compatibility hack.
 # See http://stackoverflow.com/questions/6812031/how-to-make-unicode-string-with-python3
@@ -24,11 +22,11 @@ class DOM(object):
     Component building API, abstracting the DOM implementation.
     """
 
-    HTML5LIB = 'html5lib'
-    LXML = 'lxml'
-    STRING = 'string'
+    HTML5LIB = 'draftjs_exporter.engines.html5lib.DOM_HTML5LIB'
+    LXML = 'draftjs_exporter.engines.lxml.DOM_LXML'
+    STRING = 'draftjs_exporter.engines.string.DOMString'
 
-    dom = DOM_HTML5LIB
+    dom = None
 
     @staticmethod
     def camel_to_dash(camel_cased_str):
@@ -37,23 +35,11 @@ class DOM(object):
         return dashed_case_str.replace('--', '-')
 
     @classmethod
-    def use(cls, engine=DOM_HTML5LIB):
+    def use(cls, engine):
         """
         Choose which DOM implementation to use.
         """
-        if engine:
-            if inspect.isclass(engine):
-                cls.dom = engine
-            elif engine.lower() == cls.HTML5LIB:
-                cls.dom = DOM_HTML5LIB
-            elif engine.lower() == cls.LXML:
-                from draftjs_exporter.engines.lxml import DOM_LXML
-                cls.dom = DOM_LXML
-            elif engine.lower() == cls.STRING:
-                from draftjs_exporter.engines.string import DOMString
-                cls.dom = DOMString
-            else:
-                raise ConfigException('Invalid DOM engine.')
+        cls.dom = import_string(engine)
 
     @classmethod
     def create_element(cls, type_=None, props=None, *children):
diff --git a/draftjs_exporter/html.py b/draftjs_exporter/html.py
index 0f0a9bd..2eb5264 100644
--- a/draftjs_exporter/html.py
+++ b/draftjs_exporter/html.py
@@ -25,7 +25,7 @@ class HTML:
         self.block_map = config.get('block_map', BLOCK_MAP)
         self.style_map = config.get('style_map', STYLE_MAP)
 
-        DOM.use(config.get('engine'))
+        DOM.use(config.get('engine', DOM.HTML5LIB))
 
     def render(self, content_state=None):
         """
diff --git a/draftjs_exporter/utils/__init__.py b/draftjs_exporter/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/draftjs_exporter/utils/module_loading.py b/draftjs_exporter/utils/module_loading.py
new file mode 100644
index 0000000..5207cab
--- /dev/null
+++ b/draftjs_exporter/utils/module_loading.py
@@ -0,0 +1,26 @@
+from __future__ import absolute_import, unicode_literals
+
+from importlib import import_module
+
+
+def import_string(dotted_path):
+    """
+    Import a dotted module path and return the attribute/class designated by the
+    last name in the path. Raise ImportError if the import failed.
+
+    Taken from Django:
+    https://github.com/django/django/blob/f6bd00131e687aedf2719ad31e84b097562ca5f2/django/utils/module_loading.py#L7-L24
+    """
+    try:
+        module_path, class_name = dotted_path.rsplit('.', 1)
+    except ValueError:
+        raise ImportError("%s doesn't look like a module path" % dotted_path)
+
+    module = import_module(module_path)
+
+    try:
+        return getattr(module, class_name)
+    except AttributeError:
+        raise ImportError('Module "%s" does not define a "%s" attribute/class' % (
+            module_path, class_name)
+        )
diff --git a/example.py b/example.py
index bd4f657..6fa442b 100644
--- a/example.py
+++ b/example.py
@@ -186,7 +186,7 @@ if __name__ == '__main__':
             },
         ],
         # Specify which DOM backing engine to use.
-        'engine': 'string',
+        'engine': DOM.STRING,
     }
 
     exporter = HTML(config)
@@ -623,11 +623,9 @@ if __name__ == '__main__':
     pr.disable()
     p = Stats(pr)
 
-
     def prettify(markup):
         return re.sub(r'</?(body|html|head)>', '', BeautifulSoup(markup, 'html5lib').prettify()).strip()
 
-
     pretty = prettify(markup)
 
     # Display in console.


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..21a0fb6 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,6 @@
+from __future__ import absolute_import, unicode_literals
+
+from draftjs_exporter.dom import DOM
+
+# Initialise a default engine for the test suites.
+DOM.use(DOM.HTML5LIB)
diff --git a/tests/test_dom.py b/tests/test_dom.py
index e4b7617..48eed62 100644
--- a/tests/test_dom.py
+++ b/tests/test_dom.py
@@ -5,7 +5,6 @@ import unittest
 from draftjs_exporter.dom import DOM
 from draftjs_exporter.engines.html5lib import DOM_HTML5LIB
 from draftjs_exporter.engines.lxml import DOM_LXML
-from draftjs_exporter.error import ConfigException
 from tests.test_entities import icon
 
 
@@ -19,7 +18,7 @@ class TestDOM(unittest.TestCase):
         DOM.use(DOM.HTML5LIB)
 
     def test_use_custom(self):
-        DOM.use(DOMTestImpl)
+        DOM.use('tests.test_dom.DOMTestImpl')
         self.assertEqual(DOM.dom, DOMTestImpl)
 
     def test_use_lxml(self):
@@ -31,7 +30,7 @@ class TestDOM(unittest.TestCase):
         self.assertEqual(DOM.dom, DOM_HTML5LIB)
 
     def test_use_invalid(self):
-        with self.assertRaises(ConfigException):
+        with self.assertRaises(ImportError):
             DOM.use('test')
 
 
diff --git a/tests/test_exports.py b/tests/test_exports.py
index dfd50a6..7cd4060 100644
--- a/tests/test_exports.py
+++ b/tests/test_exports.py
@@ -50,26 +50,20 @@ class TestExportsMeta(type):
     See http://stackoverflow.com/a/20870875/1798491
     """
     def __new__(mcs, name, bases, tests):
-        def gen_test(export, engine):
+        def gen_test(content, html):
             def test(self):
-                self.maxDiff = None
-                DOM.use(engine)
-                self.assertEqual(exporter.render(export['content_state']), export['output'][engine])
+                self.assertEqual(exporter.render(content), html)
 
             return test
 
-        if name == 'TestExportsHTML5LIB':
-            engine = 'html5lib'
-        elif name == 'TestExportsLXML':
-            engine = 'lxml'
-        elif name == 'TestExportsSTRING':
-            engine = 'string'
+        engine = name.replace('TestExports', '').lower()
 
         for export in fixtures:
             test_label = export['label'].lower().replace(' ', '_')
             test_name = 'test_export_{0}_{1}'.format(engine, test_label)
-            tests[test_name] = gen_test(export, engine)
-
+            content = export['content_state']
+            html = export['output'][engine]
+            tests[test_name] = gen_test(content, html)
 
         return type.__new__(mcs, name, bases, tests)
 
@@ -77,6 +71,7 @@ class TestExportsMeta(type):
 class TestExportsHTML5LIB(six.with_metaclass(TestExportsMeta, unittest.TestCase)):
     @classmethod
     def setUpClass(cls):
+        DOM.use(DOM.HTML5LIB)
         cls.pr = cProfile.Profile()
         cls.pr.enable()
         print('\nhtml5lib')
@@ -86,13 +81,11 @@ class TestExportsHTML5LIB(six.with_metaclass(TestExportsMeta, unittest.TestCase)
         cls.pr.disable()
         Stats(cls.pr).strip_dirs().sort_stats('cumulative').print_stats(0)
 
-    def test_init_html5lib(self):
-        self.assertIsInstance(exporter, HTML)
-
 
 class TestExportsLXML(six.with_metaclass(TestExportsMeta, unittest.TestCase)):
     @classmethod
     def setUpClass(cls):
+        DOM.use(DOM.LXML)
         cls.pr = cProfile.Profile()
         cls.pr.enable()
         print('\nlxml')
@@ -102,13 +95,11 @@ class TestExportsLXML(six.with_metaclass(TestExportsMeta, unittest.TestCase)):
         cls.pr.disable()
         Stats(cls.pr).strip_dirs().sort_stats('cumulative').print_stats(0)
 
-    def test_init(self):
-        self.assertIsInstance(exporter, HTML)
-
 
 class TestExportsSTRING(six.with_metaclass(TestExportsMeta, unittest.TestCase)):
     @classmethod
     def setUpClass(cls):
+        DOM.use(DOM.STRING)
         cls.pr = cProfile.Profile()
         cls.pr.enable()
         print('\nstring')
@@ -118,9 +109,6 @@ class TestExportsSTRING(six.with_metaclass(TestExportsMeta, unittest.TestCase)):
         cls.pr.disable()
         Stats(cls.pr).strip_dirs().sort_stats('cumulative').print_stats(0)
 
-    def test_init(self):
-        self.assertIsInstance(exporter, HTML)
-
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_output.py b/tests/test_output.py
index fac2bfb..28726d7 100644
--- a/tests/test_output.py
+++ b/tests/test_output.py
@@ -5,6 +5,7 @@ import unittest
 
 from draftjs_exporter.constants import BLOCK_TYPES, ENTITY_TYPES, INLINE_STYLES
 from draftjs_exporter.defaults import BLOCK_MAP
+from draftjs_exporter.dom import DOM
 from draftjs_exporter.entity_state import EntityException
 from draftjs_exporter.html import HTML
 from tests.test_composite_decorators import BR_DECORATOR, HASHTAG_DECORATOR, LINKIFY_DECORATOR
@@ -42,7 +43,7 @@ config = {
             'props': {'style': {'textDecoration': 'underline'}},
         },
     },
-    'engine': 'html5lib'
+    'engine': DOM.HTML5LIB
 }
 
 
@@ -50,6 +51,7 @@ class TestOutput(unittest.TestCase):
     """
     Test cases related to specific features of the HTML builder.
     """
+
     def setUp(self):
         self.maxDiff = None
         self.exporter = HTML(config)
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils/test_module_loading.py b/tests/utils/test_module_loading.py
new file mode 100644
index 0000000..d6735d8
--- /dev/null
+++ b/tests/utils/test_module_loading.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, unicode_literals
+
+import unittest
+
+from draftjs_exporter.utils.module_loading import import_string
+
+
+class TestModuleLoading(unittest.TestCase):
+    """
+    Taken from Django:
+    https://github.com/django/django/blob/f6bd00131e687aedf2719ad31e84b097562ca5f2/tests/utils_tests/test_module_loading.py#L122-L132
+    """
+    def test_import_string_success(self):
+        cls = import_string('draftjs_exporter.utils.module_loading.import_string')
+        self.assertEqual(cls, import_string)
+
+    def test_import_string_invalid(self):
+        with self.assertRaises(ImportError):
+            import_string('no_dots_in_path')
+
+    def test_import_string_unexistent(self):
+        with self.assertRaises(ImportError):
+            import_string('tests.utils.unexistent')