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

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

from paver.easy import sh, task

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


17
PREREQS_STATE_DIR = os.getenv('PREREQ_CACHE_DIR', Env.REPO_ROOT / '.prereqs_cache')
Will Daly committed
18
NPM_REGISTRY = "http://registry.npmjs.org/"
19
NO_PREREQ_MESSAGE = "NO_PREREQ_INSTALL is set, not installing prereqs"
20 21 22 23

# 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
24 25
PYTHON_REQ_FILES = [
    'requirements/edx/pre.txt',
26 27
    'requirements/edx/github.txt',
    'requirements/edx/local.txt',
Will Daly committed
28
    'requirements/edx/base.txt',
29
    'requirements/edx/paver.txt',
30
    'requirements/edx/post.txt',
Will Daly committed
31 32
]

33 34 35 36 37 38
# 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
39

40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
def no_prereq_install():
    """
    Determine if NO_PREREQ_INSTALL should be truthy or falsy.
    """
    vals = {
        '0': False,
        '1': True,
        'true': True,
        'false': False,
    }

    val = os.environ.get("NO_PREREQ_INSTALL", 'False').lower()

    try:
        return vals[val]
55
    except KeyError:
56 57 58
        return False


59 60 61 62 63 64 65 66 67
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
68 69 70 71 72 73 74 75
def compute_fingerprint(path_list):
    """
    Hash the contents of all the files and directories in `path_list`.
    Returns the hex digest.
    """

    hasher = hashlib.sha1()

76
    for path_item in path_list:
Will Daly committed
77

78 79
        # For directories, create a hash based on the modification times
        # of first-level subdirectories
80 81 82 83 84
        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
85 86

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

    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(" ", "_")
104
    cache_file_path = os.path.join(PREREQS_STATE_DIR, "{}.sha1".format(cache_filename))
Will Daly committed
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
    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.
120
        create_prereqs_cache_dir()
Will Daly committed
121
        with open(cache_file_path, "w") as cache_file:
122 123 124 125
            # 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
126
    else:
127
        print '{cache} unchanged, skipping...'.format(cache=cache_name)
Will Daly committed
128 129


130
def node_prereqs_installation():
Will Daly committed
131
    """
132
    Configures npm and installs Node prerequisites
Will Daly committed
133
    """
134 135 136
    sh("test `npm config get registry` = \"{reg}\" || "
       "(echo setting registry; npm config set registry"
       " {reg})".format(reg=NPM_REGISTRY))
Will Daly committed
137 138 139
    sh('npm install')


140
def python_prereqs_installation():
Will Daly committed
141 142 143 144
    """
    Installs Python prerequisites
    """
    for req_file in PYTHON_REQ_FILES:
145
        sh("pip install -q --disable-pip-version-check --exists-action w -r {req_file}".format(req_file=req_file))
Will Daly committed
146 147 148


@task
149
@timed
150 151 152 153
def install_node_prereqs():
    """
    Installs Node prerequisites
    """
154
    if no_prereq_install():
155
        print NO_PREREQ_MESSAGE
156 157
        return

158 159 160
    prereq_cache("Node prereqs", ["package.json"], node_prereqs_installation)


161 162 163 164 165 166 167
# 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.
168
    "edx-oauth2-provider",          # Because it moved from github to pypi
169 170 171
]


172
@task
173
@timed
174 175 176 177 178 179 180 181 182 183
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.

    """
184 185 186 187 188 189
    # 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")
190
    create_prereqs_cache_dir()
191

192 193
    if os.path.isfile(state_file_path):
        with open(state_file_path) as state_file:
194
            version = state_file.read()
195
        if version == expected_version:
196
            print 'Python uninstalls unchanged, skipping...'
197 198 199 200 201 202 203
            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
204
        frozen = sh("pip freeze", capture=True)
205

206 207 208 209 210
        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
211 212 213 214 215 216 217 218 219
        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:
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
        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))
238 239 240


@task
241
@timed
242 243
def install_python_prereqs():
    """
244
    Installs Python prerequisites.
245
    """
246
    if no_prereq_install():
247
        print NO_PREREQ_MESSAGE
248 249
        return

250 251
    uninstall_python_packages()

252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271
    # 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)
272 273 274


@task
275
@timed
Will Daly committed
276 277
def install_prereqs():
    """
278
    Installs Node and Python prerequisites
Will Daly committed
279
    """
280
    if no_prereq_install():
281
        print NO_PREREQ_MESSAGE
282 283
        return

284 285
    install_node_prereqs()
    install_python_prereqs()