Source code for ADCS.controller.helpers.optional_dependencies
from __future__ import annotations
import importlib.util
import os
import sys
from importlib import import_module
from types import ModuleType
from typing import Tuple
# When the trajectory_planner add-on lived in-tree, the two compiled
# pybind modules were discoverable as ``trajectory_planner.build.pysat``
# and ``trajectory_planner.build.tplaunch``. The C++ side of
# ``tplaunch`` *hard-codes* that qualified name:
#
# // PyPlanner.cpp
# PYBIND11_MODULE(tplaunch, m) {
# py::module_::import("trajectory_planner.build.pysat");
# ...
#
# That import bootstraps the ``Satellite`` type registration in
# pybind11's process-wide type table *before* ``tplaunch``'s own
# bindings reference the type. If ``tplaunch`` is loaded under a
# different qualified name, the hard-coded import fails silently and
# the next ``import pysat`` triggers a ``generic_type: type "Satellite"
# is already registered`` clash from pybind11.
#
# We now ship the actual ``.so`` files in the OldPlanner submodule
# (``OldPlanner/build/``), but to keep PyPlanner.cpp's ABI contract
# intact we *load* them under the historical qualified names. That's
# what the constants and ``_load_so_under_qualname`` calls below
# encode.
_LEGACY_TP_PKG = "trajectory_planner.build"
_TP_MODULE_NAME = f"{_LEGACY_TP_PKG}.tplaunch"
_PYSAT_MODULE_NAME = f"{_LEGACY_TP_PKG}.pysat"
def _repo_root() -> str:
current_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.abspath(os.path.join(current_dir, "../../.."))
def _ensure_path(path: str) -> None:
if path not in sys.path:
sys.path.append(path)
[docs]
def trajectory_planner_build_path() -> str:
"""Path to the compiled ``tplaunch`` / ``pysat`` pybind modules.
The trajectory-planner add-on lives in the ``OldPlanner`` submodule
(https://github.com/patrickmckeen/OldPlanner), built out-of-tree
into ``OldPlanner/build/``.
"""
return os.path.join(_repo_root(), "OldPlanner", "build")
[docs]
def saltro_build_path() -> str:
return os.path.join(_repo_root(), "SALTRO", "build")
[docs]
def trajectory_planner_missing_reason() -> str:
build_path = trajectory_planner_build_path()
return (
"Optional add-on trajectory_planner (OldPlanner) is not available. "
f"Initialise the submodule and build the C++ bindings into "
f"{build_path}. See docs/Install_Trajectory_Planner.md."
)
[docs]
def saltro_missing_reason() -> str:
build_path = saltro_build_path()
return (
"Optional add-on saltro_py is not available. "
f"Build SALTRO into {build_path}. "
"See docs/Install_SALTRO.md."
)
# trajectory_planner's `pysat` and `SALTRO`'s `saltro_py` are independently
# built pybind11 extensions that BOTH register a C++ type named `Satellite`.
# pybind11 keeps a single process-global type registry, so the second of the
# two to import raises `generic_type: type "Satellite" is already registered!`.
# This is NOT a missing/unbuilt extension -- rebuilding does not help -- so it
# must NOT be reported via *_missing_reason() (which tells the user to build
# the add-on and read the install docs, sending them down a dead end).
_PYBIND_CLASH_MARKER = "already registered"
def _is_pybind_registry_clash(exc: BaseException) -> bool:
"""True iff ``exc`` (or any exception in its cause/context chain) is the
pybind11 process-global type-registry clash between ``pysat`` and
``saltro_py`` (both register a C++ ``Satellite``)."""
seen: set[int] = set()
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
seen.add(id(cur))
if _PYBIND_CLASH_MARKER in str(cur):
return True
cur = cur.__cause__ or cur.__context__
return False
[docs]
def planner_extension_clash_reason() -> str:
return (
"trajectory_planner (pysat/tplaunch) and saltro_py each register a C++ "
"`Satellite` type via pybind11, which keeps a single process-global "
"type registry. They CANNOT be imported into the same Python process: "
"whichever loads second raises "
'`generic_type: type "Satellite" is already registered!`. This is NOT '
"a missing or unbuilt extension -- rebuilding will not help. Use only "
"one planner family per process (e.g. run the SALTRO and "
"plan_and_track controllers in separate processes / Monte-Carlo runs)."
)
def _so_filename(stem: str) -> str:
"""Return the platform-specific filename for a compiled pybind module.
pybind11 emits e.g. ``tplaunch.cpython-312-darwin.so`` /
``tplaunch.cpython-312-x86_64-linux-gnu.so`` -- match by stem.
"""
build_dir = trajectory_planner_build_path()
if os.path.isdir(build_dir):
for fname in os.listdir(build_dir):
if fname.startswith(stem + ".") and (fname.endswith(".so") or fname.endswith(".pyd")):
return fname
return stem + ".so"
def _ensure_parent_namespace_modules(qualname: str) -> None:
"""Insert dummy parent ``ModuleType`` entries into ``sys.modules``.
For ``qualname == "trajectory_planner.build.pysat"`` this seeds
``sys.modules["trajectory_planner"]`` and
``sys.modules["trajectory_planner.build"]`` with stub module
objects, so that pybind11's internal lookup of the parent module
name succeeds when loading the compiled extension.
"""
parts = qualname.split(".")
for i in range(1, len(parts)):
parent = ".".join(parts[:i])
if parent not in sys.modules:
stub = ModuleType(parent)
stub.__path__ = []
sys.modules[parent] = stub
def _load_so_under_qualname(qualname: str, so_filename: str) -> ModuleType:
"""Load a compiled pybind11 module under a chosen qualified name."""
if qualname in sys.modules:
return sys.modules[qualname]
so_path = os.path.join(trajectory_planner_build_path(), so_filename)
if not os.path.exists(so_path):
raise ImportError(f"Compiled module not found: {so_path}")
_ensure_parent_namespace_modules(qualname)
spec = importlib.util.spec_from_file_location(qualname, so_path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not build import spec for {so_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[qualname] = module
try:
spec.loader.exec_module(module)
except Exception:
del sys.modules[qualname]
raise
return module
[docs]
def get_trajectory_planner_modules() -> Tuple[ModuleType, ModuleType]:
"""Return ``(tplaunch, pysat)`` from the OldPlanner submodule build dir.
Load order matters: ``pysat`` must be loaded first so its
``Satellite`` type binding is registered in pybind11's global
type table before ``tplaunch``'s PYBIND11_MODULE runs (which
internally does ``py::module_::import("trajectory_planner.build.pysat")``
and would otherwise either re-register or fail).
"""
try:
pysat = _load_so_under_qualname(_PYSAT_MODULE_NAME, _so_filename("pysat"))
tplaunch = _load_so_under_qualname(_TP_MODULE_NAME, _so_filename("tplaunch"))
except Exception as exc:
if _is_pybind_registry_clash(exc):
raise ImportError(planner_extension_clash_reason()) from exc
raise ImportError(f"{trajectory_planner_missing_reason()} Original error: {exc}") from exc
return tplaunch, pysat
[docs]
def trajectory_planner_available() -> bool:
try:
get_trajectory_planner_modules()
except ImportError:
return False
return True
[docs]
def get_saltro_module() -> ModuleType:
_ensure_path(saltro_build_path())
try:
return import_module("saltro_py")
except Exception as exc:
if _is_pybind_registry_clash(exc):
raise ImportError(planner_extension_clash_reason()) from exc
raise ImportError(f"{saltro_missing_reason()} Original error: {exc}") from exc
[docs]
def saltro_available() -> bool:
try:
get_saltro_module()
except ImportError:
return False
return True