Bug 818744 - mach commands to run Python and Python unit tests. r=gps
authorNick Alexander <nalexander@mozilla.com>
Tue, 02 Jul 2013 17:33:48 -0700
changeset 137264 76b0a7f72e3553b956e964bfdd962efc762133cc
parent 137263 04c800fb45a6c4f5a86188cd2884559e94cd6de0
child 137265 b0f2330ed678b42c001afe86b85e8a9a63d6ef88
push idunknown
push userunknown
push dateunknown
reviewersgps
bugs818744
milestone25.0a1
Bug 818744 - mach commands to run Python and Python unit tests. r=gps DONTBUILD because NPOTB
build/mach_bootstrap.py
python/mach_commands.py
--- a/build/mach_bootstrap.py
+++ b/build/mach_bootstrap.py
@@ -50,16 +50,17 @@ SEARCH_PATHS = [
     'testing/mozbase/mozrunner',
     'testing/mozbase/mozinfo',
 ]
 
 # Individual files providing mach commands.
 MACH_MODULES = [
     'addon-sdk/mach_commands.py',
     'layout/tools/reftest/mach_commands.py',
+    'python/mach_commands.py',
     'python/mach/mach/commands/commandinfo.py',
     'python/mozboot/mozboot/mach_commands.py',
     'python/mozbuild/mozbuild/config.py',
     'python/mozbuild/mozbuild/mach_commands.py',
     'python/mozbuild/mozbuild/frontend/mach_commands.py',
     'testing/marionette/mach_commands.py',
     'testing/mochitest/mach_commands.py',
     'testing/xpcshell/mach_commands.py',
new file mode 100644
--- /dev/null
+++ b/python/mach_commands.py
@@ -0,0 +1,130 @@
+# 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 print_function, unicode_literals
+
+import argparse
+import glob
+import logging
+import mozpack.path
+import os
+import sys
+
+from mozbuild.base import (
+    MachCommandBase,
+)
+
+from mach.decorators import (
+    CommandArgument,
+    CommandProvider,
+    Command,
+)
+
+
+@CommandProvider
+class MachCommands(MachCommandBase):
+    '''
+    Easily run Python and Python unit tests.
+    '''
+    def __init__(self, context):
+        MachCommandBase.__init__(self, context)
+        self._python_executable = None
+
+    @property
+    def python_executable(self):
+        '''
+        Return path to Python executable, or print and sys.exit(1) if
+        executable does not exist.
+        '''
+        if self._python_executable:
+            return self._python_executable
+        if self._is_windows():
+            executable = '_virtualenv/Scripts/python.exe'
+        else:
+            executable = '_virtualenv/bin/python'
+        path = mozpack.path.join(self.topobjdir, executable)
+        if not os.path.exists(path):
+            print("Could not find Python executable at %s." % path,
+                  "Run |mach configure| or |mach build| to install it.")
+            sys.exit(1)
+        self._python_executable = path
+        return path
+
+    @Command('python', category='devenv',
+        allow_all_args=True,
+        description='Run Python.')
+    @CommandArgument('args', nargs=argparse.REMAINDER)
+    def python(self, args):
+        return self.run_process([self.python_executable] + args,
+            pass_thru=True, # Allow user to run Python interactively.
+            ensure_exit_code=False, # Don't throw on non-zero exit code.
+            append_env={'PYTHONDONTWRITEBYTECODE': '1'})
+
+    @Command('python-test', category='testing',
+        description='Run Python unit tests.')
+    @CommandArgument('--verbose',
+        default=False,
+        action='store_true',
+        help='Verbose output.')
+    @CommandArgument('--stop',
+        default=False,
+        action='store_true',
+        help='Stop running tests after the first error or failure.')
+    @CommandArgument('tests', nargs='+',
+        metavar='TEST',
+        help='Tests to run. Each test can be a single file or a directory.')
+    def python_test(self, tests, verbose=False, stop=False):
+        # Make sure we can find Python before doing anything else.
+        self.python_executable
+
+        # Python's unittest, and in particular discover, has problems with
+        # clashing namespaces when importing multiple test modules. What follows
+        # is a simple way to keep environments separate, at the price of
+        # launching Python multiple times. This also runs tests via mozunit,
+        # which produces output in the format Mozilla infrastructure expects.
+        return_code = 0
+        files = []
+        for test in tests:
+            if test.endswith('.py') and os.path.isfile(test):
+                files.append(test)
+            elif os.path.isfile(test + '.py'):
+                files.append(test + '.py')
+            elif os.path.isdir(test):
+                files += glob.glob(mozpack.path.join(test, 'test*.py'))
+                files += glob.glob(mozpack.path.join(test, 'unit*.py'))
+            else:
+                self.log(logging.WARN, 'python-test', {'test': test},
+                         'TEST-UNEXPECTED-FAIL | Invalid test: {test}')
+                if stop:
+                    return 1
+
+        for file in files:
+            file_displayed_test = [] # Used as a boolean.
+            def _line_handler(line):
+                if not file_displayed_test and line.startswith('TEST-'):
+                    file_displayed_test.append(True)
+
+            inner_return_code = self.run_process(
+                [self.python_executable, file],
+                ensure_exit_code=False, # Don't throw on non-zero exit code.
+                log_name='python-test',
+                append_env={'PYTHONDONTWRITEBYTECODE': '1'},
+                line_handler=_line_handler)
+            return_code += inner_return_code
+
+            if not file_displayed_test:
+                self.log(logging.WARN, 'python-test', {'file': file},
+                         'TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() call?): {file}')
+
+            if verbose:
+                if inner_return_code != 0:
+                    self.log(logging.INFO, 'python-test', {'file': file},
+                             'Test failed: {file}')
+                else:
+                    self.log(logging.INFO, 'python-test', {'file': file},
+                             'Test passed: {file}')
+            if stop and return_code > 0:
+                return 1
+
+        return 0 if return_code == 0 else 1