# Copyright 2016 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Contains helper functions used in the build scripts. This uses two environment variables to help with debugging the scripts: PRINT_ARGUMENTS - If set, will print any arguments to subprocess. RAISE_INTERRUPT - Will raise keyboard interrupts rather than swallowing them. """ from __future__ import print_function import errno import json import logging import os import platform import re import subprocess import sys import time def _node_modules_last_update_path(): return os.path.join(get_source_base(), 'node_modules', '.last_update') def _modules_need_update(): try: last_update = os.path.getmtime(_node_modules_last_update_path()) if last_update > time.time(): # Update time in the future! Something is wrong, so update. return True package_json_path = os.path.join(get_source_base(), 'package.json') last_json_change = os.path.getmtime(package_json_path) if last_json_change >= last_update: # The json file has changed, so update. return True except: # No such file, so we should update. return True return False def _parse_version(version): """Converts the given string version to a tuple of numbers.""" # Handle any prerelease or build metadata, such as -beta or -g1234 if '-' in version: version, trailer = version.split('-') else: # Versions without a trailer should sort later than those with a trailer. # For example, 2.5.0-beta comes before 2.5.0. # To accomplish this, we synthesize a trailer which sorts later than any # _reasonable_ alphanumeric version trailer would. These characters have a # high value in ASCII. trailer = '}}}' numeric_parts = [int(i) for i in version.split('.')] return tuple(numeric_parts + [trailer]) def get_source_base(): """Returns the absolute path to the source code base.""" return os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) def is_linux(): """Determines if the system is Linux.""" return platform.uname()[0] == 'Linux' def is_darwin(): """Determines if the system is a Mac.""" return platform.uname()[0] == 'Darwin' def is_windows(): """Determines if the system is native Windows (i.e. not Cygwin).""" return platform.uname()[0] == 'Windows' def is_cygwin(): """Determines if the system is Cygwin (i.e. not native Windows).""" return 'CYGWIN' in platform.uname()[0] def quote_argument(arg): """Wraps the given argument in quotes if needed. This is so execute_subprocess output can be copied and pasted into a shell. Args: arg: The string to convert. Returns: The quoted argument. """ if '"' in arg: assert "'" not in arg return "'" + arg + "'" if "'" in arg: assert '"' not in arg return '"' + arg + '"' if ' ' in arg: return '"' + arg + '"' return arg def open_file(*args, **kwargs): """Opens a file with the given mode and options.""" if sys.version_info[0] == 2: # Python 2 returns byte strings even in text mode, so the encoding doesn't # matter. return open(*args, **kwargs) else: # Python 3 requires setting an encoding so it reads strings. The default # is based on the platform, which on Windows, isn't UTF-8. return open(encoding='utf8', *args, **kwargs) def execute_subprocess(args, **kwargs): """Executes the given command using subprocess. If PRINT_ARGUMENTS environment variable is set, this will first print the arguments. Args: args: A list of strings for the subprocess to run. kwargs: Extra keyword arguments to pass to Popen. Returns: The same value as subprocess.Popen. """ if os.environ.get('PRINT_ARGUMENTS'): logging.info(' '.join([quote_argument(x) for x in args])) try: return subprocess.Popen(args, **kwargs) except OSError as e: if e.errno == errno.ENOENT: logging.error('*** A required dependency is missing: %s', args[0]) # Exit early to avoid showing a confusing stack trace. sys.exit(1) raise def execute_get_code(args): """Calls execute_subprocess and gets return code.""" obj = execute_subprocess(args) obj.communicate() return obj.returncode def execute_get_output(args): """Calls execute_subprocess and get the stdout of the process.""" obj = execute_subprocess(args, stdout=subprocess.PIPE) # This will block until the process terminates, storing the stdout in a string stdout = obj.communicate()[0] if obj.returncode != 0: raise subprocess.CalledProcessError(obj.returncode, args[0], stdout) return stdout def cygwin_safe_path(path): """Converts the given path to a Cygwin path, if needed.""" if is_cygwin(): return execute_get_output(['cygpath', '-w', path]).strip() else: return path def git_version(): """Gets the version of the library from git.""" # Check if the shaka-player source base directory has '.git' file. git_path = os.path.join(get_source_base(), '.git') if not os.path.exists(git_path): raise RuntimeError('no .git file is in the shaka-player repository.') else: try: # Check git tags for a version number, noting if the sources are dirty. cmd_line = ['git', '-C', get_source_base(), 'describe', '--tags', '--dirty'] return execute_get_output(cmd_line).decode('utf8').strip() except subprocess.CalledProcessError: raise RuntimeError('Unable to determine library version!') def npm_version(is_dirty=False): """Gets the version of the library from NPM.""" try: base = cygwin_safe_path(get_source_base()) cmd = 'npm.cmd' if is_windows() else 'npm' cmd_line = [cmd, '--prefix', base, 'ls', 'shaka-player'] text = execute_get_output(cmd_line).decode('utf8') except subprocess.CalledProcessError as e: text = e.output.decode('utf8') match = re.search(r'shaka-player@(.*) ', text) if match: return match.group(1) + ('-npm-dirty' if is_dirty else '') raise RuntimeError('Unable to determine library version!') def calculate_version(): """Returns the version of the library.""" # Fall back to NPM's installed package version, and assume the sources # are dirty since the build scripts are being run at all after install. try: return git_version() except RuntimeError: # If there is an error in |git_version|, ignore it and try NPM. If there # is an error with NPM, propagate the error. return npm_version(is_dirty=True) def get_closure_base_js_path(): return os.path.join(get_source_base(), 'node_modules', 'google-closure-library', 'closure', 'goog', 'base.js') def get_all_js_files(*path_components): """Get all JavaScript file paths recursively from the given path components. Args: *path_components: The components of the path to search. Joining is handling internally according to os path semantics. Returns: An array of absolute paths to all JS files. """ match = re.compile(r'.*\.js$') return get_all_files( os.path.join(get_source_base(), *path_components), match) def get_all_files(dir_path, exp=None): """Get all file paths recursively within the given path. This optionally will filter the output using the given regex. Args: dir_path: The string path to search. exp: A regex to match, can be None. Returns: An array of absolute paths to all the files. """ ret = [] for root, _, files in os.walk(dir_path): for f in files: if not exp or exp.match(f): ret.append(os.path.join(root, f)) ret.sort() return ret def get_node_binary(module_name, bin_name=None): """Returns an array to be used in the command-line execution of a node binary. For example, this may return ['eslint'] (global install) or ['node', 'path/to/node_modules/eslint/bin/eslint.js'] (local install). Arguments: module_name: A string, the name of the module. bin_name: An optional string, the name of the binary, which defaults to module_name if not provided. Returns: An array of strings which form the command-line to call the binary. """ if not bin_name: bin_name = module_name # Check local modules first. base = get_source_base() path = os.path.join(base, 'node_modules', module_name) if os.path.isdir(path): json_path = os.path.join(path, 'package.json') package_data = json.load(open_file(json_path, 'r')) bin_path = os.path.join(path, package_data['bin'][bin_name]) return ['node', bin_path] # Not found locally, assume it can be found in os.environ['PATH']. return [bin_name] class InDir(object): """A Context Manager that changes directories temporarily and safely.""" def __init__(self, path): self.new_path = path def __enter__(self): self.old_path = os.getcwd() os.chdir(self.new_path) def __exit__(self, type, value, traceback): os.chdir(self.old_path) def update_node_modules(): """Updates the node modules using 'npm', if they have not already been updated recently enough.""" if not _modules_need_update(): return True base = cygwin_safe_path(get_source_base()) cmd = 'npm.cmd' if is_windows() else 'npm' # Check the version of npm. version = execute_get_output([cmd, '-v']).decode('utf8') if _parse_version(version) < _parse_version('5.0.0'): logging.error('npm version is too old, please upgrade. e.g.:') logging.error(' npm install -g npm') return False # Update the modules. # Actually change directories instead of using npm --prefix. # See npm/npm#17027 and google/shaka-player#776 for more details. with InDir(base): # npm update seems to be the wrong thing in npm v5, so use install. # See google/shaka-player#854 for more details. execute_get_output([cmd, 'install']) # Update the timestamp of the file that tracks when we last updated. open(_node_modules_last_update_path(), 'wb').close() return True def run_main(main): """Executes the given function with the current command-line arguments. This calls exit with the return value. This ignores keyboard interrupts. Args: main: The main function to call. """ logging.getLogger().setLevel(logging.INFO) fmt = '[%(levelname)s] %(message)s' logging.basicConfig(format=fmt) try: sys.exit(main(sys.argv[1:])) except KeyboardInterrupt: if os.environ.get('RAISE_INTERRUPT'): raise print(file=sys.stderr) # Clear the current line that has ^C on it. logging.error('Keyboard interrupt') sys.exit(1)