Repository: readthedocs/readthedocs.org

Issue:
Remove yet another command execution pattern
`projects/utils.py` has yet another command execution pattern. We should remove this and move it to the `BuildEnvironment` pattern.

Raised in rtfd/readthedocs.org#3520


Discussion:

Resolving patch:
diff --git a/readthedocs/core/symlink.py b/readthedocs/core/symlink.py
--- a/readthedocs/core/symlink.py
+++ b/readthedocs/core/symlink.py
@@ -52,21 +52,27 @@
         fabric -> rtd-builds/fabric/en/latest/ # single version
 """
 
-from __future__ import absolute_import, unicode_literals
-from builtins import object
+from __future__ import (
+    absolute_import,
+    division,
+    print_function,
+    unicode_literals,
+)
+
+import logging
 import os
 import shutil
-import logging
 from collections import OrderedDict
 
+from builtins import object
 from django.conf import settings
 
 from readthedocs.builds.models import Version
-from readthedocs.core.utils.extend import SettingsOverrideObject
 from readthedocs.core.utils import safe_makedirs, safe_unlink
+from readthedocs.core.utils.extend import SettingsOverrideObject
+from readthedocs.doc_builder.environments import LocalEnvironment
 from readthedocs.projects import constants
 from readthedocs.projects.models import Domain
-from readthedocs.projects.utils import run
 
 log = logging.getLogger(__name__)
 
@@ -83,6 +89,7 @@ def __init__(self, project):
         self.subproject_root = os.path.join(
             self.project_root, 'projects'
         )
+        self.environment = LocalEnvironment(project)
         self.sanity_check()
 
     def sanity_check(self):
@@ -146,17 +153,27 @@ def symlink_cnames(self, domain=None):
         else:
             domains = Domain.objects.filter(project=self.project)
         for dom in domains:
-            log_msg = 'Symlinking CNAME: {0} -> {1}'.format(dom.domain, self.project.slug)
-            log.info(constants.LOG_TEMPLATE.format(project=self.project.slug,
-                                                   version='', msg=log_msg))
+            log_msg = 'Symlinking CNAME: {} -> {}'.format(
+                dom.domain, self.project.slug
+            )
+            log.info(
+                constants.LOG_TEMPLATE.format(
+                    project=self.project.slug,
+                    version='', msg=log_msg
+                )
+            )
 
             # CNAME to doc root
             symlink = os.path.join(self.CNAME_ROOT, dom.domain)
-            run(['ln', '-nsf', self.project_root, symlink])
+            self.environment.run('ln', '-nsf', self.project_root, symlink)
 
             # Project symlink
-            project_cname_symlink = os.path.join(self.PROJECT_CNAME_ROOT, dom.domain)
-            run(['ln', '-nsf', self.project.doc_path, project_cname_symlink])
+            project_cname_symlink = os.path.join(
+                self.PROJECT_CNAME_ROOT, dom.domain
+            )
+            self.environment.run(
+                'ln', '-nsf', self.project.doc_path, project_cname_symlink
+            )
 
     def remove_symlink_cname(self, domain):
         """Remove CNAME symlink."""
@@ -201,10 +218,12 @@ def symlink_subprojects(self):
                 # TODO this should use os.symlink, not a call to shell. For now,
                 # this passes command as a list to be explicit about escaping
                 # characters like spaces.
-                status, _, stderr = run(['ln', '-nsf', docs_dir, symlink])
-                if status > 0:
-                    log.error('Could not symlink path: status=%d error=%s',
-                              status, stderr)
+                result = self.environment.run('ln', '-nsf', docs_dir, symlink)
+                if result.exit_code > 0:
+                    log.error(
+                        'Could not symlink path: status=%d error=%s',
+                        result.exit_code, result.error
+                    )
 
         # Remove old symlinks
         if os.path.exists(self.subproject_root):
@@ -233,12 +252,16 @@ def symlink_translations(self):
 
         for (language, slug) in list(translations.items()):
 
-            log_msg = 'Symlinking translation: {0}->{1}'.format(language, slug)
-            log.info(constants.LOG_TEMPLATE.format(project=self.project.slug,
-                                                   version='', msg=log_msg))
+            log_msg = 'Symlinking translation: {}->{}'.format(language, slug)
+            log.info(
+                constants.LOG_TEMPLATE.format(
+                    project=self.project.slug,
+                    version='', msg=log_msg
+                )
+            )
             symlink = os.path.join(self.project_root, language)
             docs_dir = os.path.join(self.WEB_ROOT, slug, language)
-            run(['ln', '-nsf', docs_dir, symlink])
+            self.environment.run('ln', '-nsf', docs_dir, symlink)
 
         # Remove old symlinks
         for lang in os.listdir(self.project_root):
@@ -268,9 +291,13 @@ def symlink_single_version(self):
 
         # Create symlink
         if version is not None:
-            docs_dir = os.path.join(settings.DOCROOT, self.project.slug,
-                                    'rtd-builds', version.slug)
-            run(['ln', '-nsf', docs_dir, symlink])
+            docs_dir = os.path.join(
+                settings.DOCROOT,
+                self.project.slug,
+                'rtd-builds',
+                version.slug
+            )
+            self.environment.run('ln', '-nsf', docs_dir, symlink)
 
     def symlink_versions(self):
         """
