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
143 changes: 10 additions & 133 deletions src/calc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -387,145 +389,20 @@ 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,
*,
relaxed: bool,
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:
Expand Down
145 changes: 145 additions & 0 deletions src/calc/linalg.py
Original file line number Diff line number Diff line change
@@ -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")
7 changes: 4 additions & 3 deletions tests/test_cli_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

import calc.cli as cli
import calc.linalg as linalg


def test_parse_options_help_exits(capsys):
Expand Down Expand Up @@ -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]]"}


Expand Down
Loading