From 2477ab63455199e5843d42b5e79234c655b6f652 Mon Sep 17 00:00:00 2001 From: Michael Booth Date: Sun, 19 Apr 2026 07:30:20 +1000 Subject: [PATCH] Modularize CLI command handlers Co-Authored-By: Oz --- src/pdealchemy/cli/app.py | 124 +++++++--------------- src/pdealchemy/cli/commands/__init__.py | 17 +++ src/pdealchemy/cli/commands/conversion.py | 65 ++++++++++++ src/pdealchemy/cli/commands/explain.py | 24 +++++ src/pdealchemy/cli/commands/pricing.py | 23 ++++ src/pdealchemy/cli/commands/validation.py | 56 ++++++++++ 6 files changed, 223 insertions(+), 86 deletions(-) create mode 100644 src/pdealchemy/cli/commands/__init__.py create mode 100644 src/pdealchemy/cli/commands/conversion.py create mode 100644 src/pdealchemy/cli/commands/explain.py create mode 100644 src/pdealchemy/cli/commands/pricing.py create mode 100644 src/pdealchemy/cli/commands/validation.py diff --git a/src/pdealchemy/cli/app.py b/src/pdealchemy/cli/app.py index f415f86..bf87d63 100644 --- a/src/pdealchemy/cli/app.py +++ b/src/pdealchemy/cli/app.py @@ -11,18 +11,15 @@ from rich.markdown import Markdown from rich.syntax import Syntax -from pdealchemy.config.loader import load_pricing_config -from pdealchemy.core import price_config -from pdealchemy.exceptions import PDEAlchemyError, ValidationError -from pdealchemy.logging_config import configure_logging -from pdealchemy.math_bridge import build_symbolic_problem -from pdealchemy.notebook_spec import notebook_to_toml_file -from pdealchemy.render import render_explain_output -from pdealchemy.spec_bridge import ( - BlackScholesBridgeDefaults, - spec_to_runtime_toml_file, +from pdealchemy.cli.commands import ( + run_explain_command, + run_notebook_to_toml_command, + run_price_command, + run_spec_to_runtime_toml_command, + run_validate_command, ) -from pdealchemy.validation import ValidationRunner, validate_equation_library +from pdealchemy.exceptions import PDEAlchemyError +from pdealchemy.logging_config import configure_logging app = typer.Typer( name="pdealchemy", @@ -63,15 +60,7 @@ def price( ) -> None: """Run the pricing workflow.""" try: - config_data = load_pricing_config(config_path) - pricing_result = price_config(config_data) - logger.info("Price command completed for {}", config_path) - console.print( - "[bold green]Pricing successful:[/bold green] " - f"{pricing_result.price:.8f}\n" - f"Backend: {pricing_result.backend}\n" - f"Engine: {pricing_result.engine}" - ) + console.print(run_price_command(config_path)) except PDEAlchemyError as exc: console.print(exc.to_cli_message()) raise typer.Exit(code=2) from exc @@ -94,13 +83,12 @@ def notebook_to_toml( ) -> None: """Convert a marimo specification notebook into TOML.""" try: - output_path = notebook_to_toml_file( - notebook_path, - output_path=output, - overwrite=overwrite, - ) console.print( - f"[bold green]Notebook conversion successful:[/bold green] wrote `{output_path}`." + run_notebook_to_toml_command( + notebook_path, + output_path=output, + overwrite=overwrite, + ) ) except PDEAlchemyError as exc: console.print(exc.to_cli_message()) @@ -140,27 +128,25 @@ def spec_to_runtime_toml( ) -> None: """Bridge a notebook specification TOML into executable runtime pricing TOML.""" try: - defaults = BlackScholesBridgeDefaults( - spot=spot, - strike=strike, - rate=rate, - volatility=volatility, - maturity=maturity, - backend=backend, - scheme=scheme, - time_steps=time_steps, - damping_steps=damping_steps, - grid_lower=grid_lower, - grid_upper=grid_upper, - grid_points=grid_points, - ) - output_path = spec_to_runtime_toml_file( - spec_path, - output_path=output, - overwrite=overwrite, - defaults=defaults, + console.print( + run_spec_to_runtime_toml_command( + spec_path, + output_path=output, + overwrite=overwrite, + spot=spot, + strike=strike, + rate=rate, + volatility=volatility, + maturity=maturity, + backend=backend, + scheme=scheme, + time_steps=time_steps, + damping_steps=damping_steps, + grid_lower=grid_lower, + grid_upper=grid_upper, + grid_points=grid_points, + ) ) - console.print(f"[bold green]Spec bridge successful:[/bold green] wrote `{output_path}`.") except PDEAlchemyError as exc: console.print(exc.to_cli_message()) raise typer.Exit(code=2) from exc @@ -191,41 +177,13 @@ def validate( ) -> None: """Validate that the configuration file matches PDEAlchemy schema.""" try: - config_data = load_pricing_config(config_path) - _ = build_symbolic_problem(config_data) - validation_note = "" - if analytical: - runner = ValidationRunner() - outcome = runner.run_analytical_black_scholes( - config_data, + console.print( + run_validate_command( + config_path, + analytical=analytical, tolerance=tolerance, + equation_library=equation_library, ) - if not outcome.passed: - raise ValidationError( - "Analytical benchmark failed tolerance check.", - details=( - f"model={outcome.model_price:.8f}, " - f"benchmark={outcome.benchmark_price:.8f}, " - f"abs_error={outcome.absolute_error:.8f}, " - f"tolerance={outcome.tolerance:.8f}" - ), - suggestion="Relax --tolerance or review model and numerical settings.", - ) - validation_note = ( - f"\nAnalytical benchmark: passed " - f"(abs error {outcome.absolute_error:.8f} <= {outcome.tolerance:.8f})" - ) - if equation_library is not None: - equation_summary = validate_equation_library(equation_library) - validation_note += ( - f"\nEquation library: validated {equation_summary.equation_blocks_validated} " - f"equation block(s) across {equation_summary.files_scanned} file(s)" - ) - console.print( - "[bold green]Validation successful:[/bold green] " - f"{len(config_data.process.state_variables)} state variable(s), " - f"backend `{config_data.numerics.backend}`." - f"{validation_note}" ) except PDEAlchemyError as exc: console.print(exc.to_cli_message()) @@ -239,13 +197,7 @@ def explain( ) -> None: """Render a high-level description of the provided TOML config.""" try: - config_data = load_pricing_config(config_path) - symbolic_problem = build_symbolic_problem(config_data) - rendered = render_explain_output( - config_data, - symbolic_problem, - output_format=format, - ) + rendered = run_explain_command(config_path, output_format=format) if format is ExplainFormat.MARKDOWN: console.print(Markdown(rendered)) diff --git a/src/pdealchemy/cli/commands/__init__.py b/src/pdealchemy/cli/commands/__init__.py new file mode 100644 index 0000000..7cee566 --- /dev/null +++ b/src/pdealchemy/cli/commands/__init__.py @@ -0,0 +1,17 @@ +"""Command execution modules for the PDEAlchemy CLI.""" + +from pdealchemy.cli.commands.conversion import ( + run_notebook_to_toml_command, + run_spec_to_runtime_toml_command, +) +from pdealchemy.cli.commands.explain import run_explain_command +from pdealchemy.cli.commands.pricing import run_price_command +from pdealchemy.cli.commands.validation import run_validate_command + +__all__ = [ + "run_explain_command", + "run_notebook_to_toml_command", + "run_price_command", + "run_spec_to_runtime_toml_command", + "run_validate_command", +] diff --git a/src/pdealchemy/cli/commands/conversion.py b/src/pdealchemy/cli/commands/conversion.py new file mode 100644 index 0000000..1e14f88 --- /dev/null +++ b/src/pdealchemy/cli/commands/conversion.py @@ -0,0 +1,65 @@ +"""Notebook and specification conversion command execution logic.""" + +from __future__ import annotations + +from pathlib import Path + +from pdealchemy.notebook_spec import notebook_to_toml_file +from pdealchemy.spec_bridge import BlackScholesBridgeDefaults, spec_to_runtime_toml_file + + +def run_notebook_to_toml_command( + notebook_path: Path, + *, + output_path: Path | None, + overwrite: bool, +) -> str: + """Convert notebook specification source into TOML and return success message.""" + target_path = notebook_to_toml_file( + notebook_path, + output_path=output_path, + overwrite=overwrite, + ) + return f"[bold green]Notebook conversion successful:[/bold green] wrote `{target_path}`." + + +def run_spec_to_runtime_toml_command( + spec_path: Path, + *, + output_path: Path | None, + overwrite: bool, + spot: float, + strike: float, + rate: float, + volatility: float, + maturity: float, + backend: str, + scheme: str, + time_steps: int, + damping_steps: int, + grid_lower: float, + grid_upper: float, + grid_points: int, +) -> str: + """Bridge specification TOML into pricing TOML and return success message.""" + defaults = BlackScholesBridgeDefaults( + spot=spot, + strike=strike, + rate=rate, + volatility=volatility, + maturity=maturity, + backend=backend, + scheme=scheme, + time_steps=time_steps, + damping_steps=damping_steps, + grid_lower=grid_lower, + grid_upper=grid_upper, + grid_points=grid_points, + ) + target_path = spec_to_runtime_toml_file( + spec_path, + output_path=output_path, + overwrite=overwrite, + defaults=defaults, + ) + return f"[bold green]Spec bridge successful:[/bold green] wrote `{target_path}`." diff --git a/src/pdealchemy/cli/commands/explain.py b/src/pdealchemy/cli/commands/explain.py new file mode 100644 index 0000000..e96b1cf --- /dev/null +++ b/src/pdealchemy/cli/commands/explain.py @@ -0,0 +1,24 @@ +"""Explain command execution logic.""" + +from __future__ import annotations + +from pathlib import Path + +from pdealchemy.config.loader import load_pricing_config +from pdealchemy.math_bridge import build_symbolic_problem +from pdealchemy.render import render_explain_output + + +def run_explain_command( + config_path: Path, + *, + output_format: str, +) -> str: + """Render explain output for the requested format.""" + config_data = load_pricing_config(config_path) + symbolic_problem = build_symbolic_problem(config_data) + return render_explain_output( + config_data, + symbolic_problem, + output_format=output_format, + ) diff --git a/src/pdealchemy/cli/commands/pricing.py b/src/pdealchemy/cli/commands/pricing.py new file mode 100644 index 0000000..3c63c8f --- /dev/null +++ b/src/pdealchemy/cli/commands/pricing.py @@ -0,0 +1,23 @@ +"""Pricing command execution logic.""" + +from __future__ import annotations + +from pathlib import Path + +from loguru import logger + +from pdealchemy.config.loader import load_pricing_config +from pdealchemy.core import price_config + + +def run_price_command(config_path: Path) -> str: + """Execute pricing workflow and return formatted rich message text.""" + config_data = load_pricing_config(config_path) + pricing_result = price_config(config_data) + logger.info("Price command completed for {}", config_path) + return ( + "[bold green]Pricing successful:[/bold green] " + f"{pricing_result.price:.8f}\n" + f"Backend: {pricing_result.backend}\n" + f"Engine: {pricing_result.engine}" + ) diff --git a/src/pdealchemy/cli/commands/validation.py b/src/pdealchemy/cli/commands/validation.py new file mode 100644 index 0000000..b71247d --- /dev/null +++ b/src/pdealchemy/cli/commands/validation.py @@ -0,0 +1,56 @@ +"""Validation command execution logic.""" + +from __future__ import annotations + +from pathlib import Path + +from pdealchemy.config.loader import load_pricing_config +from pdealchemy.exceptions import ValidationError +from pdealchemy.math_bridge import build_symbolic_problem +from pdealchemy.validation import ValidationRunner, validate_equation_library + + +def run_validate_command( + config_path: Path, + *, + analytical: bool, + tolerance: float, + equation_library: Path | None, +) -> str: + """Validate configuration and return formatted rich summary text.""" + config_data = load_pricing_config(config_path) + _ = build_symbolic_problem(config_data) + validation_note = "" + if analytical: + runner = ValidationRunner() + outcome = runner.run_analytical_black_scholes( + config_data, + tolerance=tolerance, + ) + if not outcome.passed: + raise ValidationError( + "Analytical benchmark failed tolerance check.", + details=( + f"model={outcome.model_price:.8f}, " + f"benchmark={outcome.benchmark_price:.8f}, " + f"abs_error={outcome.absolute_error:.8f}, " + f"tolerance={outcome.tolerance:.8f}" + ), + suggestion="Relax --tolerance or review model and numerical settings.", + ) + validation_note = ( + f"\nAnalytical benchmark: passed " + f"(abs error {outcome.absolute_error:.8f} <= {outcome.tolerance:.8f})" + ) + if equation_library is not None: + equation_summary = validate_equation_library(equation_library) + validation_note += ( + f"\nEquation library: validated {equation_summary.equation_blocks_validated} " + f"equation block(s) across {equation_summary.files_scanned} file(s)" + ) + return ( + "[bold green]Validation successful:[/bold green] " + f"{len(config_data.process.state_variables)} state variable(s), " + f"backend `{config_data.numerics.backend}`." + f"{validation_note}" + )