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 5from __future__ import with_statement 6 7import errno 8import filecmp 9import os.path 10import re 11import tempfile 12import sys 13 14 15# A minimal memoizing decorator. It'll blow up if the args aren't immutable, 16# among other "problems". 17class memoize(object): 18 def __init__(self, func): 19 self.func = func 20 self.cache = {} 21 def __call__(self, *args): 22 try: 23 return self.cache[args] 24 except KeyError: 25 result = self.func(*args) 26 self.cache[args] = result 27 return result 28 29 30class GypError(Exception): 31 """Error class representing an error, which is to be presented 32 to the user. The main entry point will catch and display this. 33 """ 34 pass 35 36 37def ExceptionAppend(e, msg): 38 """Append a message to the given exception's message.""" 39 if not e.args: 40 e.args = (msg,) 41 elif len(e.args) == 1: 42 e.args = (str(e.args[0]) + ' ' + msg,) 43 else: 44 e.args = (str(e.args[0]) + ' ' + msg,) + e.args[1:] 45 46 47def FindQualifiedTargets(target, qualified_list): 48 """ 49 Given a list of qualified targets, return the qualified targets for the 50 specified |target|. 51 """ 52 return [t for t in qualified_list if ParseQualifiedTarget(t)[1] == target] 53 54 55def ParseQualifiedTarget(target): 56 # Splits a qualified target into a build file, target name and toolset. 57 58 # NOTE: rsplit is used to disambiguate the Windows drive letter separator. 59 target_split = target.rsplit(':', 1) 60 if len(target_split) == 2: 61 [build_file, target] = target_split 62 else: 63 build_file = None 64 65 target_split = target.rsplit('#', 1) 66 if len(target_split) == 2: 67 [target, toolset] = target_split 68 else: 69 toolset = None 70 71 return [build_file, target, toolset] 72 73 74def ResolveTarget(build_file, target, toolset): 75 # This function resolves a target into a canonical form: 76 # - a fully defined build file, either absolute or relative to the current 77 # directory 78 # - a target name 79 # - a toolset 80 # 81 # build_file is the file relative to which 'target' is defined. 82 # target is the qualified target. 83 # toolset is the default toolset for that target. 84 [parsed_build_file, target, parsed_toolset] = ParseQualifiedTarget(target) 85 86 if parsed_build_file: 87 if build_file: 88 # If a relative path, parsed_build_file is relative to the directory 89 # containing build_file. If build_file is not in the current directory, 90 # parsed_build_file is not a usable path as-is. Resolve it by 91 # interpreting it as relative to build_file. If parsed_build_file is 92 # absolute, it is usable as a path regardless of the current directory, 93 # and os.path.join will return it as-is. 94 build_file = os.path.normpath(os.path.join(os.path.dirname(build_file), 95 parsed_build_file)) 96 # Further (to handle cases like ../cwd), make it relative to cwd) 97 if not os.path.isabs(build_file): 98 build_file = RelativePath(build_file, '.') 99 else: 100 build_file = parsed_build_file 101 102 if parsed_toolset: 103 toolset = parsed_toolset 104 105 return [build_file, target, toolset] 106 107 108def BuildFile(fully_qualified_target): 109 # Extracts the build file from the fully qualified target. 110 return ParseQualifiedTarget(fully_qualified_target)[0] 111 112 113def GetEnvironFallback(var_list, default): 114 """Look up a key in the environment, with fallback to secondary keys 115 and finally falling back to a default value.""" 116 for var in var_list: 117 if var in os.environ: 118 return os.environ[var] 119 return default 120 121 122def QualifiedTarget(build_file, target, toolset): 123 # "Qualified" means the file that a target was defined in and the target 124 # name, separated by a colon, suffixed by a # and the toolset name: 125 # /path/to/file.gyp:target_name#toolset 126 fully_qualified = build_file + ':' + target 127 if toolset: 128 fully_qualified = fully_qualified + '#' + toolset 129 return fully_qualified 130 131 132@memoize 133def RelativePath(path, relative_to): 134 # Assuming both |path| and |relative_to| are relative to the current 135 # directory, returns a relative path that identifies path relative to 136 # relative_to. 137 138 # Convert to normalized (and therefore absolute paths). 139 path = os.path.realpath(path) 140 relative_to = os.path.realpath(relative_to) 141 142 # On Windows, we can't create a relative path to a different drive, so just 143 # use the absolute path. 144 if sys.platform == 'win32': 145 if (os.path.splitdrive(path)[0].lower() != 146 os.path.splitdrive(relative_to)[0].lower()): 147 return path 148 149 # Split the paths into components. 150 path_split = path.split(os.path.sep) 151 relative_to_split = relative_to.split(os.path.sep) 152 153 # Determine how much of the prefix the two paths share. 154 prefix_len = len(os.path.commonprefix([path_split, relative_to_split])) 155 156 # Put enough ".." components to back up out of relative_to to the common 157 # prefix, and then append the part of path_split after the common prefix. 158 relative_split = [os.path.pardir] * (len(relative_to_split) - prefix_len) + \ 159 path_split[prefix_len:] 160 161 if len(relative_split) == 0: 162 # The paths were the same. 163 return '' 164 165 # Turn it back into a string and we're done. 166 return os.path.join(*relative_split) 167 168 169@memoize 170def InvertRelativePath(path, toplevel_dir=None): 171 """Given a path like foo/bar that is relative to toplevel_dir, return 172 the inverse relative path back to the toplevel_dir. 173 174 E.g. os.path.normpath(os.path.join(path, InvertRelativePath(path))) 175 should always produce the empty string, unless the path contains symlinks. 176 """ 177 if not path: 178 return path 179 toplevel_dir = '.' if toplevel_dir is None else toplevel_dir 180 return RelativePath(toplevel_dir, os.path.join(toplevel_dir, path)) 181 182 183def FixIfRelativePath(path, relative_to): 184 # Like RelativePath but returns |path| unchanged if it is absolute. 185 if os.path.isabs(path): 186 return path 187 return RelativePath(path, relative_to) 188 189 190def UnrelativePath(path, relative_to): 191 # Assuming that |relative_to| is relative to the current directory, and |path| 192 # is a path relative to the dirname of |relative_to|, returns a path that 193 # identifies |path| relative to the current directory. 194 rel_dir = os.path.dirname(relative_to) 195 return os.path.normpath(os.path.join(rel_dir, path)) 196 197 198# re objects used by EncodePOSIXShellArgument. See IEEE 1003.1 XCU.2.2 at 199# http://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_02 200# and the documentation for various shells. 201 202# _quote is a pattern that should match any argument that needs to be quoted 203# with double-quotes by EncodePOSIXShellArgument. It matches the following 204# characters appearing anywhere in an argument: 205# \t, \n, space parameter separators 206# # comments 207# $ expansions (quoted to always expand within one argument) 208# % called out by IEEE 1003.1 XCU.2.2 209# & job control 210# ' quoting 211# (, ) subshell execution 212# *, ?, [ pathname expansion 213# ; command delimiter 214# <, >, | redirection 215# = assignment 216# {, } brace expansion (bash) 217# ~ tilde expansion 218# It also matches the empty string, because "" (or '') is the only way to 219# represent an empty string literal argument to a POSIX shell. 220# 221# This does not match the characters in _escape, because those need to be 222# backslash-escaped regardless of whether they appear in a double-quoted 223# string. 224_quote = re.compile('[\t\n #$%&\'()*;<=>?[{|}~]|^$') 225 226# _escape is a pattern that should match any character that needs to be 227# escaped with a backslash, whether or not the argument matched the _quote 228# pattern. _escape is used with re.sub to backslash anything in _escape's 229# first match group, hence the (parentheses) in the regular expression. 230# 231# _escape matches the following characters appearing anywhere in an argument: 232# " to prevent POSIX shells from interpreting this character for quoting 233# \ to prevent POSIX shells from interpreting this character for escaping 234# ` to prevent POSIX shells from interpreting this character for command 235# substitution 236# Missing from this list is $, because the desired behavior of 237# EncodePOSIXShellArgument is to permit parameter (variable) expansion. 238# 239# Also missing from this list is !, which bash will interpret as the history 240# expansion character when history is enabled. bash does not enable history 241# by default in non-interactive shells, so this is not thought to be a problem. 242# ! was omitted from this list because bash interprets "\!" as a literal string 243# including the backslash character (avoiding history expansion but retaining 244# the backslash), which would not be correct for argument encoding. Handling 245# this case properly would also be problematic because bash allows the history 246# character to be changed with the histchars shell variable. Fortunately, 247# as history is not enabled in non-interactive shells and 248# EncodePOSIXShellArgument is only expected to encode for non-interactive 249# shells, there is no room for error here by ignoring !. 250_escape = re.compile(r'(["\\`])') 251 252def EncodePOSIXShellArgument(argument): 253 """Encodes |argument| suitably for consumption by POSIX shells. 254 255 argument may be quoted and escaped as necessary to ensure that POSIX shells 256 treat the returned value as a literal representing the argument passed to 257 this function. Parameter (variable) expansions beginning with $ are allowed 258 to remain intact without escaping the $, to allow the argument to contain 259 references to variables to be expanded by the shell. 260 """ 261 262 if not isinstance(argument, str): 263 argument = str(argument) 264 265 if _quote.search(argument): 266 quote = '"' 267 else: 268 quote = '' 269 270 encoded = quote + re.sub(_escape, r'\\\1', argument) + quote 271 272 return encoded 273 274 275def EncodePOSIXShellList(list): 276 """Encodes |list| suitably for consumption by POSIX shells. 277 278 Returns EncodePOSIXShellArgument for each item in list, and joins them 279 together using the space character as an argument separator. 280 """ 281 282 encoded_arguments = [] 283 for argument in list: 284 encoded_arguments.append(EncodePOSIXShellArgument(argument)) 285 return ' '.join(encoded_arguments) 286 287 288def DeepDependencyTargets(target_dicts, roots): 289 """Returns the recursive list of target dependencies.""" 290 dependencies = set() 291 pending = set(roots) 292 while pending: 293 # Pluck out one. 294 r = pending.pop() 295 # Skip if visited already. 296 if r in dependencies: 297 continue 298 # Add it. 299 dependencies.add(r) 300 # Add its children. 301 spec = target_dicts[r] 302 pending.update(set(spec.get('dependencies', []))) 303 pending.update(set(spec.get('dependencies_original', []))) 304 return list(dependencies - set(roots)) 305 306 307def BuildFileTargets(target_list, build_file): 308 """From a target_list, returns the subset from the specified build_file. 309 """ 310 return [p for p in target_list if BuildFile(p) == build_file] 311 312 313def AllTargets(target_list, target_dicts, build_file): 314 """Returns all targets (direct and dependencies) for the specified build_file. 315 """ 316 bftargets = BuildFileTargets(target_list, build_file) 317 deptargets = DeepDependencyTargets(target_dicts, bftargets) 318 return bftargets + deptargets 319 320 321def WriteOnDiff(filename): 322 """Write to a file only if the new contents differ. 323 324 Arguments: 325 filename: name of the file to potentially write to. 326 Returns: 327 A file like object which will write to temporary file and only overwrite 328 the target if it differs (on close). 329 """ 330 331 class Writer: 332 """Wrapper around file which only covers the target if it differs.""" 333 def __init__(self): 334 # Pick temporary file. 335 tmp_fd, self.tmp_path = tempfile.mkstemp( 336 suffix='.tmp', 337 prefix=os.path.split(filename)[1] + '.gyp.', 338 dir=os.path.split(filename)[0]) 339 try: 340 self.tmp_file = os.fdopen(tmp_fd, 'wb') 341 except Exception: 342 # Don't leave turds behind. 343 os.unlink(self.tmp_path) 344 raise 345 346 def __getattr__(self, attrname): 347 # Delegate everything else to self.tmp_file 348 return getattr(self.tmp_file, attrname) 349 350 def close(self): 351 try: 352 # Close tmp file. 353 self.tmp_file.close() 354 # Determine if different. 355 same = False 356 try: 357 same = filecmp.cmp(self.tmp_path, filename, False) 358 except OSError, e: 359 if e.errno != errno.ENOENT: 360 raise 361 362 if same: 363 # The new file is identical to the old one, just get rid of the new 364 # one. 365 os.unlink(self.tmp_path) 366 else: 367 # The new file is different from the old one, or there is no old one. 368 # Rename the new file to the permanent name. 369 # 370 # tempfile.mkstemp uses an overly restrictive mode, resulting in a 371 # file that can only be read by the owner, regardless of the umask. 372 # There's no reason to not respect the umask here, which means that 373 # an extra hoop is required to fetch it and reset the new file's mode. 374 # 375 # No way to get the umask without setting a new one? Set a safe one 376 # and then set it back to the old value. 377 umask = os.umask(077) 378 os.umask(umask) 379 os.chmod(self.tmp_path, 0666 & ~umask) 380 if sys.platform == 'win32' and os.path.exists(filename): 381 # NOTE: on windows (but not cygwin) rename will not replace an 382 # existing file, so it must be preceded with a remove. Sadly there 383 # is no way to make the switch atomic. 384 os.remove(filename) 385 os.rename(self.tmp_path, filename) 386 except Exception: 387 # Don't leave turds behind. 388 os.unlink(self.tmp_path) 389 raise 390 391 return Writer() 392 393 394def EnsureDirExists(path): 395 """Make sure the directory for |path| exists.""" 396 try: 397 os.makedirs(os.path.dirname(path)) 398 except OSError: 399 pass 400 401 402def GetFlavor(params): 403 """Returns |params.flavor| if it's set, the system's default flavor else.""" 404 flavors = { 405 'cygwin': 'win', 406 'win32': 'win', 407 'darwin': 'mac', 408 } 409 410 if 'flavor' in params: 411 return params['flavor'] 412 if sys.platform in flavors: 413 return flavors[sys.platform] 414 if sys.platform.startswith('sunos'): 415 return 'solaris' 416 if sys.platform.startswith('freebsd'): 417 return 'freebsd' 418 if sys.platform.startswith('openbsd'): 419 return 'openbsd' 420 if sys.platform.startswith('aix'): 421 return 'aix' 422 423 return 'linux' 424 425 426def CopyTool(flavor, out_path): 427 """Finds (flock|mac|win)_tool.gyp in the gyp directory and copies it 428 to |out_path|.""" 429 # aix and solaris just need flock emulation. mac and win use more complicated 430 # support scripts. 431 prefix = { 432 'aix': 'flock', 433 'solaris': 'flock', 434 'mac': 'mac', 435 'win': 'win' 436 }.get(flavor, None) 437 if not prefix: 438 return 439 440 # Slurp input file. 441 source_path = os.path.join( 442 os.path.dirname(os.path.abspath(__file__)), '%s_tool.py' % prefix) 443 with open(source_path) as source_file: 444 source = source_file.readlines() 445 446 # Add header and write it out. 447 tool_path = os.path.join(out_path, 'gyp-%s-tool' % prefix) 448 with open(tool_path, 'w') as tool_file: 449 tool_file.write( 450 ''.join([source[0], '# Generated by gyp. Do not edit.\n'] + source[1:])) 451 452 # Make file executable. 453 os.chmod(tool_path, 0755) 454 455 456# From Alex Martelli, 457# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52560 458# ASPN: Python Cookbook: Remove duplicates from a sequence 459# First comment, dated 2001/10/13. 460# (Also in the printed Python Cookbook.) 461 462def uniquer(seq, idfun=None): 463 if idfun is None: 464 idfun = lambda x: x 465 seen = {} 466 result = [] 467 for item in seq: 468 marker = idfun(item) 469 if marker in seen: continue 470 seen[marker] = 1 471 result.append(item) 472 return result 473 474 475class CycleError(Exception): 476 """An exception raised when an unexpected cycle is detected.""" 477 def __init__(self, nodes): 478 self.nodes = nodes 479 def __str__(self): 480 return 'CycleError: cycle involving: ' + str(self.nodes) 481 482 483def TopologicallySorted(graph, get_edges): 484 """Topologically sort based on a user provided edge definition. 485 486 Args: 487 graph: A list of node names. 488 get_edges: A function mapping from node name to a hashable collection 489 of node names which this node has outgoing edges to. 490 Returns: 491 A list containing all of the node in graph in topological order. 492 It is assumed that calling get_edges once for each node and caching is 493 cheaper than repeatedly calling get_edges. 494 Raises: 495 CycleError in the event of a cycle. 496 Example: 497 graph = {'a': '$(b) $(c)', 'b': 'hi', 'c': '$(b)'} 498 def GetEdges(node): 499 return re.findall(r'\$\(([^))]\)', graph[node]) 500 print TopologicallySorted(graph.keys(), GetEdges) 501 ==> 502 ['a', 'c', b'] 503 """ 504 get_edges = memoize(get_edges) 505 visited = set() 506 visiting = set() 507 ordered_nodes = [] 508 def Visit(node): 509 if node in visiting: 510 raise CycleError(visiting) 511 if node in visited: 512 return 513 visited.add(node) 514 visiting.add(node) 515 for neighbor in get_edges(node): 516 Visit(neighbor) 517 visiting.remove(node) 518 ordered_nodes.insert(0, node) 519 for node in sorted(graph): 520 Visit(node) 521 return ordered_nodes 522