prereqs.py 8.64 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 14 15
from .utils.envs import Env


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

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

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

39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
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]
54
    except KeyError:
55 56 57
        return False


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

    hasher = hashlib.sha1()

75
    for path_item in path_list:
Will Daly committed
76

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

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

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


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


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


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

156 157 158
    prereq_cache("Node prereqs", ["package.json"], node_prereqs_installation)


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


170
@task
171 172 173 174 175 176 177 178 179 180
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.

    """
181 182 183 184 185 186
    # 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")
187
    create_prereqs_cache_dir()
188

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

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


@task
238 239
def install_python_prereqs():
    """
240
    Installs Python prerequisites.
241
    """
242
    if no_prereq_install():
243
        print NO_PREREQ_MESSAGE
244 245
        return

246 247
    uninstall_python_packages()

248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
    # 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)
268 269 270


@task
Will Daly committed
271 272
def install_prereqs():
    """
273
    Installs Node and Python prerequisites
Will Daly committed
274
    """
275
    if no_prereq_install():
276
        print NO_PREREQ_MESSAGE
277 278
        return

279 280
    install_node_prereqs()
    install_python_prereqs()