diff options
Diffstat (limited to '')
-rw-r--r-- | tools/lint/eslint/setup_helper.py | 432 |
1 files changed, 432 insertions, 0 deletions
diff --git a/tools/lint/eslint/setup_helper.py b/tools/lint/eslint/setup_helper.py new file mode 100644 index 0000000000..3bb10758cf --- /dev/null +++ b/tools/lint/eslint/setup_helper.py @@ -0,0 +1,432 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +import json +import os +import platform +import re +import subprocess +import sys +from filecmp import dircmp + +from mozbuild.nodeutil import ( + NODE_MIN_VERSION, + NPM_MIN_VERSION, + find_node_executable, + find_npm_executable, +) +from mozfile.mozfile import remove as mozfileremove +from packaging.version import Version + +NODE_MACHING_VERSION_NOT_FOUND_MESSAGE = """ +Could not find Node.js executable later than %s. + +Executing `mach bootstrap --no-system-changes` should +install a compatible version in ~/.mozbuild on most platforms. +""".strip() + +NPM_MACHING_VERSION_NOT_FOUND_MESSAGE = """ +Could not find npm executable later than %s. + +Executing `mach bootstrap --no-system-changes` should +install a compatible version in ~/.mozbuild on most platforms. +""".strip() + +NODE_NOT_FOUND_MESSAGE = """ +nodejs is either not installed or is installed to a non-standard path. + +Executing `mach bootstrap --no-system-changes` should +install a compatible version in ~/.mozbuild on most platforms. +""".strip() + +NPM_NOT_FOUND_MESSAGE = """ +Node Package Manager (npm) is either not installed or installed to a +non-standard path. + +Executing `mach bootstrap --no-system-changes` should +install a compatible version in ~/.mozbuild on most platforms. +""".strip() + + +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +CARET_VERSION_RANGE_RE = re.compile(r"^\^((\d+)\.\d+\.\d+)$") + +project_root = None + + +def eslint_maybe_setup(): + """Setup ESLint only if it is needed.""" + has_issues, needs_clobber = eslint_module_needs_setup() + + if has_issues: + eslint_setup(needs_clobber) + + +def eslint_setup(should_clobber=False): + """Ensure eslint is optimally configured. + + This command will inspect your eslint configuration and + guide you through an interactive wizard helping you configure + eslint for optimal use on Mozilla projects. + """ + package_setup(get_project_root(), "eslint", should_clobber=should_clobber) + + +def remove_directory(path): + print("Clobbering %s..." % path) + if sys.platform.startswith("win") and have_winrm(): + process = subprocess.Popen(["winrm", "-rf", path]) + process.wait() + else: + mozfileremove(path) + + +def package_setup( + package_root, + package_name, + should_update=False, + should_clobber=False, + no_optional=False, +): + """Ensure `package_name` at `package_root` is installed. + + When `should_update` is true, clobber, install, and produce a new + "package-lock.json" file. + + This populates `package_root/node_modules`. + + """ + orig_project_root = get_project_root() + orig_cwd = os.getcwd() + + if should_update: + should_clobber = True + + try: + set_project_root(package_root) + sys.path.append(os.path.dirname(__file__)) + + # npm sometimes fails to respect cwd when it is run using check_call so + # we manually switch folders here instead. + project_root = get_project_root() + os.chdir(project_root) + + if should_clobber: + remove_directory(os.path.join(project_root, "node_modules")) + + # Always remove the eslint-plugin-mozilla sub-directory as that can + # sometimes conflict with the top level node_modules, see bug 1809036. + remove_directory( + os.path.join( + get_eslint_module_path(), "eslint-plugin-mozilla", "node_modules" + ) + ) + + npm_path, _ = find_npm_executable() + if not npm_path: + return 1 + + node_path, _ = find_node_executable() + if not node_path: + return 1 + + extra_parameters = ["--loglevel=error"] + + if no_optional: + extra_parameters.append("--no-optional") + + package_lock_json_path = os.path.join(get_project_root(), "package-lock.json") + + if should_update: + cmd = [npm_path, "install"] + mozfileremove(package_lock_json_path) + else: + cmd = [npm_path, "ci"] + + # On non-Windows, ensure npm is called via node, as node may not be in the + # path. + if platform.system() != "Windows": + cmd.insert(0, node_path) + + cmd.extend(extra_parameters) + + # Ensure that bare `node` and `npm` in scripts, including post-install scripts, finds the + # binary we're invoking with. Without this, it's easy for compiled extensions to get + # mismatched versions of the Node.js extension API. + path = os.environ.get("PATH", "").split(os.pathsep) + node_dir = os.path.dirname(node_path) + if node_dir not in path: + path = [node_dir] + path + + print('Installing %s for mach using "%s"...' % (package_name, " ".join(cmd))) + result = call_process( + package_name, cmd, append_env={"PATH": os.pathsep.join(path)} + ) + + if not result: + return 1 + + bin_path = os.path.join( + get_project_root(), "node_modules", ".bin", package_name + ) + + print("\n%s installed successfully!" % package_name) + print("\nNOTE: Your local %s binary is at %s\n" % (package_name, bin_path)) + + finally: + set_project_root(orig_project_root) + os.chdir(orig_cwd) + + +def call_process(name, cmd, cwd=None, append_env={}): + env = dict(os.environ) + env.update(append_env) + + try: + with open(os.devnull, "w") as fnull: + subprocess.check_call(cmd, cwd=cwd, stdout=fnull, env=env) + except subprocess.CalledProcessError: + if cwd: + print("\nError installing %s in the %s folder, aborting." % (name, cwd)) + else: + print("\nError installing %s, aborting." % name) + + return False + + return True + + +def expected_eslint_modules(): + # Read the expected version of ESLint and external modules + expected_modules_path = os.path.join(get_project_root(), "package.json") + with open(expected_modules_path, "r", encoding="utf-8") as f: + sections = json.load(f) + expected_modules = sections.get("dependencies", {}) + expected_modules.update(sections.get("devDependencies", {})) + + # Also read the in-tree ESLint plugin mozilla information, to ensure the + # dependencies are up to date. + # Bug 1766659: This is disabled for now due to sub-dependencies at the top + # level providing different module versions to those in eslint-plugin-mozilla. + # mozilla_json_path = os.path.join( + # get_eslint_module_path(), "eslint-plugin-mozilla", "package.json" + # ) + # with open(mozilla_json_path, "r", encoding="utf-8") as f: + # expected_modules.update(json.load(f).get("dependencies", {})) + + # Also read the in-tree ESLint plugin spidermonkey information, to ensure the + # dependencies are up to date. + mozilla_json_path = os.path.join( + get_eslint_module_path(), "eslint-plugin-spidermonkey-js", "package.json" + ) + with open(mozilla_json_path, "r", encoding="utf-8") as f: + expected_modules.update(json.load(f).get("dependencies", {})) + + return expected_modules + + +def check_eslint_files(node_modules_path, name): + def check_file_diffs(dcmp): + # Diff files only looks at files that are different. Not for files + # that are only present on one side. This should be generally OK as + # new files will need to be added in the index.js for the package. + if dcmp.diff_files and dcmp.diff_files != ["package.json"]: + return True + + result = False + + # Again, we only look at common sub directories for the same reason + # as above. + for sub_dcmp in dcmp.subdirs.values(): + result = result or check_file_diffs(sub_dcmp) + + return result + + dcmp = dircmp( + os.path.join(node_modules_path, name), + os.path.join(get_eslint_module_path(), name), + ) + + return check_file_diffs(dcmp) + + +def eslint_module_needs_setup(): + has_issues = False + needs_clobber = False + node_modules_path = os.path.join(get_project_root(), "node_modules") + + for name, expected_data in expected_eslint_modules().items(): + # expected_eslint_modules returns a string for the version number of + # dependencies for installation of eslint generally, and an object + # for our in-tree plugins (which contains the entire module info). + if "version" in expected_data: + version_range = expected_data["version"] + else: + version_range = expected_data + + path = os.path.join(node_modules_path, name, "package.json") + + if not os.path.exists(path): + print("%s v%s needs to be installed locally." % (name, version_range)) + has_issues = True + continue + data = json.load(open(path, encoding="utf-8")) + + if version_range.startswith("file:"): + # We don't need to check local file installations for versions, as + # these are symlinked, so we'll always pick up the latest. + continue + + if name == "eslint" and Version("4.0.0") > Version(data["version"]): + print("ESLint is an old version, clobbering node_modules directory") + needs_clobber = True + has_issues = True + continue + + # For @microsoft/eslint-plugin-sdl we are loading a static version as + # long that PR is not merged into the master branch. Bug 1786290 + if (name == "@microsoft/eslint-plugin-sdl") and ( + version_range + == "github:mozfreddyb/eslint-plugin-sdl#17b22cd527682108af7a1a4edacf69cb7a9b4a06" + ): + continue + + if not version_in_range(data["version"], version_range): + print("%s v%s should be v%s." % (name, data["version"], version_range)) + has_issues = True + continue + + return has_issues, needs_clobber + + +def version_in_range(version, version_range): + """ + Check if a module version is inside a version range. Only supports explicit versions and + caret ranges for the moment, since that's all we've used so far. + """ + if version == version_range: + return True + + version_match = VERSION_RE.match(version) + if not version_match: + raise RuntimeError("mach eslint doesn't understand module version %s" % version) + version = Version(version) + + # Caret ranges as specified by npm allow changes that do not modify the left-most non-zero + # digit in the [major, minor, patch] tuple. The code below assumes the major digit is + # non-zero. + range_match = CARET_VERSION_RANGE_RE.match(version_range) + if range_match: + range_version = range_match.group(1) + range_major = int(range_match.group(2)) + + range_min = Version(range_version) + range_max = Version("%d.0.0" % (range_major + 1)) + + return range_min <= version < range_max + + return False + + +def get_possible_node_paths_win(): + """ + Return possible nodejs paths on Windows. + """ + if platform.system() != "Windows": + return [] + + return list( + { + "%s\\nodejs" % os.environ.get("SystemDrive"), + os.path.join(os.environ.get("ProgramFiles"), "nodejs"), + os.path.join(os.environ.get("PROGRAMW6432"), "nodejs"), + os.path.join(os.environ.get("PROGRAMFILES"), "nodejs"), + } + ) + + +def get_version(path): + try: + version_str = subprocess.check_output( + [path, "--version"], stderr=subprocess.STDOUT, universal_newlines=True + ) + return version_str + except (subprocess.CalledProcessError, OSError): + return None + + +def set_project_root(root=None): + """Sets the project root to the supplied path, or works out what the root + is based on looking for 'mach'. + + Keyword arguments: + root - (optional) The path to set the root to. + """ + global project_root + + if root: + project_root = root + return + + file_found = False + folder = os.getcwd() + + while folder: + if os.path.exists(os.path.join(folder, "mach")): + file_found = True + break + else: + folder = os.path.dirname(folder) + + if file_found: + project_root = os.path.abspath(folder) + + +def get_project_root(): + """Returns the absolute path to the root of the project, see set_project_root() + for how this is determined. + """ + global project_root + + if not project_root: + set_project_root() + + return project_root + + +def get_eslint_module_path(): + return os.path.join(get_project_root(), "tools", "lint", "eslint") + + +def check_node_executables_valid(): + node_path, version = find_node_executable() + if not node_path: + print(NODE_NOT_FOUND_MESSAGE) + return False + if not version: + print(NODE_MACHING_VERSION_NOT_FOUND_MESSAGE % NODE_MIN_VERSION) + return False + + npm_path, version = find_npm_executable() + if not npm_path: + print(NPM_NOT_FOUND_MESSAGE) + return False + if not version: + print(NPM_MACHING_VERSION_NOT_FOUND_MESSAGE % NPM_MIN_VERSION) + return False + + return True + + +def have_winrm(): + # `winrm -h` should print 'winrm version ...' and exit 1 + try: + p = subprocess.Popen( + ["winrm.exe", "-h"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) + return p.wait() == 1 and p.stdout.read().startswith("winrm") + except Exception: + return False |