Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pip install "./vdsim-*.whl[plot]"
vdsim-quickstart # cwd 에 run.csv + run.png 생성
```

깨끗한 환경에 설치할 것 — 최상위 `vdsim` 패키지를 제공하는 다른 배포판이 함께 설치되면
그쪽이 import 를 가져간다. VDSim 은 이를 감지해 실제 로드된 파일 경로와 함께
`CoreShadowedError` 를 던진다.

측정 (clean conda, Python 3.11, Linux x86_64, 2026-06-25): **첫 결과까지 수 초** —
pip install `[plot]` + `vdsim-quickstart` → `run.csv` + `run.png` **~6 s wall-clock**
(cold; lab 네트워크, matplotlib wheel 포함). 재실행 ~1.5 s.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pip install "./vdsim-*.whl[plot]"
vdsim-quickstart # writes run.csv + run.png in the current directory
```

Install into a clean environment: another distribution also provides a top-level
`vdsim` package, and if both are present that one wins the import. VDSim detects
this and raises `CoreShadowedError` naming the file that was loaded.

Measured on a clean conda env (Python 3.11, Linux x86_64, 2026-06-25): **first result in
seconds** — pip install `[plot]` + `vdsim-quickstart` → `run.csv` + `run.png` in **~6 s
wall-clock** (cold; lab network, matplotlib wheel included). Repeat quickstart ~1.5 s.
Expand Down
2 changes: 1 addition & 1 deletion docs/VALIDATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ tests: 505/505
config: cmake --preset validation && cmake --build --preset validation && ctest --preset validation
presets: CMakePresets.json@fb1f0c198a0201fcce2543a9f5b2242a255fb97a
toolchain: cmake 3.31.10
commit: 3a72f3c
commit: 7b7947f
date: 2026-09-22
excluded: gui_v3_e2e (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF)
excluded: gui_v3_api_smoke (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF)
Expand Down
1 change: 1 addition & 0 deletions python/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ if(pybind11_FOUND)
if(DEFINED SKBUILD)
install(TARGETS vdsim_py LIBRARY DESTINATION ".")
install(FILES
${CMAKE_SOURCE_DIR}/python/vdsim_guard.py
${CMAKE_SOURCE_DIR}/python/vdsim_lab.py
${CMAKE_SOURCE_DIR}/python/vdsim_plant.py
${CMAKE_SOURCE_DIR}/python/vdsim_trace.py
Expand Down
119 changes: 119 additions & 0 deletions python/vdsim_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Single check that the name ``vdsim`` resolves to VDSim's compiled core.

Another distribution on PyPI ships a top-level ``vdsim/`` *package*. Installed
into the same environment as our wheel it wins the import over our
``vdsim.cpython-*.so`` -- pip neither warns nor overwrites, so the failure
surfaces much later as an ``AttributeError`` on an unrelated line. The modules
that need the core therefore call :func:`load_core` instead of ``import
vdsim``; the decision itself lives here once so the modules cannot drift.

The check is structural on purpose: it asks whether the loaded module is a
compiled extension exposing the core API, never which distribution shipped it.
"""
from __future__ import annotations

import importlib
import importlib.machinery
from pathlib import Path

__all__ = ["CoreShadowedError", "check_core", "load_core", "diagnose"]


class CoreShadowedError(ImportError):
"""``vdsim`` resolved to something that is not VDSim's compiled core."""


#: Symbols every build of the core exposes (pybind11 ``cosim/bindings.cpp``).
#: Checked as a set so a foreign module cannot pass by coincidence on one name.
EXPECTED_SYMBOLS = ("SimSession", "VehicleParams", "TireParams", "make_sim_session")


def diagnose(mod):
"""Return ``None`` if *mod* is the compiled core, else a one-line reason.

:param mod: the module object bound to the name ``vdsim``.
:returns: ``None`` when the module passes both checks, otherwise a short
phrase naming which property failed.
"""
origin = getattr(mod, "__file__", None)
if origin is None:
return "it has no __file__ (a namespace package, not an extension module)"
if not any(str(origin).endswith(s)
for s in importlib.machinery.EXTENSION_SUFFIXES):
return "it is Python source, not a compiled extension module"
missing = [s for s in EXPECTED_SYMBOLS if not hasattr(mod, s)]
if missing:
return "it is missing core symbols: %s" % ", ".join(
"vdsim." + s for s in missing)
return None


def _message(mod, reason):
origin = getattr(mod, "__file__", None) or "<no __file__>"
return (
"'import vdsim' loaded %s -- %s.\n"
"Cause: another installed distribution provides a top-level 'vdsim' "
"package, which shadows VDSim's vdsim.cpython-*.so on sys.path "
"(pip does not warn about this).\n"
"Fix: uninstall the shadowing distribution "
"(e.g. `pip uninstall vehicle-dynamics-sim`) or install VDSim into "
"its own virtual environment." % (origin, reason)
)


def check_core(mod):
"""Return *mod* if it is the compiled core, else raise.

:param mod: the module object bound to the name ``vdsim``.
:raises CoreShadowedError: when :func:`diagnose` reports a mismatch.
"""
reason = diagnose(mod)
if reason is not None:
raise CoreShadowedError(_message(mod, reason))
return mod


def load_core(fallback_paths=()):
"""Import ``vdsim`` and return it only if it is the compiled core.

*fallback_paths* are development build directories tried, in order, when
the ambient import is missing **or** shadowed; this keeps a repo checkout
working even in an environment that has the shadowing package installed.

:param fallback_paths: directories prepended to ``sys.path`` on retry.
:returns: the compiled ``vdsim`` extension module.
:raises CoreShadowedError: the name resolves to a foreign module and no
fallback path yields the core.
:raises ImportError: ``vdsim`` is not importable at all.
"""
import sys

first_error = None
try:
mod = importlib.import_module("vdsim")
except ImportError as exc:
mod, first_error = None, exc
if mod is not None and diagnose(mod) is None:
return mod

for raw in fallback_paths:
path = str(raw)
if not Path(path).is_dir():
continue
while path in sys.path:
sys.path.remove(path)
sys.path.insert(0, path)
sys.modules.pop("vdsim", None)
importlib.invalidate_caches()
try:
candidate = importlib.import_module("vdsim")
except ImportError:
continue
if diagnose(candidate) is None:
return candidate
mod = candidate

if mod is None:
raise first_error
return check_core(mod)
8 changes: 3 additions & 5 deletions python/vdsim_lab.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
try:
import vdsim
except ImportError:
sys.path.insert(0, str(REPO / "build" / "python"))
import vdsim
from vdsim_guard import load_core # noqa: E402 (shadowed-core check, one place)

vdsim = load_core([REPO / "build" / "python"])


def _conf_root():
Expand Down
8 changes: 3 additions & 5 deletions python/vdsim_plant.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,9 @@
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
try:
import vdsim
except ImportError:
sys.path.insert(0, str(REPO / "build" / "python"))
import vdsim
from vdsim_guard import load_core # noqa: E402 (shadowed-core check, one place)

vdsim = load_core([REPO / "build" / "python"])

def _conf_root():
for c in (REPO / "configs", Path.cwd() / "configs",
Expand Down
4 changes: 3 additions & 1 deletion python/vdsim_rl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@

import numpy as np

import vdsim
from vdsim_guard import load_core

vdsim = load_core()

__all__ = [
"EnvConfig", "VDSimVecEnv", "VDSimEnv", "make_sb3_vec_env",
Expand Down
71 changes: 71 additions & 0 deletions tests/scripts/test_experiment_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,74 @@ def test_ref_point_position():
assert sim_u._ref == [1.0, 0.5]


# --- Q21: the name `vdsim` must resolve to the compiled core -----------------
# An unrelated distribution ships a top-level `vdsim/` package; installed beside
# our wheel it wins the import silently. vdsim_guard makes that an error.

def _shadow_probe(body, shadow_dir, extra_path=()):
"""Run *body* in a child whose sys.path starts with a fake `vdsim` package."""
import os
import subprocess
env = dict(os.environ)
env["PYTHONPATH"] = os.pathsep.join(
[str(shadow_dir), str(REPO / "python")] + [str(p) for p in extra_path])
return subprocess.run([sys.executable, "-c", body], env=env,
capture_output=True, text=True)


def _make_shadow(td):
pkg = Path(td) / "vdsim"
pkg.mkdir()
(pkg / "__init__.py").write_text("VERSION = 'foreign package'\n")
return Path(td)


def test_shadowed_core_is_rejected():
body = ("import sys, vdsim_guard\n"
"try:\n"
" vdsim_guard.load_core([])\n"
"except vdsim_guard.CoreShadowedError as exc:\n"
" print(exc); sys.exit(0)\n"
"sys.exit('no error raised')\n")
with tempfile.TemporaryDirectory() as td:
shadow = _make_shadow(td)
r = _shadow_probe(body, shadow)
assert r.returncode == 0, f"shadowed vdsim was accepted: {r.stdout}{r.stderr}"
msg = r.stdout
assert str(shadow / "vdsim") in msg, f"message must name the real load path: {msg}"
assert "Cause:" in msg and "Fix:" in msg, f"message needs cause + remedy: {msg}"


def test_shadowed_core_recovers_from_build_dir():
"""A dev checkout still works: the explicit build path wins over the shadow."""
import os
build = Path(os.environ.get("VDSIM_BUILD_DIR", REPO / "build")) / "python"
if not build.is_dir():
build = REPO / "build" / "python"
assert build.is_dir(), f"no compiled core to fall back to ({build})"
body = ("import sys, vdsim_guard\n"
"m = vdsim_guard.load_core([sys.argv[1]])\n"
"assert hasattr(m, 'SimSession'), m\n"
"print(m.__file__)\n")
with tempfile.TemporaryDirectory() as td:
shadow = _make_shadow(td)
import os as _os
import subprocess
env = dict(_os.environ)
env["PYTHONPATH"] = _os.pathsep.join([str(shadow), str(REPO / "python")])
r = subprocess.run([sys.executable, "-c", body, str(build)],
env=env, capture_output=True, text=True)
assert r.returncode == 0, f"fallback failed: {r.stdout}{r.stderr}"
assert str(shadow) not in r.stdout, r.stdout


def test_real_core_passes_the_check():
import vdsim
import vdsim_guard
assert vdsim_guard.diagnose(vdsim) is None, vdsim.__file__
assert vdsim_guard.check_core(vdsim) is vdsim


if __name__ == "__main__":
test_throttle_then_brake()
test_step_steer_yaws()
Expand All @@ -159,4 +227,7 @@ def test_ref_point_position():
test_register_metric()
test_plot_comparison()
test_ref_point_position()
test_shadowed_core_is_rejected()
test_shadowed_core_recovers_from_build_dir()
test_real_core_passes_the_check()
print("OK test_experiment_api")
Loading