xref: /aosp_15_r20/external/autotest/utils/frozen_chromite/cli/command.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"""Module that contains meta-logic related to CLI commands.
7
8This module contains two important definitions used by all commands:
9  CliCommand: The parent class of all CLI commands.
10  CommandDecorator: Decorator that must be used to ensure that the command shows
11    up in |_commands| and is discoverable.
12
13Commands can be either imported directly or looked up using this module's
14ListCommands() function.
15"""
16
17from __future__ import print_function
18
19import importlib
20import os
21
22from autotest_lib.utils.frozen_chromite.lib import constants
23from autotest_lib.utils.frozen_chromite.lib import commandline
24from autotest_lib.utils.frozen_chromite.lib import cros_build_lib
25from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging
26
27
28# Paths for finding and importing subcommand modules.
29_SUBCOMMAND_MODULE_DIRECTORY = os.path.join(os.path.dirname(__file__), 'cros')
30_SUBCOMMAND_MODULE_PREFIX = 'cros_'
31
32
33_commands = dict()
34
35
36def UseProgressBar():
37  """Determine whether the progress bar is to be used or not.
38
39  We only want the progress bar to display for the brillo commands which operate
40  at logging level NOTICE. If the user wants to see the noisy output, then they
41  can execute the command at logging level INFO or DEBUG.
42  """
43  return logging.getLogger().getEffectiveLevel() == logging.NOTICE
44
45
46def ImportCommand(name):
47  """Directly import the specified subcommand.
48
49  This method imports the module which must contain the single subcommand.  When
50  the module is loaded, the declared command (those that use CommandDecorator)
51  will automatically get added to |_commands|.
52
53  Args:
54    name: The subcommand to load.
55
56  Returns:
57    A reference to the subcommand class.
58  """
59  module_path = os.path.join(_SUBCOMMAND_MODULE_DIRECTORY,
60                             'cros_%s' % (name.replace('-', '_'),))
61  import_path = os.path.relpath(os.path.realpath(module_path),
62                                os.path.dirname(constants.CHROMITE_DIR))
63  module_parts = import_path.split(os.path.sep)
64  importlib.import_module('.'.join(module_parts))
65  return _commands[name]
66
67
68def ListCommands():
69  """Return the set of available subcommands.
70
71  We assume that there is a direct one-to-one relationship between the module
72  name on disk and the command that module implements.  We assume this as a
73  performance requirement (to avoid importing every subcommand every time even
74  though we'd only ever run a single one), and to avoid 3rd party module usage
75  in one subcommand breaking all other subcommands (not a great solution).
76  """
77  # Filenames use underscores due to python naming limitations, but subcommands
78  # use dashes as they're easier for humans to type.
79  # Strip off the leading "cros_" and the trailing ".py".
80  return set(x[5:-3].replace('_', '-')
81             for x in os.listdir(_SUBCOMMAND_MODULE_DIRECTORY)
82             if (x.startswith(_SUBCOMMAND_MODULE_PREFIX) and x.endswith('.py')
83                 and not x.endswith('_unittest.py')))
84
85
86class InvalidCommandError(Exception):
87  """Error that occurs when command class fails sanity checks."""
88
89
90def CommandDecorator(command_name):
91  """Decorator that sanity checks and adds class to list of usable commands."""
92
93  def InnerCommandDecorator(original_class):
94    """Inner Decorator that actually wraps the class."""
95    if not hasattr(original_class, '__doc__'):
96      raise InvalidCommandError('All handlers must have docstrings: %s' %
97                                original_class)
98
99    if not issubclass(original_class, CliCommand):
100      raise InvalidCommandError('All Commands must derive from CliCommand: %s' %
101                                original_class)
102
103    _commands[command_name] = original_class
104    original_class.command_name = command_name
105
106    return original_class
107
108  return InnerCommandDecorator
109
110
111class CliCommand(object):
112  """All CLI commands must derive from this class.
113
114  This class provides the abstract interface for all CLI commands. When
115  designing a new command, you must sub-class from this class and use the
116  CommandDecorator decorator. You must specify a class docstring as that will be
117  used as the usage for the sub-command.
118
119  In addition your command should implement AddParser which is passed in a
120  parser that you can add your own custom arguments. See argparse for more
121  information.
122  """
123  # Indicates whether command uses cache related commandline options.
124  use_caching_options = False
125
126  def __init__(self, options):
127    self.options = options
128
129  @classmethod
130  def AddParser(cls, parser):
131    """Add arguments for this command to the parser."""
132    parser.set_defaults(command_class=cls)
133
134  @classmethod
135  def AddDeviceArgument(cls, parser, schemes=commandline.DEVICE_SCHEME_SSH,
136                        positional=False):
137    """Add a device argument to the parser.
138
139    This standardizes the help message across all subcommands.
140
141    Args:
142      parser: The parser to add the device argument to.
143      schemes: List of device schemes or single scheme to allow.
144      positional: Whether it should be a positional or named argument.
145    """
146    help_strings = []
147    schemes = list(cros_build_lib.iflatten_instance(schemes))
148    if commandline.DEVICE_SCHEME_SSH in schemes:
149      help_strings.append('Target a device with [user@]hostname[:port]. '
150                          'IPv4/IPv6 addresses are allowed, but IPv6 must '
151                          'use brackets (e.g. [::1]).')
152    if commandline.DEVICE_SCHEME_USB in schemes:
153      help_strings.append('Target removable media with usb://[path].')
154    if commandline.DEVICE_SCHEME_SERVO in schemes:
155      help_strings.append('Target a servo by port or serial number with '
156                          'servo:port[:port] or servo:serial:serial-number. '
157                          'e.g. servo:port:1234 or servo:serial:C1230024192.')
158    if commandline.DEVICE_SCHEME_FILE in schemes:
159      help_strings.append('Target a local file with file://path.')
160    if positional:
161      parser.add_argument('device',
162                          type=commandline.DeviceParser(schemes),
163                          help=' '.join(help_strings))
164    else:
165      parser.add_argument('-d', '--device',
166                          type=commandline.DeviceParser(schemes),
167                          help=' '.join(help_strings))
168
169  def Run(self):
170    """The command to run."""
171    raise NotImplementedError()
172