Bug 942275 - Add support for setuptools' entry points to mach, r=gps
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Fri, 06 Dec 2013 09:24:09 -0500
changeset 173872 be257c6d6d2400fa30cea3744df0f7a05fc7876f
parent 173871 b0678affef0368b037bd9d2c07d54c287451bacb
child 173873 070043d0e2e167563f4fe1a959e3d4ede9550f3a
push id3224
push userlsblakk@mozilla.com
push dateTue, 04 Feb 2014 01:06:49 +0000
treeherdermozilla-beta@60c04d0987f1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgps
bugs942275
milestone28.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 942275 - Add support for setuptools' entry points to mach, r=gps * * * Bug 942275 - Ignore load_from_entry_point if setuptools not present, r=gps
python/mach/README.rst
python/mach/mach/main.py
python/mach/mach/test/common.py
python/mach/mach/test/providers/basic.py
python/mach/mach/test/test_entry_point.py
python/mach/setup.py
--- a/python/mach/README.rst
+++ b/python/mach/README.rst
@@ -82,17 +82,17 @@ Here is an example:
         return cls.build_path is not None
 
     @CommandProvider
     class MyClass(MachCommandBase):
         def __init__(self, build_path=None):
             self.build_path = build_path
 
         @Command('run_tests', conditions=[build_available])
-        def run_tests(self, force=False):
+        def run_tests(self):
             # Do stuff here.
 
 It is important to make sure that any state needed by the condition is
 available to instances of the command provider.
 
 By default all commands without any conditions applied will be runnable,
 but it is possible to change this behaviour by setting *require_conditions*
 to True:
@@ -213,8 +213,35 @@ LoggingMixin:
     import logging
     from mach.mixin.logging import LoggingMixin
 
     class MyClass(LoggingMixin):
         def foo(self):
              self.log(logging.INFO, 'foo_start', {'bar': True},
                  'Foo performed. Bar: {bar}')
 
+Entry Points
+============
+
+It is possible to use setuptools' entry points to load commands
+directly from python packages. A mach entry point is a function which
+returns a list of files or directories containing mach command
+providers.
+
+E.g:
+
+    def list_providers():
+        providers = []
+        here = os.path.abspath(os.path.dirname(__file__))
+        for p in os.listdir(here):
+            if p.endswith('.py'):
+                providers.append(os.path.join(here, p))
+        return providers
+
+See http://pythonhosted.org/setuptools/setuptools.html#dynamic-discovery-of-services-and-plugins
+for more information on creating an entry point. To search for entry
+point plugins, you can call *load_commands_from_entry_point*. This
+takes a single parameter called *group*. This is the name of the entry
+point group to load and defaults to "mach.providers".
+
+E.g:
+
+    mach.load_commands_from_entry_point("mach.external.providers")
--- a/python/mach/mach/main.py
+++ b/python/mach/mach/main.py
@@ -1,16 +1,17 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 # This module provides functionality for the command-line build tool
 # (mach). It is packaged as a module because everything is a library.
 
-from __future__ import absolute_import, unicode_literals
+from __future__ import absolute_import, print_function, unicode_literals
+from collections import Iterable
 
 import argparse
 import codecs
 import imp
 import logging
 import os
 import sys
 import traceback
@@ -89,16 +90,26 @@ The %s command does not accept the argum
 
 INVALID_COMMAND_CONTEXT = r'''
 It looks like you tried to run a mach command from an invalid context. The %s
 command failed to meet the following conditions: %s
 
 Run |mach help| to show a list of all commands available to the current context.
 '''.lstrip()
 
+INVALID_ENTRY_POINT = r'''
+Entry points should return a list of command providers or directories
+containing command providers. The following entry point is invalid:
+
+    %s
+
+You are seeing this because there is an error in an external module attempting
+to implement a mach command. Please fix the error, or uninstall the module from
+your system.
+'''.lstrip()
 
 class ArgumentParser(argparse.ArgumentParser):
     """Custom implementation argument parser to make things look pretty."""
 
     def error(self, message):
         """Custom error reporter to give more helpful text on bad commands."""
         if not message.startswith('argument command: invalid choice'):
             argparse.ArgumentParser.error(self, message)
@@ -210,16 +221,45 @@ To see more help for a specific command,
             if b'mach.commands' not in sys.modules:
                 mod = imp.new_module(b'mach.commands')
                 sys.modules[b'mach.commands'] = mod
 
             module_name = 'mach.commands.%s' % uuid.uuid1().get_hex()
 
         imp.load_source(module_name, path)
 
