Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ You can also select SevenNet with the `--model-type` flag

**Note:**
- If `--config` is not specified, `config.json` in the current directory is loaded by default.
If that file does not exist either, GPUMA runs on its built-in defaults and logs a warning
naming the default model. A `--config` path that does not exist is an error.
- Global flags (`--device`, `--model-type`, `--verbose`, `--quiet`) go before the subcommand,
e.g. `gpuma --device cuda:1 batch ...`.
- Direct CLI flags are supported, but using a config file is preferred for all workflows.
- Unless explicitly overridden, the electronic state defaults are always
`charge = 0` and `multiplicity = 1`.
Expand Down
41 changes: 39 additions & 2 deletions src/gpuma/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import argparse
import logging
import os
import sys
import warnings

Expand Down Expand Up @@ -281,6 +282,12 @@ def setup_parser() -> argparse.ArgumentParser:
required=True,
help="Output XYZ file path",
)
convert_parser.add_argument(
"--config",
"-c",
type=str,
help="Configuration file path (default: config.json if exists)",
)

# Conformer generation (utility)
generate_parser = subparsers.add_parser(
Expand All @@ -307,6 +314,12 @@ def setup_parser() -> argparse.ArgumentParser:
required=True,
help="Output multi-XYZ file path for conformer ensemble",
)
generate_parser.add_argument(
"--config",
"-c",
type=str,
help="Configuration file path (default: config.json if exists)",
)

# Configuration management
config_parser = subparsers.add_parser(
Expand Down Expand Up @@ -565,6 +578,9 @@ def cmd_config(args, config: Config) -> None:
save_config_to_file(config, args.create)
logger.info("Configuration file created")
elif args.validate:
if not os.path.exists(args.validate):
logger.error("Configuration file not found: %s", args.validate)
sys.exit(1)
logger.info("Validating configuration file at %s", args.validate)
_ = load_config_from_file(args.validate)
logger.info("Configuration file is valid")
Expand All @@ -590,6 +606,20 @@ def _apply_model_type_override(config: Config, model_type) -> None:
config.model.model_type = model_type


def _resolve_config_path(args, parser: argparse.ArgumentParser) -> tuple[str, bool]:
"""Return the config path to load and whether built-in defaults stand in for it.

An explicit ``--config`` that does not exist is a usage error, so a typo
cannot silently swap in the default model.
"""
explicit = getattr(args, "config", None)
if explicit is not None:
if not os.path.exists(explicit):
parser.error(f"config file not found: {explicit}")
return explicit, False
return "config.json", not os.path.exists("config.json")


def main(argv=None) -> int:
"""Entry point for the ``gpuma`` CLI.

Expand All @@ -608,8 +638,7 @@ def main(argv=None) -> int:
parser = setup_parser()
args = parser.parse_args(argv)

# Load configuration from file if specified, otherwise try default
cfg_path = getattr(args, "config", None) or "config.json"
cfg_path, using_defaults = _resolve_config_path(args, parser)
config = load_config_from_file(cfg_path)

# Apply verbosity, device, and model-type flags
Expand All @@ -625,6 +654,14 @@ def main(argv=None) -> int:
parser.print_help()
return 1

if using_defaults and args.command != "config":
logger.warning(
"No --config given and no config.json in the working directory; "
"using built-in defaults (model %s / %s).",
config.model.model_type,
config.model.model_name,
)

if args.command in ("optimize", "smiles"):
cmd_optimize(args, config)
elif args.command == "ensemble":
Expand Down
39 changes: 39 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,42 @@ def fake_ensemble(smiles, num_confs, multiplicity=None, seed=None, config=None):
cli.cmd_generate(args, config)

assert seen["multiplicity"] == 3


# ---------------------------------------------------------------------------
# Config handling: --config on every command, missing files
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("command", [["convert"], ["generate", "--conformers", "2"]])
def test_convert_and_generate_accept_config(command):
from gpuma.cli import setup_parser

args = setup_parser().parse_args([*command, "--smiles", "C", "-o", "x.xyz", "-c", "c.json"])

assert args.config == "c.json"


def test_missing_explicit_config_is_a_usage_error(tmp_path):
with pytest.raises(SystemExit) as exc_info:
main(["optimize", "--smiles", "C", "-o", str(tmp_path / "x.xyz"), "-c", "nope.json"])
assert exc_info.value.code == 2


def test_validate_rejects_a_missing_file(tmp_path):
with pytest.raises(SystemExit) as exc_info:
main(["config", "--validate", str(tmp_path / "nope.json")])
assert exc_info.value.code == 1


def test_no_config_anywhere_reports_defaults(tmp_path, monkeypatch):
import argparse

from gpuma.cli import _resolve_config_path, setup_parser

monkeypatch.chdir(tmp_path)
path, using_defaults = _resolve_config_path(argparse.Namespace(config=None), setup_parser())
assert (path, using_defaults) == ("config.json", True)

(tmp_path / "config.json").write_text("{}")
assert _resolve_config_path(argparse.Namespace(config=None), setup_parser())[1] is False
Loading