1 # Copyright (c) 2012 The Chromium OS Authors. 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.
5 """Purpose of this module is to hold common script/commandline functionality.
7 This ranges from optparse, to a basic script wrapper setup (much like
8 what is used for chromite.bin.*).
11 from __future__ import print_function
25 # TODO(build): sort the cbuildbot.constants/lib.constants issue;
26 # lib shouldn't have to import from buildbot like this.
27 from chromite.cbuildbot import constants
28 from chromite.lib import cros_build_lib
29 from chromite.lib import git
30 from chromite.lib import gs
31 from chromite.lib import osutils
32 from chromite.lib import terminal
35 CHECKOUT_TYPE_UNKNOWN = 'unknown'
36 CHECKOUT_TYPE_GCLIENT = 'gclient'
37 CHECKOUT_TYPE_REPO = 'repo'
38 CHECKOUT_TYPE_SUBMODULE = 'submodule'
41 CheckoutInfo = collections.namedtuple(
42 'CheckoutInfo', ['type', 'root', 'chrome_src_dir'])
45 class ChrootRequiredError(Exception):
46 """Raised when a command must be run in the chroot
48 This exception is intended to be caught by code which will restart execution
49 in the chroot. If none of the arguments passed to the script need to be
50 adjusted when that happens, it can be constructed with no parameters. If
51 something does need to be adjusted, for instance an argument that's a path,
52 the command can construct a custom command line and pass it into this
53 exception which will be used instead.
55 When customizing the command line, argv[0] will have to be fixed up manually
56 like any other element of argv.
59 def __init__(self, new_argv=None, *args, **kwargs):
60 Exception.__init__(self, *args, **kwargs)
62 new_argv = sys.argv[:]
63 new_argv = [git.ReinterpretPathForChroot(new_argv[0])] + new_argv[1:]
65 self.new_argv = new_argv
68 def DetermineCheckout(cwd):
69 """Gather information on the checkout we are in.
72 A CheckoutInfo object with these attributes:
73 type: The type of checkout. Valid values are CHECKOUT_TYPE_*.
74 root: The root of the checkout.
75 chrome_src_dir: If the checkout is a Chrome checkout, the path to the
76 Chrome src/ directory.
78 checkout_type = CHECKOUT_TYPE_UNKNOWN
79 root, path = None, None
80 for path in osutils.IteratePathParents(cwd):
81 repo_dir = os.path.join(path, '.repo')
82 if os.path.isdir(repo_dir):
83 checkout_type = CHECKOUT_TYPE_REPO
85 gclient_file = os.path.join(path, '.gclient')
86 if os.path.exists(gclient_file):
87 checkout_type = CHECKOUT_TYPE_GCLIENT
89 submodule_git = os.path.join(path, '.git')
90 if (os.path.isdir(submodule_git) and
91 git.IsSubmoduleCheckoutRoot(cwd, 'origin', constants.CHROMIUM_GOB_URL)):
92 checkout_type = CHECKOUT_TYPE_SUBMODULE
95 if checkout_type != CHECKOUT_TYPE_UNKNOWN:
98 # Determine the chrome src directory.
100 if checkout_type == CHECKOUT_TYPE_GCLIENT:
101 chrome_src_dir = os.path.join(root, 'src')
102 elif checkout_type == CHECKOUT_TYPE_SUBMODULE:
103 chrome_src_dir = root
105 return CheckoutInfo(checkout_type, root, chrome_src_dir)
109 """Calculate the current cache dir.
111 Users can configure the cache dir using the --cache-dir argument and it is
112 shared between cbuildbot and all child processes. If no cache dir is
113 specified, FindCacheDir finds an alternative location to store the cache.
116 The path to the cache dir.
118 return os.environ.get(
119 constants.SHARED_CACHE_ENVVAR,
120 BaseParser.FindCacheDir(None, None))
123 def AbsolutePath(_option, _opt, value):
124 """Expand paths and make them absolute."""
125 return osutils.ExpandPath(value)
128 def NormalizeGSPath(value):
129 """Normalize GS paths."""
130 url = gs.CanonicalizeURL(value, strict=True)
131 return '%s%s' % (gs.BASE_GS_URL, os.path.normpath(url[len(gs.BASE_GS_URL):]))
134 def NormalizeLocalOrGSPath(value):
135 """Normalize a local or GS path."""
136 ptype = 'gs_path' if value.startswith(gs.BASE_GS_URL) else 'path'
137 return VALID_TYPES[ptype](value)
140 def ParseDate(value):
141 """Parse date argument into a datetime.date object.
144 value: String representing a single date in "YYYY-MM-DD" format.
147 A datetime.date object.
150 return datetime.datetime.strptime(value, '%Y-%m-%d').date()
152 # Give a helpful error message about the format expected. Putting this
153 # message in the exception is useless because argparse ignores the
154 # exception message and just says the value is invalid.
155 cros_build_lib.Error('Date is expected to be in format YYYY-MM-DD.')
159 def NormalizeUri(value):
160 """Normalize a local path or URI."""
161 # Pylint is confused about result of urlparse.
162 # pylint: disable=E1101
163 o = urlparse.urlparse(value)
164 if o.scheme == 'file':
165 # Trim off the file:// prefix.
166 return VALID_TYPES['path'](value[7:])
167 elif o.scheme not in ('', 'gs'):
169 o[2] = os.path.normpath(o[2])
170 return urlparse.urlunparse(o)
172 return NormalizeLocalOrGSPath(value)
175 def OptparseWrapCheck(desc, check_f, _option, opt, value):
176 """Optparse adapter for type checking functionality."""
178 return check_f(value)
180 raise optparse.OptionValueError(
181 'Invalid %s given: --%s=%s' % (desc, opt, value))
186 'path': osutils.ExpandPath,
187 'gs_path': NormalizeGSPath,
188 'local_or_gs_path': NormalizeLocalOrGSPath,
189 'path_or_uri': NormalizeUri,
193 class Option(optparse.Option):
194 """Subclass to implement path evaluation & other useful types."""
196 _EXTRA_TYPES = ("path", "gs_path")
197 TYPES = optparse.Option.TYPES + _EXTRA_TYPES
198 TYPE_CHECKER = optparse.Option.TYPE_CHECKER.copy()
199 for t in _EXTRA_TYPES:
200 TYPE_CHECKER[t] = functools.partial(OptparseWrapCheck, t, VALID_TYPES[t])
203 class FilteringOption(Option):
204 """Subclass that supports Option filtering for FilteringOptionParser"""
206 def take_action(self, action, dest, opt, value, values, parser):
207 if action in FilteringOption.ACTIONS:
208 Option.take_action(self, action, dest, opt, value, values, parser)
212 elif not self.nargs or self.nargs <= 1:
215 parser.AddParsedArg(self, opt, [str(v) for v in value])
218 # TODO: logging.Formatter is not a subclass of object in python
219 # 2.6. Make ColoredFormatter explicitly inherit from object so that
220 # functions such as super() will not fail. This should be removed
221 # after python is upgraded to 2.7 on master2 (crbug.com/409273).
222 class ColoredFormatter(logging.Formatter, object):
223 """A logging formatter that can color the messages."""
226 'WARNING': terminal.Color.YELLOW,
227 'ERROR': terminal.Color.RED,
230 def __init__(self, *args, **kwargs):
231 """Initializes the formatter.
234 args: See logging.Formatter for specifics.
235 kwargs: See logging.Formatter for specifics.
236 enable_color: Whether to enable colored logging. Defaults
237 to None, where terminal.Color will set to a sane default.
239 self.color = terminal.Color(enabled=kwargs.pop('enable_color', None))
240 super(ColoredFormatter, self).__init__(*args, **kwargs)
242 def format(self, record, **kwargs):
243 """Formats |record| with color."""
244 msg = super(ColoredFormatter, self).format(record, **kwargs)
245 color = self._COLOR_MAPPING.get(record.levelname)
246 return msg if not color else self.color.Color(color, msg)
249 class ChromiteStreamHandler(logging.StreamHandler):
250 """A stream handler for logging."""
253 class BaseParser(object):
254 """Base parser class that includes the logic to add logging controls."""
256 DEFAULT_LOG_LEVELS = ('fatal', 'critical', 'error', 'warning', 'info',
259 DEFAULT_LOG_LEVEL = "info"
262 REPO_CACHE_DIR = '.cache'
263 CHROME_CACHE_DIR = '.cros_cache'
265 def __init__(self, **kwargs):
266 """Initialize this parser instance.
269 logging: Defaults to ALLOW_LOGGING from the class; if given,
271 default_log_level: If logging is enabled, override the default logging
272 level. Defaults to the class's DEFAULT_LOG_LEVEL value.
273 log_levels: If logging is enabled, this overrides the enumeration of
274 allowed logging levels. If not given, defaults to the classes
275 DEFAULT_LOG_LEVELS value.
276 manual_debug: If logging is enabled and this is True, suppress addition
277 of a --debug alias. This option defaults to True unless 'debug' has
278 been exempted from the allowed logging level targets.
279 caching: If given, must be either a callable that discerns the cache
280 location if it wasn't specified (the prototype must be akin to
281 lambda parser, values:calculated_cache_dir_path; it may return None to
282 indicate that it handles setting the value on its own later in the
283 parsing including setting the env), or True; if True, the
284 machinery defaults to invoking the class's FindCacheDir method
285 (which can be overridden). FindCacheDir $CROS_CACHEDIR, falling
286 back to $REPO/.cache, finally falling back to $TMP.
287 Note that the cache_dir is not created, just discerned where it
289 If False, or caching is not given, then no --cache-dir option will be
292 self.debug_enabled = False
293 self.caching_group = None
294 self.debug_group = None
295 self.default_log_level = None
296 self.log_levels = None
297 self.logging_enabled = kwargs.get('logging', self.ALLOW_LOGGING)
298 self.default_log_level = kwargs.get('default_log_level',
299 self.DEFAULT_LOG_LEVEL)
300 self.log_levels = tuple(x.lower() for x in
301 kwargs.get('log_levels', self.DEFAULT_LOG_LEVELS))
302 self.debug_enabled = (not kwargs.get('manual_debug', False)
303 and 'debug' in self.log_levels)
304 self.caching = kwargs.get('caching', False)
307 def PopUsedArgs(kwarg_dict):
308 """Removes keys used by the base parser from the kwarg namespace."""
309 parser_keys = ['logging', 'default_log_level', 'log_levels', 'manual_debug',
311 for key in parser_keys:
312 kwarg_dict.pop(key, None)
314 def SetupOptions(self):
315 """Sets up special chromite options for an OptionParser."""
316 if self.logging_enabled:
317 self.debug_group = self.add_option_group("Debug options")
318 self.add_option_to_group(
319 self.debug_group, '--log-level', choices=self.log_levels,
320 default=self.default_log_level,
321 help='Set logging level to report at.')
322 self.add_option_to_group(
323 self.debug_group, '--log_format', action='store',
324 default=constants.LOGGER_FMT,
325 help='Set logging format to use.')
326 if self.debug_enabled:
327 self.add_option_to_group(
328 self.debug_group, '--debug', action='store_const', const='debug',
329 dest='log_level', help='Alias for `--log-level=debug`. '
330 'Useful for debugging bugs/failures.')
331 self.add_option_to_group(
332 self.debug_group, '--nocolor', action='store_false', dest='color',
334 help='Do not use colorized output (or `export NOCOLOR=true`)')
337 self.caching_group = self.add_option_group("Caching Options")
338 self.add_option_to_group(
339 self.caching_group, "--cache-dir", default=None, type='path',
340 help="Override the calculated chromeos cache directory; "
341 "typically defaults to '$REPO/.cache' .")
343 def SetupLogging(self, opts):
344 """Sets up logging based on |opts|."""
345 value = opts.log_level.upper()
346 logger = logging.getLogger()
347 logger.setLevel(getattr(logging, value))
348 formatter = ColoredFormatter(fmt=opts.log_format,
349 datefmt=constants.LOGGER_DATE_FMT,
350 enable_color=opts.color)
352 # Only set colored formatter for ChromiteStreamHandler instances,
353 # which could have been added by ScriptWrapperMain() below.
354 chromite_handlers = [x for x in logger.handlers if
355 isinstance(x, ChromiteStreamHandler)]
356 for handler in chromite_handlers:
357 handler.setFormatter(formatter)
361 def DoPostParseSetup(self, opts, args):
362 """Method called to handle post opts/args setup.
364 This can be anything from logging setup to positional arg count validation.
367 opts: optparse.Values or argparse.Namespace instance
368 args: position arguments unconsumed from parsing.
371 (opts, args), w/ whatever modification done.
373 if self.logging_enabled:
374 value = self.SetupLogging(opts)
375 if self.debug_enabled:
376 opts.debug = (value == "DEBUG")
379 path = os.environ.get(constants.SHARED_CACHE_ENVVAR)
380 if path is not None and opts.cache_dir is None:
381 opts.cache_dir = os.path.abspath(path)
383 opts.cache_dir_specified = opts.cache_dir is not None
384 if not opts.cache_dir_specified:
385 func = self.FindCacheDir if not callable(self.caching) else self.caching
386 opts.cache_dir = func(self, opts)
387 if opts.cache_dir is not None:
388 self.ConfigureCacheDir(opts.cache_dir)
393 def ConfigureCacheDir(cache_dir):
394 if cache_dir is None:
395 os.environ.pop(constants.SHARED_CACHE_ENVVAR, None)
396 logging.debug("Removed cache_dir setting")
398 os.environ[constants.SHARED_CACHE_ENVVAR] = cache_dir
399 logging.debug("Configured cache_dir to %r", cache_dir)
402 def FindCacheDir(cls, _parser, _opts):
403 logging.debug('Cache dir lookup.')
404 checkout = DetermineCheckout(os.getcwd())
406 if checkout.type == CHECKOUT_TYPE_REPO:
407 path = os.path.join(checkout.root, cls.REPO_CACHE_DIR)
408 elif checkout.type in (CHECKOUT_TYPE_GCLIENT, CHECKOUT_TYPE_SUBMODULE):
409 path = os.path.join(checkout.root, cls.CHROME_CACHE_DIR)
410 elif checkout.type == CHECKOUT_TYPE_UNKNOWN:
411 path = os.path.join(tempfile.gettempdir(), 'chromeos-cache')
413 raise AssertionError('Unexpected type %s' % checkout.type)
417 def add_option_group(self, *args, **kwargs):
418 """Returns a new option group see optparse.OptionParser.add_option_group."""
419 raise NotImplementedError('Subclass must override this method')
422 def add_option_to_group(group, *args, **kwargs):
423 """Adds the given option defined by args and kwargs to group."""
424 group.add_option(*args, **kwargs)
427 class ArgumentNamespace(argparse.Namespace):
428 """Class to mimic argparse.Namespace with value freezing support."""
429 __metaclass__ = cros_build_lib.FrozenAttributesClass
430 _FROZEN_ERR_MSG = 'Option values are frozen, cannot alter %s.'
433 # Note that because optparse.Values is not a new-style class this class
434 # must use the mixin FrozenAttributesMixin rather than the metaclass
435 # FrozenAttributesClass.
436 class OptionValues(cros_build_lib.FrozenAttributesMixin, optparse.Values):
437 """Class to mimic optparse.Values with value freezing support."""
438 _FROZEN_ERR_MSG = 'Option values are frozen, cannot alter %s.'
440 def __init__(self, defaults, *args, **kwargs):
441 cros_build_lib.FrozenAttributesMixin.__init__(self)
442 optparse.Values.__init__(self, defaults, *args, **kwargs)
444 # Used by FilteringParser.
445 self.parsed_args = None
448 class OptionParser(optparse.OptionParser, BaseParser):
449 """Custom parser adding our custom option class in.
451 Aside from adding a couple of types (path for absolute paths,
452 gs_path for google storage urls, and log_level for logging level control),
453 this additionally exposes logging control by default; if undesired,
454 either derive from this class setting ALLOW_LOGGING to False, or
455 pass in logging=False to the constructor.
458 DEFAULT_OPTION_CLASS = Option
460 def __init__(self, usage=None, **kwargs):
461 BaseParser.__init__(self, **kwargs)
462 self.PopUsedArgs(kwargs)
463 kwargs.setdefault("option_class", self.DEFAULT_OPTION_CLASS)
464 optparse.OptionParser.__init__(self, usage=usage, **kwargs)
467 def parse_args(self, args=None, values=None):
468 # If no Values object is specified then use our custom OptionValues.
470 values = OptionValues(defaults=self.defaults)
472 opts, remaining = optparse.OptionParser.parse_args(
473 self, args=args, values=values)
474 return self.DoPostParseSetup(opts, remaining)
477 PassedOption = collections.namedtuple(
478 'PassedOption', ['opt_inst', 'opt_str', 'value_str'])
481 class FilteringParser(OptionParser):
482 """Custom option parser for filtering options."""
484 DEFAULT_OPTION_CLASS = FilteringOption
486 def parse_args(self, args=None, values=None):
487 # If no Values object is specified then use our custom OptionValues.
489 values = OptionValues(defaults=self.defaults)
491 values.parsed_args = []
493 return OptionParser.parse_args(self, args=args, values=values)
495 def AddParsedArg(self, opt_inst, opt_str, value_str):
496 """Add a parsed argument with attributes.
499 opt_inst: An instance of a raw optparse.Option object that represents the
501 opt_str: The option string.
502 value_str: A list of string-ified values dentified by OptParse.
504 self.values.parsed_args.append(PassedOption(opt_inst, opt_str, value_str))
507 def FilterArgs(parsed_args, filter_fn):
508 """Filter the argument by passing it through a function.
511 parsed_args: The list of parsed argument namedtuples to filter. Tuples
512 are of the form (opt_inst, opt_str, value_str).
513 filter_fn: A function with signature f(PassedOption), and returns True if
514 the argument is to be passed through. False if not.
517 A tuple containing two lists - one of accepted arguments and one of
522 for arg in parsed_args:
523 target = accepted if filter_fn(arg) else removed
524 target.append(arg.opt_str)
525 target.extend(arg.value_str)
527 return accepted, removed
530 # pylint: disable=R0901
531 class ArgumentParser(BaseParser, argparse.ArgumentParser):
532 """Custom argument parser for use by chromite.
534 This class additionally exposes logging control by default; if undesired,
535 either derive from this class setting ALLOW_LOGGING to False, or
536 pass in logging=False to the constructor.
538 # pylint: disable=W0231
539 def __init__(self, usage=None, **kwargs):
540 kwargs.setdefault('formatter_class', argparse.RawDescriptionHelpFormatter)
541 BaseParser.__init__(self, **kwargs)
542 self.PopUsedArgs(kwargs)
543 argparse.ArgumentParser.__init__(self, usage=usage, **kwargs)
547 def _SetupTypes(self):
548 """Register types with ArgumentParser."""
549 for t, check_f in VALID_TYPES.iteritems():
550 self.register('type', t, check_f)
552 def add_option_group(self, *args, **kwargs):
553 """Return an argument group rather than an option group."""
554 return self.add_argument_group(*args, **kwargs)
557 def add_option_to_group(group, *args, **kwargs):
558 """Adds an argument rather than an option to the given group."""
559 return group.add_argument(*args, **kwargs)
561 def parse_args(self, args=None, namespace=None):
562 """Translates OptionParser call to equivalent ArgumentParser call."""
563 # If no Namespace object is specified then use our custom ArgumentNamespace.
564 if namespace is None:
565 namespace = ArgumentNamespace()
567 # Unlike OptionParser, ArgParser works only with a single namespace and no
568 # args. Re-use BaseParser DoPostParseSetup but only take the namespace.
569 namespace = argparse.ArgumentParser.parse_args(
570 self, args=args, namespace=namespace)
571 return self.DoPostParseSetup(namespace, None)[0]
574 class _ShutDownException(SystemExit):
575 """Exception raised when user hits CTRL+C."""
577 def __init__(self, sig_num, message):
578 self.signal = sig_num
579 # Setup a usage message primarily for any code that may intercept it
580 # while this exception is crashing back up the stack to us.
581 SystemExit.__init__(self, message)
582 self.args = (sig_num, message)
585 def _DefaultHandler(signum, _frame):
586 # Don't double process sigterms; just trigger shutdown from the first
588 signal.signal(signum, signal.SIG_IGN)
589 raise _ShutDownException(
590 signum, "Received signal %i; shutting down" % (signum,))
593 def _RestartInChroot(argv):
594 """Rerun the current command inside the chroot"""
595 return cros_build_lib.RunCommand(argv, enter_chroot=True, error_code_ok=True,
596 cwd=constants.SOURCE_ROOT).returncode
599 def ScriptWrapperMain(find_target_func, argv=None,
600 log_level=logging.DEBUG,
601 log_format=constants.LOGGER_FMT):
602 """Function usable for chromite.script.* style wrapping.
604 Note that this function invokes sys.exit on the way out by default.
607 find_target_func: a function, which, when given the absolute
608 pathway the script was invoked via (for example,
609 /home/ferringb/cros/trunk/chromite/bin/cros_sdk; note that any
610 trailing .py from the path name will be removed),
611 will return the main function to invoke (that functor will take
612 a single arg- a list of arguments, and shall return either None
613 or an integer, to indicate the exit code).
614 argv: sys.argv, or an equivalent tuple for testing. If nothing is
615 given, sys.argv is defaulted to.
616 log_level: Default logging level to start at.
617 log_format: Default logging format to use.
621 target = os.path.abspath(argv[0])
622 name = os.path.basename(target)
623 if target.endswith('.py'):
624 target = os.path.splitext(target)[0]
625 target = find_target_func(target)
627 print('Internal error detected- no main functor found in module %r.' %
628 (name,), file=sys.stderr)
631 # Set up basic logging information for all modules that use logging.
632 # Note a script target may setup default logging in its module namespace
633 # which will take precedence over this.
634 logger = logging.getLogger()
635 logger.setLevel(log_level)
636 logger_handler = ChromiteStreamHandler()
637 logger_handler.setFormatter(
638 logging.Formatter(fmt=log_format, datefmt=constants.LOGGER_DATE_FMT))
639 logger.addHandler(logger_handler)
641 signal.signal(signal.SIGTERM, _DefaultHandler)
645 ret = target(argv[1:])
646 except _ShutDownException as e:
648 print('%s: Signaled to shutdown: caught %i signal.' % (name, e.signal,),
651 except SystemExit as e:
652 # Right now, let this crash through- longer term, we'll update the scripts
653 # in question to not use sys.exit, and make this into a flagged error.
655 except ChrootRequiredError as e:
656 ret = _RestartInChroot(e.new_argv)
657 except Exception as e:
659 print('%s: Unhandled exception:' % (name,), file=sys.stderr)