Skip to content

Commit bc27c61

Browse files
committed
Align Windows symlink helper with canonical Python home layout
Import the pinned CPython 3.15 PythonSymlink class through a narrow, hash-pinned update_lib mode. Preserve canonical home/VPATH behavior and explicitly supply RustPython build stdlib to the Windows child without changing runtime PYTHONHOME semantics or skipping tests. Assisted-by: OpenAI Codex:model version unavailable
1 parent 66e80ef commit bc27c61

3 files changed

Lines changed: 287 additions & 1 deletion

File tree

‎Lib/test/support/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1652,8 +1652,12 @@ def _platform_specific(self):
16521652
))
16531653

16541654
self._env = {k.upper(): os.getenv(k) for k in os.environ}
1655-
self._env["PYTHONHOME"] = os.path.dirname(self.real)
1655+
home = os.path.dirname(self.real)
16561656
if sysconfig.is_python_build():
1657+
home = os.path.join(home, sysconfig.get_config_var('VPATH'))
1658+
self._env["PYTHONHOME"] = home
1659+
# TODO: RUSTPYTHON; Cargo builds do not use CPython's Modules/Setup.
1660+
if sys.implementation.name == 'rustpython':
16571661
self._env["PYTHONPATH"] = STDLIB_DIR
16581662
else:
16591663
def _platform_specific(self):

‎scripts/update_lib/cmd_migrate.py‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,121 @@ def _verify_call(contents: str, expected: str) -> None:
469469
raise ValueError("Ambiguous environment-reset call text")
470470

471471

