|
| 1 | +#!/usr/bin/env python |
| 2 | +# *********************************************************************** |
| 3 | +# * GNU Lesser General Public License |
| 4 | +# * |
| 5 | +# * This file is part of the GFDL Flexible Modeling System (FMS) YAML |
| 6 | +# * tools. |
| 7 | +# * |
| 8 | +# * FMS_yaml_tools is free software: you can redistribute it and/or |
| 9 | +# * modify it under the terms of the GNU Lesser General Public License |
| 10 | +# * as published by the Free Software Foundation, either version 3 of the |
| 11 | +# * License, or (at your option) any later version. |
| 12 | +# * |
| 13 | +# * FMS_yaml_tools is distributed in the hope that it will be useful, but |
| 14 | +# * WITHOUT ANY WARRANTY; without even the implied warranty of |
| 15 | +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 16 | +# * General Public License for more details. |
| 17 | +# * |
| 18 | +# * You should have received a copy of the GNU Lesser General Public |
| 19 | +# * License along with FMS. If not, see <http://www.gnu.org/licenses/>. |
| 20 | +# *********************************************************************** |
| 21 | + |
| 22 | +import click |
| 23 | +from fms_yaml_tools.diag_table import DiagTable, DiagTableFile, DiagTableVar, DiagTableError, abstract_dict |
| 24 | + |
| 25 | + |
| 26 | +def echo(msg): |
| 27 | + """Print a message to STDERR""" |
| 28 | + click.echo(msg, err=True) |
| 29 | + |
| 30 | + |
| 31 | +def get_editor(): |
| 32 | + """Get the user's preferred editor as per the VISUAL or EDITOR env variable. Redirect the editor's STDOUT to STDERR, |
| 33 | + so that diag-tool's own STDOUT can be redirected or piped by the user""" |
| 34 | + from click._termui_impl import Editor |
| 35 | + return "1>&2 " + Editor().get_editor() |
| 36 | + |
| 37 | + |
| 38 | +def apply_filters(obj): |
| 39 | + """Apply file and var filters, then prune empty files if desired""" |
| 40 | + if type(obj) is DiagTable: |
| 41 | + obj = obj.filter_files(options["file"]) |
| 42 | + |
| 43 | + if type(obj) in (DiagTable, DiagTableFile): |
| 44 | + obj = obj.filter_vars(options["var"]) |
| 45 | + |
| 46 | + if type(obj) is DiagTable and options["prune"]: |
| 47 | + obj = obj.prune() |
| 48 | + |
| 49 | + return obj |
| 50 | + |
| 51 | + |
| 52 | +def get_filtered_table_obj(yaml): |
| 53 | + """Load a DiagTable object from a filename, then apply file and var filters to it""" |
| 54 | + return apply_filters(DiagTable.from_file(yaml)) |
| 55 | + |
| 56 | + |
| 57 | +def write_out(yaml, obj): |
| 58 | + """Write obj to standard output or to the YAML file provided, depending on the `in_place` option""" |
| 59 | + if options["in_place"]: |
| 60 | + if yaml == "-": |
| 61 | + echo("Warning: Ignoring --in-place option, because the original table was read from standard input") |
| 62 | + else: |
| 63 | + if not (options["force"] or click.confirm("Overwrite {:s}?".format(click.format_filename(yaml)))): |
| 64 | + echo("Changes have been discarded") |
| 65 | + return |
| 66 | + else: |
| 67 | + yaml = "-" |
| 68 | + |
| 69 | + obj.write(yaml, options["abstract"]) |
| 70 | + |
| 71 | + |
| 72 | +def get_filtered_files(table_obj): |
| 73 | + """Get an iterator over the table's files after file filters are applied""" |
| 74 | + return table_obj.get_filtered_files(options["file"]) |
| 75 | + |
| 76 | + |
| 77 | +def get_filtered_vars(table_obj): |
| 78 | + """Get an iterator over the table's variables after file and variable filters are applied""" |
| 79 | + return table_obj.filter_files(options["file"]).get_filtered_vars(options["var"]) |
| 80 | + |
| 81 | + |
| 82 | +def merge_generic(yamls, combine_func): |
| 83 | + """Perform either a symmetric or asymmetric merge according to `combine_func`""" |
| 84 | + if len(yamls) == 0: |
| 85 | + echo("At least one YAML argument is required") |
| 86 | + return |
| 87 | + elif len(yamls) == 1: |
| 88 | + yamls = ["-", yamls[0]] |
| 89 | + else: |
| 90 | + yamls = list(yamls) |
| 91 | + |
| 92 | + if len(options["var"]) > 0: |
| 93 | + cls = DiagTableVar |
| 94 | + lhs_iter = get_filtered_vars |
| 95 | + elif len(options["file"]) > 0: |
| 96 | + cls = DiagTableFile |
| 97 | + lhs_iter = get_filtered_files |
| 98 | + else: |
| 99 | + cls = DiagTable |
| 100 | + lhs_iter = lambda table_obj: (yield table_obj) |
| 101 | + |
| 102 | + try: |
| 103 | + yaml = yamls.pop() |
| 104 | + diag_table_obj = DiagTable.from_file(yaml) |
| 105 | + |
| 106 | + while len(yamls) > 0: |
| 107 | + rhs = cls.from_file(yamls.pop()) |
| 108 | + for lhs in lhs_iter(diag_table_obj): |
| 109 | + combine_func(lhs, rhs) |
| 110 | + |
| 111 | + write_out(yaml, diag_table_obj) |
| 112 | + except DiagTableError as err: |
| 113 | + echo(err) |
| 114 | + |
| 115 | + |
| 116 | +def yaml_str_from_file(yaml): |
| 117 | + """Read a YAML string from a file""" |
| 118 | + with click.open_file(yaml, "r") as fh: |
| 119 | + return fh.read() |
| 120 | + |
| 121 | + |
| 122 | +def get_obj_generic(yaml): |
| 123 | + """Get a DiagTable, DiagTableFile, or DiagTableVar object from a YAML file""" |
| 124 | + yaml_str = yaml_str_from_file(yaml) |
| 125 | + for cls in (DiagTable, DiagTableFile, DiagTableVar): |
| 126 | + try: |
| 127 | + return cls.from_yaml_str(yaml_str) |
| 128 | + except DiagTableError: |
| 129 | + pass |
| 130 | + |
| 131 | + |
| 132 | +@click.group() |
| 133 | +@click.version_option("alpha1", "-V", "--version") |
| 134 | +@click.help_option("-h", "--help") |
| 135 | +@click.option("-i", "--in-place", is_flag=True, default=False, |
| 136 | + help="Overwrite the existing table, rather than writing to standard output") |
| 137 | +@click.option("-F", "--force", is_flag=True, default=False, |
| 138 | + help="Skip the confirmation prompt when overwriting an existing table") |
| 139 | +@click.option("-f", "--file", type=click.STRING, multiple=True, |
| 140 | + help="Apply a file filter, of the form `[~]FILE`, where FILE may be an individual file name or a" |
| 141 | + + " comma-separated list thereof") |
| 142 | +@click.option("-v", "--var", type=click.STRING, multiple=True, |
| 143 | + help="Apply a variable filter, of the form `[~][FILE:[MODULE:]]VAR`, where FILE, MODULE, and VAR may be" |
| 144 | + + " individual names or comma-separated lists thereof") |
| 145 | +@click.option("-p", "--prune", is_flag=True, default=False, |
| 146 | + help="Prune files which have no variables after filters are applied") |
| 147 | +@click.option("-a", "--abstract", type=click.Choice(("table", "file", "var"), case_sensitive=True), multiple=True, |
| 148 | + help="Exclude table, file, or variable attributes from the output") |
| 149 | +def diag_tool(in_place, force, file, var, prune, abstract): |
| 150 | + """Utility to update, merge, subset, or summarize diag YAMLs""" |
| 151 | + global options |
| 152 | + options = { |
| 153 | + "in_place": in_place, |
| 154 | + "force": force, |
| 155 | + "file": file, |
| 156 | + "var": var, |
| 157 | + "prune": prune, |
| 158 | + "abstract": abstract_dict(abstract) |
| 159 | + } |
| 160 | + |
| 161 | + |
| 162 | +@diag_tool.command(name="edit") |
| 163 | +@click.help_option("-h", "--help") |
| 164 | +@click.argument("yaml", type=click.Path(), default="-") |
| 165 | +def edit_cmd(yaml): |
| 166 | + """Edit a table interactively, then merge the changes back in""" |
| 167 | + try: |
| 168 | + obj = get_obj_generic(yaml) |
| 169 | + |
| 170 | + if obj is None: |
| 171 | + echo("{:s} was not a valid table, file, or variable... exiting".format(yaml)) |
| 172 | + return |
| 173 | + |
| 174 | + yaml_str_0 = apply_filters(obj).dump_yaml(options["abstract"]) |
| 175 | + yaml_str_1 = click.edit(yaml_str_0, editor=get_editor(), extension=".yaml") |
| 176 | + |
| 177 | + if yaml_str_1: |
| 178 | + cls = obj.__class__ |
| 179 | + obj |= cls.from_yaml_str(yaml_str_1) |
| 180 | + else: |
| 181 | + if options["in_place"]: |
| 182 | + echo("No changes were made... exiting without modifying '{}'".format(yaml)) |
| 183 | + return |
| 184 | + else: |
| 185 | + echo("No changes were made... passing original table through") |
| 186 | + |
| 187 | + options["abstract"] = {} |
| 188 | + write_out(yaml, obj) |
| 189 | + except DiagTableError as err: |
| 190 | + echo(err) |
| 191 | + |
| 192 | + |
| 193 | +@diag_tool.command(name="update") |
| 194 | +@click.help_option("-h", "--help") |
| 195 | +@click.argument("yamls", type=click.Path(), nargs=-1) |
| 196 | +def update_cmd(yamls): |
| 197 | + """Update a table or its files/variables""" |
| 198 | + def combine_func(lhs, rhs): |
| 199 | + lhs |= rhs |
| 200 | + merge_generic(yamls, combine_func) |
| 201 | + |
| 202 | + |
| 203 | +@diag_tool.command(name="merge") |
| 204 | +@click.help_option("-h", "--help") |
| 205 | +@click.argument("yamls", type=click.Path(), nargs=-1) |
| 206 | +def merge_cmd(yamls): |
| 207 | + """Symmetrically merge tables, failing if any conflicts occur""" |
| 208 | + def combine_func(lhs, rhs): |
| 209 | + lhs += rhs |
| 210 | + merge_generic(yamls, combine_func) |
| 211 | + |
| 212 | + |
| 213 | +@diag_tool.command(name="filter") |
| 214 | +@click.help_option("-h", "--help") |
| 215 | +@click.argument("yaml", type=click.Path(), default="-") |
| 216 | +def filter_cmd(yaml): |
| 217 | + """Apply file or variable filters to a table""" |
| 218 | + try: |
| 219 | + diag_table_obj = get_filtered_table_obj(yaml) |
| 220 | + write_out(yaml, diag_table_obj) |
| 221 | + except DiagTableError as err: |
| 222 | + echo(err) |
| 223 | + |
| 224 | + |
| 225 | +@diag_tool.command(name="list") |
| 226 | +@click.help_option("-h", "--help") |
| 227 | +@click.argument("yaml", type=click.Path(), default="-") |
| 228 | +def list_cmd(yaml): |
| 229 | + """List the files and variables in a table""" |
| 230 | + options["abstract"] = abstract_dict(("table", "file", "var")) | options["abstract"] |
| 231 | + |
| 232 | + try: |
| 233 | + diag_table_obj = get_filtered_table_obj(yaml) |
| 234 | + write_out(yaml, diag_table_obj) |
| 235 | + except DiagTableError as err: |
| 236 | + echo(err) |
| 237 | + |
| 238 | + |
| 239 | +@diag_tool.command(name="pick") |
| 240 | +@click.help_option("-h", "--help") |
| 241 | +@click.argument("yaml", type=click.Path(), default="-") |
| 242 | +def pick_cmd(yaml): |
| 243 | + """Pick a single file or variable from a table""" |
| 244 | + if len(options["var"]) > 0: |
| 245 | + noun = "variable" |
| 246 | + pick_func = get_filtered_vars |
| 247 | + elif len(options["file"]) > 0: |
| 248 | + noun = "file" |
| 249 | + pick_func = get_filtered_files |
| 250 | + else: |
| 251 | + echo("`diag-tool pick` requires a file or variable filter to be provided") |
| 252 | + return |
| 253 | + |
| 254 | + try: |
| 255 | + diag_table_obj = DiagTable.from_file(yaml) |
| 256 | + picked_objs = tuple(pick_func(diag_table_obj)) |
| 257 | + n = len(picked_objs) |
| 258 | + |
| 259 | + if n == 0: |
| 260 | + echo("No {:s} was found matching the filter criteria".format(noun)) |
| 261 | + return |
| 262 | + elif n > 1: |
| 263 | + echo("Selecting the first out of {:d} {:s}s which satisfy the filter criteria".format(n, noun)) |
| 264 | + |
| 265 | + write_out(yaml, picked_objs[0]) |
| 266 | + except DiagTableError as err: |
| 267 | + echo(err) |
| 268 | + |
| 269 | + |
| 270 | +if __name__ == "__main__": |
| 271 | + diag_tool() |
0 commit comments