+    def load_commands_from_entry_point(self, group='mach.providers'):
+        """Scan installed packages for mach command provider entry points. An
+        entry point is a function that returns a list of paths to files or
+        directories containing command providers.
+
+        This takes an optional group argument which specifies the entry point
+        group to use. If not specified, it defaults to 'mach.providers'.
+        """
+        try:
+            import pkg_resources
+        except ImportError:
+            print("Could not find setuptools, ignoring command entry points",
+                  file=sys.stderr)
+            return
+
+        for entry in pkg_resources.iter_entry_points(group=group, name=None):
+            paths = entry.load()()
+            if not isinstance(paths, Iterable):
+                print(INVALID_ENTRY_POINT % entry)
+                sys.exit(1)
+
+            for path in paths:
+                if os.path.isfile(path):
+                    self.load_commands_from_file(path)
+                elif os.path.isdir(path):
+                    self.load_commands_from_directory(path)
+                else:
+                    print("command provider '%s' does not exist" % path)
+
     def define_category(self, name, title, description, priority=50):
         """Provide a description for a named command category."""
 
         Registrar.register_category(name, title, description, priority)
 
     @property
     def require_conditions(self):
         return Registrar.require_conditions
--- a/python/mach/mach/test/common.py
+++ b/python/mach/mach/test/common.py
@@ -11,22 +11,26 @@ import unittest
 from mach.main import Mach
 from mach.base import CommandContext
 
 here = os.path.abspath(os.path.dirname(__file__))
 
 class TestBase(unittest.TestCase):
     provider_dir = os.path.join(here, 'providers')
 
-    def _run_mach(self, args, provider_file, context_handler=None):
+    def _run_mach(self, args, provider_file=None, entry_point=None, context_handler=None):
         m = Mach(os.getcwd())
         m.define_category('testing', 'Mach unittest', 'Testing for mach core', 10)
         m.populate_context_handler = context_handler
 
-        m.load_commands_from_file(os.path.join(self.provider_dir, provider_file))
+        if provider_file:
+            m.load_commands_from_file(os.path.join(self.provider_dir, provider_file))
+
+        if entry_point:
+            m.load_commands_from_entry_point(entry_point)
 
         stdout = StringIO()
         stderr = StringIO()
         stdout.encoding = 'UTF-8'
         stderr.encoding = 'UTF-8'
 
         try:
             result = m.run(args, stdout=stdout, stderr=stderr)
new file mode 100644
--- /dev/null
+++ b/python/mach/mach/test/providers/basic.py
@@ -0,0 +1,15 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+from __future__ import unicode_literals
+
+from mach.decorators import (
+    CommandProvider,
+    Command,
+)
+
+@CommandProvider
+class ConditionsProvider(object):
+    @Command('cmd_foo', category='testing')
+    def run_foo(self):
+        pass
new file mode 100644
--- /dev/null
+++ b/python/mach/mach/test/test_entry_point.py
@@ -0,0 +1,52 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+from __future__ import unicode_literals
+
+import imp
+import os
+import sys
+
+from mach.base import MachError
+from mach.test.common import TestBase
+from mock import patch
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+class Entry():
+    """Stub replacement for pkg_resources.EntryPoint"""
+    def __init__(self, providers):
+        self.providers = providers
+
+    def load(self):
+        def _providers():
+            return self.providers
+        return _providers
+
+class TestEntryPoints(TestBase):
+    """Test integrating with setuptools entry points"""
+    provider_dir = os.path.join(here, 'providers')
+
+    def _run_mach(self):
+        return TestBase._run_mach(self, ['help'], entry_point='mach.providers')
+
+    @patch('pkg_resources.iter_entry_points')
+    def test_load_entry_point_from_directory(self, mock):
+        # Ensure parent module is present otherwise we'll (likely) get
+        # an error due to unknown parent.
+        if b'mach.commands' not in sys.modules:
+            mod = imp.new_module(b'mach.commands')
+            sys.modules[b'mach.commands'] = mod
+
+        mock.return_value = [Entry(['providers'])]
+        # Mach error raised due to conditions_invalid.py
+        with self.assertRaises(MachError):
+            self._run_mach()
+
+    @patch('pkg_resources.iter_entry_points')
+    def test_load_entry_point_from_file(self, mock):
+        mock.return_value = [Entry([os.path.join('providers', 'basic.py')])]
+
+        result, stdout, stderr = self._run_mach()
+        self.assertIsNone(result)
+        self.assertIn('cmd_foo', stdout)
--- a/python/mach/setup.py
+++ b/python/mach/setup.py
@@ -6,11 +6,12 @@ from setuptools import setup
 
 VERSION = '0.1'
 
 setup(
     name='mach',
     description='CLI frontend to mozilla-central.',
     license='MPL 2.0',
     packages=['mach'],
-    version=VERSION
+    version=VERSION,
+    tests_require=['mock']
 )