@@ -280,7 +307,9 @@ def symlink_versions(self):
                   HOME/user_builds/<project>/rtd-builds/<version>
         """
         versions = set()
-        version_dir = os.path.join(self.WEB_ROOT, self.project.slug, self.project.language)
+        version_dir = os.path.join(
+            self.WEB_ROOT, self.project.slug, self.project.language
+        )
         # Include active public versions,
         # as well as public versions that are built but not active, for archived versions
         version_queryset = self.get_version_queryset()
@@ -289,11 +318,21 @@ def symlink_versions(self):
                 safe_makedirs(version_dir)
         for version in version_queryset:
             log_msg = 'Symlinking Version: {}'.format(version)
-            log.info(constants.LOG_TEMPLATE.format(project=self.project.slug,
-                                                   version='', msg=log_msg))
+            log.info(
+                constants.LOG_TEMPLATE.format(
+                    project=self.project.slug,
+                    version='',
+                    msg=log_msg
+                )
+            )
             symlink = os.path.join(version_dir, version.slug)
-            docs_dir = os.path.join(settings.DOCROOT, self.project.slug, 'rtd-builds', version.slug)
-            run(['ln', '-nsf', docs_dir, symlink])
+            docs_dir = os.path.join(
+                settings.DOCROOT,
+                self.project.slug,
+                'rtd-builds',
+                version.slug
+            )
+            self.environment.run('ln', '-nsf', docs_dir, symlink)
             versions.add(version.slug)
 
         # Remove old symlinks
diff --git a/readthedocs/projects/utils.py b/readthedocs/projects/utils.py
--- a/readthedocs/projects/utils.py
+++ b/readthedocs/projects/utils.py
@@ -2,16 +2,16 @@
 """Utility functions used by projects."""
 
 from __future__ import (
-    absolute_import, division, print_function, unicode_literals)
+    absolute_import,
+    division,
+    print_function,
+    unicode_literals,
+)
 
-import fnmatch
 import logging
 import os
-import subprocess
-import traceback
 
-import six
-from builtins import object, open
+from builtins import open
 from django.conf import settings
 
 log = logging.getLogger(__name__)
@@ -32,82 +32,6 @@ def version_from_slug(slug, version):
     return v
 
 
-def find_file(filename):
-    """
-    Recursively find matching file from the current working path.
-
-    :param file: Filename to match
-    :returns: A list of matching filenames.
-    """
-    matches = []
-    for root, __, filenames in os.walk('.'):
-        for match in fnmatch.filter(filenames, filename):
-            matches.append(os.path.join(root, match))
-    return matches
-
-
-def run(*commands):
-    """
-    Run one or more commands.
-
-    Each argument in `commands` can be passed as a string or as a list. Passing
-    as a list is the preferred method, as space escaping is more explicit and it
-    avoids the need for executing anything in a shell.
-
-    If more than one command is given, then this is equivalent to
-    chaining them together with ``&&``; if all commands succeed, then
-    ``(status, out, err)`` will represent the last successful command.
-    If one command failed, then ``(status, out, err)`` will represent
-    the failed command.
-
-    :returns: ``(status, out, err)``
-    """
-    environment = os.environ.copy()
-    environment['READTHEDOCS'] = 'True'
-    if 'DJANGO_SETTINGS_MODULE' in environment:
-        del environment['DJANGO_SETTINGS_MODULE']
-    if 'PYTHONPATH' in environment:
-        del environment['PYTHONPATH']
-    # Remove PYTHONHOME env variable if set, otherwise pip install of requirements
-    # into virtualenv will install incorrectly
-    if 'PYTHONHOME' in environment:
-        del environment['PYTHONHOME']
-    cwd = os.getcwd()
-    if not commands:
-        raise ValueError('run() requires one or more command-line strings')
-
-    for command in commands:
-        # If command is a string, split it up by spaces to pass into Popen.
-        # Otherwise treat the command as an iterable.
-        if isinstance(command, six.string_types):
-            run_command = command.split()
-        else:
-            try:
-                run_command = list(command)
-                command = ' '.join(command)
-            except TypeError:
-                run_command = command
-        log.debug('Running command: cwd=%s command=%s', cwd, command)
-        try:
-            p = subprocess.Popen(
-                run_command,
-                cwd=cwd,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-                env=environment,
-            )
-
-            out, err = p.communicate()
-            ret = p.returncode
-        except OSError:
-            out = ''
-            err = traceback.format_exc()
-            ret = -1
-            log.exception('Command failed')
-
-    return (ret, out, err)
-
-
 def safe_write(filename, contents):
     """
     Normalize and write to filename.
@@ -126,9 +50,3 @@ def safe_write(filename, contents):
     with open(filename, 'w', encoding='utf-8', errors='ignore') as fh:
         fh.write(contents)
         fh.close()
-
-
-class DictObj(object):
-
-    def __getattr__(self, attr):
-        return self.__dict__.get(attr)


Test patch: