117 lines
3.5 KiB
Python
117 lines
3.5 KiB
Python
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])
|