xref: /aosp_15_r20/external/autotest/utils/frozen_chromite/lib/commandline.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# -*- coding: utf-8 -*-
2# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Purpose of this module is to hold common script/commandline functionality.
7
8This ranges from optparse, to a basic script wrapper setup (much like
9what is used for chromite.bin.*).
10"""
11
12from __future__ import print_function
13
14import argparse
15import collections
16import datetime
17import functools
18import os
19import optparse  # pylint: disable=deprecated-module
20import signal
21import sys
22
23import six
24from six.moves import urllib
25
26from autotest_lib.utils.frozen_chromite.lib import constants
27from autotest_lib.utils.frozen_chromite.lib import cros_build_lib
28from autotest_lib.utils.frozen_chromite.lib import cros_collections
29from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging
30from autotest_lib.utils.frozen_chromite.lib import gs
31from autotest_lib.utils.frozen_chromite.lib import osutils
32from autotest_lib.utils.frozen_chromite.lib import path_util
33from autotest_lib.utils.frozen_chromite.lib import terminal
34from autotest_lib.utils.frozen_chromite.utils import attrs_freezer
35
36
37DEVICE_SCHEME_FILE = 'file'
38DEVICE_SCHEME_SERVO = 'servo'
39DEVICE_SCHEME_SSH = 'ssh'
40DEVICE_SCHEME_USB = 'usb'
41
42
43class ChrootRequiredError(Exception):
44  """Raised when a command must be run in the chroot
45
46  This exception is intended to be caught by code which will restart execution
47  in the chroot. Throwing this exception allows contexts to be exited and
48  general cleanup to happen before we exec an external binary.
49
50  The command to run inside the chroot, and (optionally) special cros_sdk
51  arguments are attached to the exception. Any adjustments to the arguments
52  should be done before raising the exception.
53  """
54  def __init__(self, cmd, chroot_args=None, extra_env=None):
55    """Constructor for ChrootRequiredError.
56
57    Args:
58      cmd: Command line to run inside the chroot as a list of strings.
59      chroot_args: Arguments to pass directly to cros_sdk.
60      extra_env: Environmental variables to set in the chroot.
61    """
62    super(ChrootRequiredError, self).__init__()
63    self.cmd = cmd
64    self.chroot_args = chroot_args
65    self.extra_env = extra_env
66
67
68class ExecRequiredError(Exception):
69  """Raised when a command needs to exec, after cleanup.
70
71  This exception is intended to be caught by code which will exec another
72  command. Throwing this exception allows contexts to be exited and general
73  cleanup to happen before we exec an external binary.
74
75  The command to run is attached to the exception. Any adjustments to the
76  arguments should be done before raising the exception.
77  """
78  def __init__(self, cmd):
79    """Constructor for ExecRequiredError.
80
81    Args:
82      cmd: Command line to run inside the chroot as a list of strings.
83    """
84    super(ExecRequiredError, self).__init__()
85    self.cmd = cmd
86
87
88def AbsolutePath(_option, _opt, value):
89  """Expand paths and make them absolute."""
90  return osutils.ExpandPath(value)
91
92
93def NormalizeGSPath(value):
94  """Normalize GS paths."""
95  url = gs.CanonicalizeURL(value, strict=True)
96  return '%s%s' % (gs.BASE_GS_URL, os.path.normpath(url[len(gs.BASE_GS_URL):]))
97
98
99def NormalizeLocalOrGSPath(value):
100  """Normalize a local or GS path."""
101  ptype = 'gs_path' if gs.PathIsGs(value) else 'path'
102  return VALID_TYPES[ptype](value)
103
104
105def NormalizeAbUrl(value):
106  """Normalize an androidbuild URL."""
107  if not value.startswith('ab://'):
108    # Give a helpful error message about the format expected.  Putting this
109    # message in the exception is useless because argparse ignores the
110    # exception message and just says the value is invalid.
111    msg = 'Invalid ab:// URL format: [%s].' % value
112    logging.error(msg)
113    raise ValueError(msg)
114
115  # If no errors, just return the unmodified value.
116  return value
117
118
119def ValidateCipdURL(value):
120  """Return plain string."""
121  if not value.startswith('cipd://'):
122    msg = 'Invalid cipd:// URL format: %s' % value
123    logging.error(msg)
124    raise ValueError(msg)
125  return value
126
127
128def ParseBool(value):
129  """Parse bool argument into a bool value.
130
131  For the existing type=bool functionality, the parser uses the built-in bool(x)
132  function to determine the value.  This function will only return false if x
133  is False or omitted.  Even with this type specified, however, arguments that
134  are generated from a command line initially get parsed as a string, and for
135  any string value passed in to bool(x), it will always return True.
136
137  Args:
138    value: String representing a boolean value.
139
140  Returns:
141    True or False.
142  """
143  return cros_build_lib.BooleanShellValue(value, False)
144
145
146def ParseDate(value):
147  """Parse date argument into a datetime.date object.
148
149  Args:
150    value: String representing a single date in "YYYY-MM-DD" format.
151
152  Returns:
153    A datetime.date object.
154  """
155  try:
156    return datetime.datetime.strptime(value, '%Y-%m-%d').date()
157  except ValueError:
158    # Give a helpful error message about the format expected.  Putting this
159    # message in the exception is useless because argparse ignores the
160    # exception message and just says the value is invalid.
161    logging.error('Date is expected to be in format YYYY-MM-DD.')
162    raise
163
164
165def NormalizeUri(value):
166  """Normalize a local path or URI."""
167  o = urllib.parse.urlparse(value)
168  if o.scheme == 'file':
169    # Trim off the file:// prefix.
170    return VALID_TYPES['path'](value[7:])
171  elif o.scheme not in ('', 'gs'):
172    o = list(o)
173    o[2] = os.path.normpath(o[2])
174    return urllib.parse.urlunparse(o)
175  else:
176    return NormalizeLocalOrGSPath(value)
177
178
179# A Device object holds information parsed from the command line input:
180#   scheme: DEVICE_SCHEME_SSH, DEVICE_SCHEME_USB, DEVICE_SCHEME_SERVO,
181#     or DEVICE_SCHEME_FILE.
182#   username: String SSH username or None.
183#   hostname: String SSH hostname or None.
184#   port: Int SSH or Servo port or None.
185#   path: String USB/file path or None.
186#   raw: String raw input from the command line.
187#   serial_number: String Servo serial number or None.
188# For now this is a superset of all information for USB, SSH, or file devices.
189# If functionality diverges based on type, it may be useful to split this into
190# separate device classes instead.
191Device = cros_collections.Collection(
192    'Device', scheme=None, username=None, hostname=None, port=None, path=None,
193    raw=None, serial_number=None)
194
195
196class DeviceParser(object):
197  """Parses devices as an argparse argument type.
198
199  In addition to parsing user input, this class will also ensure that only
200  supported device schemes are accepted by the parser. For example,
201  `cros deploy` only makes sense with an SSH device, but `cros flash` can use
202  SSH, USB, or file device schemes.
203
204  If the device input is malformed or the scheme is wrong, an error message will
205  be printed and the program will exit.
206
207  Valid device inputs are:
208    - [ssh://][username@]hostname[:port].
209    - usb://[path].
210    - file://path or /absolute_path.
211    - servo:port[:port] to use a port via dut-control, e.g. servo:port:1234.
212    - servo:serial:serial-number to use the servo's serial number,
213        e.g. servo:serial:641220-00057 servo:serial:C1230024192.
214    - [ssh://]:vm:.
215
216  The last item above is an alias for ssh'ing into a virtual machine on a
217  localhost.  It gets translated into 'localhost:9222'.
218
219  Examples:
220    parser = argparse.ArgumentParser()
221
222    parser.add_argument(
223      'ssh_device',
224      type=commandline.DeviceParser(commandline.DEVICE_SCHEME_SSH))
225
226    parser.add_argument(
227      'usb_or_file_device',
228      type=commandline.DeviceParser([commandline.DEVICE_SCHEME_USB,
229                                     commandline.DEVICE_SCHEME_FILE]))
230  """
231
232  def __init__(self, schemes):
233    """Initializes the parser.
234
235    See the class comments for usage examples.
236
237    Args:
238      schemes: A scheme or list of schemes to accept.
239    """
240    self.schemes = ([schemes] if isinstance(schemes, six.string_types)
241                    else schemes)
242    # Provide __name__ for argparse to print on failure, or else it will use
243    # repr() which creates a confusing error message.
244    self.__name__ = type(self).__name__
245
246  def __call__(self, value):
247    """Parses a device input and enforces constraints.
248
249    DeviceParser is an object so that a set of valid schemes can be specified,
250    but argparse expects a parsing function, so we overload __call__() for
251    argparse to use.
252
253    Args:
254      value: String representing a device target. See class comments for
255        valid device input formats.
256
257    Returns:
258      A Device object.
259
260    Raises:
261      ValueError: |value| is not a valid device specifier or doesn't
262        match the supported list of schemes.
263    """
264    try:
265      device = self._ParseDevice(value)
266      self._EnforceConstraints(device, value)
267      return device
268    except ValueError as e:
269      # argparse ignores exception messages, so print the message manually.
270      logging.error(e)
271      raise
272    except Exception as e:
273      logging.error('Internal error while parsing device input: %s', e)
274      raise
275
276  def _EnforceConstraints(self, device, value):
277    """Verifies that user-specified constraints are upheld.
278
279    Checks that the parsed device has a scheme that matches what the user
280    expects. Additional constraints can be added if needed.
281
282    Args:
283      device: Device object.
284      value: String representing a device target.
285
286    Raises:
287      ValueError: |device| has the wrong scheme.
288    """
289    if device.scheme not in self.schemes:
290      raise ValueError('Unsupported scheme "%s" for device "%s"' %
291                       (device.scheme, value))
292
293  def _ParseDevice(self, value):
294    """Parse a device argument.
295
296    Args:
297      value: String representing a device target.
298
299    Returns:
300      A Device object.
301
302    Raises:
303      ValueError: |value| is not a valid device specifier.
304    """
305    # ':vm:' is an alias for ssh'ing into a virtual machihne on localhost;
306    # translate it appropriately.
307    if value.strip().lower() == ':vm:':
308      value = 'localhost:9222'
309    elif value.strip().lower() == 'ssh://:vm:':
310      value = 'ssh://localhost:9222'
311    parsed = urllib.parse.urlparse(value)
312
313    # crbug.com/1069325: Starting in python 3.7 urllib has different parsing
314    # results. 127.0.0.1:9999 parses as scheme='127.0.0.1' path='9999'
315    # instead of scheme='' path='127.0.0.1:9999'. We want that parsed as ssh.
316    # Check for '.' or 'localhost' in the scheme to catch the most common cases
317    # for this result.
318    if (not parsed.scheme or '.' in parsed.scheme or
319        parsed.scheme == 'localhost'):
320      # Default to a file scheme for absolute paths, SSH scheme otherwise.
321      if value and value[0] == '/':
322        scheme = DEVICE_SCHEME_FILE
323      else:
324        # urlparse won't provide hostname/username/port unless a scheme is
325        # specified so we need to re-parse.
326        parsed = urllib.parse.urlparse('%s://%s' % (DEVICE_SCHEME_SSH, value))
327        scheme = DEVICE_SCHEME_SSH
328    else:
329      scheme = parsed.scheme.lower()
330
331    if scheme == DEVICE_SCHEME_SSH:
332      hostname = parsed.hostname
333      port = parsed.port
334      if hostname == 'localhost' and not port:
335        # Use of localhost as the actual machine is uncommon enough relative to
336        # the use of KVM that we require users to specify localhost:22 if they
337        # actually want to connect to the localhost.  Otherwise the expectation
338        # is that they intend to access the VM but forget or didn't know to use
339        # port 9222.
340        raise ValueError('To connect to localhost, use ssh://localhost:22 '
341                         'explicitly, or use ssh://localhost:9222 for the local'
342                         ' VM.')
343      if not hostname:
344        raise ValueError('Hostname is required for device "%s"' % value)
345      return Device(scheme=scheme, username=parsed.username, hostname=hostname,
346                    port=port, raw=value)
347    elif scheme == DEVICE_SCHEME_USB:
348      path = parsed.netloc + parsed.path
349      # Change path '' to None for consistency.
350      return Device(scheme=scheme, path=path if path else None, raw=value)
351    elif scheme == DEVICE_SCHEME_FILE:
352      path = parsed.netloc + parsed.path
353      if not path:
354        raise ValueError('Path is required for "%s"' % value)
355      return Device(scheme=scheme, path=path, raw=value)
356    elif scheme == DEVICE_SCHEME_SERVO:
357      # Parse the identifier type and value.
358      servo_type, _, servo_id = parsed.path.partition(':')
359      # Don't want to do the netloc before the split in case of serial number.
360      servo_type = servo_type.lower()
361
362      return self._parse_servo(servo_type, servo_id)
363    else:
364      raise ValueError('Unknown device scheme "%s" in "%s"' % (scheme, value))
365
366  @staticmethod
367  def _parse_servo(servo_type, servo_id):
368    """Parse a servo device from the parsed servo uri info.
369
370    Args:
371      servo_type: The servo identifier type, either port or serial.
372      servo_id: The servo identifier, either the port number it is
373        communicating through or its serial number.
374    """
375    servo_port = None
376    serial_number = None
377    if servo_type == 'serial':
378      if servo_id:
379        serial_number = servo_id
380      else:
381        raise ValueError('No serial number given.')
382    elif servo_type == 'port':
383      if servo_id:
384        # Parse and validate when given.
385        try:
386          servo_port = int(servo_id)
387        except ValueError:
388          raise ValueError('Invalid servo port value: %s' % servo_id)
389        if servo_port <= 0 or servo_port > 65535:
390          raise ValueError(
391              'Invalid port, must be 1-65535: %d given.' % servo_port)
392    else:
393      raise ValueError('Invalid servo type given: %s' % servo_type)
394
395    return Device(
396        scheme=DEVICE_SCHEME_SERVO,
397        port=servo_port,
398        serial_number=serial_number)
399
400
401class _AppendOption(argparse.Action):
402  """Append the command line option (with no arguments) to dest.
403
404  parser.add_argument('-b', '--barg', dest='out', action='append_option')
405  options = parser.parse_args(['-b', '--barg'])
406  options.out == ['-b', '--barg']
407  """
408  def __init__(self, option_strings, dest, **kwargs):
409    if 'nargs' in kwargs:
410      raise ValueError('nargs is not supported for append_option action')
411    super(_AppendOption, self).__init__(
412        option_strings, dest, nargs=0, **kwargs)
413
414  def __call__(self, parser, namespace, values, option_string=None):
415    if getattr(namespace, self.dest, None) is None:
416      setattr(namespace, self.dest, [])
417    getattr(namespace, self.dest).append(option_string)
418
419
420class _AppendOptionValue(argparse.Action):
421  """Append the command line option to dest. Useful for pass along arguments.
422
423  parser.add_argument('-b', '--barg', dest='out', action='append_option_value')
424  options = parser.parse_args(['--barg', 'foo', '-b', 'bar'])
425  options.out == ['-barg', 'foo', '-b', 'bar']
426  """
427  def __call__(self, parser, namespace, values, option_string=None):
428    if getattr(namespace, self.dest, None) is None:
429      setattr(namespace, self.dest, [])
430    getattr(namespace, self.dest).extend([option_string, str(values)])
431
432
433class _SplitExtendAction(argparse.Action):
434  """Callback to split the argument and extend existing value.
435
436  We normalize whitespace before splitting.  This is to support the forms:
437    cbuildbot -p 'proj:branch ' ...
438    cbuildbot -p ' proj:branch' ...
439    cbuildbot -p 'proj:branch  proj2:branch' ...
440    cbuildbot -p "$(some_command_that_returns_nothing)" ...
441  """
442  def __call__(self, parser, namespace, values, option_string=None):
443    if getattr(namespace, self.dest, None) is None:
444      setattr(namespace, self.dest, [])
445    getattr(namespace, self.dest).extend(values.split())
446
447
448VALID_TYPES = {
449    'ab_url': NormalizeAbUrl,
450    'bool': ParseBool,
451    'cipd': ValidateCipdURL,
452    'date': ParseDate,
453    'path': osutils.ExpandPath,
454    'gs_path': NormalizeGSPath,
455    'local_or_gs_path': NormalizeLocalOrGSPath,
456    'path_or_uri': NormalizeUri,
457}
458
459VALID_ACTIONS = {
460    'append_option': _AppendOption,
461    'append_option_value': _AppendOptionValue,
462    'split_extend': _SplitExtendAction,
463}
464
465_DEPRECATE_ACTIONS = [None, 'store', 'store_const', 'store_true', 'store_false',
466                      'append', 'append_const', 'count'] + list(VALID_ACTIONS)
467
468
469class _DeprecatedAction(object):
470  """Base functionality to allow adding warnings for deprecated arguments.
471
472  To add a deprecated warning, simply include a deprecated=message argument
473  to the add_argument call for the deprecated argument. Beside logging the
474  deprecation warning, the argument will behave as normal.
475  """
476
477  def __init__(self, *args, **kwargs):
478    """Init override to extract the deprecated argument when it exists."""
479    self.deprecated_message = kwargs.pop('deprecated', None)
480    super(_DeprecatedAction, self).__init__(*args, **kwargs)
481
482  def __call__(self, parser, namespace, values, option_string=None):
483    """Log the message then defer to the parent action."""
484    if self.deprecated_message:
485      logging.warning('Argument %s is deprecated: %s', option_string,
486                      self.deprecated_message)
487    return super(_DeprecatedAction, self).__call__(
488        parser, namespace, values, option_string=option_string)
489
490
491def OptparseWrapCheck(desc, check_f, _option, opt, value):
492  """Optparse adapter for type checking functionality."""
493  try:
494    return check_f(value)
495  except ValueError:
496    raise optparse.OptionValueError(
497        'Invalid %s given: --%s=%s' % (desc, opt, value))
498
499
500class Option(optparse.Option):
501  """Subclass to implement path evaluation & other useful types."""
502
503  _EXTRA_TYPES = ('path', 'gs_path')
504  TYPES = optparse.Option.TYPES + _EXTRA_TYPES
505  TYPE_CHECKER = optparse.Option.TYPE_CHECKER.copy()
506  for t in _EXTRA_TYPES:
507    TYPE_CHECKER[t] = functools.partial(OptparseWrapCheck, t, VALID_TYPES[t])
508
509
510class FilteringOption(Option):
511  """Subclass that supports Option filtering for FilteringOptionParser"""
512
513  _EXTRA_ACTIONS = ('split_extend',)
514  ACTIONS = Option.ACTIONS + _EXTRA_ACTIONS
515  STORE_ACTIONS = Option.STORE_ACTIONS + _EXTRA_ACTIONS
516  TYPED_ACTIONS = Option.TYPED_ACTIONS + _EXTRA_ACTIONS
517  ALWAYS_TYPED_ACTIONS = (Option.ALWAYS_TYPED_ACTIONS + _EXTRA_ACTIONS)
518
519  def take_action(self, action, dest, opt, value, values, parser):
520    if action == 'split_extend':
521      lvalue = value.split()
522      values.ensure_value(dest, []).extend(lvalue)
523    else:
524      Option.take_action(self, action, dest, opt, value, values, parser)
525
526    if value is None:
527      value = []
528    elif not self.nargs or self.nargs <= 1:
529      value = [value]
530
531    parser.AddParsedArg(self, opt, [str(v) for v in value])
532
533
534class ColoredFormatter(logging.Formatter):
535  """A logging formatter that can color the messages."""
536
537  _COLOR_MAPPING = {
538      'WARNING': terminal.Color.YELLOW,
539      'ERROR': terminal.Color.RED,
540  }
541
542  def __init__(self, *args, **kwargs):
543    """Initializes the formatter.
544
545    Args:
546      args: See logging.Formatter for specifics.
547      kwargs: See logging.Formatter for specifics.
548      enable_color: Whether to enable colored logging. Defaults
549        to None, where terminal.Color will set to a sane default.
550    """
551    self.color = terminal.Color(enabled=kwargs.pop('enable_color', None))
552    super(ColoredFormatter, self).__init__(*args, **kwargs)
553
554  def format(self, record):
555    """Formats |record| with color."""
556    msg = super(ColoredFormatter, self).format(record)
557    color = self._COLOR_MAPPING.get(record.levelname)
558    return msg if not color else self.color.Color(color, msg)
559
560
561class ChromiteStreamHandler(logging.StreamHandler):
562  """A stream handler for logging."""
563
564
565class BaseParser(object):
566  """Base parser class that includes the logic to add logging controls."""
567
568  DEFAULT_LOG_LEVELS = ('fatal', 'critical', 'error', 'warning', 'notice',
569                        'info', 'debug')
570
571  DEFAULT_LOG_LEVEL = 'info'
572  ALLOW_LOGGING = True
573
574  def __init__(self, **kwargs):
575    """Initialize this parser instance.
576
577    kwargs:
578      logging: Defaults to ALLOW_LOGGING from the class; if given,
579        add --log-level.
580      default_log_level: If logging is enabled, override the default logging
581        level.  Defaults to the class's DEFAULT_LOG_LEVEL value.
582      log_levels: If logging is enabled, this overrides the enumeration of
583        allowed logging levels.  If not given, defaults to the classes
584        DEFAULT_LOG_LEVELS value.
585      manual_debug: If logging is enabled and this is True, suppress addition
586        of a --debug alias.  This option defaults to True unless 'debug' has
587        been exempted from the allowed logging level targets.
588      caching: If given, must be either a callable that discerns the cache
589        location if it wasn't specified (the prototype must be akin to
590        lambda parser, values:calculated_cache_dir_path; it may return None to
591        indicate that it handles setting the value on its own later in the
592        parsing including setting the env), or True; if True, the
593        machinery defaults to invoking the class's FindCacheDir method
594        (which can be overridden).  FindCacheDir $CROS_CACHEDIR, falling
595        back to $REPO/.cache, finally falling back to $TMP.
596        Note that the cache_dir is not created, just discerned where it
597        should live.
598        If False, or caching is not given, then no --cache-dir option will be
599        added.
600    """
601    self.debug_enabled = False
602    self.caching_group = None
603    self.debug_group = None
604    self.default_log_level = None
605    self.log_levels = None
606    self.logging_enabled = kwargs.get('logging', self.ALLOW_LOGGING)
607    self.default_log_level = kwargs.get('default_log_level',
608                                        self.DEFAULT_LOG_LEVEL)
609    self.log_levels = tuple(x.lower() for x in
610                            kwargs.get('log_levels', self.DEFAULT_LOG_LEVELS))
611    self.debug_enabled = (not kwargs.get('manual_debug', False)
612                          and 'debug' in self.log_levels)
613    self.caching = kwargs.get('caching', False)
614    self._cros_defaults = {}
615
616  @staticmethod
617  def PopUsedArgs(kwarg_dict):
618    """Removes keys used by the base parser from the kwarg namespace."""
619    parser_keys = ['logging', 'default_log_level', 'log_levels', 'manual_debug',
620                   'caching']
621    for key in parser_keys:
622      kwarg_dict.pop(key, None)
623
624  def SetupOptions(self):
625    """Sets up standard chromite options."""
626    # NB: All options here must go through add_common_argument_to_group.
627    # You cannot use add_argument or such helpers directly.  This is to
628    # support default values with subparsers.
629    #
630    # You should also explicitly add default=None here when you want the
631    # default to be set up in the parsed option namespace.
632    if self.logging_enabled:
633      self.debug_group = self.add_argument_group('Debug options')
634      self.add_common_argument_to_group(
635          self.debug_group, '--log-level', choices=self.log_levels,
636          default=self.default_log_level,
637          help='Set logging level to report at.')
638      self.add_common_argument_to_group(
639          self.debug_group, '--log-format', action='store',
640          default=constants.LOGGER_FMT,
641          help='Set logging format to use.')
642      # Backwards compat name.  We should delete this at some point.
643      self.add_common_argument_to_group(
644          self.debug_group, '--log_format', action='store',
645          default=constants.LOGGER_FMT,
646          help=argparse.SUPPRESS)
647      self.add_common_argument_to_group(
648          self.debug_group,
649          '-v',
650          '--verbose',
651          action='store_const',
652          const='info',
653          dest='log_level',
654          help='Alias for `--log-level=info`.')
655      if self.debug_enabled:
656        self.add_common_argument_to_group(
657            self.debug_group, '--debug', action='store_const', const='debug',
658            dest='log_level', help='Alias for `--log-level=debug`. '
659            'Useful for debugging bugs/failures.')
660      self.add_common_argument_to_group(
661          self.debug_group, '--nocolor', action='store_false', dest='color',
662          default=None,
663          help='Do not use colorized output (or `export NOCOLOR=true`)')
664
665    if self.caching:
666      self.caching_group = self.add_argument_group('Caching Options')
667      self.add_common_argument_to_group(
668          self.caching_group, '--cache-dir', default=None, type='path',
669          help='Override the calculated chromeos cache directory; '
670          "typically defaults to '$REPO/.cache' .")
671
672  def SetupLogging(self, opts):
673    """Sets up logging based on |opts|."""
674    value = opts.log_level.upper()
675    logger = logging.getLogger()
676    logger.setLevel(getattr(logging, value))
677    formatter = ColoredFormatter(fmt=opts.log_format,
678                                 datefmt=constants.LOGGER_DATE_FMT,
679                                 enable_color=opts.color)
680
681    # Only set colored formatter for ChromiteStreamHandler instances,
682    # which could have been added by ScriptWrapperMain() below.
683    chromite_handlers = [x for x in logger.handlers if
684                         isinstance(x, ChromiteStreamHandler)]
685    for handler in chromite_handlers:
686      handler.setFormatter(formatter)
687
688    logging.captureWarnings(True)
689
690    return value
691
692  def DoPostParseSetup(self, opts, args):
693    """Method called to handle post opts/args setup.
694
695    This can be anything from logging setup to positional arg count validation.
696
697    Args:
698      opts: optparse.Values or argparse.Namespace instance
699      args: position arguments unconsumed from parsing.
700
701    Returns:
702      (opts, args), w/ whatever modification done.
703    """
704    for dest, default in self._cros_defaults.items():
705      if not hasattr(opts, dest):
706        setattr(opts, dest, default)
707
708    if self.logging_enabled:
709      value = self.SetupLogging(opts)
710      if self.debug_enabled:
711        opts.debug = (value == 'DEBUG')
712      opts.verbose = value in ('INFO', 'DEBUG')
713
714    if self.caching:
715      path = os.environ.get(constants.SHARED_CACHE_ENVVAR)
716      if path is not None and opts.cache_dir is None:
717        opts.cache_dir = os.path.abspath(path)
718
719      opts.cache_dir_specified = opts.cache_dir is not None
720      if not opts.cache_dir_specified:
721        func = self.FindCacheDir if not callable(self.caching) else self.caching
722        opts.cache_dir = func(self, opts)
723      if opts.cache_dir is not None:
724        self.ConfigureCacheDir(opts.cache_dir)
725
726    return opts, args
727
728  @staticmethod
729  def ConfigureCacheDir(cache_dir):
730    if cache_dir is None:
731      os.environ.pop(constants.SHARED_CACHE_ENVVAR, None)
732      logging.debug('Removed cache_dir setting')
733    else:
734      os.environ[constants.SHARED_CACHE_ENVVAR] = cache_dir
735      logging.debug('Configured cache_dir to %r', cache_dir)
736
737  @classmethod
738  def FindCacheDir(cls, _parser, _opts):
739    logging.debug('Cache dir lookup.')
740    return path_util.FindCacheDir()
741
742
743@six.add_metaclass(attrs_freezer.Class)
744class ArgumentNamespace(argparse.Namespace):
745  """Class to mimic argparse.Namespace with value freezing support."""
746  _FROZEN_ERR_MSG = 'Option values are frozen, cannot alter %s.'
747
748
749# Note that because optparse.Values is not a new-style class this class
750# must use the mixin rather than the metaclass.
751class OptionValues(attrs_freezer.Mixin, optparse.Values):
752  """Class to mimic optparse.Values with value freezing support."""
753  _FROZEN_ERR_MSG = 'Option values are frozen, cannot alter %s.'
754
755  def __init__(self, defaults, *args, **kwargs):
756    attrs_freezer.Mixin.__init__(self)
757    optparse.Values.__init__(self, defaults, *args, **kwargs)
758
759    # Used by FilteringParser.
760    self.parsed_args = None
761
762
763PassedOption = collections.namedtuple(
764    'PassedOption', ['opt_inst', 'opt_str', 'value_str'])
765
766
767class FilteringParser(optparse.OptionParser, BaseParser):
768  """Custom option parser for filtering options.
769
770  Aside from adding a couple of types (path for absolute paths,
771  gs_path for google storage urls, and log_level for logging level control),
772  this additionally exposes logging control by default; if undesired,
773  either derive from this class setting ALLOW_LOGGING to False, or
774  pass in logging=False to the constructor.
775  """
776
777  DEFAULT_OPTION_CLASS = FilteringOption
778
779  def __init__(self, usage=None, **kwargs):
780    BaseParser.__init__(self, **kwargs)
781    self.PopUsedArgs(kwargs)
782    kwargs.setdefault('option_class', self.DEFAULT_OPTION_CLASS)
783    optparse.OptionParser.__init__(self, usage=usage, **kwargs)
784    self.SetupOptions()
785
786  def add_common_argument_to_group(self, group, *args, **kwargs):
787    """Adds the given option defined by args and kwargs to group."""
788    return group.add_option(*args, **kwargs)
789
790  def add_argument_group(self, *args, **kwargs):
791    """Return an option group rather than an argument group."""
792    return self.add_option_group(*args, **kwargs)
793
794  def parse_args(self, args=None, values=None):
795    # If no Values object is specified then use our custom OptionValues.
796    if values is None:
797      values = OptionValues(defaults=self.defaults)
798
799    values.parsed_args = []
800
801    opts, remaining = optparse.OptionParser.parse_args(
802        self, args=args, values=values)
803    return self.DoPostParseSetup(opts, remaining)
804
805  def AddParsedArg(self, opt_inst, opt_str, value_str):
806    """Add a parsed argument with attributes.
807
808    Args:
809      opt_inst: An instance of a raw optparse.Option object that represents the
810                option.
811      opt_str: The option string.
812      value_str: A list of string-ified values dentified by OptParse.
813    """
814    self.values.parsed_args.append(PassedOption(opt_inst, opt_str, value_str))
815
816  @staticmethod
817  def FilterArgs(parsed_args, filter_fn):
818    """Filter the argument by passing it through a function.
819
820    Args:
821      parsed_args: The list of parsed argument namedtuples to filter.  Tuples
822        are of the form (opt_inst, opt_str, value_str).
823      filter_fn: A function with signature f(PassedOption), and returns True if
824        the argument is to be passed through.  False if not.
825
826    Returns:
827      A tuple containing two lists - one of accepted arguments and one of
828      removed arguments.
829    """
830    removed = []
831    accepted = []
832    for arg in parsed_args:
833      target = accepted if filter_fn(arg) else removed
834      target.append(arg.opt_str)
835      target.extend(arg.value_str)
836
837    return accepted, removed
838
839
840class ArgumentParser(BaseParser, argparse.ArgumentParser):
841  """Custom argument parser for use by chromite.
842
843  This class additionally exposes logging control by default; if undesired,
844  either derive from this class setting ALLOW_LOGGING to False, or
845  pass in logging=False to the constructor.
846  """
847
848  def __init__(self, usage=None, **kwargs):
849    kwargs.setdefault('formatter_class', argparse.RawDescriptionHelpFormatter)
850    BaseParser.__init__(self, **kwargs)
851    self.PopUsedArgs(kwargs)
852    argparse.ArgumentParser.__init__(self, usage=usage, **kwargs)
853    self._SetupTypes()
854    self.SetupOptions()
855    self._RegisterActions()
856
857  def _SetupTypes(self):
858    """Register types with ArgumentParser."""
859    for t, check_f in VALID_TYPES.items():
860      self.register('type', t, check_f)
861    for a, class_a in VALID_ACTIONS.items():
862      self.register('action', a, class_a)
863
864  def _RegisterActions(self):
865    """Update the container's actions.
866
867    This method builds out a new action class to register for each action type.
868    The new action class allows handling the deprecated argument without any
869    other changes to the argument parser logic. See _DeprecatedAction.
870    """
871    for action in _DEPRECATE_ACTIONS:
872      current_class = self._registry_get('action', action, object)
873      # Base classes for the new class. The _DeprecatedAction must be first to
874      # ensure its method overrides are called first.
875      bases = (_DeprecatedAction, current_class)
876      try:
877        self.register('action', action, type('deprecated-wrapper', bases, {}))
878      except TypeError:
879        # Method resolution order error. This occurs when the _DeprecatedAction
880        # class is inherited multiple times, so we've already registered the
881        # replacement class. The underlying _ActionsContainer gets passed
882        # around, so this may get triggered in non-obvious ways.
883        continue
884
885  def add_common_argument_to_group(self, group, *args, **kwargs):
886    """Adds the given argument to the group.
887
888    This argument is expected to show up across the base parser and subparsers
889    that might be added later on.  The default argparse module does not handle
890    this scenario well -- it processes the base parser first (defaults and the
891    user arguments), then it processes the subparser (defaults and arguments).
892    That means defaults in the subparser will clobber user arguments passed in
893    to the base parser!
894    """
895    default = kwargs.pop('default', None)
896    kwargs['default'] = argparse.SUPPRESS
897    action = group.add_argument(*args, **kwargs)
898    self._cros_defaults.setdefault(action.dest, default)
899    return action
900
901  def parse_args(self, args=None, namespace=None):
902    """Translates OptionParser call to equivalent ArgumentParser call."""
903    # If no Namespace object is specified then use our custom ArgumentNamespace.
904    if namespace is None:
905      namespace = ArgumentNamespace()
906
907    # Unlike OptionParser, ArgParser works only with a single namespace and no
908    # args. Re-use BaseParser DoPostParseSetup but only take the namespace.
909    namespace = argparse.ArgumentParser.parse_args(
910        self, args=args, namespace=namespace)
911    return self.DoPostParseSetup(namespace, None)[0]
912
913
914class _ShutDownException(SystemExit):
915  """Exception raised when user hits CTRL+C."""
916
917  def __init__(self, sig_num, message):
918    self.signal = sig_num
919    # Setup a usage message primarily for any code that may intercept it
920    # while this exception is crashing back up the stack to us.
921    SystemExit.__init__(self, 128 + sig_num)
922    self.args = (sig_num, message)
923
924  def __str__(self):
925    """Stringify this exception."""
926    return self.args[1]
927
928
929def _DefaultHandler(signum, _frame):
930  # Don't double process sigterms; just trigger shutdown from the first
931  # exception.
932  signal.signal(signum, signal.SIG_IGN)
933  raise _ShutDownException(
934      signum, 'Received signal %i; shutting down' % (signum,))
935
936
937def _RestartInChroot(cmd, chroot_args, extra_env):
938  """Rerun inside the chroot.
939
940  Args:
941    cmd: Command line to run inside the chroot as a list of strings.
942    chroot_args: Arguments to pass directly to cros_sdk (or None).
943    extra_env: Dictionary of environmental variables to set inside the
944        chroot (or None).
945  """
946  return cros_build_lib.run(cmd, check=False, enter_chroot=True,
947                            chroot_args=chroot_args, extra_env=extra_env,
948                            cwd=constants.SOURCE_ROOT).returncode
949
950
951def RunInsideChroot(command=None, chroot_args=None):
952  """Restart the current command inside the chroot.
953
954  This method is only valid for any code that is run via ScriptWrapperMain.
955  It allows proper cleanup of the local context by raising an exception handled
956  in ScriptWrapperMain.
957
958  Args:
959    command: An instance of CliCommand to be restarted inside the chroot.
960             |command| can be None if you do not wish to modify the log_level.
961    chroot_args: List of command-line arguments to pass to cros_sdk, if invoked.
962  """
963  if cros_build_lib.IsInsideChroot():
964    return
965
966  # Produce the command line to execute inside the chroot.
967  argv = sys.argv[:]
968  argv[0] = path_util.ToChrootPath(argv[0])
969
970  # Set log-level of cros_sdk to be same as log-level of command entering the
971  # chroot.
972  if chroot_args is None:
973    chroot_args = []
974  if command is not None:
975    chroot_args += ['--log-level', command.options.log_level]
976
977  raise ChrootRequiredError(argv, chroot_args)
978
979
980def ReExec():
981  """Restart the current command.
982
983  This method is only valid for any code that is run via ScriptWrapperMain.
984  It allows proper cleanup of the local context by raising an exception handled
985  in ScriptWrapperMain.
986  """
987  # The command to exec.
988  raise ExecRequiredError(sys.argv[:])
989
990
991def ScriptWrapperMain(find_target_func, argv=None,
992                      log_level=logging.DEBUG,
993                      log_format=constants.LOGGER_FMT):
994  """Function usable for chromite.script.* style wrapping.
995
996  Note that this function invokes sys.exit on the way out by default.
997
998  Args:
999    find_target_func: a function, which, when given the absolute
1000      pathway the script was invoked via (for example,
1001      /home/ferringb/cros/trunk/chromite/bin/cros_sdk; note that any
1002      trailing .py from the path name will be removed),
1003      will return the main function to invoke (that functor will take
1004      a single arg- a list of arguments, and shall return either None
1005      or an integer, to indicate the exit code).
1006    argv: sys.argv, or an equivalent tuple for testing.  If nothing is
1007      given, sys.argv is defaulted to.
1008    log_level: Default logging level to start at.
1009    log_format: Default logging format to use.
1010  """
1011  if argv is None:
1012    argv = sys.argv[:]
1013  target = os.path.abspath(argv[0])
1014  name = os.path.basename(target)
1015  if target.endswith('.py'):
1016    target = os.path.splitext(target)[0]
1017  target = find_target_func(target)
1018  if target is None:
1019    print('Internal error detected- no main functor found in module %r.' %
1020          (name,), file=sys.stderr)
1021    sys.exit(100)
1022
1023  # Set up basic logging information for all modules that use logging.
1024  # Note a script target may setup default logging in its module namespace
1025  # which will take precedence over this.
1026  logger = logging.getLogger()
1027  logger.setLevel(log_level)
1028  logger_handler = ChromiteStreamHandler()
1029  logger_handler.setFormatter(
1030      logging.Formatter(fmt=log_format, datefmt=constants.LOGGER_DATE_FMT))
1031  logger.addHandler(logger_handler)
1032  logging.captureWarnings(True)
1033
1034  signal.signal(signal.SIGTERM, _DefaultHandler)
1035
1036  ret = 1
1037  try:
1038    ret = target(argv[1:])
1039  except _ShutDownException as e:
1040    sys.stdout.flush()
1041    print('%s: Signaled to shutdown: caught %i signal.' % (name, e.signal),
1042          file=sys.stderr)
1043    sys.stderr.flush()
1044  except SystemExit as e:
1045    # Right now, let this crash through- longer term, we'll update the scripts
1046    # in question to not use sys.exit, and make this into a flagged error.
1047    raise
1048  except ChrootRequiredError as e:
1049    ret = _RestartInChroot(e.cmd, e.chroot_args, e.extra_env)
1050  except ExecRequiredError as e:
1051    logging.shutdown()
1052    # This does not return.
1053    os.execv(e.cmd[0], e.cmd)
1054  except Exception as e:
1055    sys.stdout.flush()
1056    print('%s: Unhandled exception:' % (name,), file=sys.stderr)
1057    sys.stderr.flush()
1058    raise
1059  finally:
1060    logging.shutdown()
1061
1062  if ret is None:
1063    ret = 0
1064  sys.exit(ret)
1065