|
1 | 1 | import os |
| 2 | +import shutil |
2 | 3 | import subprocess |
3 | 4 | import sys |
| 5 | +import tempfile |
4 | 6 | import warnings |
| 7 | +from pathlib import Path |
5 | 8 |
|
6 | 9 | from testutils import assert_raises |
7 | 10 |
|
@@ -192,3 +195,132 @@ def test_getframemodulename(): |
192 | 195 | del os.environ["PYTHONBREAKPOINT"] |
193 | 196 | else: |
194 | 197 | os.environ["PYTHONBREAKPOINT"] = saved_breakpoint_env |
| 198 | + |
| 199 | + |
| 200 | +# A venv's symlink target takes precedence over home/<invoked name>. |
| 201 | +if os.name == "posix" and sys.platform not in ("wasi", "emscripten"): |
| 202 | + with tempfile.TemporaryDirectory() as directory: |
| 203 | + root = Path(directory).resolve() |
| 204 | + interpreter = Path(sys.executable).resolve() |
| 205 | + # A framework build can also run its embedded app directly. Match the |
| 206 | + # installed launcher's file identity before applying launcher semantics. |
| 207 | + framework_base = ( |
| 208 | + Path(sys.base_prefix) |
| 209 | + / "bin" |
| 210 | + / f"python{sys.version_info.major}.{sys.version_info.minor}" |
| 211 | + ) |
| 212 | + framework_launcher = ( |
| 213 | + sys.platform == "darwin" |
| 214 | + and bool(getattr(sys, "_framework", "")) |
| 215 | + and framework_base.is_file() |
| 216 | + and interpreter.samefile(framework_base) |
| 217 | + ) |
| 218 | + home = root / "home" |
| 219 | + home.mkdir() |
| 220 | + venv_env = dict(os.environ) |
| 221 | + for name in ("PYTHONHOME", "PYTHONEXECUTABLE", "__PYVENV_LAUNCHER__"): |
| 222 | + venv_env.pop(name, None) |
| 223 | + # Keep startup imports available even with the synthetic home directory. |
| 224 | + venv_env["PYTHONPATH"] = os.pathsep.join(sys.path) |
| 225 | + path_code = ( |
| 226 | + "import sys; print(sys.executable); " |
| 227 | + "print(sys._base_executable); print(sys.prefix)" |
| 228 | + ) |
| 229 | + |
| 230 | + def executable_paths(executable, *, cwd=None, env=venv_env): |
| 231 | + result = subprocess.run( |
| 232 | + [str(executable), "-B", "-S", "-c", path_code], |
| 233 | + cwd=cwd, |
| 234 | + env=env, |
| 235 | + capture_output=True, |
| 236 | + text=True, |
| 237 | + timeout=10, |
| 238 | + ) |
| 239 | + assert result.returncode == 0, result.stderr |
| 240 | + return result.stdout.splitlines() |
| 241 | + |
| 242 | + for kind in ("absolute", "relative", "chain"): |
| 243 | + bin_dir = root / kind / "bin" |
| 244 | + bin_dir.mkdir(parents=True) |
| 245 | + # Exercise both parent and adjacent pyvenv.cfg discovery. |
| 246 | + prefix = bin_dir if kind == "chain" else bin_dir.parent |
| 247 | + (prefix / "pyvenv.cfg").write_text(f"home = {home}\n") |
| 248 | + executable = bin_dir / kind |
| 249 | + # This regular file must never be preferred to a symlink's target. |
| 250 | + (home / kind).touch() |
| 251 | + if kind == "absolute": |
| 252 | + executable.symlink_to(interpreter) |
| 253 | + elif kind == "relative": |
| 254 | + executable.symlink_to(os.path.relpath(interpreter, bin_dir)) |
| 255 | + else: |
| 256 | + (bin_dir / "target").symlink_to(interpreter) |
| 257 | + executable.symlink_to("target") |
| 258 | + invocation = f"./bin/{kind}" if kind == "relative" else executable |
| 259 | + assert executable_paths(invocation, cwd=bin_dir.parent) == [ |
| 260 | + str(executable), |
| 261 | + str(framework_base if framework_launcher else interpreter), |
| 262 | + str(prefix), |
| 263 | + ] |
| 264 | + |
| 265 | + # Framework launchers preset their base; ordinary binaries keep argv[0]. |
| 266 | + plain = root / "plain" / "python" |
| 267 | + plain.parent.mkdir() |
| 268 | + plain.symlink_to(interpreter) |
| 269 | + assert executable_paths(plain)[:2] == [ |
| 270 | + str(plain), |
| 271 | + str(framework_base if framework_launcher else plain), |
| 272 | + ] |
| 273 | + |
| 274 | + # Without a launcher preset, copies fall back to home/<invoked name>. |
| 275 | + copied = root / "copied" / "python" |
| 276 | + copied.parent.mkdir() |
| 277 | + shutil.copy2(interpreter, copied) |
| 278 | + (home / copied.name).touch() |
| 279 | + (copied.parent / "pyvenv.cfg").write_text(f"home = {home}\n") |
| 280 | + assert executable_paths(copied) == [ |
| 281 | + str(copied), |
| 282 | + str(framework_base if framework_launcher else home / copied.name), |
| 283 | + str(copied.parent), |
| 284 | + ] |
| 285 | + |
| 286 | + # A directory alias does not turn a copied executable into a symlink. |
| 287 | + directory_alias = root / "directory-alias" |
| 288 | + directory_alias.symlink_to(copied.parent, target_is_directory=True) |
| 289 | + aliased_copy = directory_alias / copied.name |
| 290 | + # The framework launcher canonicalizes its directory before dispatch. |
| 291 | + assert executable_paths(aliased_copy) == [ |
| 292 | + str(copied if framework_launcher else aliased_copy), |
| 293 | + str(framework_base if framework_launcher else home / copied.name), |
| 294 | + str(copied.parent if framework_launcher else directory_alias), |
| 295 | + ] |
| 296 | + |
| 297 | + # Without a launcher preset, final-link resolution preserves target aliases. |
| 298 | + target_link = root / "linked" / "python" |
| 299 | + target_link.parent.mkdir() |
| 300 | + (target_link.parent / "pyvenv.cfg").write_text(f"home = {home}\n") |
| 301 | + target_link.symlink_to("../directory-alias/python") |
| 302 | + assert executable_paths(target_link) == [ |
| 303 | + str(target_link), |
| 304 | + str(framework_base if framework_launcher else aliased_copy), |
| 305 | + str(target_link.parent), |
| 306 | + ] |
| 307 | + |
| 308 | + # macOS framework builds use a framework-derived base for overrides. |
| 309 | + if sys.platform == "linux": |
| 310 | + # Relative targets also preserve a double-leading-slash directory. |
| 311 | + double_target = root / "double-target" |
| 312 | + double_target.symlink_to("directory-alias/python") |
| 313 | + target_link.unlink() |
| 314 | + target_link.symlink_to("/" + str(double_target)) |
| 315 | + assert executable_paths(target_link) == [ |
| 316 | + str(target_link), |
| 317 | + "/" + str(aliased_copy), |
| 318 | + str(target_link.parent), |
| 319 | + ] |
| 320 | + |
| 321 | + override_env = dict(venv_env, __PYVENV_LAUNCHER__=str(executable)) |
| 322 | + assert executable_paths(plain, env=override_env) == [ |
| 323 | + str(executable), |
| 324 | + str(plain), |
| 325 | + str(prefix), |
| 326 | + ] |
0 commit comments