prereqs.py 10.3 KB
Newer Older
Will Daly committed
1
"""
2
Install Python and Node prerequisites.
Will Daly committed
3 4
"""

5 6
import hashlib
import os
7 8
import re
import sys
9
from distutils import sysconfig
10

11
from paver.easy import BuildFailure, sh, task
12

Will Daly committed
13
from .utils.envs import Env
14
from .utils.timer import timed
Will Daly committed
15

16
PREREQS_STATE_DIR = os.getenv('PREREQ_CACHE_DIR', Env.REPO_ROOT / '.prereqs_cache')
17
NPM_REGISTRY = "https://registry.npmjs.org/"
18
NO_PREREQ_MESSAGE = "NO_PREREQ_INSTALL is set, not installing prereqs"
19
NO_PYTHON_UNINSTALL_MESSAGE = 'NO_PYTHON_UNINSTALL is set. No attempts will be made to uninstall old Python libs.'
20
COVERAGE_REQ_FILE = 'requirements/edx/coverage.txt'
21 22 23 24

# If you make any changes to this list you also need to make
# a corresponding change to circle.yml, which is how the python
# prerequisites are installed for builds on circleci.com
Will Daly committed
25 26
PYTHON_REQ_FILES = [
    'requirements/edx/pre.txt',
27 28
    'requirements/edx/github.txt',
    'requirements/edx/local.txt',
29
    'requirements/edx/django.txt',
Will Daly committed
30
    'requirements/edx/base.txt',
31
    'requirements/edx/paver.txt',
32 33
    'requirements/edx/development.txt',
    'requirements/edx/testing.txt',
34
    'requirements/edx/post.txt',
Will Daly committed
35 36
]

37 38 39 40 41 42
# Developers can have private requirements, for local copies of github repos,
# or favorite debugging tools, etc.
PRIVATE_REQS = 'requirements/private.txt'
if os.path.exists(PRIVATE_REQS):
    PYTHON_REQ_FILES.append(PRIVATE_REQS)

Will Daly committed
43

44 45 46 47 48
def str2bool(s):
    s = str(s)
    return s.lower() in ('yes', 'true', 't', '1')


49 50 51 52
def no_prereq_install():
    """
    Determine if NO_PREREQ_INSTALL should be truthy or falsy.
    """
53
    return str2bool(os.environ.get('NO_PREREQ_INSTALL', 'False'))
54 55


56 57 58
def no_python_uninstall():
    """ Determine if we should run the uninstall_python_packages task. """
    return str2bool(os.environ.get('NO_PYTHON_UNINSTALL', 'False'))
59 60


61 62 63 64 65 66 67 68 69
def create_prereqs_cache_dir():
    """Create the directory for storing the hashes, if it doesn't exist already."""
    try:
        os.makedirs(PREREQS_STATE_DIR)
    except OSError:
        if not os.path.isdir(PREREQS_STATE_DIR):
            raise


Will Daly committed
70 71 72 73 74 75 76 77
def compute_fingerprint(path_list):
    """
    Hash the contents of all the files and directories in `path_list`.
    Returns the hex digest.
    """

    hasher = hashlib.sha1()

78
    for path_item in path_list:
Will Daly committed
79

80 81
        # For directories, create a hash based on the modification times
        # of first-level subdirectories
82 83 84 85 86
        if os.path.isdir(path_item):
            for dirname in sorted(os.listdir(path_item)):
                path_name = os.path.join(path_item, dirname)
                if os.path.isdir(path_name):
                    hasher.update(str(os.stat(path_name).st_mtime))
Will Daly committed
87 88

        # For files, hash the contents of the file
89 90
        if os.path.isfile(path_item):
            with open(path_item, "rb") as file_handle:
91
                hasher.update(file_handle.read())
Will Daly committed
92 93 94 95 96 97 98 99 100 101 102 103 104 105

    return hasher.hexdigest()


def prereq_cache(cache_name, paths, install_func):
    """
    Conditionally execute `install_func()` only if the files/directories
    specified by `paths` have changed.

    If the code executes successfully (no exceptions are thrown), the cache
    is updated with the new hash.
    """
    # Retrieve the old hash
    cache_filename = cache_name.replace(" ", "_")
