Coverage for src/CSET/__init__.py: 97%
113 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 12:33 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 12:33 +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_probe = subparsers.add_parser(
119 "probe",
120 help="list variables in input data understood by CSET",
121 )
122 parser_probe.add_argument(
123 "file_paths",
124 type=str,
125 nargs="+",
126 help="input files, directories, or glob patterns",
127 )
128 parser_probe.set_defaults(func=_probe_command)
130 parser_graph = subparsers.add_parser("graph", help="visualise a recipe file")
131 parser_graph.add_argument(
132 "-d",
133 "--details",
134 action="store_true",
135 help="include operator arguments in output",
136 )
137 parser_graph.add_argument(
138 "-o",
139 "--output-path",
140 type=Path,
141 nargs="?",
142 help="persistent file to save the graph. Otherwise the file is opened",
143 default=None,
144 )
145 parser_graph.add_argument(
146 "-r",
147 "--recipe",
148 type=Path,
149 required=True,
150 help="recipe file to read",
151 )
152 parser_graph.set_defaults(func=_graph_command)
154 parser_cookbook = subparsers.add_parser(
155 "cookbook", help="unpack included recipes to a folder"
156 )
157 parser_cookbook.add_argument(
158 "-d",
159 "--details",
160 action="store_true",
161 help="list available recipes. Supplied recipes are detailed",
162 )
163 parser_cookbook.add_argument(
164 "-o",
165 "--output-dir",
166 type=Path,
167 help="directory to save recipes. If omitted uses $PWD",
168 default=Path.cwd(),
169 )
170 parser_cookbook.add_argument(
171 "recipe",
172 type=str,
173 nargs="?",
174 help="recipe to output or detail",
175 default="",
176 )
177 parser_cookbook.set_defaults(func=_cookbook_command)
179 parser_extract_workflow = subparsers.add_parser(
180 "extract-workflow", help="extract the CSET cylc workflow"
181 )
182 parser_extract_workflow.add_argument(
183 "location", type=Path, help="directory to save workflow into"
184 )
185 parser_extract_workflow.add_argument(
186 "--restricted",
187 action="store_true",
188 help="install restricted site-specific files during extraction",
189 )
190 parser_extract_workflow.add_argument(
191 "--restricted-url",
192 type=str,
193 help=(
194 "Alternative Git URL to fetch the restricted files from. "
195 "If omitted, defaults to trying to clone first from "
196 "'localmirrors:', then from GitHub via SSH and HTTPS."
197 ),
198 )
199 parser_extract_workflow.set_defaults(func=_extract_workflow_command)
201 parser_install_restricted_files = subparsers.add_parser(
202 "install-restricted-files",
203 help="download and install restricted site-specific files for the CSET cylc workflow",
204 )
205 parser_install_restricted_files.add_argument(
206 "location", type=Path, help="directory containing workflow"
207 )
208 parser_install_restricted_files.add_argument(
209 "--restricted-url",
210 type=str,
211 help=(
212 "Alternative Git URL to fetch the restricted files from. "
213 "If omitted, defaults to trying to clone first from "
214 "'localmirrors:', then from GitHub via SSH and HTTPS."
215 ),
216 )
217 parser_install_restricted_files.set_defaults(func=_install_restricted_files_command)
219 return parser
222def setup_logging(verbosity: int):
223 """Configure logging level, format and output stream.
225 Level is based on verbose argument and the LOGLEVEL environment variable.
226 """
227 logging.captureWarnings(True)
229 # Calculate logging level.
230 # Level from CLI flags.
231 if verbosity >= 2:
232 cli_loglevel = logging.DEBUG
233 elif verbosity == 1:
234 cli_loglevel = logging.INFO
235 else:
236 cli_loglevel = logging.WARNING
238 # Level from $LOGLEVEL environment variable.
239 env_loglevel = logging.getLevelNamesMapping().get(
240 os.getenv("LOGLEVEL", ""), logging.ERROR
241 )
243 # Logging verbosity is the most verbose of CLI and environment setting.
244 loglevel = min(cli_loglevel, env_loglevel)
246 # Configure the root logger.
247 logger = logging.getLogger()
248 # Set logging level.
249 logger.setLevel(loglevel)
251 # Suppress matplotlib's verbose debug output.
252 logging.getLogger("matplotlib").setLevel(logging.WARNING)
254 stderr_log = logging.StreamHandler(stream=sys.stdout)
255 stderr_log.setFormatter(
256 logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
257 )
258 logger.addHandler(stderr_log)
261def _bake_command(args, unparsed_args):
262 from CSET._common import parse_recipe, parse_variable_options
263 from CSET.operators import execute_recipe
265 recipe_variables = parse_variable_options(unparsed_args, args.input_dir)
266 recipe = parse_recipe(args.recipe, recipe_variables)
267 execute_recipe(
268 recipe,
269 args.output_dir,
270 args.style_file,
271 args.plot_resolution,
272 args.skip_write,
273 )
276def _probe_command(args, unparsed_args):
277 from CSET.operators.read import _probe_variables
279 variables = _probe_variables(args.file_paths)
281 print("Valid variables...")
282 print([name for name in variables])
285def _graph_command(args, unparsed_args):
286 from CSET.graph import save_graph
288 save_graph(
289 args.recipe,
290 args.output_path,
291 auto_open=not args.output_path,
292 detailed=args.details,
293 )
296def _cookbook_command(args, unparsed_args):
297 from CSET.recipes import detail_recipe, list_available_recipes, unpack_recipe
299 if args.recipe:
300 if args.details:
301 detail_recipe(args.recipe)
302 else:
303 try:
304 unpack_recipe(args.output_dir, args.recipe)
305 except FileNotFoundError:
306 logger.error("Recipe %s does not exist.", args.recipe)
307 sys.exit(1)
308 else:
309 list_available_recipes()
312def _extract_workflow_command(args, unparsed_args):
313 from CSET.extract_workflow import install_restricted_files, install_workflow
315 workflow_dir = install_workflow(args.location)
316 if args.restricted:
317 install_restricted_files(workflow_dir, args.restricted_url)
320def _install_restricted_files_command(args, unparsed_args):
321 from CSET.extract_workflow import install_restricted_files
323 install_restricted_files(args.location, args.restricted_url)