Coverage for python/lsst/daf/butler/cli/butler.py : 25%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22import click
23from collections import defaultdict
24import logging
25import os
26import yaml
28from . import cmd as butlerCommands
29from .utils import to_upper
30from lsst.utils import doImport
32# localCmdPkg identifies commands that are in this package, in the dict of
33# commands used in this file. This string is used in error reporting.
34localCmdPkg = "lsst.daf.butler.cli.cmd"
36log = logging.getLogger(__name__)
39def _initLogging(logLevel):
40 numeric_level = getattr(logging, logLevel, None)
41 if not isinstance(numeric_level, int):
42 raise click.ClickException(f"Invalid log level: {logLevel}")
43 logging.basicConfig(level=numeric_level)
46def funcNameToCmdName(functionName):
47 """Change underscores, used in functions, to dashes, used in commands."""
48 return functionName.replace("_", "-")
51def cmdNameToFuncName(commandName):
52 """Change dashes, used in commands, to underscores, used in functions."""
53 return commandName.replace("-", "_")
56class LoaderCLI(click.MultiCommand):
58 def __init__(self, *args, **kwargs):
59 self.commands = None
60 super().__init__(*args, **kwargs)
62 @staticmethod
63 def _getPluginList():
64 """Get the list of importable yaml files that contain butler cli data.
66 Returns
67 -------
68 `list` [`str`]
69 The list of files that contain yaml data about a cli plugin.
70 """
71 pluginModules = os.environ.get("DAF_BUTLER_PLUGINS")
72 if pluginModules:
73 return pluginModules.split(":")
74 return []
76 @staticmethod
77 def _importPlugin(pluginName):
78 """Import a plugin that contains Click commands.
80 Parameters
81 ----------
82 pluginName : string
83 An importable module whose __all__ parameter contains the commands
84 that can be called.
86 Returns
87 -------
88 An imported module or None
89 The imported module, or None if the module could not be imported.
90 """
91 try:
92 return doImport(pluginName)
93 except (TypeError, ModuleNotFoundError, ImportError) as err:
94 log.warning("Could not import plugin from %s, skipping.", pluginName)
95 log.debug("Plugin import exception: %s", err)
96 return None
98 @staticmethod
99 def _mergeCommandLists(a, b):
100 """Combine two dicts whose keys are strings (command name) and values
101 are list of string (the package(s) that provide the named command).
103 Parameters
104 ----------
105 a : `defaultdict` [`str`: `list` [`str`]]
106 The key is the command name. The value is a list of package(s) that
107 contains the command.
108 b : (same as a)
110 Returns
111 -------
112 commands : `defaultdict` [`str`: [`str`]]
113 For convenience, returns a extended with b. ('a' is modified in
114 place.)
115 """
116 for key, val in b.items():
117 a[key].extend(val)
118 return a
120 @staticmethod
121 def _getLocalCommands():
122 """Get the commands offered by daf_butler.
124 Returns
125 -------
126 commands : `defaultdict` [`str`, `list` [`str`]]
127 The key is the command name. The value is a list of package(s) that
128 contains the command.
129 """
130 return defaultdict(list, {funcNameToCmdName(f): [localCmdPkg] for f in butlerCommands.__all__})
132 @classmethod
133 def _getPluginCommands(cls):
134 """Get the commands offered by plugin packages.
136 Returns
137 -------
138 commands : `defaultdict` [`str`, `list` [`str`]]
139 The key is the command name. The value is a list of package(s) that
140 contains the command.
141 """
142 commands = defaultdict(list)
143 for pluginName in cls._getPluginList():
144 try:
145 with open(pluginName, "r") as resourceFile:
146 resources = defaultdict(list, yaml.safe_load(resourceFile))
147 except Exception as err:
148 log.warning(f"Error loading commands from {pluginName}, skipping. {err}")
149 continue
150 if "cmd" not in resources:
151 log.warning(f"No commands found in {pluginName}, skipping.")
152 continue
153 pluginCommands = {cmd: [resources["cmd"]["import"]] for cmd in resources["cmd"]["commands"]}
154 cls._mergeCommandLists(commands, defaultdict(list, pluginCommands))
155 return commands
157 @classmethod
158 def _getCommands(cls):
159 """Get the commands offered by daf_butler and plugin packages.
161 Returns
162 -------
163 commands : `defaultdict` [`str`, `list` [`str`]]
164 The key is the command name. The value is a list of package(s) that
165 contains the command.
166 """
167 commands = cls._mergeCommandLists(cls._getLocalCommands(), cls._getPluginCommands())
168 return commands
170 @staticmethod
171 def _raiseIfDuplicateCommands(commands):
172 """If any provided command is offered by more than one package raise an
173 exception.
175 Parameters
176 ----------
177 commands : `defaultdict` [`str`, `list` [`str`]]
178 The key is the command name. The value is a list of package(s) that
179 contains the command.
181 Raises
182 ------
183 click.ClickException
184 Raised if a command is offered by more than one package, with an
185 error message to be displayed to the user.
186 """
188 msg = ""
189 for command, packages in commands.items():
190 if len(packages) > 1:
191 msg += f"Command '{command}' exists in packages {', '.join(packages)}. "
192 if msg:
193 raise click.ClickException(msg + "Duplicate commands are not supported, aborting.")
195 def list_commands(self, ctx):
196 """Used by Click to get all the commands that can be called by the
197 butler command, it is used to generate the --help output.
199 Parameters
200 ----------
201 ctx : click.Context
202 The current Click context.
204 Returns
205 -------
206 commands : `list` [`str`]
207 The names of the commands that can be called by the butler command.
208 """
209 if self.commands is None:
210 self.commands = self._getCommands()
211 self._raiseIfDuplicateCommands(self.commands)
212 log.debug(self.commands.keys())
213 return self.commands.keys()
215 def get_command(self, context, name):
216 """Used by Click to get a single command for execution.
218 Parameters
219 ----------
220 ctx : click.Context
221 The current Click context.
222 name : string
223 The name of the command to return.
225 Returns
226 -------
227 command : click.Command
228 A Command that wraps a callable command function.
229 """
230 if self.commands is None:
231 self.commands = self._getCommands()
232 if name not in self.commands:
233 return None
234 self._raiseIfDuplicateCommands(self.commands)
235 if self.commands[name][0] == localCmdPkg:
236 return getattr(butlerCommands, cmdNameToFuncName(name))
237 return doImport(self.commands[name][0] + "." + cmdNameToFuncName(name))
240@click.command(cls=LoaderCLI)
241@click.option("--log-level",
242 type=click.Choice(["critical", "error", "warning", "info", "debug",
243 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]),
244 default="warning",
245 help="The Python log level to use.",
246 callback=to_upper)
247def cli(log_level):
248 _initLogging(log_level)
251def main():
252 return cli()