diff --git a/relenv/common.py b/relenv/common.py index 779f1c09..25e14bb5 100644 --- a/relenv/common.py +++ b/relenv/common.py @@ -68,6 +68,9 @@ def _toolchain_cache_root() -> Optional[pathlib.Path]: if override.strip().lower() == "none": return None return pathlib.Path(override).expanduser() + # If RELENV_DATA is set, return None to use DATA_DIR/toolchain + if "RELENV_DATA" in os.environ: + return None cache_home = os.environ.get("XDG_CACHE_HOME") if cache_home: base = pathlib.Path(cache_home) diff --git a/tests/test_common.py b/tests/test_common.py index 217b3e38..84ade7ad 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -464,6 +464,68 @@ def test_makepath_oserror() -> None: assert case == os.path.normcase(expected) +def test_toolchain_respects_relenv_data( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + Test that RELENV_DATA environment variable controls toolchain location. + + This is a regression test for issue #XXX where RELENV_DATA was ignored + in version 0.20.2+ after toolchains were moved to a cache directory. + When RELENV_DATA is set (as in saltstack CI pipelines), the toolchain + should be found in $RELENV_DATA/toolchain, not in the cache directory. + """ + data_dir = tmp_path / "custom_data" + triplet = "x86_64-linux-gnu" + toolchain_path = data_dir / "toolchain" / triplet + toolchain_path.mkdir(parents=True) + + # Patch sys.platform to simulate Linux + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr( + relenv.common, "get_triplet", lambda machine=None, plat=None: triplet + ) + + # Set RELENV_DATA environment variable + monkeypatch.setenv("RELENV_DATA", str(data_dir)) + + # Patch DATA_DIR to reflect the new RELENV_DATA value + monkeypatch.setattr(relenv.common, "DATA_DIR", data_dir) + + # Verify toolchain_root_dir returns DATA_DIR/toolchain + from relenv.common import toolchain_root_dir + + result = toolchain_root_dir() + assert result == data_dir / "toolchain" + + +def test_toolchain_uses_cache_without_relenv_data( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + Test that toolchain uses cache directory when RELENV_DATA is not set. + + When RELENV_DATA is not set, the toolchain should be stored in the + cache directory (~/.cache/relenv/toolchains or $XDG_CACHE_HOME/relenv/toolchains) + to allow reuse across different relenv environments. + """ + cache_dir = tmp_path / ".cache" / "relenv" / "toolchains" + + # Patch sys.platform to simulate Linux + monkeypatch.setattr(sys, "platform", "linux") + + # Remove RELENV_DATA if set + monkeypatch.delenv("RELENV_DATA", raising=False) + + # Set XDG_CACHE_HOME to control cache location + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / ".cache")) + + from relenv.common import toolchain_root_dir + + result = toolchain_root_dir() + assert result == cache_dir + + def test_copyright_headers() -> None: """Verify all Python source files have the correct copyright header.""" expected_header = ( diff --git a/tests/test_runtime.py b/tests/test_runtime.py index b2c8979d..225a5b2e 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1821,3 +1821,169 @@ def __init__(self) -> None: ) monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) assert relenv.runtime.load_openssl_provider("default") == 456 + + +def test_sysconfig_wrapper_applied_for_python_313_plus( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Test that sysconfig wrapper is applied for Python 3.13+. + + This is a regression test for Python 3.13 where sysconfig changed from + a single module to a package. The RelenvImporter no longer intercepts + the import automatically, so we must manually apply the wrapper. + + Without this fix, Python 3.13+ would use the toolchain gcc with full path + even when RELENV_BUILDENV is not set, causing build failures with packages + like mysqlclient that compile native extensions. + """ + # Simulate Python 3.13+ + fake_version = (3, 13, 0, "final", 0) + monkeypatch.setattr(relenv.runtime.sys, "version_info", fake_version) + + # Track whether wrap_sysconfig was called + wrap_called = {"count": 0, "module_name": None} + + def fake_wrap_sysconfig(name: str) -> ModuleType: + wrap_called["count"] += 1 + wrap_called["module_name"] = name + return ModuleType("sysconfig") + + monkeypatch.setattr(relenv.runtime, "wrap_sysconfig", fake_wrap_sysconfig) + + # Mock other dependencies to avoid side effects + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/root")) + monkeypatch.setattr(relenv.runtime, "setup_openssl", lambda: None) + monkeypatch.setattr(relenv.runtime, "setup_crossroot", lambda: None) + monkeypatch.setattr(relenv.runtime, "install_cargo_config", lambda: None) + monkeypatch.setattr( + relenv.runtime.site, "execsitecustomize", lambda: None, raising=False + ) + monkeypatch.setattr( + relenv.runtime, "wrapsitecustomize", lambda func: func, raising=False + ) + + # Mock importer + fake_importer = SimpleNamespace() + monkeypatch.setattr(relenv.runtime, "importer", fake_importer, raising=False) + + # Clear sys.meta_path to avoid side effects + original_meta_path = sys.meta_path.copy() + monkeypatch.setattr(sys, "meta_path", []) + + try: + # Execute the module initialization code at the end of runtime.py + # This simulates what happens when the runtime module is imported + exec( + """ +import sys +sys.RELENV = relenv_root() +setup_openssl() +site.execsitecustomize = wrapsitecustomize(site.execsitecustomize) +setup_crossroot() +install_cargo_config() +sys.meta_path = [importer] + sys.meta_path + +# For Python 3.13+, sysconfig became a package so the importer doesn't +# intercept it. Manually wrap it here. +if sys.version_info >= (3, 13): + wrap_sysconfig("sysconfig") +""", + { + "sys": relenv.runtime.sys, + "relenv_root": relenv.runtime.relenv_root, + "setup_openssl": relenv.runtime.setup_openssl, + "site": relenv.runtime.site, + "wrapsitecustomize": relenv.runtime.wrapsitecustomize, + "setup_crossroot": relenv.runtime.setup_crossroot, + "install_cargo_config": relenv.runtime.install_cargo_config, + "importer": fake_importer, + "wrap_sysconfig": fake_wrap_sysconfig, + }, + ) + + # Verify wrap_sysconfig was called for Python 3.13+ + assert wrap_called["count"] == 1 + assert wrap_called["module_name"] == "sysconfig" + + finally: + # Restore original meta_path + monkeypatch.setattr(sys, "meta_path", original_meta_path) + + +def test_sysconfig_wrapper_not_applied_for_python_312( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Test that sysconfig wrapper is NOT applied for Python 3.12 and earlier. + + For Python 3.12 and earlier, sysconfig is a single module file and the + RelenvImporter intercepts it automatically. We should not manually wrap + it to avoid double-wrapping. + """ + # Simulate Python 3.12 + fake_version = (3, 12, 0, "final", 0) + monkeypatch.setattr(relenv.runtime.sys, "version_info", fake_version) + + # Track whether wrap_sysconfig was called + wrap_called = {"count": 0} + + def fake_wrap_sysconfig(name: str) -> ModuleType: + wrap_called["count"] += 1 + return ModuleType("sysconfig") + + monkeypatch.setattr(relenv.runtime, "wrap_sysconfig", fake_wrap_sysconfig) + + # Mock other dependencies + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/root")) + monkeypatch.setattr(relenv.runtime, "setup_openssl", lambda: None) + monkeypatch.setattr(relenv.runtime, "setup_crossroot", lambda: None) + monkeypatch.setattr(relenv.runtime, "install_cargo_config", lambda: None) + monkeypatch.setattr( + relenv.runtime.site, "execsitecustomize", lambda: None, raising=False + ) + monkeypatch.setattr( + relenv.runtime, "wrapsitecustomize", lambda func: func, raising=False + ) + + fake_importer = SimpleNamespace() + monkeypatch.setattr(relenv.runtime, "importer", fake_importer, raising=False) + + original_meta_path = sys.meta_path.copy() + monkeypatch.setattr(sys, "meta_path", []) + + try: + # Execute the module initialization code + exec( + """ +import sys +sys.RELENV = relenv_root() +setup_openssl() +site.execsitecustomize = wrapsitecustomize(site.execsitecustomize) +setup_crossroot() +install_cargo_config() +sys.meta_path = [importer] + sys.meta_path + +# For Python 3.13+, sysconfig became a package so the importer doesn't +# intercept it. Manually wrap it here. +if sys.version_info >= (3, 13): + wrap_sysconfig("sysconfig") +""", + { + "sys": relenv.runtime.sys, + "relenv_root": relenv.runtime.relenv_root, + "setup_openssl": relenv.runtime.setup_openssl, + "site": relenv.runtime.site, + "wrapsitecustomize": relenv.runtime.wrapsitecustomize, + "setup_crossroot": relenv.runtime.setup_crossroot, + "install_cargo_config": relenv.runtime.install_cargo_config, + "importer": fake_importer, + "wrap_sysconfig": fake_wrap_sysconfig, + }, + ) + + # Verify wrap_sysconfig was NOT called for Python 3.12 + assert wrap_called["count"] == 0 + + finally: + monkeypatch.setattr(sys, "meta_path", original_meta_path)