# ----------------------------------------------------------------------------
# SymForce - Copyright 2022, Skydio, Inc.
# This source code is under the Apache 2.0 license found in the LICENSE file.
# ----------------------------------------------------------------------------
"""
The top-level symforce package
Importing this by itself performs minimal initialization configuration, and the functions here are
mostly for configuration purposes.
In particular, this primarily performs configuration that you might need before importing
:mod:`symforce.symbolic`.
"""
import os
import sys
import typing as T
from dataclasses import dataclass
from types import ModuleType
# -------------------------------------------------------------------------------------------------
# Version
# -------------------------------------------------------------------------------------------------
# isort: split
from ._version import version as __version__
# -------------------------------------------------------------------------------------------------
# Logging configuration
# -------------------------------------------------------------------------------------------------
# isort: split
import logging
# Create a logger with this print format
LOGGING_FORMAT = "%(module)s.%(funcName)s():%(lineno)s %(levelname)s -- %(message)s"
logging.basicConfig(format=LOGGING_FORMAT)
logger = logging.getLogger(__package__)
[docs]def set_log_level(log_level: str) -> None:
"""
Set symforce logger level.
The default is INFO, but can be set by one of:
1) The SYMFORCE_LOGLEVEL environment variable
2) Calling this function before any other symforce imports
Args:
log_level: {DEBUG, INFO, WARNING, ERROR, CRITICAL}
"""
# Set default log level
if not hasattr(logging, log_level.upper()):
raise RuntimeError(f'Unknown log level: "{log_level}"')
logger.setLevel(getattr(logging, log_level.upper()))
# Only do this if already imported, in case users don't want to use any C++ binaries
if "cc_sym" in sys.modules:
import cc_sym
cc_sym.set_log_level(log_level)
# Set default
set_log_level(os.environ.get("SYMFORCE_LOGLEVEL", "INFO"))
# -------------------------------------------------------------------------------------------------
# Symbolic API configuration
# -------------------------------------------------------------------------------------------------
[docs]class InvalidSymbolicApiError(Exception):
def __init__(self, api: str):
super().__init__(f'Symbolic API is "{api}", must be one of ("sympy", "symengine")')
def _find_symengine() -> ModuleType:
"""
Attempts to import symengine from its location in the symforce build directory
If symengine is already in sys.modules, will return that module. If symengine cannot be
imported, raises ImportError.
Returns the imported symengine module
"""
if "symengine" in sys.modules:
return sys.modules["symengine"]
try:
# If symengine is available on python path, use it
# TODO(will, aaron): this might not be the version of symengine that we want
import symengine
return symengine
except ImportError as ex:
import importlib
import importlib.abc
import importlib.util
from . import path_util
try:
symengine_install_dir = path_util.symenginepy_install_dir()
except path_util.MissingManifestException:
raise ImportError(
"Unable to import SymEngine, either installed or in the manifest.json"
) from ex
symengine_path_candidates = list(
symengine_install_dir.glob("lib/python3*/site-packages/symengine/__init__.py")
) + list(
symengine_install_dir.glob("local/lib/python3*/dist-packages/symengine/__init__.py")
)
if len(symengine_path_candidates) != 1:
raise ImportError(
f"Should be exactly one symengine package, found candidates {symengine_path_candidates} in directory {path_util.symenginepy_install_dir()}"
) from ex
symengine_path = symengine_path_candidates[0]
# Import symengine from the directory where we installed it. See
# https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
spec = importlib.util.spec_from_file_location("symengine", symengine_path)
assert spec is not None
symengine = importlib.util.module_from_spec(spec)
sys.modules["symengine"] = symengine
# For mypy: https://github.com/python/typeshed/issues/2793
assert isinstance(spec.loader, importlib.abc.Loader)
try:
spec.loader.exec_module(symengine)
except: # pylint: disable=bare-except
# If executing the module fails for any reason, it shouldn't be in `sys.modules`
del sys.modules["symengine"]
raise
return symengine
_symbolic_api: T.Optional[T.Literal["sympy", "symengine"]] = None
_have_imported_symbolic = False
def _set_symbolic_api(sympy_module: T.Literal["sympy", "symengine"]) -> None:
# Set this as the default symbolic API
global _symbolic_api # pylint: disable=global-statement
_symbolic_api = sympy_module
def _use_symengine() -> None:
try:
_find_symengine()
except ImportError:
logger.critical("Commanded to use symengine, but failed to import.")
raise
_set_symbolic_api("symengine")
def _use_sympy() -> None:
# Import just to make sure it's importable and fail here if it's not (as opposed to failing
# later)
import sympy as sympy_py # pylint: disable=unused-import
_set_symbolic_api("sympy")
[docs]def set_symbolic_api(name: str) -> None:
"""
Set the symbolic API for symforce
See the SymPy tutorial for information on the symbolic APIs that can be used:
https://symforce.org/tutorials/sympy_tutorial.html
By default, SymForce will use the ``symengine`` API if it is available. If the symbolic API is
set to ``sympy`` it will use that. If ``symengine`` is not available and the symbolic API was
not set, it will emit a warning and use the ``sympy`` API.
The symbolic API can be set by one of:
1) The ``SYMFORCE_SYMBOLIC_API`` environment variable
2) Calling this function before any other symforce imports
Args:
name: {sympy, symengine}
"""
if _have_imported_symbolic and name != _symbolic_api:
raise ValueError(
"The symbolic API cannot be changed after `symforce.symbolic` has been imported. "
"Import the top-level `symforce` module and call `symforce.set_symbolic_api` before "
"importing anything else!"
)
if _symbolic_api is not None and name == _symbolic_api:
logger.debug(f'already on symbolic API "{name}"')
return
else:
logger.debug(f'symbolic API: "{name}"')
if name == "sympy":
_use_sympy()
elif name == "symengine":
_use_symengine()
else:
raise NotImplementedError(f'Unknown symbolic API: "{name}"')
# Set default to symengine if available, else sympy
if "SYMFORCE_SYMBOLIC_API" in os.environ:
set_symbolic_api(os.environ["SYMFORCE_SYMBOLIC_API"])
else:
try:
_find_symengine()
logger.debug("No SYMFORCE_SYMBOLIC_API set, found and using symengine.")
set_symbolic_api("symengine")
except ImportError:
logger.debug("No SYMFORCE_SYMBOLIC_API set, no symengine found. Will use sympy.")
pass
[docs]def get_symbolic_api() -> T.Literal["sympy", "symengine"]:
"""
Return the current symbolic API as a string.
"""
return _symbolic_api or "sympy"
# --------------------------------------------------------------------------------
# Default epsilon
# --------------------------------------------------------------------------------
# Should match C++ default epsilon in epsilon.h
numeric_epsilon = 10 * sys.float_info.epsilon
[docs]class AlreadyUsedEpsilon(Exception):
"""
Exception thrown on attempting to modify the default epsilon after it has been used elsewhere
"""
pass
_epsilon: T.Any = 0.0
_have_used_epsilon = False
def _set_epsilon(new_epsilon: T.Any) -> None:
"""
Set the default epsilon for SymForce
This must be called before :mod:`symforce.symbolic` or other symbolic libraries have been
imported. Typically it should be set to some kind of Scalar, such as an int, float, or Symbol.
See :func:`symforce.symbolic.epsilon` for more information.
Args:
new_epsilon: The new default epsilon to use
"""
global _epsilon # pylint: disable=global-statement
if _have_used_epsilon and new_epsilon != _epsilon:
raise AlreadyUsedEpsilon(
f"Cannot set return value of epsilon to {new_epsilon} after it has already been "
f"accessed with value {_epsilon}."
)
_epsilon = new_epsilon
[docs]@dataclass
class SymbolicEpsilon:
"""
An indicator that SymForce should use a symbolic epsilon
"""
name: str
[docs]def set_epsilon_to_symbol(name: str = "epsilon") -> None:
"""
Set the default epsilon for Symforce to a Symbol.
This must be called before :mod:`symforce.symbolic` or other symbolic libraries have been
imported. See :func:`symforce.symbolic.epsilon` for more information.
Args:
name: The name of the symbol for the new default epsilon to use
"""
_set_epsilon(SymbolicEpsilon(name))
[docs]def set_epsilon_to_number(value: T.Any = numeric_epsilon) -> None:
"""
Set the default epsilon for Symforce to a number.
This must be called before :mod:`symforce.symbolic` or other symbolic libraries have been
imported. See :func:`symforce.symbolic.epsilon` for more information.
Args:
value: The new default epsilon to use
"""
_set_epsilon(value)
[docs]def set_epsilon_to_zero() -> None:
"""
Set the default epsilon for Symforce to zero.
This must be called before :mod:`symforce.symbolic` or other symbolic libraries have been
imported. See :func:`symforce.symbolic.epsilon` for more information.
"""
_set_epsilon(0.0)
[docs]def set_epsilon_to_invalid() -> None:
"""
Set the default epsilon for SymForce to ``None``. Should not be used to actually create
expressions or generate code.
This is useful if you've forgotten to pass an epsilon somewhere, but are not sure where - using
this epsilon in an expression should throw a ``TypeError`` near the location where you forgot to
pass an epsilon.
This must be called before :mod:`symforce.symbolic` or other symbolic libraries have been
imported. See :func:`symforce.symbolic.epsilon` for more information.
"""
_set_epsilon(None)