[3.14] gh-136264: Fix `--relative-paths` for PEP 739's build-details.json (GH-138510) (#138638)
Some checks are pending
Tests / Windows MSI (push) Blocked by required conditions
Tests / (push) Blocked by required conditions
Tests / Change detection (push) Waiting to run
Tests / Docs (push) Blocked by required conditions
Tests / Check if the ABI has changed (push) Blocked by required conditions
Tests / Check if Autoconf files are up to date (push) Blocked by required conditions
Tests / Check if generated files are up to date (push) Blocked by required conditions
Tests / Ubuntu SSL tests with OpenSSL (push) Blocked by required conditions
Tests / Android (aarch64) (push) Blocked by required conditions
Tests / Android (x86_64) (push) Blocked by required conditions
Tests / WASI (push) Blocked by required conditions
Tests / Hypothesis tests on Ubuntu (push) Blocked by required conditions
Tests / Address sanitizer (push) Blocked by required conditions
Tests / Sanitizers (push) Blocked by required conditions
Tests / Cross build Linux (push) Blocked by required conditions
Tests / CIFuzz (push) Blocked by required conditions
Tests / All required checks pass (push) Blocked by required conditions
Lint / lint (push) Waiting to run

* gh-136264: Fix ``--relative-paths`` for PEP 739's build-details.json (GH-138510)

* KeyError is not raised for defaultdict
* Fix relative paths on different drives on Windows
* Add a round-trip test
(cherry picked from commit 057ee17410)

Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com>
Co-authored-by: Itamar Oren <itamarost@gmail.com>

* Update test_build_details.py

* Update Lib/test/test_build_details.py

---------

Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com>
Co-authored-by: Itamar Oren <itamarost@gmail.com>
Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
This commit is contained in:
Miss Islington (bot) 2025-09-09 18:15:40 +02:00 committed by GitHub
parent 829c81ae21
commit 389450399a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 125 additions and 20 deletions

View file

