import sympy as sp from mcp_calculator.config import Limits from mcp_calculator.ops.context import MathError, set_limits from mcp_calculator.ops.parsing import apply_assumptions, parse, parse_symbols from tests.support import MathTestCase x, y = sp.symbols("x y") VALID = [ ("2x + 3(x+1)", 5 * x + 3), ("x^2", x**2), ("x**2", x**2), ("0.1 + 0.2", sp.Rational(3, 10)), ("5!", 120), ("sin(30*deg)", sp.Rational(1, 2)), ("e^(I*pi)", -1), ("ln(e)", 1), ("log(8, 2)", 3), ("log10(1000)", 3), ("abs(-3)", 3), ("max(3, 7)", 7), ("10 % 3", 1), ("1e5", 100000), ("2.5e-3", sp.Rational(1, 400)), ("√8", 2 * sp.sqrt(2)), ("3×4÷2", 6), ("2π", 2 * sp.pi), ("x²", x**2), ("arcsin(1)", sp.pi / 2), ("inf", sp.oo), ] MALICIOUS = [ "__import__('os').system('ls')", "().__class__.__bases__", "x.subs(x, 1)", "lambda x: x", "open('/etc/passwd')", "import os", "getattr(x, 'y')", "[1, 2, 3]", "Symbol('x')", "_x + 1", "a; b", "exec\n('1')", "{1: 2}", "x if x else y", "`ls`", "\\x41", ] class ParseTest(MathTestCase): def test_valid_expressions(self): for text, expected in VALID: with self.subTest(text=text): result = parse(text) self.assertTrue(result == expected or sp.simplify(result - expected) == 0, f"{text!r} -> {result}") def test_relations(self): self.assertEqual(parse("x^2 = 4"), sp.Eq(x**2, 4)) self.assertEqual(parse("x^2 == 4"), sp.Eq(x**2, 4)) self.assertEqual(parse("x != 3"), sp.Ne(x, 3)) self.assertEqual(parse("x <= 3"), sp.Le(x, 3)) self.assertEqual(parse("x^2 > 4"), sp.Gt(x**2, 4)) def test_only_one_equals_sign(self): self.assertEqual(parse("x = 1"), sp.Eq(x, 1)) with self.assertRaisesRegex(MathError, "only one '='"): parse("x = 1 = 2") def test_multi_letter_names_are_single_symbols(self): rate, time = sp.symbols("rate time") self.assertEqual(parse("rate*time"), rate * time) def test_exact_false_keeps_floats(self): self.assertIsInstance(parse("0.5", exact=False), sp.Float) def test_empty_expression(self): with self.assertRaisesRegex(MathError, "empty"): parse(" ") def test_syntax_error_message(self): with self.assertRaisesRegex(MathError, "syntax error|could not parse"): parse("2 +* 3") def test_length_limit(self): set_limits(Limits(max_input_length=10)) with self.assertRaisesRegex(MathError, "longer than 10"): parse("1+1+1+1+1+1+1") class SecurityTest(MathTestCase): def test_code_is_rejected(self): for text in MALICIOUS: with self.subTest(text=text), self.assertRaises(MathError): parse(text) def test_python_builtins_are_not_reachable(self): # Unknown names become plain symbols; they never resolve to Python builtins. self.assertEqual(parse("eval"), sp.Symbol("eval")) self.assertEqual(parse("exec"), sp.Symbol("exec")) with self.assertRaises(MathError): parse("globals()") class SymbolTest(MathTestCase): def test_parse_symbols(self): self.assertEqual(parse_symbols("x, y"), [x, y]) self.assertEqual(parse_symbols(["x"]), [x]) with self.assertRaises(MathError): parse_symbols(["sin"]) def test_apply_assumptions(self): expr, mapping = apply_assumptions(sp.sqrt(x**2), "positive") self.assertEqual(sp.simplify(expr), mapping[x])