codekingpro/portable-devtools
114k
1# Copyright (c) 2012 Google Inc. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import errno
6import filecmp
7import os.path
8import re
9import shlex
10import subprocess
11import sys
12import tempfile
13from collections.abc import MutableSet
14
15
16# A minimal memoizing decorator. It'll blow up if the args aren't immutable,
17# among other "problems".
18class memoize:
19 def __init__(self, func):
20 self.func = func
21 self.cache = {}
22
23 def __call__(self, *args):
24 try:
25 return self.cache[args]
26 except KeyError:
27 result = self.func(*args)
28 self.cache[args] = result
29 return result
30
31
32class GypError(Exception):
33 """Error class representing an error, which is to be presented
34 to the user. The main entry point will catch and display this.
35 """
36
37
38def ExceptionAppend(e, msg):
39 """Append a message to the given exception's message."""
40 if not e.args:
41 e.args = (msg,)
42 elif len(e.args) == 1:
43 e.args = (str(e.args[0]) + " " + msg,)
44 else:
45 e.args = (str(e.args[0]) + " " + msg,) + e.args[1:]
46
47
48def FindQualifiedTargets(target, qualified_list):
49 """
50 Given a list of qualified targets, return the qualified targets for the
51 specified |target|.
52 """
53 return [t for t in qualified_list if ParseQualifiedTarget(t)[1] == target]
54
55
56def ParseQualifiedTarget(target):
57 # Splits a qualified target into a build file, target name and toolset.
58
59 # NOTE: rsplit is used to disambiguate the Windows drive letter separator.
60 target_split = target.rsplit(":", 1)
61 if len(target_split) == 2:
62 [build_file, target] = target_split
63 else:
64 build_file = None
65
66 target_split = target.rsplit("#", 1)
67 if len(target_split) == 2:
68 [target, toolset] = target_split
69 else:
70 toolset = None
71
72 return [build_file, target, toolset]
73
74
75def ResolveTarget(build_file, target, toolset):
76 # This function resolves a target into a canonical form:
77 # - a fully defined build file, either absolute or relative to the current
78 # directory
79 # - a target name
80 # - a toolset
81 #
82 # build_file is the file relative to which 'target' is defined.
83 # target is the qualified target.
84 # toolset is the default toolset for that target.
85 [parsed_build_file, target, parsed_toolset] = ParseQualifiedTarget(target)
86
87 if parsed_build_file:
88 if build_file:
89 # If a relative path, parsed_build_file is relative to the directory
90 # containing build_file. If build_file is not in the current directory,
91 # parsed_build_file is not a usable path as-is. Resolve it by
92 # interpreting it as relative to build_file. If parsed_build_file is
93 # absolute, it is usable as a path regardless of the current directory,
94 # and os.path.join will return it as-is.
95 build_file = os.path.normpath(
96 os.path.join(os.path.dirname(build_file), parsed_build_file)
97 )
98 # Further (to handle cases like ../cwd), make it relative to cwd)
99 if not os.path.isabs(build_file):
100 build_file = RelativePath(build_file, ".")
101 else:
102 build_file = parsed_build_file
103
104 if parsed_toolset:
105 toolset = parsed_toolset
106
107 return [build_file, target, toolset]
108
109
110def BuildFile(fully_qualified_target):
111 # Extracts the build file from the fully qualified target.
112 return ParseQualifiedTarget(fully_qualified_target)[0]
113
114
115def GetEnvironFallback(var_list, default):
116 """Look up a key in the environment, with fallback to secondary keys
117 and finally falling back to a default value."""
118 for var in var_list:
119 if var in os.environ:
120 return os.environ[var]
121 return default
122
123
124def QualifiedTarget(build_file, target, toolset):
125 # "Qualified" means the file that a target was defined in and the target
126 # name, separated by a colon, suffixed by a # and the toolset name:
127 # /path/to/file.gyp:target_name#toolset
128 fully_qualified = build_file + ":" + target
129 if toolset:
130 fully_qualified = fully_qualified + "#" + toolset
131 return fully_qualified
132
133
134@memoize
135def RelativePath(path, relative_to, follow_path_symlink=True):
136 # Assuming both |path| and |relative_to| are relative to the current
137 # directory, returns a relative path that identifies path relative to
138 # relative_to.
139 # If |follow_symlink_path| is true (default) and |path| is a symlink, then
140 # this method returns a path to the real file represented by |path|. If it is
141 # false, this method returns a path to the symlink. If |path| is not a
142 # symlink, this option has no effect.
143
144 # Convert to normalized (and therefore absolute paths).
145 path = os.path.realpath(path) if follow_path_symlink else os.path.abspath(path)
146 relative_to = os.path.realpath(relative_to)
147
148 # On Windows, we can't create a relative path to a different drive, so just
149 # use the absolute path.
150 if sys.platform == "win32" and (
151 os.path.splitdrive(path)[0].lower()
152 != os.path.splitdrive(relative_to)[0].lower()
153 ):
154 return path
155
156 # Split the paths into components.
157 path_split = path.split(os.path.sep)
158 relative_to_split = relative_to.split(os.path.sep)
159
160 # Determine how much of the prefix the two paths share.
161 prefix_len = len(os.path.commonprefix([path_split, relative_to_split]))
162
163 # Put enough ".." components to back up out of relative_to to the common
164 # prefix, and then append the part of path_split after the common prefix.
165 relative_split = [os.path.pardir] * (
166 len(relative_to_split) - prefix_len
167 ) + path_split[prefix_len:]
168
169 if len(relative_split) == 0:
170 # The paths were the same.
171 return ""
172
173 # Turn it back into a string and we're done.
174 return os.path.join(*relative_split)
175
176
177@memoize
178def InvertRelativePath(path, toplevel_dir=None):
179 """Given a path like foo/bar that is relative to toplevel_dir, return
180 the inverse relative path back to the toplevel_dir.
181
182 E.g. os.path.normpath(os.path.join(path, InvertRelativePath(path)))
183 should always produce the empty string, unless the path contains symlinks.
184 """
185 if not path:
186 return path
187 toplevel_dir = "." if toplevel_dir is None else toplevel_dir
188 return RelativePath(toplevel_dir, os.path.join(toplevel_dir, path))
189
190
191def FixIfRelativePath(path, relative_to):
192 # Like RelativePath but returns |path| unchanged if it is absolute.
193 if os.path.isabs(path):
194 return path
195 return RelativePath(path, relative_to)
196
197
198def UnrelativePath(path, relative_to):
199 # Assuming that |relative_to| is relative to the current directory, and |path|
200 # is a path relative to the dirname of |relative_to|, returns a path that
201 # identifies |path| relative to the current directory.
202 rel_dir = os.path.dirname(relative_to)
203 return os.path.normpath(os.path.join(rel_dir, path))
204
205
206# re objects used by EncodePOSIXShellArgument. See IEEE 1003.1 XCU.2.2 at
207# http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_02
208# and the documentation for various shells.
209
210# _quote is a pattern that should match any argument that needs to be quoted
211# with double-quotes by EncodePOSIXShellArgument. It matches the following
212# characters appearing anywhere in an argument:
213# \t, \n, space parameter separators
214# # comments
215# $ expansions (quoted to always expand within one argument)
216# % called out by IEEE 1003.1 XCU.2.2
217# & job control
218# ' quoting
219# (, ) subshell execution
220# *, ?, [ pathname expansion
221# ; command delimiter
222# <, >, | redirection
223# = assignment
224# {, } brace expansion (bash)
225# ~ tilde expansion
226# It also matches the empty string, because "" (or '') is the only way to
227# represent an empty string literal argument to a POSIX shell.
228#
229# This does not match the characters in _escape, because those need to be
230# backslash-escaped regardless of whether they appear in a double-quoted
231# string.
232_quote = re.compile("[\t\n #$%&'()*;<=>?[{|}~]|^$")
233
234# _escape is a pattern that should match any character that needs to be
235# escaped with a backslash, whether or not the argument matched the _quote
236# pattern. _escape is used with re.sub to backslash anything in _escape's
237# first match group, hence the (parentheses) in the regular expression.
238#
239# _escape matches the following characters appearing anywhere in an argument:
240# " to prevent POSIX shells from interpreting this character for quoting
241# \ to prevent POSIX shells from interpreting this character for escaping
242# ` to prevent POSIX shells from interpreting this character for command
243# substitution
244# Missing from this list is $, because the desired behavior of
245# EncodePOSIXShellArgument is to permit parameter (variable) expansion.
246#
247# Also missing from this list is !, which bash will interpret as the history
248# expansion character when history is enabled. bash does not enable history
249# by default in non-interactive shells, so this is not thought to be a problem.
250# ! was omitted from this list because bash interprets "\!" as a literal string
251# including the backslash character (avoiding history expansion but retaining
252# the backslash), which would not be correct for argument encoding. Handling
253# this case properly would also be problematic because bash allows the history
254# character to be changed with the histchars shell variable. Fortunately,
255# as history is not enabled in non-interactive shells and
256# EncodePOSIXShellArgument is only expected to encode for non-interactive
257# shells, there is no room for error here by ignoring !.
258_escape = re.compile(r'(["\\`])')
259
260
261def EncodePOSIXShellArgument(argument):
262 """Encodes |argument| suitably for consumption by POSIX shells.
263
264 argument may be quoted and escaped as necessary to ensure that POSIX shells
265 treat the returned value as a literal representing the argument passed to
266 this function. Parameter (variable) expansions beginning with $ are allowed
267 to remain intact without escaping the $, to allow the argument to contain
268 references to variables to be expanded by the shell.
269 """
270
271 if not isinstance(argument, str):
272 argument = str(argument)
273
274 quote = '"' if _quote.search(argument) else ""
275
276 encoded = quote + re.sub(_escape, r"\\\1", argument) + quote
277
278 return encoded
279
280
281def EncodePOSIXShellList(list):
282 """Encodes |list| suitably for consumption by POSIX shells.
283
284 Returns EncodePOSIXShellArgument for each item in list, and joins them
285 together using the space character as an argument separator.
286 """
287
288 encoded_arguments = []
289 for argument in list:
290 encoded_arguments.append(EncodePOSIXShellArgument(argument))
291 return " ".join(encoded_arguments)
292
293
294def DeepDependencyTargets(target_dicts, roots):
295 """Returns the recursive list of target dependencies."""
296 dependencies = set()
297 pending = set(roots)
298 while pending:
299 # Pluck out one.
300 r = pending.pop()
301 # Skip if visited already.
302 if r in dependencies:
303 continue
304 # Add it.
305 dependencies.add(r)
306 # Add its children.
307 spec = target_dicts[r]
308 pending.update(set(spec.get("dependencies", [])))
309 pending.update(set(spec.get("dependencies_original", [])))
310 return list(dependencies - set(roots))
311
312
313def BuildFileTargets(target_list, build_file):
314 """From a target_list, returns the subset from the specified build_file."""
315 return [p for p in target_list if BuildFile(p) == build_file]
316
317
318def AllTargets(target_list, target_dicts, build_file):
319 """Returns all targets (direct and dependencies) for the specified build_file."""
320 bftargets = BuildFileTargets(target_list, build_file)
321 deptargets = DeepDependencyTargets(target_dicts, bftargets)
322 return bftargets + deptargets
323
324
325def WriteOnDiff(filename):
326 """Write to a file only if the new contents differ.
327
328 Arguments:
329 filename: name of the file to potentially write to.
330 Returns:
331 A file like object which will write to temporary file and only overwrite
332 the target if it differs (on close).
333 """
334
335 class Writer:
336 """Wrapper around file which only covers the target if it differs."""
337
338 def __init__(self):
339 # On Cygwin remove the "dir" argument
340 # `C:` prefixed paths are treated as relative,
341 # consequently ending up with current dir "/cygdrive/c/..."
342 # being prefixed to those, which was
343 # obviously a non-existent path,
344 # for example: "/cygdrive/c/<some folder>/C:\<my win style abs path>".
345 # For more details see:
346 # https://docs.python.org/2/library/tempfile.html#tempfile.mkstemp
347 base_temp_dir = "" if IsCygwin() else os.path.dirname(filename)
348 # Pick temporary file.
349 tmp_fd, self.tmp_path = tempfile.mkstemp(
350 suffix=".tmp",
351 prefix=os.path.split(filename)[1] + ".gyp.",
352 dir=base_temp_dir,
353 )
354 try:
355 self.tmp_file = os.fdopen(tmp_fd, "wb")
356 except Exception:
357 # Don't leave turds behind.
358 os.unlink(self.tmp_path)
359 raise
360
361 def __getattr__(self, attrname):
362 # Delegate everything else to self.tmp_file
363 return getattr(self.tmp_file, attrname)
364
365 def close(self):
366 try:
367 # Close tmp file.
368 self.tmp_file.close()
369 # Determine if different.
370 same = False
371 try:
372 same = filecmp.cmp(self.tmp_path, filename, False)
373 except OSError as e:
374 if e.errno != errno.ENOENT:
375 raise
376
377 if same:
378 # The new file is identical to the old one, just get rid of the new
379 # one.
380 os.unlink(self.tmp_path)
381 else:
382 # The new file is different from the old one,
383 # or there is no old one.
384 # Rename the new file to the permanent name.
385 #
386 # tempfile.mkstemp uses an overly restrictive mode, resulting in a
387 # file that can only be read by the owner, regardless of the umask.
388 # There's no reason to not respect the umask here,
389 # which means that an extra hoop is required
390 # to fetch it and reset the new file's mode.
391 #
392 # No way to get the umask without setting a new one? Set a safe one
393 # and then set it back to the old value.
394 umask = os.umask(0o77)
395 os.umask(umask)
396 os.chmod(self.tmp_path, 0o666 & ~umask)
397 if sys.platform == "win32" and os.path.exists(filename):
398 # NOTE: on windows (but not cygwin) rename will not replace an
399 # existing file, so it must be preceded with a remove.
400 # Sadly there is no way to make the switch atomic.
401 os.remove(filename)
402 os.rename(self.tmp_path, filename)
403 except Exception:
404 # Don't leave turds behind.
405 os.unlink(self.tmp_path)
406 raise
407
408 def write(self, s):
409 self.tmp_file.write(s.encode("utf-8"))
410
411 return Writer()
412
413
414def EnsureDirExists(path):
415 """Make sure the directory for |path| exists."""
416 try:
417 os.makedirs(os.path.dirname(path))
418 except OSError:
419 pass
420
421
422def GetCompilerPredefines(): # -> dict
423 cmd = []
424 defines = {}
425
426 # shlex.split() will eat '\' in posix mode, but
427 # setting posix=False will preserve extra '"' cause CreateProcess fail on Windows
428 # this makes '\' in %CC_target% and %CFLAGS% work
429 def replace_sep(s):
430 return s.replace(os.sep, "/") if os.sep != "/" else s
431
432 if CC := os.environ.get("CC_target") or os.environ.get("CC"):
433 cmd += shlex.split(replace_sep(CC))
434 if CFLAGS := os.environ.get("CFLAGS"):
435 cmd += shlex.split(replace_sep(CFLAGS))
436 elif CXX := os.environ.get("CXX_target") or os.environ.get("CXX"):
437 cmd += shlex.split(replace_sep(CXX))
438 if CXXFLAGS := os.environ.get("CXXFLAGS"):
439 cmd += shlex.split(replace_sep(CXXFLAGS))
440 else:
441 return defines
442
443 if sys.platform == "win32":
444 fd, input = tempfile.mkstemp(suffix=".c")
445 real_cmd = [*cmd, "-dM", "-E", "-x", "c", input]
446 try:
447 os.close(fd)
448 stdout = subprocess.run(
449 real_cmd, shell=True, capture_output=True, check=True
450 ).stdout
451 except subprocess.CalledProcessError as e:
452 print(
453 "Warning: failed to get compiler predefines\n"
454 "cmd: %s\n"
455 "status: %d" % (e.cmd, e.returncode),
456 file=sys.stderr,
457 )
458 return defines
459 finally:
460 os.unlink(input)
461 else:
462 input = "/dev/null"
463 real_cmd = [*cmd, "-dM", "-E", "-x", "c", input]
464 try:
465 stdout = subprocess.run(
466 real_cmd, shell=False, capture_output=True, check=True
467 ).stdout
468 except subprocess.CalledProcessError as e:
469 print(
470 "Warning: failed to get compiler predefines\n"
471 "cmd: %s\n"
472 "status: %d" % (e.cmd, e.returncode),
473 file=sys.stderr,
474 )
475 return defines
476
477 lines = stdout.decode("utf-8").replace("\r\n", "\n").split("\n")
478 for line in lines:
479 if (line or "").startswith("#define "):
480 _, key, *value = line.split(" ")
481 defines[key] = " ".join(value)
482 return defines
483
484
485def GetFlavorByPlatform():
486 """Returns |params.flavor| if it's set, the system's default flavor else."""
487 flavors = {
488 "cygwin": "win",
489 "win32": "win",
490 "darwin": "mac",
491 }
492
493 if sys.platform in flavors:
494 return flavors[sys.platform]
495 if sys.platform.startswith("sunos"):
496 return "solaris"
497 if sys.platform.startswith(("dragonfly", "freebsd")):
498 return "freebsd"
499 if sys.platform.startswith("openbsd"):
500 return "openbsd"
501 if sys.platform.startswith("netbsd"):
502 return "netbsd"
503 if sys.platform.startswith("aix"):
504 return "aix"
505 if sys.platform.startswith(("os390", "zos")):
506 return "zos"
507 if sys.platform == "os400":
508 return "os400"
509
510 return "linux"
511
512
513def GetFlavor(params):
514 if "flavor" in params:
515 return params["flavor"]
516
517 defines = GetCompilerPredefines()
518 if "__EMSCRIPTEN__" in defines:
519 return "emscripten"
520 if "__wasm__" in defines:
521 return "wasi" if "__wasi__" in defines else "wasm"
522
523 return GetFlavorByPlatform()
524
525
526def CopyTool(flavor, out_path, generator_flags={}):
527 """Finds (flock|mac|win)_tool.gyp in the gyp directory and copies it
528 to |out_path|."""
529 # aix and solaris just need flock emulation. mac and win use more complicated
530 # support scripts.
531 prefix = {
532 "aix": "flock",
533 "os400": "flock",
534 "solaris": "flock",
535 "mac": "mac",
536 "ios": "mac",
537 "win": "win",
538 }.get(flavor, None)
539 if not prefix:
540 return
541
542 # Slurp input file.
543 source_path = os.path.join(
544 os.path.dirname(os.path.abspath(__file__)), "%s_tool.py" % prefix
545 )
546 with open(source_path) as source_file:
547 source = source_file.readlines()
548
549 # Set custom header flags.
550 header = "# Generated by gyp. Do not edit.\n"
551 mac_toolchain_dir = generator_flags.get("mac_toolchain_dir", None)
552 if flavor == "mac" and mac_toolchain_dir:
553 header += "import os;\nos.environ['DEVELOPER_DIR']='%s'\n" % mac_toolchain_dir
554
555 # Add header and write it out.
556 tool_path = os.path.join(out_path, "gyp-%s-tool" % prefix)
557 with open(tool_path, "w") as tool_file:
558 tool_file.write("".join([source[0], header] + source[1:]))
559
560 # Make file executable.
561 os.chmod(tool_path, 0o755)
562
563
564# From Alex Martelli,
565# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52560
566# ASPN: Python Cookbook: Remove duplicates from a sequence
567# First comment, dated 2001/10/13.
568# (Also in the printed Python Cookbook.)
569
570
571def uniquer(seq, idfun=lambda x: x):
572 seen = {}
573 result = []
574 for item in seq:
575 marker = idfun(item)
576 if marker in seen:
577 continue
578 seen[marker] = 1
579 result.append(item)
580 return result
581
582
583# Based on http://code.activestate.com/recipes/576694/.
584class OrderedSet(MutableSet): # noqa: PLW1641
585 # TODO (cclauss): Fix eq-without-hash ruff rule PLW1641
586 def __init__(self, iterable=None):
587 self.end = end = []
588 end += [None, end, end] # sentinel node for doubly linked list
589 self.map = {} # key --> [key, prev, next]
590 if iterable is not None:
591 self |= iterable
592
593 def __len__(self):
594 return len(self.map)
595
596 def __contains__(self, key):
597 return key in self.map
598
599 def add(self, key):
600 if key not in self.map:
601 end = self.end
602 curr = end[1]
603 curr[2] = end[1] = self.map[key] = [key, curr, end]
604
605 def discard(self, key):
606 if key in self.map:
607 key, prev_item, next_item = self.map.pop(key)
608 prev_item[2] = next_item
609 next_item[1] = prev_item
610
611 def __iter__(self):
612 end = self.end
613 curr = end[2]
614 while curr is not end:
615 yield curr[0]
616 curr = curr[2]
617
618 def __reversed__(self):
619 end = self.end
620 curr = end[1]
621 while curr is not end:
622 yield curr[0]
623 curr = curr[1]
624
625 # The second argument is an addition that causes a pylint warning.
626 def pop(self, last=True): # pylint: disable=W0221
627 if not self:
628 raise KeyError("set is empty")
629 key = self.end[1][0] if last else self.end[2][0]
630 self.discard(key)
631 return key
632
633 def __repr__(self):
634 if not self:
635 return f"{self.__class__.__name__}()"
636 return f"{self.__class__.__name__}({list(self)!r})"
637
638 def __eq__(self, other):
639 if isinstance(other, OrderedSet):
640 return len(self) == len(other) and list(self) == list(other)
641 return set(self) == set(other)
642
643 # Extensions to the recipe.
644 def update(self, iterable):
645 for i in iterable:
646 if i not in self:
647 self.add(i)
648
649
650class CycleError(Exception):
651 """An exception raised when an unexpected cycle is detected."""
652
653 def __init__(self, nodes):
654 self.nodes = nodes
655
656 def __str__(self):
657 return "CycleError: cycle involving: " + str(self.nodes)
658
659
660def TopologicallySorted(graph, get_edges):
661 r"""Topologically sort based on a user provided edge definition.
662
663 Args:
664 graph: A list of node names.
665 get_edges: A function mapping from node name to a hashable collection
666 of node names which this node has outgoing edges to.
667 Returns:
668 A list containing all of the node in graph in topological order.
669 It is assumed that calling get_edges once for each node and caching is
670 cheaper than repeatedly calling get_edges.
671 Raises:
672 CycleError in the event of a cycle.
673 Example:
674 graph = {'a': '$(b) $(c)', 'b': 'hi', 'c': '$(b)'}
675 def GetEdges(node):
676 return re.findall(r'\$\(([^))]\)', graph[node])
677 print TopologicallySorted(graph.keys(), GetEdges)
678 ==>
679 ['a', 'c', b']
680 """
681 get_edges = memoize(get_edges)
682 visited = set()
683 visiting = set()
684 ordered_nodes = []
685
686 def Visit(node):
687 if node in visiting:
688 raise CycleError(visiting)
689 if node in visited:
690 return
691 visited.add(node)
692 visiting.add(node)
693 for neighbor in get_edges(node):
694 Visit(neighbor)
695 visiting.remove(node)
696 ordered_nodes.insert(0, node)
697
698 for node in sorted(graph):
699 Visit(node)
700 return ordered_nodes
701
702
703def CrossCompileRequested():
704 # TODO: figure out how to not build extra host objects in the
705 # non-cross-compile case when this is enabled, and enable unconditionally.
706 return (
707 os.environ.get("GYP_CROSSCOMPILE")
708 or os.environ.get("AR_host")
709 or os.environ.get("CC_host")
710 or os.environ.get("CXX_host")
711 or os.environ.get("AR_target")
712 or os.environ.get("CC_target")
713 or os.environ.get("CXX_target")
714 )
715
716
717def IsCygwin():
718 try:
719 out = subprocess.Popen(
720 "uname", stdout=subprocess.PIPE, stderr=subprocess.STDOUT
721 )
722 stdout = out.communicate()[0].decode("utf-8")
723 return "CYGWIN" in str(stdout)
724 except Exception:
725 return False
726 