mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-15 16:06:41 +03:00
0535e2f35b
Without this patch, subprocess fails to find .CMD scripts on Windows, even though these are executables and should be treated as such according to the PATHEXT environment variable. Many people "work around" this issue on Windows with subprocess's shell=True argument, but this comes with a risk of shell injection vulnerabilities. Another solution is to explicitly add ".CMD" to the end of some commands on Windows, but this breaks portability and requires "if windows" to be scattered around a codebase. We previously took this approach in Shaka Player. This monkeypatch allows the caller of subprocess to stop worrying about Windows nuances and to go back to the security best practice of shell=False. Any .CMD script that would be found by the Windows shell will now be found by subprocess. And because we're using the standard Windows PATHEXT environment variable, this can be extended to other types of executable scripts, as well. This monkeypatch can be used in any Python project that has this issue, and merely has to be imported to function. It has been verified in Python 3.8, both in the Cygwin version and in the native Windows version of Python. Change-Id: I37bb522fbf5f058431a209c73508bd225052999a
382 lines
11 KiB
Python
382 lines
11 KiB
Python
# 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
|
|
|
|
import subprocessWindowsPatch
|
|
|
|
|
|
# Python 3 no longer has a separate unicode type. For type-checking done in
|
|
# get_node_binary, create an alias to the str type.
|
|
if sys.version_info[0] == 3:
|
|
unicode = str
|
|
|
|
|
|
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.
|
|
"""
|
|
# Windows can't run scripts directly, even if executable. We need to
|
|
# explicitly run the interpreter.
|
|
if args[0].endswith('.js'):
|
|
raise ValueError('Use "node" to run JavaScript scripts')
|
|
if args[0].endswith('.py'):
|
|
raise ValueError('Use sys.executable to run Python scripts')
|
|
|
|
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_line = ['npm', '--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_data = package_data['bin']
|
|
|
|
if type(bin_data) is str or type(bin_data) is unicode:
|
|
# There's only one binary here.
|
|
bin_rel_path = bin_data
|
|
else:
|
|
# It's a dictionary, so look up the specific binary we want.
|
|
bin_rel_path = bin_data[bin_name]
|
|
|
|
bin_path = os.path.join(path, bin_rel_path)
|
|
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())
|
|
|
|
# Check the version of npm.
|
|
version = execute_get_output(['npm', '-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(['npm', '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)
|