pycodemath 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ """Symbolic engine (module 1) — algebra + analysis.
2
+
3
+ Operates on IR (``Expr``). Delegates to SymPy, but the API belongs to Pycodemath.
4
+ MVP: simplify, expand, derivative, integral, solve.
5
+ Module P2: limit, series, summation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sympy as sp
11
+
12
+ from ..core.errors import PycodemathError
13
+ from ..core.ir import Expr
14
+ from ..frontend.parser import parse
15
+
16
+
17
+ def simplify(expr: Expr) -> Expr:
18
+ return expr.simplify()
19
+
20
+
21
+ def expand(expr: Expr) -> Expr:
22
+ return expr.expand()
23
+
24
+
25
+ def diff(expr: Expr, var: str) -> Expr:
26
+ return expr.diff(var)
27
+
28
+
29
+ def integrate(expr: Expr, var: str) -> Expr:
30
+ return expr.integrate(var)
31
+
32
+
33
+ def _is_real_value(s: sp.Basic) -> bool:
34
+ """Is the root real? Filters out junk complex roots that ``sympy.solve``
35
+ returns for transcendental equations (e.g. ``cos(x) = 2``). Resolves
36
+ three-valued: explicitly complex → reject, explicitly real → keep. When
37
+ SymPy cannot decide (``is_real`` = None), we check the imaginary part; if
38
+ that too is ambiguous, we KEEP the root — we do not pretend to know more
39
+ than the engine, and the filter only cuts obvious junk."""
40
+ if s.is_real is True:
41
+ return True
42
+ if s.is_real is False:
43
+ return False
44
+ im = sp.simplify(sp.im(s))
45
+ if im.is_zero is True:
46
+ return True
47
+ if im.is_zero is False:
48
+ return False
49
+ return True # ambiguous — we do not discard
50
+
51
+
52
+ def solve(expr: Expr, var: str, real: bool = False) -> list[Expr]:
53
+ """Solve the equation ``expr = 0`` for ``var``. Returns a list of roots.
54
+
55
+ ``sympy.solve`` can be inconsistent in the shape of its result: usually a
56
+ list of roots, but sometimes a dict ``{var: value}`` or a list of dicts
57
+ (parametric solutions). We normalize these shapes into a list of values
58
+ for ``var``; a shape that cannot be interpreted as a root with respect to
59
+ ``var`` (e.g. a tuple from a system solution) we do NOT let through as a
60
+ raw ``SympifyError`` from the ``Expr`` constructor — we return a
61
+ ``PycodemathError``.
62
+
63
+ ``real=True`` filters out complex roots (``_is_real_value``). Defaults to
64
+ ``False`` (the full API also returns complex ones — no regression); the
65
+ REPL calls with ``real=True``, because transcendental equations returned
66
+ junk complex roots there. We filter AFTER solving, on the same
67
+ (non-real) symbol as the expression — otherwise ``sp.solve`` would look
68
+ for a different symbol.
69
+ """
70
+ sym = sp.Symbol(var)
71
+ try:
72
+ raw = sp.solve(expr.sy, sym)
73
+ except (
74
+ NotImplementedError,
75
+ sp.PolynomialError,
76
+ sp.SympifyError,
77
+ ValueError,
78
+ AttributeError,
79
+ ) as exc:
80
+ raise PycodemathError(
81
+ f"solve: cannot solve the equation for '{var}': {exc}"
82
+ ) from exc
83
+ if isinstance(raw, dict): # single solution as a map {sym: value}
84
+ raw = [raw]
85
+
86
+ roots: list[Expr] = []
87
+ for r in raw:
88
+ if isinstance(r, dict): # list of maps (parametric solutions)
89
+ if sym in r:
90
+ roots.append(Expr(r[sym]))
91
+ # a map without our variable carries no root for var — we skip it
92
+ elif isinstance(r, (tuple, list)): # system tuple — not for a single variable
93
+ raise PycodemathError(
94
+ f"solve: unexpected solution shape for '{var}' "
95
+ f"(parametric/system result) — use solve_nd for systems"
96
+ )
97
+ else:
98
+ roots.append(Expr(r))
99
+ if real:
100
+ roots = [rt for rt in roots if _is_real_value(rt.sy)]
101
+ return roots
102
+
103
+
104
+ # --- module P2: limit / series / summation ---------------------------------
105
+ def _point(value: "Expr | str | int | float") -> sp.Basic:
106
+ """Point/interval bound: Expr, text (via the parser — the same whitelist
107
+ as the whole input) or a number. We let nothing else through, so as not to
108
+ open a side door into sympify with the full namespace."""
109
+ if isinstance(value, Expr):
110
+ return value.sy
111
+ if isinstance(value, str):
112
+ return parse(value).sy
113
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
114
+ return sp.sympify(value)
115
+ raise PycodemathError(f"invalid point/bound: {value!r}")
116
+
117
+
118
+ def limit(
119
+ expr: Expr, var: str, to: "Expr | str | int | float", dir: str = "+"
120
+ ) -> Expr:
121
+ """Limit of ``expr`` as ``var → to`` (``to`` also accepts ``oo``/``-oo``).
122
+
123
+ ``dir``: "+" (from the right, default) or "-" (from the left). A
124
+ nonexistent limit (oscillation → AccumBounds) or an undefined one (nan/zoo,
125
+ an unevaluated ``Limit``) is a clear refusal, not a raw SymPy object.
126
+ """
127
+ if dir not in ("+", "-"):
128
+ raise PycodemathError(f"limit: dir must be '+' or '-', not {dir!r}")
129
+ sym = sp.Symbol(var)
130
+ point = _point(to)
131
+ try:
132
+ res = sp.limit(expr.sy, sym, point, dir)
133
+ except (sp.PoleError, NotImplementedError, ValueError) as exc:
134
+ raise PycodemathError(
135
+ f"limit: cannot determine the limit at {point}: {exc}"
136
+ ) from exc
137
+ if isinstance(res, sp.AccumBounds):
138
+ raise PycodemathError(
139
+ f"limit: the limit at {point} does not exist (the expression oscillates)"
140
+ )
141
+ if res.has(sp.Limit) or res.has(sp.nan) or res.has(sp.zoo):
142
+ raise PycodemathError(f"limit: the limit at {point} is undefined")
143
+ return Expr(res)
144
+
145
+
146
+ def series(
147
+ expr: Expr, var: str, at: "Expr | str | int | float" = 0, n: int = 6
148
+ ) -> Expr:
149
+ """Series expansion (Taylor/Laurent) around ``at`` up to order ``n``.
150
+
151
+ Returns a polynomial WITHOUT the ``O(...)`` term (``removeO``) — a
152
+ deliberate decision: the result is an ordinary ``Expr`` that round-trips
153
+ through the parser (``O`` is not in the whitelist and makes no sense
154
+ outside the series context). The truncation order is documented by the
155
+ parameter ``n``, not the result.
156
+ """
157
+ if not isinstance(n, int) or n < 1:
158
+ raise PycodemathError(
159
+ f"series: n must be a positive integer, not {n!r}"
160
+ )
161
+ sym = sp.Symbol(var)
162
+ point = _point(at)
163
+ try:
164
+ res = expr.sy.series(sym, point, n).removeO()
165
+ except (sp.PoleError, NotImplementedError, ValueError) as exc:
166
+ raise PycodemathError(
167
+ f"series: cannot expand around {point}: {exc}"
168
+ ) from exc
169
+ return Expr(res)
170
+
171
+
172
+ def summation(
173
+ expr: Expr,
174
+ var: str,
175
+ lo: "Expr | str | int | float",
176
+ hi: "Expr | str | int | float",
177
+ ) -> Expr:
178
+ """Symbolic summation of ``expr`` over ``var`` from ``lo`` to ``hi``.
179
+
180
+ The bounds may be numbers, symbols (``n``) or ``oo``. A divergent sum or
181
+ one without a closed form is a ``PycodemathError`` with a hint, not
182
+ ``oo``/an unevaluated ``Sum`` in the result (contract: zero junk).
183
+ """
184
+ sym = sp.Symbol(var)
185
+ a, b = _point(lo), _point(hi)
186
+ try:
187
+ res = sp.summation(expr.sy, (sym, a, b))
188
+ except (NotImplementedError, ValueError) as exc:
189
+ raise PycodemathError(f"summation: cannot compute the sum: {exc}") from exc
190
+ # First the no-closed-form case: an unevaluated Sum with an oo bound
191
+ # CARRIES oo inside, so the divergence check would catch it with a
192
+ # misleading message (the sum may be convergent, just without a closed form).
193
+ if res.has(sp.Sum):
194
+ raise PycodemathError(
195
+ "summation: no closed form — try finite bounds "
196
+ "(partial sum) or compute numerically"
197
+ )
198
+ if isinstance(res, sp.AccumBounds) or res.has(
199
+ sp.oo, -sp.oo, sp.zoo, sp.nan
200
+ ):
201
+ raise PycodemathError(
202
+ "summation: divergent sum — narrow the bounds to finite ones"
203
+ )
204
+ return Expr(res)
File without changes
@@ -0,0 +1,299 @@
1
+ """Parser — natural math notation → IR.
2
+
3
+ A thin layer over ``sympy.parsing``. Accepts standard mathematical
4
+ notation (``sin(x)*x + 2^3``) and returns an ``Expr`` from the IR core.
5
+ Guarantees round-trip: ``parse(str(parse(s))) == parse(s)``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import token as _token
12
+ from tokenize import TokenError
13
+
14
+ import sympy as sp
15
+ from sympy import SympifyError
16
+ from sympy.parsing.sympy_parser import (
17
+ convert_xor,
18
+ implicit_multiplication_application,
19
+ parse_expr,
20
+ standard_transformations,
21
+ )
22
+
23
+ from ..core.errors import PycodemathError
24
+ from ..core.ir import Expr
25
+
26
+ # Tokens that make no sense in math notation but open up an eval surface:
27
+ # STRING/f-string (a string result would land in sympify with the FULL namespace) and
28
+ # OP "." = attribute access (x.__class__ etc.; fractions like 1.5 are NUMBER,
29
+ # so a decimal point does not fall in here).
30
+ _DENIED_TOKEN_TYPES = frozenset(
31
+ t
32
+ for t in (
33
+ _token.STRING,
34
+ getattr(_token, "FSTRING_START", None), # Python 3.12+
35
+ )
36
+ if t is not None
37
+ )
38
+
39
+
40
+ def _deny_unsafe_tokens(tokens, local_dict, global_dict):
41
+ """Guard transformation: rejects tokens outside math notation."""
42
+ for tok_type, tok_val in tokens:
43
+ if tok_type in _DENIED_TOKEN_TYPES:
44
+ raise PycodemathError(
45
+ f"text literals are not a mathematical expression: {tok_val!r}"
46
+ )
47
+ if tok_type == _token.OP and tok_val == ".":
48
+ raise PycodemathError(
49
+ "attribute access (dot) is not mathematical notation"
50
+ )
51
+ return tokens
52
+
53
+
54
+ # Token guard BEFORE everything, then ^ as exponentiation + implicit
55
+ # multiplication (2x -> 2*x), as in math notation.
56
+ _TRANSFORMS = (_deny_unsafe_tokens,) + standard_transformations + (
57
+ convert_xor,
58
+ implicit_multiplication_application,
59
+ )
60
+
61
+ # Parser whitelist: the ONLY names that resolve to SymPy objects.
62
+ # Everything off the list becomes a Symbol/undefined function (auto_symbol),
63
+ # so __import__/eval/open are not reachable at all from the input text.
64
+ _ALLOWED_FUNCTIONS = (
65
+ # trigonometry + inverses
66
+ "sin", "cos", "tan", "cot", "sec", "csc",
67
+ "asin", "acos", "atan", "acot", "asec", "acsc", "atan2",
68
+ # hyperbolic + inverses
69
+ "sinh", "cosh", "tanh", "coth", "sech", "csch",
70
+ "asinh", "acosh", "atanh", "acoth",
71
+ # exponential / power
72
+ "exp", "log", "sqrt", "cbrt", "root",
73
+ # piecewise / discrete
74
+ "Abs", "sign", "floor", "ceiling",
75
+ "Min", "Max", "gamma", "factorial", "binomial",
76
+ )
77
+ _LOCAL_DICT: dict[str, object] = {
78
+ name: getattr(sp, name) for name in _ALLOWED_FUNCTIONS
79
+ }
80
+ _LOCAL_DICT.update(
81
+ {
82
+ # aliases for common notation (previously captured by Python builtins)
83
+ "ln": sp.log,
84
+ "abs": sp.Abs,
85
+ "min": sp.Min,
86
+ "max": sp.Max,
87
+ # constants
88
+ "pi": sp.pi,
89
+ "E": sp.E,
90
+ "I": sp.I,
91
+ "oo": sp.oo,
92
+ }
93
+ )
94
+
95
+ # global_dict passed EXPLICITLY: without it parse_expr does `from sympy import *`
96
+ # and adds Python builtins (including __import__). Here only the constructors
97
+ # that the token transformations emit (auto_symbol/auto_number/…), plus
98
+ # an empty __builtins__ so eval does not get the real builtins.
99
+ _GLOBAL_DICT: dict[str, object] = {
100
+ "Symbol": sp.Symbol,
101
+ "Function": sp.Function,
102
+ "Integer": sp.Integer,
103
+ "Float": sp.Float,
104
+ "Rational": sp.Rational,
105
+ "__builtins__": {},
106
+ }
107
+
108
+ # --- Evaluation cost guard (DoS) -------------------------------------
109
+ # The math whitelist allows exponentiation and factorial/gamma/binomial — and on
110
+ # CONCRETE numbers these can build a gigantic int: ``9**9**9`` is
111
+ # ~1.2 billion bits, ``factorial(10**8)`` likewise. This is reachable directly from the
112
+ # MCP server (``math_eval``), so a single line would hang/OOM the host. SymPy
113
+ # computes such things eagerly under ``evaluate=True`` — that is why we FIRST
114
+ # parse the same syntax WITHOUT evaluation and with "blind" functions (so the
115
+ # guard itself computes nothing), then estimate the cost and refuse in advance.
116
+
117
+ #: Upper bit limit for a CONCRETE integer produced by evaluation.
118
+ #: ~200k bits ≈ 60k digits — generous for real math, yet cuts off DoS.
119
+ _MAX_RESULT_BITS = 200_000
120
+ #: An exponent/argument with more bits than this WILL surely blow up the result.
121
+ _SAFE_EXPONENT_BITS = 64
122
+ #: The largest factorial/gamma/binomial argument (n! grows ~n·log2(n)).
123
+ _MAX_FACTORIAL_ARG = 10_000
124
+ #: Functions whose CONCRETE integer argument alone blows up the result.
125
+ _COST_FUNCS = frozenset({"factorial", "gamma", "binomial"})
126
+ _LN2 = math.log(2)
127
+
128
+
129
+ class _CostExceeded(Exception):
130
+ """Internal signal: evaluation would build a number that is too large."""
131
+
132
+
133
+ def _int_value_bounded(node: sp.Basic) -> int | None:
134
+ """Exact integer value of a node built EXCLUSIVELY from numbers (powers,
135
+ products, sums, factorial/gamma/binomial), or ``None`` when the node contains
136
+ symbols/floats/other functions. Raises ``_CostExceeded`` BEFORE computing anything
137
+ whose bit count would exceed ``_MAX_RESULT_BITS`` — never materializes
138
+ a huge int.
139
+ """
140
+ if node.is_Integer:
141
+ return int(node)
142
+ if isinstance(node, sp.Pow):
143
+ base = _int_value_bounded(node.base)
144
+ exp = _int_value_bounded(node.exp)
145
+ if base is None or exp is None or exp < 0:
146
+ return None # symbolic or negative exp. (Rational) — not a "big int"
147
+ if abs(base) <= 1:
148
+ return base**exp # 0/1/-1 to any power: negligible cost
149
+ # estimate the RESULT bits before computing base**exp
150
+ if exp > _MAX_RESULT_BITS or exp * abs(base).bit_length() > _MAX_RESULT_BITS:
151
+ raise _CostExceeded(f"power {node} exceeds {_MAX_RESULT_BITS} bits")
152
+ return base**exp
153
+ if isinstance(node, sp.Mul):
154
+ prod, bits = 1, 0
155
+ for arg in node.args:
156
+ val = _int_value_bounded(arg)
157
+ if val is None:
158
+ return None
159
+ bits += abs(val).bit_length() or 1
160
+ if bits > _MAX_RESULT_BITS:
161
+ raise _CostExceeded(f"product {node} exceeds {_MAX_RESULT_BITS} bits")
162
+ prod *= val
163
+ return prod
164
+ if isinstance(node, sp.Add):
165
+ total, bits = 0, 0
166
+ for arg in node.args:
167
+ val = _int_value_bounded(arg)
168
+ if val is None:
169
+ return None
170
+ bits = max(bits, abs(val).bit_length())
171
+ if bits > _MAX_RESULT_BITS:
172
+ raise _CostExceeded(f"sum {node} exceeds {_MAX_RESULT_BITS} bits")
173
+ total += val
174
+ return total
175
+ if isinstance(node, sp.Function):
176
+ name = type(node).__name__
177
+ if name in ("factorial", "gamma") and len(node.args) == 1:
178
+ arg = _int_value_bounded(node.args[0])
179
+ if arg is None:
180
+ return None
181
+ n = arg - 1 if name == "gamma" else arg # gamma(n) = (n-1)!
182
+ if n < 0:
183
+ return None
184
+ if n > _MAX_FACTORIAL_ARG:
185
+ raise _CostExceeded(f"{name}({arg}) — argument > {_MAX_FACTORIAL_ARG}")
186
+ return math.factorial(n)
187
+ if name == "binomial" and len(node.args) == 2:
188
+ top = _int_value_bounded(node.args[0])
189
+ bottom = _int_value_bounded(node.args[1])
190
+ if top is None or bottom is None:
191
+ return None
192
+ if abs(top) > _MAX_FACTORIAL_ARG:
193
+ raise _CostExceeded(f"binomial({top}, …) — argument > {_MAX_FACTORIAL_ARG}")
194
+ return math.comb(top, bottom) if 0 <= bottom <= top else None
195
+ return None # Symbol/Float/non-integer Rational/other function → not a "big int"
196
+
197
+
198
+ def _check_cost(node: sp.Basic) -> None:
199
+ """Walk the tree and reject any CONCRETE subexpression whose evaluation
200
+ would build a number that is too large. ``_int_value_bounded`` closes over the whole
201
+ subgraph built from numbers (raising ``_CostExceeded``); for subgraphs with symbols
202
+ we descend deeper to catch a dangerous nested fragment (e.g. in ``sin(9**9**9)``).
203
+ """
204
+ if _int_value_bounded(node) is not None:
205
+ return
206
+ for arg in node.args:
207
+ _check_cost(arg)
208
+
209
+
210
+ # The guard parses the SAME syntax but without evaluation and with dummy functions
211
+ # (computes nothing). Constants become plain symbols (their value does not affect
212
+ # the cost). The Pow/Mul/Add constructors are needed because ``evaluate=False`` emits
213
+ # explicit constructor calls.
214
+ _GUARD_FUNC_NAMES = set(_ALLOWED_FUNCTIONS) | {"ln", "abs", "min", "max"}
215
+ _GUARD_LOCAL_DICT: dict[str, object] = {name: sp.Function(name) for name in _GUARD_FUNC_NAMES}
216
+ _GUARD_LOCAL_DICT.update({c: sp.Symbol(c) for c in ("pi", "E", "I", "oo")})
217
+ _GUARD_GLOBAL_DICT: dict[str, object] = {
218
+ **_GLOBAL_DICT,
219
+ "Pow": sp.Pow,
220
+ "Mul": sp.Mul,
221
+ "Add": sp.Add,
222
+ }
223
+
224
+
225
+ def _guard_cost(source: str) -> None:
226
+ """Refuse when evaluating ``source`` would build a gigantic number.
227
+
228
+ Parses without evaluation and with dummy functions, so the guard itself
229
+ computes nothing; the real (costly) ``parse_expr`` runs only after this check.
230
+ Structurally invalid input we let through — the real parse will report it.
231
+ """
232
+ try:
233
+ tree = parse_expr(
234
+ source,
235
+ local_dict=dict(_GUARD_LOCAL_DICT),
236
+ global_dict=dict(_GUARD_GLOBAL_DICT),
237
+ transformations=_TRANSFORMS,
238
+ evaluate=False,
239
+ )
240
+ except Exception: # noqa: BLE001 — syntax/name error: the real parse handles it
241
+ return
242
+ _check_cost(tree)
243
+
244
+
245
+ def parse(source: str) -> Expr:
246
+ """Parse mathematical text into IR.
247
+
248
+ A syntax error yields a readable ``PycodemathError`` (not a raw
249
+ ``SympifyError``/``SyntaxError``) — the REPL shows a concise message.
250
+
251
+ Security note: the parser resolves ONLY the explicit whitelist of math
252
+ functions and constants (``_LOCAL_DICT``); unknown names become
253
+ symbols, not Python code — ``__import__``/``eval``/``open`` are not
254
+ resolvable, text literals and attribute access are rejected
255
+ at the token level. Honest boundary: this raises the bar (no
256
+ builtin is reachable from text), but evaluation still runs
257
+ through SymPy in this process — this is not a whole-process sandbox.
258
+
259
+ Unknown names: single-letter ``f(x)`` reads as multiplication ``f*x``,
260
+ multi-letter ``foo(x)`` breaks up into a product of letters (``split_symbols``
261
+ from ``implicit_multiplication_application`` — the price of ``2x`` notation);
262
+ names with ``_`` (e.g. ``x_1``) stay a single symbol. None of these
263
+ paths calls Python code.
264
+ Building ``Expr`` is in the same ``try`` because ``sympify`` in
265
+ ``Expr.__init__`` can also raise ``SympifyError`` on the parser's output.
266
+
267
+ Evaluation cost is bounded: expressions that would build a huge
268
+ number (``9**9**9``, ``factorial(10**8)``) are rejected BEFORE SymPy
269
+ computes them — otherwise a single line (reachable directly from the MCP server) could
270
+ hang or OOM the process.
271
+ """
272
+ try:
273
+ _guard_cost(source)
274
+ except _CostExceeded as exc:
275
+ raise PycodemathError(
276
+ f"expression {source!r} is too costly to compute "
277
+ f"({exc}) — the result would exceed the safe size"
278
+ ) from exc
279
+ try:
280
+ sy = parse_expr(
281
+ source,
282
+ local_dict=dict(_LOCAL_DICT),
283
+ global_dict=dict(_GLOBAL_DICT),
284
+ transformations=_TRANSFORMS,
285
+ evaluate=True,
286
+ )
287
+ return Expr(sy)
288
+ # NameError: notation emitting a name outside _GLOBAL_DICT (e.g. lambda);
289
+ # AttributeError: attribute access (x.y) — both are a refusal, not a crash.
290
+ except (
291
+ SympifyError,
292
+ SyntaxError,
293
+ TokenError,
294
+ TypeError,
295
+ ValueError,
296
+ NameError,
297
+ AttributeError,
298
+ ) as exc:
299
+ raise PycodemathError(f"cannot understand expression {source!r}: {exc}") from exc