1# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- 2# vim: set filetype=python: 3# This Source Code Form is subject to the terms of the Mozilla Public 4# License, v. 2.0. If a copy of the MPL was not distributed with this 5# file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 7import json 8import os 9import platform 10import re 11import subprocess 12import sys 13from distutils.version import LooseVersion 14from filecmp import dircmp 15 16from mozbuild.nodeutil import ( 17 find_node_executable, 18 find_npm_executable, 19 NPM_MIN_VERSION, 20 NODE_MIN_VERSION, 21) 22from mozbuild.util import ensure_subprocess_env 23from mozfile.mozfile import remove as mozfileremove 24 25 26NODE_MACHING_VERSION_NOT_FOUND_MESSAGE = """ 27Could not find Node.js executable later than %s. 28 29Executing `mach bootstrap --no-system-changes` should 30install a compatible version in ~/.mozbuild on most platforms. 31""".strip() 32 33NPM_MACHING_VERSION_NOT_FOUND_MESSAGE = """ 34Could not find npm executable later than %s. 35 36Executing `mach bootstrap --no-system-changes` should 37install a compatible version in ~/.mozbuild on most platforms. 38""".strip() 39 40NODE_NOT_FOUND_MESSAGE = """ 41nodejs is either not installed or is installed to a non-standard path. 42 43Executing `mach bootstrap --no-system-changes` should 44install a compatible version in ~/.mozbuild on most platforms. 45""".strip() 46 47NPM_NOT_FOUND_MESSAGE = """ 48Node Package Manager (npm) is either not installed or installed to a 49non-standard path. 50 51Executing `mach bootstrap --no-system-changes` should 52install a compatible version in ~/.mozbuild on most platforms. 53""".strip() 54 55 56VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") 57CARET_VERSION_RANGE_RE = re.compile(r"^\^((\d+)\.\d+\.\d+)$") 58 59project_root = None 60 61 62def eslint_maybe_setup(): 63 """Setup ESLint only if it is needed.""" 64 has_issues, needs_clobber = eslint_module_needs_setup() 65 66 if has_issues: 67 eslint_setup(needs_clobber) 68 69 70def eslint_setup(should_clobber=False): 71 """Ensure eslint is optimally configured. 72 73 This command will inspect your eslint configuration and 74 guide you through an interactive wizard helping you configure 75 eslint for optimal use on Mozilla projects. 76 """ 77 package_setup(get_project_root(), "eslint", should_clobber=should_clobber) 78 79 80def package_setup( 81 package_root, 82 package_name, 83 should_update=False, 84 should_clobber=False, 85 no_optional=False, 86): 87 """Ensure `package_name` at `package_root` is installed. 88 89 When `should_update` is true, clobber, install, and produce a new 90 "package-lock.json" file. 91 92 This populates `package_root/node_modules`. 93 94 """ 95 orig_project_root = get_project_root() 96 orig_cwd = os.getcwd() 97 98 if should_update: 99 should_clobber = True 100 101 try: 102 set_project_root(package_root) 103 sys.path.append(os.path.dirname(__file__)) 104 105 # npm sometimes fails to respect cwd when it is run using check_call so 106 # we manually switch folders here instead. 107 project_root = get_project_root() 108 os.chdir(project_root) 109 110 if should_clobber: 111 node_modules_path = os.path.join(project_root, "node_modules") 112 print("Clobbering %s..." % node_modules_path) 113 if sys.platform.startswith("win") and have_winrm(): 114 process = subprocess.Popen(["winrm", "-rf", node_modules_path]) 115 process.wait() 116 else: 117 mozfileremove(node_modules_path) 118 119 npm_path, _ = find_npm_executable() 120 if not npm_path: 121 return 1 122 123 node_path, _ = find_node_executable() 124 if not node_path: 125 return 1 126 127 extra_parameters = ["--loglevel=error"] 128 129 if no_optional: 130 extra_parameters.append("--no-optional") 131 132 package_lock_json_path = os.path.join(get_project_root(), "package-lock.json") 133 134 if should_update: 135 cmd = [npm_path, "install"] 136 mozfileremove(package_lock_json_path) 137 else: 138 cmd = [npm_path, "ci"] 139 140 # On non-Windows, ensure npm is called via node, as node may not be in the 141 # path. 142 if platform.system() != "Windows": 143 cmd.insert(0, node_path) 144 145 # Ensure that bare `node` and `npm` in scripts, including post-install scripts, finds the 146 # binary we're invoking with. Without this, it's easy for compiled extensions to get 147 # mismatched versions of the Node.js extension API. 148 extra_parameters.append("--scripts-prepend-node-path") 149 150 cmd.extend(extra_parameters) 151 152 print('Installing %s for mach using "%s"...' % (package_name, " ".join(cmd))) 153 result = call_process(package_name, cmd) 154 155 if not result: 156 return 1 157 158 bin_path = os.path.join( 159 get_project_root(), "node_modules", ".bin", package_name 160 ) 161 162 print("\n%s installed successfully!" % package_name) 163 print("\nNOTE: Your local %s binary is at %s\n" % (package_name, bin_path)) 164 165 finally: 166 set_project_root(orig_project_root) 167 os.chdir(orig_cwd) 168 169 170def call_process(name, cmd, cwd=None, append_env={}): 171 env = dict(os.environ) 172 env.update(ensure_subprocess_env(append_env)) 173 174 try: 175 with open(os.devnull, "w") as fnull: 176 subprocess.check_call(cmd, cwd=cwd, stdout=fnull, env=env) 177 except subprocess.CalledProcessError: 178 if cwd: 179 print("\nError installing %s in the %s folder, aborting." % (name, cwd)) 180 else: 181 print("\nError installing %s, aborting." % name) 182 183 return False 184 185 return True 186 187 188def expected_eslint_modules(): 189 # Read the expected version of ESLint and external modules 190 expected_modules_path = os.path.join(get_project_root(), "package.json") 191 with open(expected_modules_path, "r", encoding="utf-8") as f: 192 sections = json.load(f) 193 expected_modules = sections.get("dependencies", {}) 194 expected_modules.update(sections.get("devDependencies", {})) 195 196 # Also read the in-tree ESLint plugin mozilla information, to ensure the 197 # dependencies are up to date. 198 mozilla_json_path = os.path.join( 199 get_eslint_module_path(), "eslint-plugin-mozilla", "package.json" 200 ) 201 with open(mozilla_json_path, "r", encoding="utf-8") as f: 202 expected_modules.update(json.load(f).get("dependencies", {})) 203 204 # Also read the in-tree ESLint plugin spidermonkey information, to ensure the 205 # dependencies are up to date. 206 mozilla_json_path = os.path.join( 207 get_eslint_module_path(), "eslint-plugin-spidermonkey-js", "package.json" 208 ) 209 with open(mozilla_json_path, "r", encoding="utf-8") as f: 210 expected_modules.update(json.load(f).get("dependencies", {})) 211 212 return expected_modules 213 214 215def check_eslint_files(node_modules_path, name): 216 def check_file_diffs(dcmp): 217 # Diff files only looks at files that are different. Not for files 218 # that are only present on one side. This should be generally OK as 219 # new files will need to be added in the index.js for the package. 220 if dcmp.diff_files and dcmp.diff_files != ["package.json"]: 221 return True 222 223 result = False 224 225 # Again, we only look at common sub directories for the same reason 226 # as above. 227 for sub_dcmp in dcmp.subdirs.values(): 228 result = result or check_file_diffs(sub_dcmp) 229 230 return result 231 232 dcmp = dircmp( 233 os.path.join(node_modules_path, name), 234 os.path.join(get_eslint_module_path(), name), 235 ) 236 237 return check_file_diffs(dcmp) 238 239 240def eslint_module_needs_setup(): 241 has_issues = False 242 needs_clobber = False 243 node_modules_path = os.path.join(get_project_root(), "node_modules") 244 245 for name, expected_data in expected_eslint_modules().items(): 246 # expected_eslint_modules returns a string for the version number of 247 # dependencies for installation of eslint generally, and an object 248 # for our in-tree plugins (which contains the entire module info). 249 if "version" in expected_data: 250 version_range = expected_data["version"] 251 else: 252 version_range = expected_data 253 254 path = os.path.join(node_modules_path, name, "package.json") 255 256 if not os.path.exists(path): 257 print("%s v%s needs to be installed locally." % (name, version_range)) 258 has_issues = True 259 continue 260 data = json.load(open(path, encoding="utf-8")) 261 262 if version_range.startswith("file:"): 263 # We don't need to check local file installations for versions, as 264 # these are symlinked, so we'll always pick up the latest. 265 continue 266 267 if name == "eslint" and LooseVersion("4.0.0") > LooseVersion(data["version"]): 268 print("ESLint is an old version, clobbering node_modules directory") 269 needs_clobber = True 270 has_issues = True 271 continue 272 273 if not version_in_range(data["version"], version_range): 274 print("%s v%s should be v%s." % (name, data["version"], version_range)) 275 has_issues = True 276 continue 277 278 return has_issues, needs_clobber 279 280 281def version_in_range(version, version_range): 282 """ 283 Check if a module version is inside a version range. Only supports explicit versions and 284 caret ranges for the moment, since that's all we've used so far. 285 """ 286 if version == version_range: 287 return True 288 289 version_match = VERSION_RE.match(version) 290 if not version_match: 291 raise RuntimeError("mach eslint doesn't understand module version %s" % version) 292 version = LooseVersion(version) 293 294 # Caret ranges as specified by npm allow changes that do not modify the left-most non-zero 295 # digit in the [major, minor, patch] tuple. The code below assumes the major digit is 296 # non-zero. 297 range_match = CARET_VERSION_RANGE_RE.match(version_range) 298 if range_match: 299 range_version = range_match.group(1) 300 range_major = int(range_match.group(2)) 301 302 range_min = LooseVersion(range_version) 303 range_max = LooseVersion("%d.0.0" % (range_major + 1)) 304 305 return range_min <= version < range_max 306 307 return False 308 309 310def get_possible_node_paths_win(): 311 """ 312 Return possible nodejs paths on Windows. 313 """ 314 if platform.system() != "Windows": 315 return [] 316 317 return list( 318 { 319 "%s\\nodejs" % os.environ.get("SystemDrive"), 320 os.path.join(os.environ.get("ProgramFiles"), "nodejs"), 321 os.path.join(os.environ.get("PROGRAMW6432"), "nodejs"), 322 os.path.join(os.environ.get("PROGRAMFILES"), "nodejs"), 323 } 324 ) 325 326 327def get_version(path): 328 try: 329 version_str = subprocess.check_output( 330 [path, "--version"], stderr=subprocess.STDOUT, universal_newlines=True 331 ) 332 return version_str 333 except (subprocess.CalledProcessError, OSError): 334 return None 335 336 337def set_project_root(root=None): 338 """Sets the project root to the supplied path, or works out what the root 339 is based on looking for 'mach'. 340 341 Keyword arguments: 342 root - (optional) The path to set the root to. 343 """ 344 global project_root 345 346 if root: 347 project_root = root 348 return 349 350 file_found = False 351 folder = os.getcwd() 352 353 while folder: 354 if os.path.exists(os.path.join(folder, "mach")): 355 file_found = True 356 break 357 else: 358 folder = os.path.dirname(folder) 359 360 if file_found: 361 project_root = os.path.abspath(folder) 362 363 364def get_project_root(): 365 """Returns the absolute path to the root of the project, see set_project_root() 366 for how this is determined. 367 """ 368 global project_root 369 370 if not project_root: 371 set_project_root() 372 373 return project_root 374 375 376def get_eslint_module_path(): 377 return os.path.join(get_project_root(), "tools", "lint", "eslint") 378 379 380def check_node_executables_valid(): 381 node_path, version = find_node_executable() 382 if not node_path: 383 print(NODE_NOT_FOUND_MESSAGE) 384 return False 385 if not version: 386 print(NODE_MACHING_VERSION_NOT_FOUND_MESSAGE % NODE_MIN_VERSION) 387 return False 388 389 npm_path, version = find_npm_executable() 390 if not npm_path: 391 print(NPM_NOT_FOUND_MESSAGE) 392 return False 393 if not version: 394 print(NPM_MACHING_VERSION_NOT_FOUND_MESSAGE % NPM_MIN_VERSION) 395 return False 396 397 return True 398 399 400def have_winrm(): 401 # `winrm -h` should print 'winrm version ...' and exit 1 402 try: 403 p = subprocess.Popen( 404 ["winrm.exe", "-h"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT 405 ) 406 return p.wait() == 1 and p.stdout.read().startswith("winrm") 407 except Exception: 408 return False 409