Coverage for src/CSET/__init__.py: 100%
105 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-07 15:12 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-07 15:12 +0000
1# © Crown copyright, Met Office (2022-2026) and CSET contributors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""CSET: Community Seamless Evaluation Toolkit."""
17import argparse
18import logging
19import os
20import sys
21from importlib.metadata import version
22from pathlib import Path
24from CSET._common import ArgumentError
25from CSET._common import sample_data_path as sample_data_path
27logger = logging.getLogger(__name__)
30def main(raw_cli_args: list[str] = sys.argv):
31 """CLI entrypoint.
33 Handles argument parsing, setting up logging, top level error capturing,
34 and execution of the desired subcommand.
35 """
36 parser = setup_argument_parser()
37 args, unparsed_args = parser.parse_known_args(raw_cli_args[1:])
39 setup_logging(args.verbose)
41 if args.subparser is None:
42 print("Please choose a command.", file=sys.stderr)
43 parser.print_usage()
44 sys.exit(127)
46 try:
47 # Execute the specified subcommand.
48 args.func(args, unparsed_args)
49 except ArgumentError as err:
50 # Error message for when needed template variables are missing.
51 print(err, file=sys.stderr)
52 parser.print_usage()
53 sys.exit(127)
54 except Exception as err:
55 # Provide slightly nicer error messages for unhandled exceptions.
56 print(err, file=sys.stderr)
57 # Display the time and full traceback when debug logging.
58 logger.debug("An unhandled exception occurred.")
59 if logger.isEnabledFor(logging.DEBUG):
60 raise
61 sys.exit(1)
64def setup_argument_parser() -> argparse.ArgumentParser:
65 """Create argument parser for CSET CLI."""
66 parser = argparse.ArgumentParser(
67 prog="cset", description="Community Seamless Evaluation Toolkit"
68 )
69 parser.add_argument(
70 "-v",
71 "--verbose",
72 action="count",
73 default=0,
74 help="increase output verbosity, may be specified multiple times",
75 )
76 parser.add_argument(
77 "--version", action="version", version=f"CSET v{version('CSET')}"
78 )
80 # https://docs.python.org/3/library/argparse.html#sub-commands
81 subparsers = parser.add_subparsers(title="subcommands", dest="subparser")
83 # Run operator chain
84 parser_bake = subparsers.add_parser("bake", help="run steps from a recipe file")
85 parser_bake.add_argument(
86 "-i",
87 "--input-dir",
88 type=str,
89 action="extend",
90 nargs="+",
91 help="Alternate way to set the INPUT_PATHS recipe variable",
92 )
93 parser_bake.add_argument(
94 "-o",
95 "--output-dir",
96 type=Path,
97 required=True,
98 help="directory to write output into",
99 )
100 parser_bake.add_argument(
101 "-r",
102 "--recipe",
103 type=Path,
104 required=True,
105 help="recipe file to read",
106 )
107 parser_bake.add_argument(
108 "-s", "--style-file", type=Path, help="colour bar definition to use"
109 )
110 parser_bake.add_argument(
111 "--plot-resolution", type=int, help="plotting resolution in dpi"
112 )
113 parser_bake.add_argument(
114 "--skip-write", action="store_true", help="Skip saving processed output"
115 )
116 parser_bake.set_defaults(func=_bake_command)
118 parser_graph = subparsers.add_parser("graph", help="visualise a recipe file")
119 parser_graph.add_argument(
120 "-d",
121 "--details",
122 action="store_true",
123 help="include operator arguments in output",
124 )
125 parser_graph.add_argument(
126 "-o",
127 "--output-path",
128 type=Path,
129 nargs="?",
130 help="persistent file to save the graph. Otherwise the file is opened",
131 default=None,
132 )
133 parser_graph.add_argument(
134 "-r",
135 "--recipe",
136 type=Path,
137 required=True,
138 help="recipe file to read",
139 )
140 parser_graph.set_defaults(func=_graph_command)
142 parser_cookbook = subparsers.add_parser(
143 "cookbook", help="unpack included recipes to a folder"
144 )
145 parser_cookbook.add_argument(
146 "-d",
147 "--details",
148 action="store_true",
149 help="list available recipes. Supplied recipes are detailed",
150 )
151 parser_cookbook.add_argument(
152 "-o",
153 "--output-dir",
154 type=Path,
155 help="directory to save recipes. If omitted uses $PWD",
156 default=Path.cwd(),
157 )
158 parser_cookbook.add_argument(
159 "recipe",
160 type=str,
161 nargs="?",
162 help="recipe to output or detail",
163 default="",
164 )
165 parser_cookbook.set_defaults(func=_cookbook_command)
167 parser_extract_workflow = subparsers.add_parser(
168 "extract-workflow", help="extract the CSET cylc workflow"
169 )
170 parser_extract_workflow.add_argument(
171 "location", type=Path, help="directory to save workflow into"
172 )
173 parser_extract_workflow.add_argument(
174 "--restricted",
175 action="store_true",
176 help="install restricted site-specific files during extraction",
177 )
178 parser_extract_workflow.add_argument(
179 "--restricted-url",
180 type=str,
181 help=(
182 "Alternative Git URL to fetch the restricted files from. "
183 "If omitted, defaults to trying to clone first from "
184 "'localmirrors:', then from GitHub via SSH and HTTPS."
185 ),
186 )
187 parser_extract_workflow.set_defaults(func=_extract_workflow_command)
189 parser_install_restricted_files = subparsers.add_parser(
190 "install-restricted-files",
191 help="download and install restricted site-specific files for the CSET cylc workflow",
192 )
193 parser_install_restricted_files.add_argument(
194 "location", type=Path, help="directory containing workflow"
195 )
196 parser_install_restricted_files.add_argument(
197 "--restricted-url",
198 type=str,
199 help=(
200 "Alternative Git URL to fetch the restricted files from. "
201 "If omitted, defaults to trying to clone first from "
202 "'localmirrors:', then from GitHub via SSH and HTTPS."
203 ),
204 )
205 parser_install_restricted_files.set_defaults(func=_install_restricted_files_command)
207 return parser
210def setup_logging(verbosity: int):
211 """Configure logging level, format and output stream.
213 Level is based on verbose argument and the LOGLEVEL environment variable.
214 """
215 logging.captureWarnings(True)
217 # Calculate logging level.
218 # Level from CLI flags.
219 if verbosity >= 2:
220 cli_loglevel = logging.DEBUG
221 elif verbosity == 1:
222 cli_loglevel = logging.INFO
223 else:
224 cli_loglevel = logging.WARNING
226 # Level from $LOGLEVEL environment variable.
227 env_loglevel = logging.getLevelNamesMapping().get(
228 os.getenv("LOGLEVEL", ""), logging.ERROR
229 )
231 # Logging verbosity is the most verbose of CLI and environment setting.
232 loglevel = min(cli_loglevel, env_loglevel)
234 # Configure the root logger.
235 logger = logging.getLogger()
236 # Set logging level.
237 logger.setLevel(loglevel)
239 # Suppress matplotlib's verbose debug output.
240 logging.getLogger("matplotlib").setLevel(logging.WARNING)
242 stderr_log = logging.StreamHandler(stream=sys.stdout)
243 stderr_log.setFormatter(
244 logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
245 )
246 logger.addHandler(stderr_log)
249def _bake_command(args, unparsed_args):
250 from CSET._common import parse_recipe, parse_variable_options
251 from CSET.operators import execute_recipe
253 recipe_variables = parse_variable_options(unparsed_args, args.input_dir)
254 recipe = parse_recipe(args.recipe, recipe_variables)
255 execute_recipe(
256 recipe,
257 args.output_dir,
258 args.style_file,
259 args.plot_resolution,
260 args.skip_write,
261 )
264def _graph_command(args, unparsed_args):
265 from CSET.graph import save_graph
267 save_graph(
268 args.recipe,
269 args.output_path,
270 auto_open=not args.output_path,
271 detailed=args.details,
272 )
275def _cookbook_command(args, unparsed_args):
276 from CSET.recipes import detail_recipe, list_available_recipes, unpack_recipe
278 if args.recipe:
279 if args.details:
280 detail_recipe(args.recipe)
281 else:
282 try:
283 unpack_recipe(args.output_dir, args.recipe)
284 except FileNotFoundError:
285 logger.error("Recipe %s does not exist.", args.recipe)
286 sys.exit(1)
287 else:
288 list_available_recipes()
291def _extract_workflow_command(args, unparsed_args):
292 from CSET.extract_workflow import install_restricted_files, install_workflow
294 workflow_dir = install_workflow(args.location)
295 if args.restricted:
296 install_restricted_files(workflow_dir, args.restricted_url)
299def _install_restricted_files_command(args, unparsed_args):
300 from CSET.extract_workflow import install_restricted_files
302 install_restricted_files(args.location, args.restricted_url)