Repository: ultrabug/py3status

Issue:
Add possibility to register modules via entry points
As an additional way of adding modules to py3status we can use entry points to find modules that are installed in the same host interpreter like documented [here](https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins).

Advantages of this approach: 
* a py3status module can be a real package installable via pip and bring its own dependencies. 
* the modules does not need to be contained in one file and does not need to be copied into one of the configuration directories and be free of dependencies
* modules can be developed independently of py3status with a different life cycle from py3status itself


Discussion:

Resolving patch:
diff --git a/py3status/core.py b/py3status/core.py
--- a/py3status/core.py
+++ b/py3status/core.py
@@ -2,6 +2,7 @@
 from __future__ import division
 
 import os
+import pkg_resources
 import sys
 import time
 
@@ -39,6 +40,9 @@
     "py3status",
 ]
 
+ENTRY_POINT_NAME = "py3status"
+ENTRY_POINT_KEY = "entry_point"
+
 
 class Runner(Thread):
     """
@@ -421,6 +425,19 @@ def gevent_monkey_patch_report(self):
         return False
 
     def get_user_modules(self):
+        """Mapping from module name to relevant objects.
+
+        There are two ways of discovery and storage:
+        `include_paths` (no installation): include_path, f_name
+        `entry_point` (from installed package): "entry_point", <Py3Status class>
+
+        Modules of the same name from entry points shadow all other modules.
+        """
+        user_modules = self._get_path_based_modules()
+        user_modules.update(self._get_entry_point_based_modules())
+        return user_modules
+
+    def _get_path_based_modules(self):
         """
         Search configured include directories for user provided modules.
 
@@ -438,12 +455,35 @@ def get_user_modules(self):
                 if module_name in user_modules:
                     pass
                 user_modules[module_name] = (include_path, f_name)
+                self.log(
+                    "available module from {}: {}".format(include_path, module_name)
+                )
         return user_modules
 
+    def _get_entry_point_based_modules(self):
+        classes_from_entry_points = {}
+        for entry_point in pkg_resources.iter_entry_points(ENTRY_POINT_NAME):
+            try:
+                module = entry_point.load()
+            except Exception as err:
+                self.log("entry_point '{}' error: {}".format(entry_point, err))
+                continue
+            klass = getattr(module, Module.EXPECTED_CLASS, None)
+            if klass:
+                module_name = entry_point.module_name.split(".")[-1]
+                classes_from_entry_points[module_name] = (ENTRY_POINT_KEY, klass)
+                self.log(
+                    "available module from {}: {}".format(ENTRY_POINT_KEY, module_name)
+                )
+        return classes_from_entry_points
+
     def get_user_configured_modules(self):
         """
         Get a dict of all available and configured py3status modules
         in the user's i3status.conf.
+
+        As we already have a convenient way of loading the module, we'll
+        populate the map with the Py3Status class right away
         """
         user_modules = {}
         if not self.py3_modules:
@@ -451,8 +491,8 @@ def get_user_configured_modules(self):
         for module_name, module_info in self.get_user_modules().items():
             for module in self.py3_modules:
                 if module_name == module.split(" ")[0]:
-                    include_path, f_name = module_info
-                    user_modules[module_name] = (include_path, f_name)
+                    source, item = module_info
+                    user_modules[module_name] = (source, item)
         return user_modules
 
     def load_modules(self, modules_list, user_modules):
@@ -460,9 +500,10 @@ def load_modules(self, modules_list, user_modules):
         Load the given modules from the list (contains instance name) with
         respect to the user provided modules dict.
 
-        modules_list: ['weather_yahoo paris', 'net_rate']
+        modules_list: ['weather_yahoo paris', 'pewpew', 'net_rate']
         user_modules: {
-            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
+            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py'),
+            'pewpew': (entry_point', <Py3Status class>),
         }
         """
         for module in modules_list:
@@ -470,7 +511,13 @@ def load_modules(self, modules_list, user_modules):
             if module in self.modules:
                 continue
             try:
-                my_m = Module(module, user_modules, self)
+                instance = None
+                payload = user_modules.get(module)
+                if payload:
+                    kind, Klass = payload
+                    if kind == ENTRY_POINT_KEY:
+                        instance = Klass()
+                my_m = Module(module, user_modules, self, instance=instance)
                 # only handle modules with available methods
                 if my_m.methods:
                     self.modules[module] = my_m
diff --git a/py3status/module.py b/py3status/module.py
--- a/py3status/module.py
+++ b/py3status/module.py
@@ -28,6 +28,7 @@ class Module:
 
     PARAMS_NEW = "new"
     PARAMS_LEGACY = "legacy"
+    EXPECTED_CLASS = "Py3status"
 
     def __init__(self, module, user_modules, py3_wrapper, instance=None):
         """
@@ -101,17 +102,16 @@ def __init__(self, module, user_modules, py3_wrapper, instance=None):
     def __repr__(self):
         return "<Module {}>".format(self.module_full_name)
 
-    @staticmethod
-    def load_from_file(filepath):
+    @classmethod
+    def load_from_file(cls, filepath):
         """
         Return user-written class object from given path.
         """
         class_inst = None
-        expected_class = "Py3status"
         module_name, file_ext = os.path.splitext(os.path.split(filepath)[-1])
         if file_ext.lower() == ".py":
             py_mod = imp.load_source(module_name, filepath)
-            if hasattr(py_mod, expected_class):
+            if hasattr(py_mod, cls.EXPECTED_CLASS):
                 class_inst = py_mod.Py3status()
         return class_inst
 


Test patch:
diff --git a/tests/test_user_modules.py b/tests/test_user_modules.py
new file mode 100644
--- /dev/null
+++ b/tests/test_user_modules.py
@@ -0,0 +1,55 @@
+import argparse
+import os
+
+import pkg_resources
+import pytest
+
+import py3status
+from py3status.core import Py3statusWrapper, ENTRY_POINT_KEY
+
+
+@pytest.fixture(name="status_wrapper")
+def make_status_wrapper():
+    args = argparse.Namespace()
+    status_wrapper = Py3statusWrapper(args)
+    return status_wrapper
+
+
+def test__get_path_based_modules(status_wrapper):
+    """Use the list of inbuilt modules as reference to check against."""
+    included_modules_path = os.path.join(os.path.dirname(py3status.__file__), "modules")
+    status_wrapper.options.__dict__["include_paths"] = [included_modules_path]
+    assert os.path.exists(included_modules_path)
+    expected_keys = [
+        n[:-3] for n in os.listdir(included_modules_path) if n.endswith(".py")
+    ]
+    modules = status_wrapper._get_path_based_modules()
+    assert sorted(modules.keys()) == sorted(expected_keys)
+
+
+def test__get_entry_point_based_modules(status_wrapper, monkeypatch):
+    def return_fake_entry_points(*_):
+        class FakePy3status(object):
+            Py3status = "I am a fake class"
+
+            def __init__(self, name):
+                self.name = name
+                self.module_name = "module_name_" + self.name
+
+            @staticmethod
+            def load():
+                from py3status.modules import air_quality
+
+                return air_quality
+
+        return [FakePy3status("spam"), FakePy3status("eggs")]
+
+    monkeypatch.setattr(pkg_resources, "iter_entry_points", return_fake_entry_points)
+
+    user_modules = status_wrapper._get_entry_point_based_modules()
+    assert len(user_modules) == 2
+    for name, info in user_modules.items():
+        assert any(n in name for n in ["spam", "eggs"])
+        kind, klass = info
+        assert kind == ENTRY_POINT_KEY
+        assert klass.__name__ == "Py3status"