diff --git a/spatialmath/base/argcheck.py b/spatialmath/base/argcheck.py index 0db73497..8feb24d5 100644 --- a/spatialmath/base/argcheck.py +++ b/spatialmath/base/argcheck.py @@ -16,10 +16,10 @@ from collections.abc import Iterable # from spatialmath.base import symbolic as sym # HACK -from spatialmath.base.symbolic import issymbol, symtype +from spatialmath.base.symbolic import issymbol # valid scalar types -_scalartypes = (int, np.integer, float, np.floating) + symtype +_scalartypes = (int, np.integer, float, np.floating) # from typing import Union, List, Tuple, Any, Optional, Type, Callable # from numpy.typing import DTypeLike @@ -47,7 +47,9 @@ def isscalar(x: Any) -> bool: >>> isscalar([1]) """ - return isinstance(x, _scalartypes) + return isinstance(x, _scalartypes) or ( + not isinstance(x, (list, tuple)) and issymbol(x) + ) def isinteger(x: Any) -> bool: @@ -386,7 +388,7 @@ def getvector( """ dt = dtype - if isinstance(v, _scalartypes): # handle scalar case + if isscalar(v): # handle scalar case v = [v] # type: ignore if isinstance(v, (list, tuple)): # list or tuple was passed in @@ -501,7 +503,7 @@ def isvector(v: Any, dim: Optional[int] = None) -> bool: if ( isinstance(v, (list, tuple)) and (dim is None or len(v) == dim) - and all(map(lambda x: isinstance(x, _scalartypes), v)) + and all(map(isscalar, v)) ): return True # list or tuple @@ -516,7 +518,7 @@ def isvector(v: Any, dim: Optional[int] = None) -> bool: else: return s == (dim,) or s == (1, dim) or s == (dim, 1) - if (dim is None or dim == 1) and isinstance(v, _scalartypes): + if (dim is None or dim == 1) and isscalar(v): return True return False @@ -620,11 +622,7 @@ def isnumberlist(x: Any) -> bool: >>> isnumberlist(np.r_[1,2]) """ - return ( - isinstance(x, (list, tuple)) - and len(x) > 0 - and all(map(lambda x: isinstance(x, _scalartypes), x)) - ) + return isinstance(x, (list, tuple)) and len(x) > 0 and all(map(isscalar, x)) def isvectorlist(x: Any, n: int) -> bool: diff --git a/spatialmath/base/symbolic.py b/spatialmath/base/symbolic.py index aebb827a..2031b3c1 100644 --- a/spatialmath/base/symbolic.py +++ b/spatialmath/base/symbolic.py @@ -11,61 +11,67 @@ supported. """ +from __future__ import annotations + import math +from typing import TYPE_CHECKING from spatialmath.base.types import * -try: # pragma: no cover - # print('Using SymPy') - import sympy +import importlib as _importlib +import sys as _sys - _symbolics = True - symtype = (sympy.Expr,) +if TYPE_CHECKING: from sympy import Symbol -except ImportError: # pragma: no cover - # SymPy is not installed - _symbolics = False - symtype = () - Symbol = Any +def _sympy(): + """Load the optional dependency only for a symbolic operation.""" + return _importlib.import_module("sympy") -# ---------------------------------------------------------------------------------------# -if _symbolics: +def __getattr__(name): + """Keep explicit access to the SymPy type aliases lazy.""" + if name == "Symbol": + return _sympy().Symbol + if name == "symtype": + try: + return (_sympy().Expr,) + except ImportError: + return () + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - def symbol( - name: str, real: Optional[bool] = True - ) -> Union[Symbol, Tuple[Symbol, ...]]: - """ - Create symbolic variables - :param name: symbol names - :type name: str - :param real: assume variable is real, defaults to True - :type real: bool, optional - :return: SymPy symbols - :rtype: sympy +def symbol(name: str, real: Optional[bool] = True) -> Union[Symbol, Tuple[Symbol, ...]]: + """ + Create symbolic variables - .. runblock:: pycon + :param name: symbol names + :type name: str + :param real: assume variable is real, defaults to True + :type real: bool, optional + :return: SymPy symbols + :rtype: sympy - >>> from spatialmath.base.symbolic import * - >>> theta = symbol('theta') - >>> theta - >>> theta, psi = symbol('theta psi') - >>> theta - >>> psi - >>> q = symbol('q_:6') - >>> q + .. runblock:: pycon + + >>> from spatialmath.base.symbolic import * + >>> theta = symbol('theta') + >>> theta + >>> theta, psi = symbol('theta psi') + >>> theta + >>> psi + >>> q = symbol('q_:6') + >>> q - .. note:: In Jupyter symbols are pretty printed. + .. note:: In Jupyter symbols are pretty printed. - - symbols named after greek letters will appear as greek letters - - underscore means subscript as it does in LaTex, so the symbols ``q`` - above will be subscripted. + - symbols named after greek letters will appear as greek letters + - underscore means subscript as it does in LaTex, so the symbols ``q`` + above will be subscripted. - :seealso: :func:`sympy.symbols` - """ - return sympy.symbols(name, real=real) + :seealso: :func:`sympy.symbols` + """ + return _sympy().symbols(name, real=real) def issymbol(var: Any) -> bool: @@ -84,13 +90,12 @@ def issymbol(var: Any) -> bool: >>> issymbol(3.4) """ - if _symbolics: - if isinstance(var, (list, tuple)): - return any([isinstance(x, symtype) for x in var]) - else: - return isinstance(var, symtype) - else: + sympy = _sys.modules.get("sympy") + if sympy is None: return False + if isinstance(var, (list, tuple)): + return any(isinstance(x, sympy.Expr) for x in var) + return isinstance(var, sympy.Expr) @overload @@ -122,7 +127,7 @@ def sin(theta): :seealso: :func:`sympy.sin` """ if issymbol(theta): - return sympy.sin(theta) + return _sympy().sin(theta) else: return math.sin(theta) @@ -156,7 +161,7 @@ def cos(theta): :seealso: :func:`sympy.cos` """ if issymbol(theta): - return sympy.cos(theta) + return _sympy().cos(theta) else: return math.cos(theta) @@ -190,7 +195,7 @@ def tan(theta): :seealso: :func:`sympy.cos` """ if issymbol(theta): - return sympy.tan(theta) + return _sympy().tan(theta) else: return math.tan(theta) @@ -224,7 +229,7 @@ def sqrt(v): :seealso: :func:`sympy.sqrt` """ if issymbol(v): - return sympy.sqrt(v) + return _sympy().sqrt(v) else: return math.sqrt(v) @@ -245,7 +250,7 @@ def zero() -> Symbol: :seealso: :func:`sympy.S.Zero` """ - return sympy.S.Zero + return _sympy().S.Zero def one() -> Symbol: @@ -264,7 +269,7 @@ def one() -> Symbol: :seealso: :func:`sympy.S.One` """ - return sympy.S.One + return _sympy().S.One def negative_one() -> Symbol: @@ -283,26 +288,27 @@ def negative_one() -> Symbol: :seealso: :func:`sympy.S.NegativeOne` """ - return sympy.S.NegativeOne + return _sympy().S.NegativeOne def pi() -> Symbol: """ - Symbolic constant: pi + Symbolic constant: pi - :return: π - :rtype: symbolic + :return: π + :rtype: symbolic - .. runblock:: pycon + .. runblock:: pycon - >>> from spatialmath.base.symbolic import * - >>> import math - >>> sin(pi()) - >>> sin(math.pi) + >>> from spatialmath.base.symbolic import * + >>> import math + from typing import TYPE_CHECKING + >>> sin(pi()) + >>> sin(math.pi) - :seealso: :func:`sympy.S.Pi` + :seealso: :func:`sympy.S.Pi` """ - return sympy.S.Pi + return _sympy().S.Pi def simplify(x: Symbol) -> Symbol: @@ -324,10 +330,11 @@ def simplify(x: Symbol) -> Symbol: :seealso: :func:`sympy.simplify` """ - if _symbolics: - return sympy.simplify(x) - else: + try: + sympy = _sympy() + except ImportError: return x + return sympy.simplify(x) def det(x): @@ -352,4 +359,4 @@ def det(x): .. note:: Converts to a SymPy ``Matrix`` and then back again. """ - return sympy.Matrix(x).det() + return _sympy().Matrix(x).det() diff --git a/spatialmath/base/transforms2d.py b/spatialmath/base/transforms2d.py index 6aad235c..fe48fa6d 100644 --- a/spatialmath/base/transforms2d.py +++ b/spatialmath/base/transforms2d.py @@ -39,15 +39,6 @@ _eps = np.finfo(np.float64).eps -try: # pragma: no cover - # print('Using SymPy') - import sympy - - _symbolics = True - -except ImportError: # pragma: no cover - _symbolics = False - # ---------------------------------------------------------------------------------------# def rot2(theta: float, unit: str = "rad") -> SO2Array: @@ -170,8 +161,13 @@ def tr2xyt(T: SE2Array, unit: str = "rad") -> R3: :seealso: trot2 """ - if T.dtype == "O" and _symbolics: - angle = sympy.atan2(T[1, 0], T[0, 0]) + if T.dtype == "O": + try: + import sympy + except ImportError: + angle = math.atan2(T[1, 0], T[0, 0]) + else: + angle = sympy.atan2(T[1, 0], T[0, 0]) else: angle = math.atan2(T[1, 0], T[0, 0]) return np.r_[T[0, 2], T[1, 2], angle] diff --git a/spatialmath/base/transformsNd.py b/spatialmath/base/transformsNd.py index 673c079a..0b23c078 100644 --- a/spatialmath/base/transformsNd.py +++ b/spatialmath/base/transformsNd.py @@ -22,14 +22,7 @@ # from spatialmath.base.transforms2d import transl2 -try: # pragma: no cover - # print('Using SymPy') - from sympy import Matrix - - _symbolics = True - -except ImportError: # pragma: no cover - _symbolics = False +from spatialmath.base import symbolic as sym _eps = np.finfo(np.float64).eps @@ -905,10 +898,12 @@ def det(m: np.ndarray) -> float: :SymPy: supported """ - if m.dtype.kind == "O" and _symbolics: - return Matrix(m).det() - else: - return np.linalg.det(m) + if m.dtype.kind == "O": + try: + return sym.det(m) + except ImportError: + pass + return np.linalg.det(m) if __name__ == "__main__": # pragma: no cover diff --git a/spatialmath/base/vectors.py b/spatialmath/base/vectors.py index 59c61797..4f40acad 100644 --- a/spatialmath/base/vectors.py +++ b/spatialmath/base/vectors.py @@ -16,14 +16,7 @@ from spatialmath.base.argcheck import getvector from spatialmath.base.types import * -try: # pragma: no cover - # print('Using SymPy') - import sympy - - _symbolics = True - -except ImportError: # pragma: no cover - _symbolics = False +from spatialmath.base import symbolic as sym _eps = np.finfo(np.float64).eps @@ -55,8 +48,8 @@ def norm(v: ArrayLikePure) -> float: for x in v: sum += x * x - if _symbolics and isinstance(sum, sympy.Expr): - return sympy.sqrt(sum) + if sym.issymbol(sum): + return sym.sqrt(sum) else: return math.sqrt(sum) diff --git a/tests/test_lazy_sympy.py b/tests/test_lazy_sympy.py new file mode 100644 index 00000000..6b5c4c1b --- /dev/null +++ b/tests/test_lazy_sympy.py @@ -0,0 +1,111 @@ +"""Regression tests for optional SymPy loading in fresh interpreters.""" + +import subprocess +import sys +import textwrap + +import pytest + + +def run_python(source): + """Run an isolated check with the same interpreter as pytest.""" + subprocess.run([sys.executable, "-c", textwrap.dedent(source)], check=True) + + +def test_numeric_import_does_not_load_sympy(): + run_python( + """ + import sys + import numpy as np + import spatialmath + from spatialmath import base as smb + assert not any(n == 'sympy' or n.startswith('sympy.') for n in sys.modules) + assert smb.isscalar(1.0) + assert smb.isvector([1, 2]) + assert smb.isnumberlist([1, 2]) + assert smb.norm([3, 4]) == 5 + assert np.allclose(smb.rot2(0), np.eye(2)) + assert not any(n == 'sympy' or n.startswith('sympy.') for n in sys.modules) + """ + ) + + +@pytest.mark.parametrize("sympy_first", [False, True]) +def test_symbolic_operations_in_both_import_orders(sympy_first): + prefix = "import sympy as sp\n" if sympy_first else "" + run_python( + prefix + + """ +import numpy as np +from spatialmath import base as smb +import sympy as sp +x = sp.Symbol('x', real=True) +assert smb.issymbol(x) +assert smb.issymbol([1, x]) +assert smb.issymbol((x, 1)) +assert not smb.issymbol([1, 2]) +assert smb.isscalar(x) +assert not smb.isscalar([x]) +assert not smb.isscalar((x,)) +assert smb.isvector([x, 1]) +assert smb.isnumberlist([x, 1]) +assert smb.getvector(x)[0] == x +assert smb.getvector([x, 1])[0] == x +assert smb.sin(x) == sp.sin(x) +assert smb.cos(x) == sp.cos(x) +assert smb.tan(x) == sp.tan(x) +assert smb.sqrt(x) == sp.sqrt(x) +assert smb.norm([x, 1]) == sp.sqrt(x*x + 1) +assert smb.det(np.array([[x, 0], [0, 1]], dtype=object)) == x +T = smb.trot2(x) +assert sp.simplify(smb.tr2xyt(T)[2] - sp.atan2(sp.sin(x), sp.cos(x))) == 0 +assert smb.simplify(sp.sin(x)**2 + sp.cos(x)**2) == 1 +""" + ) + + +@pytest.mark.parametrize("helper", ["symbol", "zero", "one", "negative_one", "pi"]) +def test_explicit_symbolic_helpers_load_sympy(helper): + run_python( + textwrap.dedent(f"helper = {helper!r}\n") + + textwrap.dedent( + """ + import sys + from spatialmath.base import symbolic as sym + assert 'sympy' not in sys.modules + getattr(sym, helper)(*(['x'] if helper == 'symbol' else [])) + assert 'sympy' in sys.modules + x = sym.symbol('x') + import sympy as sp + assert isinstance(x, sp.Symbol) + from spatialmath.base.symbolic import Symbol, symtype + assert Symbol is sp.Symbol + assert symtype == (sp.Expr,) + assert sym.zero() == sp.S.Zero + assert sym.one() == sp.S.One + assert sym.negative_one() == sp.S.NegativeOne + assert sym.pi() == sp.pi + """ + ) + ) + + +def test_numeric_operations_without_sympy(): + run_python( + """ + import importlib.abc + import sys + class BlockSympy(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == 'sympy' or fullname.startswith('sympy.'): + raise ModuleNotFoundError('SymPy blocked for this test') + sys.meta_path.insert(0, BlockSympy()) + from spatialmath import base as smb + assert not smb.issymbol(1) + assert smb.norm([3, 4]) == 5 + assert smb.sin(0) == 0 + assert smb.simplify(2) == 2 + assert smb.isscalar(1) + assert smb.isvector([1, 2]) + """ + )