/
shakaBuildHelpers.py
356 lines (274 loc) · 10.5 KB
/
shakaBuildHelpers.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# 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 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())
# Update the modules.
# Actually change directories instead of using npm --prefix.
# See npm/npm#17027 and shaka-project/shaka-player#776 for more details.
with InDir(base):
# npm ci uses package-lock.json to get a stable, reproducible set of
# packages installed.
execute_get_output(['npm', 'ci'])
# 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)