@ -1,12 +1,34 @@
import importlib
import json import json
import os import os
import os.path
import sys import sys
import sysconfig import sysconfig
import string import string
import unittest import unittest
from pathlib import Path
from test.support import is_android, is_apple_mobile, is_wasm32 from test.support import is_android, is_apple_mobile, is_wasm32
BASE_PATH = Path(
__file__, # Lib/test/test_build_details.py
'..', # Lib/test
'..', # Lib
'..', # <src/install dir>
).resolve()
MODULE_PATH = BASE_PATH / 'Tools' / 'build' / 'generate-build-details.py'
try:
# Import "generate-build-details.py" as "generate_build_details"
spec = importlib.util.spec_from_file_location(
"generate_build_details", MODULE_PATH
)
generate_build_details = importlib.util.module_from_spec(spec)
sys.modules["generate_build_details"] = generate_build_details
spec.loader.exec_module(generate_build_details)
except (FileNotFoundError, ImportError):
generate_build_details = None
class FormatTestsBase: class FormatTestsBase:
@property @property
@ -31,16 +53,15 @@ class FormatTestsBase:
value = value[part] value = value[part]
return value return value
def test_parse(self):
self.data
def test_top_level_container(self): def test_top_level_container(self):
self.assertIsInstance(self.data, dict) self.assertIsInstance(self.data, dict)
for key, value in self.data.items(): for key, value in self.data.items():
with self.subTest(key=key): with self.subTest(key=key):
if key in ('schema_version', 'base_prefix', 'base_interpreter', 'platform'): if key in ('schema_version', 'base_prefix', 'base_interpreter',
'platform'):
self.assertIsInstance(value, str) self.assertIsInstance(value, str)
elif key in ('language', 'implementation', 'abi', 'suffixes', 'libpython', 'c_api', 'arbitrary_data'): elif key in ('language', 'implementation', 'abi', 'suffixes',
'libpython', 'c_api', 'arbitrary_data'):
self.assertIsInstance(value, dict) self.assertIsInstance(value, dict)
def test_base_prefix(self): def test_base_prefix(self):
@ -71,15 +92,20 @@ class FormatTestsBase:
self.assertEqual(len(value), sys.version_info.n_fields) self.assertEqual(len(value), sys.version_info.n_fields)
for part_name, part_value in value.items(): for part_name, part_value in value.items():
with self.subTest(part=part_name): with self.subTest(part=part_name):
self.assertEqual(part_value, getattr(sys.version_info, part_name)) sys_version_value = getattr(sys.version_info, part_name)
self.assertEqual(part_value, sys_version_value)
def test_implementation(self): def test_implementation(self):
impl_ver = sys.implementation.version
for key, value in self.key('implementation').items(): for key, value in self.key('implementation').items():
with self.subTest(part=key): with self.subTest(part=key):
if key == 'version': if key == 'version':
self.assertEqual(len(value), len(sys.implementation.version)) self.assertEqual(len(value), len(impl_ver))
for part_name, part_value in value.items(): for part_name, part_value in value.items():
self.assertEqual(getattr(sys.implementation.version, part_name), part_value) self.assertFalse(isinstance(sys.implementation.version, dict))
getattr(sys.implementation.version, part_name)
sys_implementation_value = getattr(impl_ver, part_name)
self.assertEqual(sys_implementation_value, part_value)
else: else:
self.assertEqual(getattr(sys.implementation, key), value) self.assertEqual(getattr(sys.implementation, key), value)
@ -99,7 +125,8 @@ class CPythonBuildDetailsTests(unittest.TestCase, FormatTestsBase):
def location(self): def location(self):
if sysconfig.is_python_build(): if sysconfig.is_python_build():
projectdir = sysconfig.get_config_var('projectbase') projectdir = sysconfig.get_config_var('projectbase')
with open(os.path.join(projectdir, 'pybuilddir.txt')) as f: pybuilddir = os.path.join(projectdir, 'pybuilddir.txt')
with open(pybuilddir, encoding='utf-8') as f:
dirname = os.path.join(projectdir, f.read()) dirname = os.path.join(projectdir, f.read())
else: else:
dirname = sysconfig.get_path('stdlib') dirname = sysconfig.get_path('stdlib')
@ -107,7 +134,7 @@ class CPythonBuildDetailsTests(unittest.TestCase, FormatTestsBase):
@property @property
def contents(self): def contents(self):
with open(self.location, 'r') as f: with open(self.location, 'r', encoding='utf-8') as f:
return f.read() return f.read()
@needs_installed_python @needs_installed_python
@ -147,5 +174,64 @@ class CPythonBuildDetailsTests(unittest.TestCase, FormatTestsBase):
self.assertTrue(os.path.exists(os.path.join(value['pkgconfig_path'], f'python-{version}.pc'))) self.assertTrue(os.path.exists(os.path.join(value['pkgconfig_path'], f'python-{version}.pc')))
@unittest.skipIf(
generate_build_details is None,
"Failed to import generate-build-details"
)
@unittest.skipIf(os.name != 'posix', 'Feature only implemented on POSIX right now')
@unittest.skipIf(is_wasm32, 'Feature not available on WebAssembly builds')
class BuildDetailsRelativePathsTests(unittest.TestCase):
@property
def build_details_absolute_paths(self):
data = generate_build_details.generate_data(schema_version='1.0')
return json.loads(json.dumps(data))
@property
def build_details_relative_paths(self):
data = self.build_details_absolute_paths
generate_build_details.make_paths_relative(data, config_path=None)
return data
def test_round_trip(self):
data_abs_path = self.build_details_absolute_paths
data_rel_path = self.build_details_relative_paths
self.assertEqual(data_abs_path['base_prefix'],
data_rel_path['base_prefix'])
base_prefix = data_abs_path['base_prefix']
top_level_keys = ('base_interpreter',)
for key in top_level_keys:
self.assertEqual(key in data_abs_path, key in data_rel_path)
if key not in data_abs_path:
continue
abs_rel_path = os.path.join(base_prefix, data_rel_path[key])
abs_rel_path = os.path.normpath(abs_rel_path)
self.assertEqual(data_abs_path[key], abs_rel_path)
second_level_keys = (
('libpython', 'dynamic'),
('libpython', 'dynamic_stableabi'),
('libpython', 'static'),
('c_api', 'headers'),
('c_api', 'pkgconfig_path'),
)
for part, key in second_level_keys:
self.assertEqual(part in data_abs_path, part in data_rel_path)
if part not in data_abs_path:
continue
self.assertEqual(key in data_abs_path[part],
key in data_rel_path[part])
if key not in data_abs_path[part]:
continue
abs_rel_path = os.path.join(base_prefix, data_rel_path[part][key])
abs_rel_path = os.path.normpath(abs_rel_path)
self.assertEqual(data_abs_path[part][key], abs_rel_path)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -55,7 +55,7 @@ def generate_data(schema_version: str) -> collections.defaultdict[str, Any]:
data['language']['version'] = sysconfig.get_python_version() data['language']['version'] = sysconfig.get_python_version()
data['language']['version_info'] = version_info_to_dict(sys.version_info) data['language']['version_info'] = version_info_to_dict(sys.version_info)
data['implementation'] = vars(sys.implementation) data['implementation'] = vars(sys.implementation).copy()
data['implementation']['version'] = version_info_to_dict(sys.implementation.version) data['implementation']['version'] = version_info_to_dict(sys.implementation.version)
# Fix cross-compilation # Fix cross-compilation
if '_multiarch' in data['implementation']: if '_multiarch' in data['implementation']:
@ -133,33 +133,51 @@ def generate_data(schema_version: str) -> collections.defaultdict[str, Any]:
def make_paths_relative(data: dict[str, Any], config_path: str | None = None) -> None: def make_paths_relative(data: dict[str, Any], config_path: str | None = None) -> None:
# Make base_prefix relative to the config_path directory # Make base_prefix relative to the config_path directory
if config_path: if config_path:
data['base_prefix'] = os.path.relpath(data['base_prefix'], os.path.dirname(config_path)) data['base_prefix'] = relative_path(data['base_prefix'],
os.path.dirname(config_path))
base_prefix = data['base_prefix']
# Update path values to make them relative to base_prefix # Update path values to make them relative to base_prefix
PATH_KEYS = [ PATH_KEYS = (
'base_interpreter', 'base_interpreter',
'libpython.dynamic', 'libpython.dynamic',
'libpython.dynamic_stableabi', 'libpython.dynamic_stableabi',
'libpython.static', 'libpython.static',
'c_api.headers', 'c_api.headers',
'c_api.pkgconfig_path', 'c_api.pkgconfig_path',
] )
for entry in PATH_KEYS: for entry in PATH_KEYS:
parent, _, child = entry.rpartition('.') *parents, child = entry.split('.')
# Get the key container object # Get the key container object
try: try:
container = data container = data
for part in parent.split('.'): for part in parents:
container = container[part] container = container[part]
if child not in container:
raise KeyError(child)
current_path = container[child] current_path = container[child]
except KeyError: except KeyError:
continue continue
# Get the relative path # Get the relative path
new_path = os.path.relpath(current_path, data['base_prefix']) new_path = relative_path(current_path, base_prefix)
# Join '.' so that the path is formated as './path' instead of 'path' # Join '.' so that the path is formated as './path' instead of 'path'
new_path = os.path.join('.', new_path) new_path = os.path.join('.', new_path)
container[child] = new_path container[child] = new_path
def relative_path(path: str, base: str) -> str:
if os.name != 'nt':
return os.path.relpath(path, base)
# There are no relative paths between drives on Windows.
path_drv, _ = os.path.splitdrive(path)
base_drv, _ = os.path.splitdrive(base)
if path_drv.lower() == base_drv.lower():
return os.path.relpath(path, base)
return path
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(exit_on_error=False) parser = argparse.ArgumentParser(exit_on_error=False)
parser.add_argument('location') parser.add_argument('location')
@ -186,8 +204,9 @@ def main() -> None:
make_paths_relative(data, args.config_file_path) make_paths_relative(data, args.config_file_path)
json_output = json.dumps(data, indent=2) json_output = json.dumps(data, indent=2)
with open(args.location, 'w') as f: with open(args.location, 'w', encoding='utf-8') as f:
print(json_output, file=f) f.write(json_output)
f.write('\n')
if __name__ == '__main__': if __name__ == '__main__':