# SPDX-FileCopyrightText: 2025 - 2026 Rahil Miten Doshi, Friedrich-Alexander-Universität Erlangen-Nürnberg
# SPDX-License-Identifier: BSD-3-Clause
"""Command-line interface for MaterForge.
Exposes the most common library operations as a ``materforge`` command so YAML
material files can be inspected, validated, plotted, and evaluated without
writing any Python. Every subcommand is a thin wrapper over the public API in
:mod:`materforge`; see ``materforge --help`` for the full list.
"""
from __future__ import annotations
import argparse
import logging
import sys
from typing import Optional, Sequence
import sympy as sp
from materforge import __version__
from materforge.catalog import get_material_path, list_materials
from materforge.parsing.api import (
create_material,
get_material_info,
validate_yaml_file,
)
logger = logging.getLogger(__name__)
# Exit codes: 0 success, 1 a handled error (bad file, invalid YAML, ...),
# 2 is reserved by argparse for usage errors.
_EXIT_OK = 0
_EXIT_ERROR = 1
# ====================================================================
# SUBCOMMANDS
# ====================================================================
def _cmd_list(args: argparse.Namespace) -> int:
"""Lists the example material files bundled with the package."""
names = list_materials()
if not names:
print("No bundled materials found.", file=sys.stderr)
return _EXIT_ERROR
if args.paths:
width = max(len(name) for name in names)
for name in names:
print(f"{name:<{width}} {get_material_path(name)}")
else:
print(f"Bundled example materials ({len(names)}):")
for name in names:
print(f" {name}")
return _EXIT_OK
def _cmd_validate(args: argparse.Namespace) -> int:
"""Validates a YAML material file without building the full material."""
validate_yaml_file(args.yaml_path)
print(f"OK: {args.yaml_path} is valid.")
return _EXIT_OK
def _cmd_info(args: argparse.Namespace) -> int:
"""Prints a material's metadata without fully processing its properties."""
info = get_material_info(args.yaml_path)
print(f"Material: {info.get('name', 'Unknown')}")
print(f"Source: {args.yaml_path}")
properties = info.get("properties", [])
print(f"Properties ({len(properties)}):")
for name in properties:
print(f" - {name}")
property_types = info.get("property_types")
if property_types:
print("Property types:")
for type_name, count in property_types.items():
print(f" {type_name}: {count}")
reserved = {"name", "properties", "total_properties", "property_types"}
extras = {key: value for key, value in info.items() if key not in reserved}
if extras:
print("Other fields:")
for key, value in extras.items():
print(f" {key}: {value}")
return _EXIT_OK
def _cmd_plot(args: argparse.Namespace) -> int:
"""Builds a material with plotting enabled and reports where plots landed."""
symbol = sp.Symbol(args.symbol)
material = create_material(args.yaml_path, symbol, enable_plotting=True)
plot_dir = args.yaml_path.resolve().parent / "materforge_plots"
print(f"Built material '{material.name}' ({len(material.property_names())} properties).")
print(f"Plots saved under: {plot_dir}")
return _EXIT_OK
def _cmd_evaluate(args: argparse.Namespace) -> int:
"""Evaluates every property at a single dependency value."""
symbol = sp.Symbol(args.symbol)
material = create_material(args.yaml_path, symbol, enable_plotting=False)
evaluated = material.evaluate(symbol, args.value)
names = sorted(evaluated.property_names())
if not names:
print(f"No properties could be evaluated at {args.symbol}={args.value}.",
file=sys.stderr)
return _EXIT_ERROR
print(f"{material.name} @ {args.symbol} = {args.value}")
width = max(len(name) for name in names)
for name in names:
print(f" {name:<{width}} {_format_value(evaluated.properties[name])}")
return _EXIT_OK
def _format_value(value: sp.Basic) -> str:
"""Formats an evaluated property value for display, preferring a short float."""
try:
return f"{float(value):.6g}"
except (TypeError, ValueError):
return str(value)
# ====================================================================
# PARSER
# ====================================================================
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="materforge",
description="Inspect, validate, plot, and evaluate MaterForge YAML materials.",
)
parser.add_argument("--version", action="version",
version=f"materforge {__version__}")
# Shared options that apply to every subcommand.
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-v", "--verbose", action="count", default=0,
help="increase log verbosity (-v for info, -vv for debug)")
# Options shared by subcommands that build a material from a symbol.
symbol_opt = argparse.ArgumentParser(add_help=False)
symbol_opt.add_argument("-s", "--symbol", default="T",
help="dependency symbol to substitute for the YAML "
"placeholder 'T' (default: T)")
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
p_list = subparsers.add_parser(
"list", parents=[common], help="list bundled example materials")
p_list.add_argument("--paths", action="store_true",
help="also show the YAML file path for each material")
p_list.set_defaults(func=_cmd_list)
p_validate = subparsers.add_parser(
"validate", parents=[common], help="validate a YAML material file")
p_validate.add_argument("yaml_path", type=_existing_path,
help="path to the YAML material file")
p_validate.set_defaults(func=_cmd_validate)
p_info = subparsers.add_parser(
"info", parents=[common], help="show a material's metadata")
p_info.add_argument("yaml_path", type=_existing_path,
help="path to the YAML material file")
p_info.set_defaults(func=_cmd_info)
p_plot = subparsers.add_parser(
"plot", parents=[common, symbol_opt],
help="build a material and save its property plots")
p_plot.add_argument("yaml_path", type=_existing_path,
help="path to the YAML material file")
p_plot.set_defaults(func=_cmd_plot)
p_evaluate = subparsers.add_parser(
"evaluate", parents=[common, symbol_opt],
help="evaluate all properties at a dependency value")
p_evaluate.add_argument("yaml_path", type=_existing_path,
help="path to the YAML material file")
p_evaluate.add_argument("value", type=float,
help="numeric value of the dependency to evaluate at")
p_evaluate.set_defaults(func=_cmd_evaluate)
return parser
def _existing_path(raw: str):
"""argparse type that resolves a path and fails early if it is missing."""
from pathlib import Path
path = Path(raw)
if not path.exists():
raise argparse.ArgumentTypeError(f"file not found: {raw}")
return path
def _configure_logging(verbosity: int) -> None:
"""Routes library logs to stderr, quiet by default and louder with -v/-vv."""
level = {0: logging.CRITICAL, 1: logging.INFO}.get(verbosity, logging.DEBUG)
package_logger = logging.getLogger("materforge")
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
package_logger.handlers = [handler]
package_logger.setLevel(level)
package_logger.propagate = False
# ====================================================================
# ENTRY POINTS
# ====================================================================
[docs]
def main(argv: Optional[Sequence[str]] = None) -> int:
"""Runs the MaterForge CLI and returns a process exit code.
Args:
argv: Argument list to parse (defaults to ``sys.argv[1:]``).
Returns:
0 on success, 1 on a handled error.
"""
parser = _build_parser()
args = parser.parse_args(argv)
_configure_logging(getattr(args, "verbose", 0))
if not getattr(args, "func", None):
parser.print_help()
return _EXIT_ERROR
try:
return args.func(args)
except Exception as exc: # noqa: BLE001 - surface any failure as a clean CLI error
logger.debug("Command '%s' failed", args.command, exc_info=True)
print(f"Error: {exc}", file=sys.stderr)
return _EXIT_ERROR
[docs]
def validate_entry(argv: Optional[Sequence[str]] = None) -> int:
"""Console-script shim for the legacy ``materforge-validate`` command.
Forwards its arguments to ``materforge validate`` so the standalone entry
point keeps working alongside the unified CLI.
"""
raw = list(sys.argv[1:] if argv is None else argv)
return main(["validate", *raw])
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())