diff --git a/src/calc/cli.py b/src/calc/cli.py index 7bf68c8..d472e14 100644 --- a/src/calc/cli.py +++ b/src/calc/cli.py @@ -11,7 +11,6 @@ from urllib.request import urlopen from sympy import Eq, zoo -from sympy.matrices.matrixbase import MatrixBase from .core import evaluate, normalize_expression from .diagnostics import ( @@ -21,6 +20,9 @@ should_print_wolfram_hint, ) from .options import COLOR_MODES, CLIOptions, parse_options +from .linalg import ( + evaluate_linalg_alias as evaluate_linalg_alias_impl, +) from .ode import ( evaluate_ode_alias as evaluate_ode_alias_impl, infer_ode_dependent as infer_ode_dependent_impl, @@ -387,59 +389,6 @@ def _evaluate_ode_alias( ) -def _consume_bracket_literal(text: str, start: int) -> tuple[str, int]: - if start >= len(text) or text[start] != "[": - raise ValueError("expected bracketed literal like [[...]]") - depth = 0 - idx = start - while idx < len(text): - ch = text[idx] - if ch == "[": - depth += 1 - elif ch == "]": - depth -= 1 - if depth == 0: - return text[start : idx + 1], idx + 1 - idx += 1 - raise ValueError("unclosed bracket literal; expected closing ']'") - - -def _parse_linalg_keyed_literals(text: str, required_keys: set[str]) -> dict[str, str]: - idx = 0 - parsed: dict[str, str] = {} - while idx < len(text): - while idx < len(text) and text[idx] in {",", " "}: - idx += 1 - while idx < len(text) and text[idx].isspace(): - idx += 1 - if idx >= len(text): - break - key_start = idx - while idx < len(text) and text[idx].isalpha(): - idx += 1 - key = text[key_start:idx] - if key not in required_keys: - expected = ", ".join(sorted(required_keys)) - raise ValueError(f"unknown linalg parameter '{key}'; expected: {expected}") - if key in parsed: - raise ValueError(f"duplicate linalg parameter '{key}'") - while idx < len(text) and text[idx].isspace(): - idx += 1 - if idx >= len(text) or text[idx] != "=": - raise ValueError(f"linalg parameter '{key}' must use '='") - idx += 1 - while idx < len(text) and text[idx].isspace(): - idx += 1 - literal, idx = _consume_bracket_literal(text, idx) - parsed[key] = literal - - missing = sorted(required_keys - set(parsed)) - if missing: - missing_text = ", ".join(missing) - raise ValueError(f"missing linalg parameter(s): {missing_text}") - return parsed - - def _evaluate_linalg_alias( expr: str, *, @@ -447,85 +396,13 @@ def _evaluate_linalg_alias( simplify_output: bool, session_locals: dict | None = None, ): - body = expr[7:].strip() - if not body: - raise ValueError("linalg expects a subcommand: solve or rref") - pieces = body.split(maxsplit=1) - subcommand = pieces[0].lower() - rest = pieces[1] if len(pieces) > 1 else "" - - if subcommand == "solve": - params = _parse_linalg_keyed_literals(rest, {"A", "b"}) - matrix_text = params["A"] - rhs_text = params["b"] - matrix_value = evaluate( - f"Matrix({matrix_text})", - relaxed=relaxed, - session_locals=session_locals, - simplify_output=simplify_output, - ) - rhs_value = evaluate( - f"Matrix({rhs_text})", - relaxed=relaxed, - session_locals=session_locals, - simplify_output=simplify_output, - ) - if not isinstance(matrix_value, MatrixBase) or not isinstance(rhs_value, MatrixBase): - raise ValueError("linalg solve expects matrix literals for A and b") - if matrix_value.rows != matrix_value.cols: - raise ValueError("linalg solve expects square A") - if rhs_value.cols != 1: - raise ValueError("linalg solve expects b as a column vector, e.g. b=[1,2]") - if rhs_value.rows != matrix_value.rows: - raise ValueError("linalg solve expects len(b) to match rows of A") - result = matrix_value.LUsolve(rhs_value) - parsed_expr = f"msolve(Matrix({matrix_text}), Matrix({rhs_text}))" - return result, parsed_expr - - if subcommand == "rref": - params = _parse_linalg_keyed_literals(rest, {"A"}) - matrix_text = params["A"] - matrix_value = evaluate( - f"Matrix({matrix_text})", - relaxed=relaxed, - session_locals=session_locals, - simplify_output=simplify_output, - ) - if not isinstance(matrix_value, MatrixBase): - raise ValueError("linalg rref expects a matrix literal for A") - result = matrix_value.rref() - parsed_expr = f"rref(Matrix({matrix_text}))" - return result, parsed_expr - - if subcommand in ("det", "inv", "rank", "nullspace", "eig", "eigvals"): - params = _parse_linalg_keyed_literals(rest, {"A"}) - matrix_text = params["A"] - matrix_value = evaluate( - f"Matrix({matrix_text})", - relaxed=relaxed, - session_locals=session_locals, - simplify_output=simplify_output, - ) - if not isinstance(matrix_value, MatrixBase): - raise ValueError(f"linalg {subcommand} expects a matrix literal for A") - if subcommand == "det": - result = matrix_value.det() - parsed_expr = f"det(Matrix({matrix_text}))" - elif subcommand == "inv": - result = matrix_value.inv() - parsed_expr = f"inv(Matrix({matrix_text}))" - elif subcommand == "rank": - result = matrix_value.rank() - parsed_expr = f"rank(Matrix({matrix_text}))" - elif subcommand == "nullspace": - result = matrix_value.nullspace() - parsed_expr = f"nullspace(Matrix({matrix_text}))" - else: # eig / eigvals - result = matrix_value.eigenvals() - parsed_expr = f"eigvals(Matrix({matrix_text}))" - return result, parsed_expr - - raise ValueError("unknown linalg subcommand; use: solve, rref, det, inv, rank, nullspace, eig") + return evaluate_linalg_alias_impl( + expr, + evaluate_fn=evaluate, + relaxed=relaxed, + simplify_output=simplify_output, + session_locals=session_locals, + ) def _latest_pypi_version() -> str | None: diff --git a/src/calc/linalg.py b/src/calc/linalg.py new file mode 100644 index 0000000..7c7b2ab --- /dev/null +++ b/src/calc/linalg.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from sympy.matrices.matrixbase import MatrixBase + + +def consume_bracket_literal(text: str, start: int) -> tuple[str, int]: + if start >= len(text) or text[start] != "[": + raise ValueError("expected bracketed literal like [[...]]") + depth = 0 + idx = start + while idx < len(text): + ch = text[idx] + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return text[start : idx + 1], idx + 1 + idx += 1 + raise ValueError("unclosed bracket literal; expected closing ']'") + + +def parse_linalg_keyed_literals(text: str, required_keys: set[str]) -> dict[str, str]: + idx = 0 + parsed: dict[str, str] = {} + while idx < len(text): + while idx < len(text) and text[idx] in {",", " "}: + idx += 1 + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text): + break + key_start = idx + while idx < len(text) and text[idx].isalpha(): + idx += 1 + key = text[key_start:idx] + if key not in required_keys: + expected = ", ".join(sorted(required_keys)) + raise ValueError(f"unknown linalg parameter '{key}'; expected: {expected}") + if key in parsed: + raise ValueError(f"duplicate linalg parameter '{key}'") + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text) or text[idx] != "=": + raise ValueError(f"linalg parameter '{key}' must use '='") + idx += 1 + while idx < len(text) and text[idx].isspace(): + idx += 1 + literal, idx = consume_bracket_literal(text, idx) + parsed[key] = literal + + missing = sorted(required_keys - set(parsed)) + if missing: + missing_text = ", ".join(missing) + raise ValueError(f"missing linalg parameter(s): {missing_text}") + return parsed + + +def evaluate_linalg_alias( + expr: str, + *, + evaluate_fn, + relaxed: bool, + simplify_output: bool, + session_locals: dict | None = None, +): + body = expr[7:].strip() + if not body: + raise ValueError("linalg expects a subcommand: solve, rref, det, inv, rank, eig, nullspace") + pieces = body.split(maxsplit=1) + subcommand = pieces[0].lower() + rest = pieces[1] if len(pieces) > 1 else "" + + if subcommand == "solve": + params = parse_linalg_keyed_literals(rest, {"A", "b"}) + matrix_text = params["A"] + rhs_text = params["b"] + matrix_value = evaluate_fn( + f"Matrix({matrix_text})", + relaxed=relaxed, + session_locals=session_locals, + simplify_output=simplify_output, + ) + rhs_value = evaluate_fn( + f"Matrix({rhs_text})", + relaxed=relaxed, + session_locals=session_locals, + simplify_output=simplify_output, + ) + if not isinstance(matrix_value, MatrixBase) or not isinstance(rhs_value, MatrixBase): + raise ValueError("linalg solve expects matrix literals for A and b") + if matrix_value.rows != matrix_value.cols: + raise ValueError("linalg solve expects square A") + if rhs_value.cols != 1: + raise ValueError("linalg solve expects b as a column vector, e.g. b=[1,2]") + if rhs_value.rows != matrix_value.rows: + raise ValueError("linalg solve expects len(b) to match rows of A") + result = matrix_value.LUsolve(rhs_value) + parsed_expr = f"msolve(Matrix({matrix_text}), Matrix({rhs_text}))" + return result, parsed_expr + + if subcommand == "rref": + params = parse_linalg_keyed_literals(rest, {"A"}) + matrix_text = params["A"] + matrix_value = evaluate_fn( + f"Matrix({matrix_text})", + relaxed=relaxed, + session_locals=session_locals, + simplify_output=simplify_output, + ) + if not isinstance(matrix_value, MatrixBase): + raise ValueError("linalg rref expects a matrix literal for A") + result = matrix_value.rref() + parsed_expr = f"rref(Matrix({matrix_text}))" + return result, parsed_expr + + if subcommand in ("det", "inv", "rank", "nullspace", "eig", "eigvals"): + params = parse_linalg_keyed_literals(rest, {"A"}) + matrix_text = params["A"] + matrix_value = evaluate_fn( + f"Matrix({matrix_text})", + relaxed=relaxed, + session_locals=session_locals, + simplify_output=simplify_output, + ) + if not isinstance(matrix_value, MatrixBase): + raise ValueError(f"linalg {subcommand} expects a matrix literal for A") + if subcommand == "det": + result = matrix_value.det() + parsed_expr = f"det(Matrix({matrix_text}))" + elif subcommand == "inv": + result = matrix_value.inv() + parsed_expr = f"inv(Matrix({matrix_text}))" + elif subcommand == "rank": + result = matrix_value.rank() + parsed_expr = f"rank(Matrix({matrix_text}))" + elif subcommand == "nullspace": + result = matrix_value.nullspace() + parsed_expr = f"nullspace(Matrix({matrix_text}))" + else: # eig / eigvals + result = matrix_value.eigenvals() + parsed_expr = f"eigvals(Matrix({matrix_text}))" + return result, parsed_expr + + raise ValueError("unknown linalg subcommand; use: solve, rref, det, inv, rank, nullspace, eig") diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index 4ba04d4..4465d05 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -4,6 +4,7 @@ import pytest import calc.cli as cli +import calc.linalg as linalg def test_parse_options_help_exits(capsys): @@ -830,15 +831,15 @@ def test_run_repl_tutorial_enter_advances(monkeypatch, capsys): def test_consume_bracket_literal_invalid_start(): with pytest.raises(ValueError, match="expected bracketed literal"): - cli._consume_bracket_literal("A=[[1,2]]", 0) + linalg.consume_bracket_literal("A=[[1,2]]", 0) def test_parse_linalg_keyed_literals_space_only_text(): - assert cli._parse_linalg_keyed_literals(" ", set()) == {} + assert linalg.parse_linalg_keyed_literals(" ", set()) == {} def test_parse_linalg_keyed_literals_allows_space_after_equals(): - parsed = cli._parse_linalg_keyed_literals("A= [[1,2],[3,4]]", {"A"}) + parsed = linalg.parse_linalg_keyed_literals("A= [[1,2],[3,4]]", {"A"}) assert parsed == {"A": "[[1,2],[3,4]]"}