106
    cache_file_path = os.path.join(PREREQS_STATE_DIR, "{}.sha1".format(cache_filename))
Will Daly committed
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
    old_hash = None
    if os.path.isfile(cache_file_path):
        with open(cache_file_path) as cache_file:
            old_hash = cache_file.read()

    # Compare the old hash to the new hash
    # If they do not match (either the cache hasn't been created, or the files have changed),
    # then execute the code within the block.
    new_hash = compute_fingerprint(paths)
    if new_hash != old_hash:
        install_func()

        # Update the cache with the new hash
        # If the code executed within the context fails (throws an exception),
        # then this step won't get executed.
122
        create_prereqs_cache_dir()
Will Daly committed
123
        with open(cache_file_path, "w") as cache_file:
124 125 126 127
            # Since the pip requirement files are modified during the install
            # process, we need to store the hash generated AFTER the installation
            post_install_hash = compute_fingerprint(paths)
            cache_file.write(post_install_hash)
Will Daly committed
128
    else:
129
        print '{cache} unchanged, skipping...'.format(cache=cache_name)
Will Daly committed
130 131


132
def node_prereqs_installation():
Will Daly committed
133
    """
134
    Configures npm and installs Node prerequisites
Will Daly committed
135
    """
136
    cb_error_text = "Subprocess return code: 1"
137 138 139
    sh("test `npm config get registry` = \"{reg}\" || "
       "(echo setting registry; npm config set registry"
       " {reg})".format(reg=NPM_REGISTRY))
140 141

    # Error handling around a race condition that produces "cb() never called" error. This
142 143
    # evinces itself as `cb_error_text` and it ought to disappear when we upgrade
    # npm to 3 or higher. TODO: clean this up when we do that.
144 145 146 147 148 149
    try:
        sh('npm install')
    except BuildFailure, error_text:
        if cb_error_text in error_text:
            print "npm install error detected. Retrying..."
            sh('npm install')
150 151
        else:
            raise BuildFailure(error_text)
Will Daly committed
152 153


154
def python_prereqs_installation():
Will Daly committed
155 156 157 158
    """
    Installs Python prerequisites
    """
    for req_file in PYTHON_REQ_FILES:
159 160 161 162 163 164 165
        pip_install_req_file(req_file)


def pip_install_req_file(req_file):
    """Pip install the requirements file."""
    pip_cmd = 'pip install -q --disable-pip-version-check --exists-action w'
    sh("{pip_cmd} -r {req_file}".format(pip_cmd=pip_cmd, req_file=req_file))
Will Daly committed
166 167 168


@task
169
@timed
170 171 172 173
def install_node_prereqs():
    """
    Installs Node prerequisites
    """
174
    if no_prereq_install():
175
        print NO_PREREQ_MESSAGE
176 177
        return

178 179 180
    prereq_cache("Node prereqs", ["package.json"], node_prereqs_installation)


181 182 183 184 185 186 187
# To add a package to the uninstall list, just add it to this list! No need
# to touch any other part of this file.
PACKAGES_TO_UNINSTALL = [
    "South",                        # Because it interferes with Django 1.8 migrations.
    "edxval",                       # Because it was bork-installed somehow.
    "django-storages",
    "django-oauth2-provider",       # Because now it's called edx-django-oauth2-provider.
188
    "edx-oauth2-provider",          # Because it moved from github to pypi
189
    "i18n-tools",                   # Because now it's called edx-i18n-tools
190 191 192
]


193
@task
194
@timed
195 196 197 198 199 200 201 202 203
def uninstall_python_packages():
    """
    Uninstall Python packages that need explicit uninstallation.

    Some Python packages that we no longer want need to be explicitly
    uninstalled, notably, South.  Some other packages were once installed in
    ways that were resistant to being upgraded, like edxval.  Also uninstall
    them.
    """
204 205 206 207 208

    if no_python_uninstall():
        print(NO_PYTHON_UNINSTALL_MESSAGE)
        return

