From 8d25f0d552a142468b3346c2ac07e31284be0132 Mon Sep 17 00:00:00 2001 From: Chris Green Date: Wed, 27 May 2026 13:24:39 +1000 Subject: [PATCH] Add a config file option for multiple options --- README.md | 49 +++++++++++++- em2ex.py | 88 ++++++++++++++++++++++++- test/eclipse/config_bad_key.yaml | 4 ++ test/eclipse/config_extract_refine.yaml | 12 ++++ test/eclipse/tests | 17 +++++ 5 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 test/eclipse/config_bad_key.yaml create mode 100644 test/eclipse/config_extract_refine.yaml diff --git a/README.md b/README.md index e04bc10..611164b 100644 --- a/README.md +++ b/README.md @@ -82,21 +82,59 @@ Similarly, the `test/leapfrog` directory contains a set of example Leapfrog rese ``` for example. +## Configuration files + +Once a workflow uses more than a handful of options, putting them in a config file is easier than re-typing the same command line. `em2ex` accepts a YAML config via `--config`: + +```bash +./em2ex.py --config my_workflow.yaml model.grdecl +``` + +The config is a YAML mapping. Each key is **the CLI flag name with the leading `--` stripped and hyphens converted to underscores** — exactly what you'd type after `--` in the shell, normalised for YAML. So `--refine-xy` becomes `refine_xy` (or, if you prefer, `refine-xy` — both work). Values follow the option's natural type: a single string or number for single-value flags, a YAML list for multi-value flags, a boolean for switches: + +```yaml +# Geometry transforms +extract_i: [10, 50] +extract_j: [10, 50] +refine_xy: [2, 2] + +# Property handling +extra_keywords: + - PVTNUM + - EQLNUM + +# Output controls +output: model.e # corresponds to --output +force: true # corresponds to --force +fault_sidesets: true +convert_to_m: true + +# Mesh quality +strict_jacobians: true +``` + +The `filename` can be specified in the config too (`filename: path/to/model.grdecl`), in which case the positional argument can be omitted on the CLI. + +**Precedence**: command-line flags always win over config values, and config values win over the parser's own defaults. So a config that defaults `force: false` can still be overridden by passing `-f` at the prompt. + +**Unknown keys are rejected** with a list of valid keys, so typos like `refine_xz` (when you meant `refine_xy`) surface immediately rather than being silently ignored. + ## Commandline options A number of optional commandline options are available, and can be seen by passing the `--help` flag: ```bash $ ./em2ex.py --help -usage: em2ex.py [-h] [-o OUTPUT_FILE] [--filetype {eclipse,leapfrog}] - [--no-nodesets] [--no-sidesets] [-f] [-u] [--flip] +usage: em2ex.py [-h] [--config FILE] [-o OUTPUT_FILE] + [--filetype {eclipse,leapfrog}] [--no-nodesets] + [--no-sidesets] [-f] [-u] [--flip] [--translate TRANSLATE TRANSLATE] [--mapaxes] [--pinch] [--pinch-tol PINCH_TOL] [--refine-xy RX RY] [--extract-i I_LO I_HI] [--extract-j J_LO J_HI] [--extract-k K_LO K_HI] [--extra-keywords KEY [KEY ...]] [--fault-sidesets] [--convert-to-m] [--no-check-jacobians] [--strict-jacobians] - filename + [filename] Converts earth model to Exodus II format @@ -105,6 +143,11 @@ positional arguments: options: -h, --help show this help message and exit + --config FILE YAML config file specifying default values for any of + this script's options. Values from the config are + overridden by command-line flags. Use the option's + `dest` name as the key (e.g. refine_xy, extract_i, + extra_keywords). -o OUTPUT_FILE, --output OUTPUT_FILE File name for output --filetype {eclipse,leapfrog} diff --git a/em2ex.py b/em2ex.py index 5512668..984d8d3 100755 --- a/em2ex.py +++ b/em2ex.py @@ -7,6 +7,75 @@ from exodus_model import ExodusModel import argparse import os +import sys + +def _load_config(path): + ''' Load a YAML config file. The file must contain a mapping from option + `dest` names (e.g. `refine_xy`, `extract_i`, `extra_keywords`) to values. + Lists are written as YAML sequences; booleans as `true` / `false`. + Returns the loaded dict. ''' + import yaml + if not os.path.exists(path): + sys.exit("Config file not found: {}".format(path)) + with open(path) as f: + cfg = yaml.safe_load(f) or {} + if not isinstance(cfg, dict): + sys.exit("Config file must contain a YAML mapping (got {})".format(type(cfg).__name__)) + return cfg + +def _build_config_key_mapping(parser): + ''' Build a mapping from acceptable config-file key names to canonical + argparse `dest` names. The user can write any of: + + - the CLI flag name with leading dashes stripped (e.g. `refine-xy`) + - the CLI flag name with hyphens converted to underscores (`refine_xy`) + - the argparse `dest` itself (also `refine_xy` here, but for options like + `--flip` -> `flip_z` or `--force` -> `force_overwrite` the dest is + different from the flag and both forms are accepted) + + Short options (e.g. `-f`) are deliberately excluded to keep the config + keys descriptive. Returns (mapping, valid_canonical_keys). + ''' + mapping = {} + canonical = [] + for action in parser._actions: + if action.dest == 'help': + continue + canonical.append(action.dest) + # The dest itself is always accepted. + mapping[action.dest] = action.dest + # Each long-option flag is also accepted (after stripping `--` and + # normalising hyphens to underscores). + for opt in action.option_strings: + stripped = opt.lstrip('-') + if len(stripped) < 2: + continue # skip short flags like -f / -o / -u + mapping[stripped.replace('-', '_')] = action.dest + return mapping, sorted(canonical) + + +def _validate_and_normalize_config(cfg, parser): + ''' Resolve every config key to its canonical `dest` name. Accepts the + flexible forms documented in _build_config_key_mapping; rejects anything + that doesn't match any option, listing the valid keys. + + Returns a new dict keyed by canonical `dest` names. ''' + mapping, canonical = _build_config_key_mapping(parser) + normalized = {} + unknown = [] + for key, value in cfg.items(): + candidate = key.lstrip('-').replace('-', '_') + if candidate in mapping: + normalized[mapping[candidate]] = value + else: + unknown.append(key) + if unknown: + sys.exit( + "Unknown keys in config file: {}\n" + "Valid keys (or any long CLI flag name with leading dashes\n" + "stripped and hyphens converted to underscores): {}".format( + ', '.join(unknown), ', '.join(canonical))) + return normalized def _positive_int(s): ''' argparse type for strictly positive integers (refinement factors and @@ -34,7 +103,10 @@ def get_parser(): ''' Read commandline options and filename ''' parser = argparse.ArgumentParser(description='Converts earth model to Exodus II format') - parser.add_argument('filename') + parser.add_argument('filename', nargs='?', default=None, + help='Input reservoir model file. Optional only when provided via --config.') + parser.add_argument('--config', dest='config_file', default=None, metavar='FILE', + help='YAML config file specifying default values for any of this script\'s options. Values from the config are overridden by command-line flags. Use the option\'s `dest` name as the key (e.g. refine_xy, extract_i, extra_keywords).') parser.add_argument('-o', '--output', default = None, dest = 'output_file', help = 'File name for output') parser.add_argument('--filetype', default = None, dest = 'filetype', choices = ['eclipse', 'leapfrog'], help = 'Explicitly state the filetype for unknown extensions') @@ -75,10 +147,22 @@ def get_parser(): def main(): ''' Parse the Earth model and write out an Exodus II file ''' - # Parse commandline options + # Parse commandline options. If --config is given, load the YAML file and + # apply its values as parser defaults; command-line flags then override + # those defaults in the usual way (precedence: CLI > config > parser default). parser = get_parser() + pre_parser = argparse.ArgumentParser(add_help=False) + pre_parser.add_argument('--config', dest='config_file', default=None) + pre_args, _ = pre_parser.parse_known_args() + if pre_args.config_file: + config = _load_config(pre_args.config_file) + config = _validate_and_normalize_config(config, parser) + parser.set_defaults(**config) args = parser.parse_args() + if not args.filename: + parser.error('filename is required (provide as a positional argument or as `filename: ...` in --config)') + # If --use-official-api is passed, then import exodus from exodus.py. Note: # this requires that exodus.py is in the $PYTHONPATH environment variable if args.use_official_api: diff --git a/test/eclipse/config_bad_key.yaml b/test/eclipse/config_bad_key.yaml new file mode 100644 index 0000000..75233f6 --- /dev/null +++ b/test/eclipse/config_bad_key.yaml @@ -0,0 +1,4 @@ +# Deliberately-bad config: `refine_xz` is not a real option (refine_xy was meant). +# Used by the yaml exception test to verify the config loader rejects typos. + +refine_xz: [2, 2] diff --git a/test/eclipse/config_extract_refine.yaml b/test/eclipse/config_extract_refine.yaml new file mode 100644 index 0000000..0a4df4a --- /dev/null +++ b/test/eclipse/config_extract_refine.yaml @@ -0,0 +1,12 @@ +# Example em2ex config file. Drives the same conversion as the yaml entry +# `simple_cube_extract_refine` (extract a sub-region and then refine it). +# +# Keys are CLI flag names with the leading `--` stripped (and hyphens may be +# converted to underscores for YAML cleanliness). Sequences map to multi-value +# flags, booleans to store_true/store_false flags. CLI flags override these +# values when both are given. + +extract_i: [2, 3] +extract_j: [1, 2] +extract_k: [1, 1] +refine_xy: [2, 2] diff --git a/test/eclipse/tests b/test/eclipse/tests index ea2ac6a..4150468 100644 --- a/test/eclipse/tests +++ b/test/eclipse/tests @@ -150,6 +150,23 @@ strict_jacobians_inverted: cli_args: --strict-jacobians expected_error: exiting due to invalid Jacobians +# Driven entirely by a YAML config file (extract + refine settings). The +# output must match the same gold as the CLI-driven simple_cube_extract_refine +# test, proving the config loader applies values correctly. +config_extract_refine: + filename: simple_cube_extract.grdecl + type: exodiff + cli_args: --config test/eclipse/config_extract_refine.yaml + gold: simple_cube_extract_refine.e + +# Typo in config key name -- the loader should reject it up front with a +# helpful "Unknown keys" message rather than silently ignore it. +config_bad_key: + filename: simple_cube.grdecl + type: exception + cli_args: --config test/eclipse/config_bad_key.yaml + expected_error: "Unknown keys in config file: refine_xz" + missing_specgrid: filename: missing_specgrid.grdecl type: exception