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.*).
23 # TODO(build): sort the cbuildbot.constants/lib.constants issue;
24 # lib shouldn't have to import from buildbot like this.
25 from chromite.cbuildbot import constants
26 from chromite.lib import cros_build_lib
27 from chromite.lib import git
28 from chromite.lib import gs
29 from chromite.lib import osutils
32 CHECKOUT_TYPE_UNKNOWN = 'unknown'
33 CHECKOUT_TYPE_GCLIENT = 'gclient'
34 CHECKOUT_TYPE_REPO = 'repo'
35 CHECKOUT_TYPE_SUBMODULE = 'submodule'
38 CheckoutInfo = collections.namedtuple(
39 'CheckoutInfo', ['type', 'root', 'chrome_src_dir'])
42 class ChrootRequiredError(Exception):
43 """Raised when a command must be run in the chroot
45 This exception is intended to be caught by code which will restart execution
46 in the chroot. If none of the arguments passed to the script need to be
47 adjusted when that happens, it can be constructed with no parameters. If
48 something does need to be adjusted, for instance an argument that's a path,
49 the command can construct a custom command line and pass it into this
50 exception which will be used instead.
52 When customizing the command line, argv[0] will have to be fixed up manually
53 like any other element of argv.
56 def __init__(self, new_argv=None, *args, **kwargs):
57 Exception.__init__(self, *args, **kwargs)
59 new_argv = sys.argv[:]
60 new_argv = [git.ReinterpretPathForChroot(new_argv[0])] + new_argv[1:]
62 self.new_argv = new_argv
65 def DetermineCheckout(cwd):
66 """Gather information on the checkout we are in.
69 A CheckoutInfo object with these attributes:
70 type: The type of checkout. Valid values are CHECKOUT_TYPE_*.
71 root: The root of the checkout.
72 chrome_src_dir: If the checkout is a Chrome checkout, the path to the
73 Chrome src/ directory.
75 checkout_type = CHECKOUT_TYPE_UNKNOWN
76 root, path = None, None
77 for path in osutils.IteratePathParents(cwd):
78 repo_dir = os.path.join(path, '.repo')
79 if os.path.isdir(repo_dir):
80 checkout_type = CHECKOUT_TYPE_REPO
82 gclient_file = os.path.join(path, '.gclient')
83 if os.path.exists(gclient_file):
84 checkout_type = CHECKOUT_TYPE_GCLIENT
86 submodule_git = os.path.join(path, '.git')
87 if (os.path.isdir(submodule_git) and
88 git.IsSubmoduleCheckoutRoot(cwd, 'origin', constants.CHROMIUM_GOB_URL)):
89 checkout_type = CHECKOUT_TYPE_SUBMODULE
92 if checkout_type != CHECKOUT_TYPE_UNKNOWN:
95 # Determine the chrome src directory.
97 if checkout_type == CHECKOUT_TYPE_GCLIENT:
98 chrome_src_dir = os.path.join(root, 'src')
99 elif checkout_type == CHECKOUT_TYPE_SUBMODULE:
100 chrome_src_dir = root
102 return CheckoutInfo(checkout_type, root, chrome_src_dir)
106 """Calculate the current cache dir.
108 Users can configure the cache dir using the --cache-dir argument and it is
109 shared between cbuildbot and all child processes. If no cache dir is
110 specified, FindCacheDir finds an alternative location to store the cache.
113 The path to the cache dir.
115 return os.environ.get(
116 constants.SHARED_CACHE_ENVVAR,
117 BaseParser.FindCacheDir(None, None))
120 def AbsolutePath(_option, _opt, value):
121 """Expand paths and make them absolute."""
122 return osutils.ExpandPath(value)
125 def NormalizeGSPath(value):
126 """Normalize GS paths."""
127 url = gs.CanonicalizeURL(value, strict=True)
128 return '%s%s' % (gs.BASE_GS_URL, os.path.normpath(url[len(gs.BASE_GS_URL):]))
131 def NormalizeLocalOrGSPath(value):
132 """Normalize a local or GS path."""
133 ptype = 'gs_path' if value.startswith(gs.BASE_GS_URL) else 'path'
134 return VALID_TYPES[ptype](value)
137 def ParseDate(value):
138 """Parse date argument into a datetime.date object.
141 value: String representing a single date in "YYYY-MM-DD" format.
144 A datetime.date object.
147 return datetime.datetime.strptime(value, '%Y-%m-%d').date()
149 # Give a helpful error message about the format expected. Putting this
150 # message in the exception is useless because argparse ignores the
151 # exception message and just says the value is invalid.
152 cros_build_lib.Error('Date is expected to be in format YYYY-MM-DD.')
156 def NormalizeUri(value):
157 """Normalize a local path or URI."""
158 # Pylint is confused about result of urlparse.
159 # pylint: disable=E1101
160 o = urlparse.urlparse(value)
161 if o.scheme == 'file':
162 # Trim off the file:// prefix.
163 return VALID_TYPES['path'](value[7:])
164 elif o.scheme not in ('', 'gs'):
166 o[2] = os.path.normpath(o[2])
167 return urlparse.urlunparse(o)
169 return NormalizeLocalOrGSPath(value)
172 def OptparseWrapCheck(desc, check_f, _option, opt, value):
173 """Optparse adapter for type checking functionality."""
175 return check_f(value)
177 raise optparse.OptionValueError(
178 'Invalid %s given: --%s=%s' % (desc, opt, value))
183 'path': osutils.ExpandPath,
184 'gs_path': NormalizeGSPath,
185 'local_or_gs_path': NormalizeLocalOrGSPath,
186 'path_or_uri': NormalizeUri,
190 class Option(optparse.Option):
191 """Subclass to implement path evaluation & other useful types."""
193 _EXTRA_TYPES = ("path", "gs_path")
194 TYPES = optparse.Option.TYPES + _EXTRA_TYPES
195 TYPE_CHECKER = optparse.Option.TYPE_CHECKER.copy()
196 for t in _EXTRA_TYPES:
197 TYPE_CHECKER[t] = functools.partial(OptparseWrapCheck, t, VALID_TYPES[t])
200 class FilteringOption(Option):
201 """Subclass that supports Option filtering for FilteringOptionParser"""
203 def take_action(self, action, dest, opt, value, values, parser):
204 if action in FilteringOption.ACTIONS:
205 Option.take_action(self, action, dest, opt, value, values, parser)
209 elif not self.nargs or self.nargs <= 1:
212 parser.AddParsedArg(self, opt, [str(v) for v in value])
215 class BaseParser(object):
216 """Base parser class that includes the logic to add logging controls."""
218 DEFAULT_LOG_LEVELS = ('fatal', 'critical', 'error', 'warning', 'info',
221 DEFAULT_LOG_LEVEL = "info"
224 REPO_CACHE_DIR = '.cache'
225 CHROME_CACHE_DIR = '.cros_cache'
227 def __init__(self, **kwargs):
228 """Initialize this parser instance.
231 logging: Defaults to ALLOW_LOGGING from the class; if given,
233 default_log_level: If logging is enabled, override the default logging
234 level. Defaults to the class's DEFAULT_LOG_LEVEL value.
235 log_levels: If logging is enabled, this overrides the enumeration of
236 allowed logging levels. If not given, defaults to the classes
237 DEFAULT_LOG_LEVELS value.
238 manual_debug: If logging is enabled and this is True, suppress addition
239 of a --debug alias. This option defaults to True unless 'debug' has
240 been exempted from the allowed logging level targets.
241 caching: If given, must be either a callable that discerns the cache
242 location if it wasn't specified (the prototype must be akin to
243 lambda parser, values:calculated_cache_dir_path; it may return None to
244 indicate that it handles setting the value on its own later in the
245 parsing including setting the env), or True; if True, the
246 machinery defaults to invoking the class's FindCacheDir method
247 (which can be overridden). FindCacheDir $CROS_CACHEDIR, falling
248 back to $REPO/.cache, finally falling back to $TMP.
249 Note that the cache_dir is not created, just discerned where it
251 If False, or caching is not given, then no --cache-dir option will be
254 self.debug_enabled = False
255 self.caching_group = None
256 self.debug_group = None
257 self.default_log_level = None
258 self.log_levels = None
259 self.logging_enabled = kwargs.get('logging', self.ALLOW_LOGGING)
260 self.default_log_level = kwargs.get('default_log_level',
261 self.DEFAULT_LOG_LEVEL)
262 self.log_levels = tuple(x.lower() for x in
263 kwargs.get('log_levels', self.DEFAULT_LOG_LEVELS))
264 self.debug_enabled = (not kwargs.get('manual_debug', False)
265 and 'debug' in self.log_levels)
266 self.caching = kwargs.get('caching', False)
269 def PopUsedArgs(kwarg_dict):
270 """Removes keys used by the base parser from the kwarg namespace."""
271 parser_keys = ['logging', 'default_log_level', 'log_levels', 'manual_debug',
273 for key in parser_keys:
274 kwarg_dict.pop(key, None)
276 def SetupOptions(self):
277 """Sets up special chromite options for an OptionParser."""
278 if self.logging_enabled:
279 self.debug_group = self.add_option_group("Debug options")
280 self.add_option_to_group(
281 self.debug_group, "--log-level", choices=self.log_levels,
282 default=self.default_log_level,
283 help="Set logging level to report at.")
284 if self.debug_enabled:
285 self.add_option_to_group(
286 self.debug_group, "--debug", action="store_const", const="debug",
287 dest="log_level", help="Alias for `--log-level=debug`. "
288 "Useful for debugging bugs/failures.")
289 self.add_option_to_group(
290 self.debug_group, '--nocolor', action='store_false', dest='color',
292 help='Do not use colorized output (or `export NOCOLOR=true`)')
295 self.caching_group = self.add_option_group("Caching Options")
296 self.add_option_to_group(
297 self.caching_group, "--cache-dir", default=None, type='path',
298 help="Override the calculated chromeos cache directory; "
299 "typically defaults to '$REPO/.cache' .")
301 def SetupLogging(self, opts):
302 value = opts.log_level.upper()
303 logging.getLogger().setLevel(getattr(logging, value))
306 def DoPostParseSetup(self, opts, args):
307 """Method called to handle post opts/args setup.
309 This can be anything from logging setup to positional arg count validation.
312 opts: optparse.Values or argparse.Namespace instance
313 args: position arguments unconsumed from parsing.
316 (opts, args), w/ whatever modification done.
318 if self.logging_enabled:
319 value = self.SetupLogging(opts)
320 if self.debug_enabled:
321 opts.debug = (value == "DEBUG")
324 path = os.environ.get(constants.SHARED_CACHE_ENVVAR)
325 if path is not None and opts.cache_dir is None:
326 opts.cache_dir = os.path.abspath(path)
328 opts.cache_dir_specified = opts.cache_dir is not None
329 if not opts.cache_dir_specified:
330 func = self.FindCacheDir if not callable(self.caching) else self.caching
331 opts.cache_dir = func(self, opts)
332 if opts.cache_dir is not None:
333 self.ConfigureCacheDir(opts.cache_dir)
338 def ConfigureCacheDir(cache_dir):
339 if cache_dir is None:
340 os.environ.pop(constants.SHARED_CACHE_ENVVAR, None)
341 logging.debug("Removed cache_dir setting")
343 os.environ[constants.SHARED_CACHE_ENVVAR] = cache_dir
344 logging.debug("Configured cache_dir to %r", cache_dir)
347 def FindCacheDir(cls, _parser, _opts):
348 logging.debug('Cache dir lookup.')
349 checkout = DetermineCheckout(os.getcwd())
351 if checkout.type == CHECKOUT_TYPE_REPO:
352 path = os.path.join(checkout.root, cls.REPO_CACHE_DIR)
353 elif checkout.type in (CHECKOUT_TYPE_GCLIENT, CHECKOUT_TYPE_SUBMODULE):
354 path = os.path.join(checkout.root, cls.CHROME_CACHE_DIR)
355 elif checkout.type == CHECKOUT_TYPE_UNKNOWN:
356 path = os.path.join(tempfile.gettempdir(), 'chromeos-cache')
358 raise AssertionError('Unexpected type %s' % checkout.type)
362 def add_option_group(self, *args, **kwargs):
363 """Returns a new option group see optparse.OptionParser.add_option_group."""
364 raise NotImplementedError('Subclass must override this method')
367 def add_option_to_group(group, *args, **kwargs):
368 """Adds the given option defined by args and kwargs to group."""
369 group.add_option(*args, **kwargs)
372 class ArgumentNamespace(argparse.Namespace):
373 """Class to mimic argparse.Namespace with value freezing support."""
374 __metaclass__ = cros_build_lib.FrozenAttributesClass
375 _FROZEN_ERR_MSG = 'Option values are frozen, cannot alter %s.'
378 # Note that because optparse.Values is not a new-style class this class
379 # must use the mixin FrozenAttributesMixin rather than the metaclass
380 # FrozenAttributesClass.
381 class OptionValues(cros_build_lib.FrozenAttributesMixin, optparse.Values):
382 """Class to mimic optparse.Values with value freezing support."""
383 _FROZEN_ERR_MSG = 'Option values are frozen, cannot alter %s.'
385 def __init__(self, defaults, *args, **kwargs):
386 cros_build_lib.FrozenAttributesMixin.__init__(self)
387 optparse.Values.__init__(self, defaults, *args, **kwargs)
389 # Used by FilteringParser.
390 self.parsed_args = None
393 class OptionParser(optparse.OptionParser, BaseParser):
394 """Custom parser adding our custom option class in.
396 Aside from adding a couple of types (path for absolute paths,
397 gs_path for google storage urls, and log_level for logging level control),
398 this additionally exposes logging control by default; if undesired,
399 either derive from this class setting ALLOW_LOGGING to False, or
400 pass in logging=False to the constructor.
403 DEFAULT_OPTION_CLASS = Option
405 def __init__(self, usage=None, **kwargs):
406 BaseParser.__init__(self, **kwargs)
407 self.PopUsedArgs(kwargs)
408 kwargs.setdefault("option_class", self.DEFAULT_OPTION_CLASS)
409 optparse.OptionParser.__init__(self, usage=usage, **kwargs)
412 def parse_args(self, args=None, values=None):
413 # If no Values object is specified then use our custom OptionValues.
415 values = OptionValues(defaults=self.defaults)
417 opts, remaining = optparse.OptionParser.parse_args(
418 self, args=args, values=values)
419 return self.DoPostParseSetup(opts, remaining)
422 PassedOption = collections.namedtuple(
423 'PassedOption', ['opt_inst', 'opt_str', 'value_str'])
426 class FilteringParser(OptionParser):
427 """Custom option parser for filtering options."""
429 DEFAULT_OPTION_CLASS = FilteringOption
431 def parse_args(self, args=None, values=None):
432 # If no Values object is specified then use our custom OptionValues.
434 values = OptionValues(defaults=self.defaults)
436 values.parsed_args = []
438 return OptionParser.parse_args(self, args=args, values=values)
440 def AddParsedArg(self, opt_inst, opt_str, value_str):
441 """Add a parsed argument with attributes.
444 opt_inst: An instance of a raw optparse.Option object that represents the
446 opt_str: The option string.
447 value_str: A list of string-ified values dentified by OptParse.
449 self.values.parsed_args.append(PassedOption(opt_inst, opt_str, value_str))
452 def FilterArgs(parsed_args, filter_fn):
453 """Filter the argument by passing it through a function.
456 parsed_args: The list of parsed argument namedtuples to filter. Tuples
457 are of the form (opt_inst, opt_str, value_str).
458 filter_fn: A function with signature f(PassedOption), and returns True if
459 the argument is to be passed through. False if not.
462 A tuple containing two lists - one of accepted arguments and one of
467 for arg in parsed_args:
468 target = accepted if filter_fn(arg) else removed
469 target.append(arg.opt_str)
470 target.extend(arg.value_str)
472 return accepted, removed
475 # pylint: disable=R0901
476 class ArgumentParser(BaseParser, argparse.ArgumentParser):
477 """Custom argument parser for use by chromite.
479 This class additionally exposes logging control by default; if undesired,
480 either derive from this class setting ALLOW_LOGGING to False, or
481 pass in logging=False to the constructor.
483 # pylint: disable=W0231
484 def __init__(self, usage=None, **kwargs):
485 kwargs.setdefault('formatter_class', argparse.RawDescriptionHelpFormatter)
486 BaseParser.__init__(self, **kwargs)
487 self.PopUsedArgs(kwargs)
488 argparse.ArgumentParser.__init__(self, usage=usage, **kwargs)
492 def _SetupTypes(self):
493 """Register types with ArgumentParser."""
494 for t, check_f in VALID_TYPES.iteritems():
495 self.register('type', t, check_f)
497 def add_option_group(self, *args, **kwargs):
498 """Return an argument group rather than an option group."""
499 return self.add_argument_group(*args, **kwargs)
502 def add_option_to_group(group, *args, **kwargs):
503 """Adds an argument rather than an option to the given group."""
504 return group.add_argument(*args, **kwargs)
506 def parse_args(self, args=None, namespace=None):
507 """Translates OptionParser call to equivalent ArgumentParser call."""
508 # If no Namespace object is specified then use our custom ArgumentNamespace.
509 if namespace is None:
510 namespace = ArgumentNamespace()
512 # Unlike OptionParser, ArgParser works only with a single namespace and no
513 # args. Re-use BaseParser DoPostParseSetup but only take the namespace.
514 namespace = argparse.ArgumentParser.parse_args(
515 self, args=args, namespace=namespace)
516 return self.DoPostParseSetup(namespace, None)[0]
519 class _ShutDownException(SystemExit):
520 """Exception raised when user hits CTRL+C."""
522 def __init__(self, sig_num, message):
523 self.signal = sig_num
524 # Setup a usage message primarily for any code that may intercept it
525 # while this exception is crashing back up the stack to us.
526 SystemExit.__init__(self, message)
527 self.args = (sig_num, message)
530 def _DefaultHandler(signum, _frame):
531 # Don't double process sigterms; just trigger shutdown from the first
533 signal.signal(signum, signal.SIG_IGN)
534 raise _ShutDownException(
535 signum, "Received signal %i; shutting down" % (signum,))
538 def _RestartInChroot(argv):
539 """Rerun the current command inside the chroot"""
540 return cros_build_lib.RunCommand(argv, enter_chroot=True, error_code_ok=True,
541 cwd=constants.SOURCE_ROOT).returncode
544 def ScriptWrapperMain(find_target_func, argv=None,
545 log_level=logging.DEBUG,
546 log_format=constants.LOGGER_FMT):
547 """Function usable for chromite.script.* style wrapping.
549 Note that this function invokes sys.exit on the way out by default.
552 find_target_func: a function, which, when given the absolute
553 pathway the script was invoked via (for example,
554 /home/ferringb/cros/trunk/chromite/bin/cros_sdk; note that any
555 trailing .py from the path name will be removed),
556 will return the main function to invoke (that functor will take
557 a single arg- a list of arguments, and shall return either None
558 or an integer, to indicate the exit code).
559 argv: sys.argv, or an equivalent tuple for testing. If nothing is
560 given, sys.argv is defaulted to.
561 log_level: Default logging level to start at.
562 log_format: Default logging format to use.
566 target = os.path.abspath(argv[0])
567 name = os.path.basename(target)
568 if target.endswith('.py'):
569 target = os.path.splitext(target)[0]
570 target = find_target_func(target)
572 print >> sys.stderr, ("Internal error detected- no main "
573 "functor found in module %r." % (name,))
576 # Set up basic logging information for all modules that use logging.
577 # Note a script target may setup default logging in its module namespace
578 # which will take precedence over this.
582 datefmt=constants.LOGGER_DATE_FMT)
584 signal.signal(signal.SIGTERM, _DefaultHandler)
588 ret = target(argv[1:])
589 except _ShutDownException as e:
591 print >> sys.stderr, ("%s: Signaled to shutdown: caught %i signal." %
594 except SystemExit as e:
595 # Right now, let this crash through- longer term, we'll update the scripts
596 # in question to not use sys.exit, and make this into a flagged error.
598 except ChrootRequiredError as e:
599 ret = _RestartInChroot(e.new_argv)
600 except Exception as e:
602 print >> sys.stderr, ("%s: Unhandled exception:" % (name,))