209 210 211 212 213 214
    # So that we don't constantly uninstall things, use a hash of the packages
    # to be uninstalled.  Check it, and skip this if we're up to date.
    hasher = hashlib.sha1()
    hasher.update(repr(PACKAGES_TO_UNINSTALL))
    expected_version = hasher.hexdigest()
    state_file_path = os.path.join(PREREQS_STATE_DIR, "Python_uninstall.sha1")
215
    create_prereqs_cache_dir()
216

217 218
    if os.path.isfile(state_file_path):
        with open(state_file_path) as state_file:
219
            version = state_file.read()
220
        if version == expected_version:
221
            print 'Python uninstalls unchanged, skipping...'
222 223 224 225 226 227 228
            return

    # Run pip to find the packages we need to get rid of.  Believe it or not,
    # edx-val is installed in a way that it is present twice, so we have a loop
    # to really really get rid of it.
    for _ in range(3):
        uninstalled = False
229
        frozen = sh("pip freeze", capture=True)
230

231 232 233 234 235
        for package_name in PACKAGES_TO_UNINSTALL:
            if package_in_frozen(package_name, frozen):
                # Uninstall the pacakge
                sh("pip uninstall --disable-pip-version-check -y {}".format(package_name))
                uninstalled = True
236 237 238 239 240 241 242 243 244
        if not uninstalled:
            break
    else:
        # We tried three times and didn't manage to get rid of the pests.
        print "Couldn't uninstall unwanted Python packages!"
        return

    # Write our version.
    with open(state_file_path, "w") as state_file:
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262
        state_file.write(expected_version)


def package_in_frozen(package_name, frozen_output):
    """Is this package in the output of 'pip freeze'?"""
    # Look for either:
    #
    #   PACKAGE-NAME==
    #
    # or:
    #
    #   blah_blah#egg=package_name-version
    #
    pattern = r"(?mi)^{pkg}==|#egg={pkg_under}-".format(
        pkg=re.escape(package_name),
        pkg_under=re.escape(package_name.replace("-", "_")),
    )
    return bool(re.search(pattern, frozen_output))
263 264 265


@task
266
@timed
267 268 269 270 271 272 273 274 275 276
def install_coverage_prereqs():
    """ Install python prereqs for measuring coverage. """
    if no_prereq_install():
        print NO_PREREQ_MESSAGE
        return
    pip_install_req_file(COVERAGE_REQ_FILE)


@task
@timed
277 278
def install_python_prereqs():
    """
279
    Installs Python prerequisites.
280
    """
281
    if no_prereq_install():
282
        print NO_PREREQ_MESSAGE
283 284
        return

285 286
    uninstall_python_packages()

287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306
    # Include all of the requirements files in the fingerprint.
    files_to_fingerprint = list(PYTHON_REQ_FILES)

    # Also fingerprint the directories where packages get installed:
    # ("/edx/app/edxapp/venvs/edxapp/lib/python2.7/site-packages")
    files_to_fingerprint.append(sysconfig.get_python_lib())

    # In a virtualenv, "-e installs" get put in a src directory.
    src_dir = os.path.join(sys.prefix, "src")
    if os.path.isdir(src_dir):
        files_to_fingerprint.append(src_dir)

    # Also fingerprint this source file, so that if the logic for installations
    # changes, we will redo the installation.
    this_file = __file__
    if this_file.endswith(".pyc"):
        this_file = this_file[:-1]      # use the .py file instead of the .pyc
    files_to_fingerprint.append(this_file)

    prereq_cache("Python prereqs", files_to_fingerprint, python_prereqs_installation)
307

308 309
    sh("pip freeze")

310 311

@task
312
@timed
Will Daly committed
313 314
def install_prereqs():
    """
315
    Installs Node and Python prerequisites
Will Daly committed
316
    """
317
    if no_prereq_install():
318
        print NO_PREREQ_MESSAGE
319 320
        return

321 322
    install_node_prereqs()
    install_python_prereqs()
323 324 325 326 327 328 329
    log_installed_python_prereqs()


def log_installed_python_prereqs():
    """  Logs output of pip freeze for debugging. """
    sh("pip freeze > {}".format(Env.GEN_LOG_DIR + "/pip_freeze.log"))
    return