472+
SYMLINK_UPSTREAM = """ home = os.path.dirname(self.real)
473+
if sysconfig.is_python_build():
474+
home = os.path.join(home, sysconfig.get_config_var('VPATH'))
475+
self._env["PYTHONHOME"] = home
476+
"""
477+
SYMLINK_LEGACY = """ self._env["PYTHONHOME"] = os.path.dirname(self.real)
478+
if sysconfig.is_python_build():
479+
self._env["PYTHONPATH"] = STDLIB_DIR
480+
"""
481+
SYMLINK_LOCAL = (
482+
SYMLINK_UPSTREAM
483+
+ """ # TODO: RUSTPYTHON; Cargo builds do not use CPython's Modules/Setup.
484+
if sys.implementation.name == 'rustpython':
485+
self._env["PYTHONPATH"] = STDLIB_DIR
486+
"""
487+
)
488+
489+
490+
def copy_support_python_symlink(source, destination):
491+
"""Adapt only the canonical Windows symlink helper's library environment."""
492+
owners = []
493+
for contents in (source, destination):
494+
tree = ast.parse(contents)
495+
owner = _unique_anchor(
496+
contents,
497+
tree,
498+
lambda node: (
499+
isinstance(node, ast.ClassDef) and node.name == "PythonSymlink"
500+
),
501+
direct=True,
502+
)
503+
if list(_bytes_bindings(tree, "PythonSymlink")) != [owner]:
504+
raise ValueError("Shadowed PythonSymlink class")
505+
owners.append(owner)
506+
canonical = ast.get_source_segment(source, owners[0]) + "\n"
507+
current = ast.get_source_segment(destination, owners[1]) + "\n"
508+
if canonical.count(SYMLINK_UPSTREAM) != 1 or SYMLINK_LOCAL in canonical:
509+
raise ValueError("Changed canonical PythonSymlink environment anchor")
510+
platform = _unique_anchor(
511+
canonical,
512+
ast.parse(canonical).body[0],
513+
lambda node: (
514+
isinstance(node, ast.If)
515+
and ast.dump(node.test)
516+
== ast.dump(ast.parse("sys.platform == 'win32'", mode="eval").body)
517+
),
518+
direct=True,
519+
)
520+
method = _unique_anchor(
521+
canonical,
522+
ast.Module(body=platform.body, type_ignores=[]),
523+
lambda node: (
524+
isinstance(node, ast.FunctionDef) and node.name == "_platform_specific"
525+
),
526+
direct=True,
527+
)
528+
condition = _unique_anchor(
529+
canonical,
530+
method,
531+
lambda node: (
532+
isinstance(node, ast.If)
533+
and ast.dump(node.test)
534+
== ast.dump(ast.parse("sysconfig.is_python_build()", mode="eval").body)
535+
),
536+
direct=True,
537+
)
538+
lines = canonical.splitlines(keepends=True)
539+
if (
540+
"".join(lines[condition.lineno - 2 : condition.end_lineno + 1])
541+
!= SYMLINK_UPSTREAM
542+
):
543+
raise ValueError("Changed canonical PythonSymlink environment body")
544+
adapted = canonical.replace(SYMLINK_UPSTREAM, SYMLINK_LOCAL, 1)
545+
legacy = canonical.replace(SYMLINK_UPSTREAM, SYMLINK_LEGACY, 1)
546+
if current not in (canonical, legacy, adapted):
547+
raise ValueError("Changed local PythonSymlink; review before migration")
548+
lines = destination.splitlines(keepends=True)
549+
lines[owners[1].lineno - 1 : owners[1].end_lineno] = [adapted]
550+
result = "".join(lines)
551+
ast.parse(result)
552+
return result
553+
554+
555+
def migrate_support_python_symlink(path, source_hash, destination_hash):
556+
target = parse_lib_path(path)
557+
if target.parts != ("Lib", "test", "support", "__init__.py"):
558+
raise ValueError(
559+
"PythonSymlink migration requires Lib/test/support/__init__.py"
560+
)
561+
source, destination = path.read_bytes(), target.read_bytes()
562+
for label, contents, expected in (
563+
("source", source, source_hash),
564+
("destination", destination, destination_hash),
565+
):
566+
if hashlib.sha256(contents).hexdigest() != expected:
567+
raise ValueError(f"{label} SHA256 does not match the pinned input")
568+
result = copy_support_python_symlink(
569+
source.decode("utf-8"), destination.decode("utf-8")
570+
).encode("utf-8")
571+
if target.read_bytes() != destination:
572+
raise ValueError("Destination changed during PythonSymlink preparation")
573+
target.write_bytes(result)
574+
print(
575+
json.dumps(
576+
{
577+
"helper": "PythonSymlink",
578+
"source_sha256": source_hash,
579+
"destination_before_sha256": destination_hash,
580+
"destination_after_sha256": hashlib.sha256(result).hexdigest(),
581+
},
582+
sort_keys=True,
583+
)
584+
)
585+
586+
472587
def copy_support_assignments(source, destination, names, exports):
473588
"""Copy contiguous canonical assignments and export lines, failing closed.
474589
@@ -1176,6 +1291,11 @@ def main(argv: list[str] | None = None) -> int:
11761291
nargs="+",
11771292
help="Add selected canonical test.support assignments",
11781293
)
1294+
parser.add_argument(
1295+
"--support-python-symlink",
1296+
action="store_true",
1297+
help="Adapt the canonical Windows PythonSymlink library environment",
1298+
)
11791299
parser.add_argument("--support-exports", nargs="+", default=[])
11801300
parser.add_argument("--source-sha256")
11811301
parser.add_argument("--destination-sha256")
@@ -1187,6 +1307,7 @@ def main(argv: list[str] | None = None) -> int:
11871307
args.legacy_map is not None
11881308
or args.patches is not None
11891309
or args.support_assignments
1310+
or args.support_python_symlink
11901311
or args.support_exports
11911312
or args.source_sha256
11921313
or args.destination_sha256
@@ -1203,6 +1324,20 @@ def main(argv: list[str] | None = None) -> int:
12031324
return 0
12041325
if args.dry_run:
12051326
raise ValueError("--dry-run requires --canonical-removals")
1327+
if args.support_python_symlink:
1328+
if (
1329+
args.legacy_map is not None
1330+
or args.patches is not None
1331+
or args.support_assignments
1332+
or args.support_exports
1333+
):
1334+
raise ValueError(
1335+
"PythonSymlink migration cannot combine with other migration modes"
1336+
)
1337+
migrate_support_python_symlink(
1338+
args.path, args.source_sha256, args.destination_sha256
1339+
)
1340+
return 0
12061341
if args.support_assignments:
12071342
if args.legacy_map is not None or args.patches is not None:
12081343
raise ValueError(

‎scripts/update_lib/tests/test_migrate.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@
2424
LOCK_SPECIALIZATION,
2525
MULTIPHASE_LOCAL,
2626
MULTIPHASE_UPSTREAM,
27+
SYMLINK_LEGACY,
28+
SYMLINK_LOCAL,
29+
SYMLINK_UPSTREAM,
2730
SYSCONFIG_LOCAL,
2831
SYSCONFIG_UPSTREAM,
2932
TYPES_DATETIME_GUARD,
3033
TYPES_UNITTEST_IMPORT,
3134
copy_support_assignments,
35+
copy_support_python_symlink,
3236
main,
3337
migrate_package,
3438
patch_directory,
@@ -49,6 +53,149 @@
4953
)
5054

5155

56+
class TestPythonSymlinkMigration(unittest.TestCase):
57+
prefix = """class PythonSymlink:
58+
if sys.platform == "win32":
59+
def _platform_specific(self):
60+
self._env = {}
61+
"""
62+
suffix = """ else:
63+
def _platform_specific(self):
64+
pass
65+
"""
66+
canonical = prefix + SYMLINK_UPSTREAM + suffix
67+
adapted = prefix + SYMLINK_LOCAL + suffix
68+
legacy = prefix + SYMLINK_LEGACY + suffix
69+
70+
def test_canonical_and_legacy_import_are_idempotent(self):
71+
for before in (self.canonical, self.legacy, self.adapted):
72+
with self.subTest(before=before):
73+
self.assertEqual(
74+
copy_support_python_symlink(self.canonical, before), self.adapted
75+
)
76+
77+
def test_unrelated_support_content_is_preserved(self):
78+
destination = "existing_guard = True\n" + self.legacy + "other_helper = 1\n"
79+
self.assertEqual(
80+
copy_support_python_symlink(
81+
"new_upstream = 1\n" + self.canonical, destination
82+
),
83+
"existing_guard = True\n" + self.adapted + "other_helper = 1\n",
84+
)
85+
86+
def test_changed_missing_duplicate_or_nested_class_fails_closed(self):
87+
for value in (self.canonical, self.legacy):
88+
for changed in (
89+
value.replace("PythonSymlink", "OtherHelper"),
90+
value + value,
91+
"if True:\n"
92+
+ "".join(" " + line for line in value.splitlines(True)),
93+
value.replace("sys.platform", "other.platform"),
94+
value.replace("self._env = {}", "self._env = None"),
95+
):
96+
with self.subTest(changed=changed):
97+
with self.assertRaises(ValueError):
98+
copy_support_python_symlink(self.canonical, changed)
99+
100+
def test_source_anchor_drift_fails_closed(self):
101+
for changed in (
102+
self.canonical.replace("'VPATH'", "'OTHER'"),
103+
self.canonical.replace("sys.platform", "other.platform"),
104+
self.canonical.replace("sysconfig.is_python_build()", "True"),
105+
self.canonical.replace(SYMLINK_UPSTREAM, SYMLINK_UPSTREAM * 2),
106+
self.adapted,
107+
):
108+
with self.subTest(changed=changed):
109+
with self.assertRaises(ValueError):
110+
copy_support_python_symlink(changed, self.legacy)
111+
112+
def test_class_shadow_fails_closed(self):
113+
for shadow in (
114+
"PythonSymlink = None\n",
115+
"del PythonSymlink\n",
116+
"import other as PythonSymlink\n",
117+
):
118+
with self.subTest(shadow=shadow):
119+
for source, target in (
120+
(self.canonical + shadow, self.legacy),
121+
(self.canonical, self.legacy + shadow),
122+
):
123+
with self.assertRaises(ValueError):
124+
copy_support_python_symlink(source, target)
125+
126+
def test_cli_pins_inputs_rejects_conflicts_and_repeats(self):
127+
with tempfile.TemporaryDirectory() as root, contextlib.chdir(root):
128+
source = Path("canonical/Lib/test/support/__init__.py")
129+
target = Path("Lib/test/support/__init__.py")
130+
for path, contents in ((source, self.canonical), (target, self.legacy)):
131+
path.parent.mkdir(parents=True)
132+
path.write_text(contents)
133+
base = [str(source), "--support-python-symlink"]
134+
source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
135+
destination_hash = hashlib.sha256(target.read_bytes()).hexdigest()
136+
pins = [
137+
"--source-sha256",
138+
source_hash,
139+
"--destination-sha256",
140+
destination_hash,
141+
]
142+
for args in (
143+
base,
144+
base
145+
+ [
146+
"--source-sha256",
147+
"0" * 64,
148+
"--destination-sha256",
149+
destination_hash,
150+
],
151+
base
152+
+ ["--source-sha256", source_hash, "--destination-sha256", "0" * 64],
153+
base + pins + ["--support-assignments", "other"],
154+
base + pins + ["--support-exports", "other"],
155+
base + pins + ["--patches", "unused.json"],
156+
base + pins + ["--dry-run"],
157+
):
158+
with (
159+
self.subTest(args=args),
160+
contextlib.redirect_stdout(io.StringIO()),
161+
contextlib.redirect_stderr(io.StringIO()),
162+
):
163+
self.assertEqual(main(args), 1)
164+
self.assertEqual(target.read_text(), self.legacy)
165+
with contextlib.redirect_stdout(io.StringIO()):
166+
self.assertEqual(main(base + pins), 0)
167+
self.assertEqual(target.read_text(), self.adapted)
168+
pins[-1] = hashlib.sha256(target.read_bytes()).hexdigest()
169+
self.assertEqual(main(base + pins), 0)
170+
self.assertEqual(target.read_text(), self.adapted)
171+
172+
def test_adapted_environment_preserves_cpython_and_sets_rustpython_library(self):
173+
import textwrap
174+
from types import SimpleNamespace
175+
176+
for implementation, build, expected_home, expected_path in (
177+
("cpython", False, "/build", None),
178+
("cpython", True, os.path.join("/build", "source"), None),
179+
("rustpython", False, "/build", "/actual/Lib"),
180+
):
181+
helper = SimpleNamespace(_env={"KEEP": "value"}, real="/build/python")
182+
namespace = {
183+
"self": helper,
184+
"os": os,
185+
"sys": SimpleNamespace(
186+
implementation=SimpleNamespace(name=implementation)
187+
),
188+
"sysconfig": SimpleNamespace(
189+
is_python_build=lambda: build, get_config_var=lambda name: "source"
190+
),
191+
"STDLIB_DIR": "/actual/Lib",
192+
}
193+
exec(textwrap.dedent(SYMLINK_LOCAL), namespace)
194+
self.assertEqual(helper._env["PYTHONHOME"], expected_home)
195+
self.assertEqual(helper._env.get("PYTHONPATH"), expected_path)
196+
self.assertEqual(helper._env["KEEP"], "value")
197+
198+
52199
class TestBytesPreservation(unittest.TestCase):
53200
path = Path("Lib/test/test_bytes.py")
54201
prefix = "class ByteArrayTest(BaseBytesTest, unittest.TestCase):\n"

0 commit comments

Comments
 (0)