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