Skip to content

Commit 057ee17

Browse files
AA-Turneritamaro
andauthored
gh-136264: Fix --relative-paths for PEP 739's build-details.json (#138510)
* KeyError is not raised for defaultdict * Fix relative paths on different drives on Windows * Add a round-trip test Co-authored-by: Itamar Oren <[email protected]>
1 parent 6401823 commit 057ee17

File tree

2 files changed

+125
-20
lines changed

2 files changed

+125
-20
lines changed

Lib/test/test_build_details.py

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
1+
import importlib
12
import json
23
import os
4+
import os.path
35
import sys
46
import sysconfig
57
import string
68
import unittest
9+
from pathlib import Path
710

811
from test.support import is_android, is_apple_mobile, is_wasm32
912

13+
BASE_PATH = Path(
14+
__file__, # Lib/test/test_build_details.py
15+
'..', # Lib/test
16+
'..', # Lib
17+
'..', # <src/install dir>
18+
).resolve()
19+
MODULE_PATH = BASE_PATH / 'Tools' / 'build' / 'generate-build-details.py'
20+
21+
try:
22+
# Import "generate-build-details.py" as "generate_build_details"
23+
spec = importlib.util.spec_from_file_location(
24+
"generate_build_details", MODULE_PATH
25+
)
26+
generate_build_details = importlib.util.module_from_spec(spec)
27+
sys.modules["generate_build_details"] = generate_build_details
28+
spec.loader.exec_module(generate_build_details)
29+
except (FileNotFoundError, ImportError):
30+
generate_build_details = None
31+
1032

1133
class FormatTestsBase:
1234
@property
@@ -31,16 +53,15 @@ def key(self, name):
3153
value = value[part]
3254
return value
3355

34-
def test_parse(self):
35-
self.data
36-
3756
def test_top_level_container(self):
3857
self.assertIsInstance(self.data, dict)
3958
for key, value in self.data.items():
4059
with self.subTest(key=key):
41-
if key in ('schema_version', 'base_prefix', 'base_interpreter', 'platform'):
60+
if key in ('schema_version', 'base_prefix', 'base_interpreter',
61+
'platform'):
4262
self.assertIsInstance(value, str)
43-
elif key in ('language', 'implementation', 'abi', 'suffixes', 'libpython', 'c_api', 'arbitrary_data'):
63+
elif key in ('language', 'implementation', 'abi', 'suffixes',
64+
'libpython', 'c_api', 'arbitrary_data'):
4465
self.assertIsInstance(value, dict)
4566

4667
def test_base_prefix(self):
@@ -71,15 +92,20 @@ def test_language_version_info(self):
7192
self.assertEqual(len(value), sys.version_info.n_fields)
7293
for part_name, part_value in value.items():
7394
with self.subTest(part=part_name):
74-
self.assertEqual(part_value, getattr(sys.version_info, part_name))
95+
sys_version_value = getattr(sys.version_info, part_name)
96+
self.assertEqual(part_value, sys_version_value)
7597

7698
def test_implementation(self):
99+
impl_ver = sys.implementation.version
77100
for key, value in self.key('implementation').items():
78101
with self.subTest(part=key):
79102
if key == 'version':
80-
self.assertEqual(len(value), len(sys.implementation.version))
103+
self.assertEqual(len(value), len(impl_ver))
81104
for part_name, part_value in value.items():
82-
self.assertEqual(getattr(sys.implementation.version, part_name), part_value)
105+
self.assertFalse(isinstance(sys.implementation.version, dict))
106+
getattr(sys.implementation.version, part_name)
107+
sys_implementation_value = getattr(impl_ver, part_name)
108+
self.assertEqual(sys_implementation_value, part_value)
83109
else:
84110
self.assertEqual(getattr(sys.implementation, key), value)
85111

@@ -99,15 +125,16 @@ class CPythonBuildDetailsTests(unittest.TestCase, FormatTestsBase):
99125
def location(self):
100126
if sysconfig.is_python_build():
101127
projectdir = sysconfig.get_config_var('projectbase')
102-
with open(os.path.join(projectdir, 'pybuilddir.txt')) as f:
128+
pybuilddir = os.path.join(projectdir, 'pybuilddir.txt')
129+
with open(pybuilddir, encoding='utf-8') as f:
103130
dirname = os.path.join(projectdir, f.read())
104131
else:
105132
dirname = sysconfig.get_path('stdlib')
106133
return os.path.join(dirname, 'build-details.json')
107134

108135
@property
109136
def contents(self):
110-
with open(self.location, 'r') as f:
137+
with open(self.location, 'r', encoding='utf-8') as f:
111138
return f.read()
112139

113140
@needs_installed_python
@@ -147,5 +174,64 @@ def test_c_api(self):
147174
self.assertTrue(os.path.exists(os.path.join(value['pkgconfig_path'], f'python-{version}.pc')))
148175

149176

177+
@unittest.skipIf(
178+
generate_build_details is None,
179+
"Failed to import generate-build-details"
180+
)
181+
@unittest.skipIf(os.name != 'posix', 'Feature only implemented on POSIX right now')
182+
@unittest.skipIf(is_wasm32, 'Feature not available on WebAssembly builds')
183+
class BuildDetailsRelativePathsTests(unittest.TestCase):
184+
@property
185+
def build_details_absolute_paths(self):
186+
data = generate_build_details.generate_data(schema_version='1.0')
187+
return json.loads(json.dumps(data))
188+
189+
@property
190+
def build_details_relative_paths(self):
191+
data = self.build_details_absolute_paths
192+
generate_build_details.make_paths_relative(data, config_path=None)
193+
return data
194+
195+
def test_round_trip(self):
196+
data_abs_path = self.build_details_absolute_paths
197+
data_rel_path = self.build_details_relative_paths
198+
199+
self.assertEqual(data_abs_path['base_prefix'],
200+
data_rel_path['base_prefix'])
201+
202+
base_prefix = data_abs_path['base_prefix']
203+
204+
top_level_keys = ('base_interpreter',)
205+
for key in top_level_keys:
206+
self.assertEqual(key in data_abs_path, key in data_rel_path)
207+
if key not in data_abs_path:
208+
continue
209+
210+
abs_rel_path = os.path.join(base_prefix, data_rel_path[key])
211+
abs_rel_path = os.path.normpath(abs_rel_path)
212+
self.assertEqual(data_abs_path[key], abs_rel_path)
213+
214+
second_level_keys = (
215+
('libpython', 'dynamic'),
216+
('libpython', 'dynamic_stableabi'),
217+
('libpython', 'static'),
218+
('c_api', 'headers'),
219+
('c_api', 'pkgconfig_path'),
220+
221+
)
222+
for part, key in second_level_keys:
223+
self.assertEqual(part in data_abs_path, part in data_rel_path)
224+
if part not in data_abs_path:
225+
continue
226+
self.assertEqual(key in data_abs_path[part],
227+
key in data_rel_path[part])
228+
if key not in data_abs_path[part]:
229+
continue
230+
231+
abs_rel_path = os.path.join(base_prefix, data_rel_path[part][key])
232+
abs_rel_path = os.path.normpath(abs_rel_path)
233+
self.assertEqual(data_abs_path[part][key], abs_rel_path)
234+
235+
150236
if __name__ == '__main__':
151237
unittest.main()

Tools/build/generate-build-details.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def generate_data(schema_version: str) -> collections.defaultdict[str, Any]:
5555
data['language']['version'] = sysconfig.get_python_version()
5656
data['language']['version_info'] = version_info_to_dict(sys.version_info)
5757

58-
data['implementation'] = vars(sys.implementation)
58+
data['implementation'] = vars(sys.implementation).copy()
5959
data['implementation']['version'] = version_info_to_dict(sys.implementation.version)
6060
# Fix cross-compilation
6161
if '_multiarch' in data['implementation']:
@@ -104,7 +104,7 @@ def generate_data(schema_version: str) -> collections.defaultdict[str, Any]:
104104
data['abi']['extension_suffix'] = sysconfig.get_config_var('EXT_SUFFIX')
105105

106106
# EXTENSION_SUFFIXES has been constant for a long time, and currently we
107-
# don't have a better information source to find the stable ABI suffix.
107+
# don't have a better information source to find the stable ABI suffix.
108108
for suffix in importlib.machinery.EXTENSION_SUFFIXES:
109109
if suffix.startswith('.abi'):
110110
data['abi']['stable_abi_suffix'] = suffix
@@ -133,33 +133,51 @@ def generate_data(schema_version: str) -> collections.defaultdict[str, Any]:
133133
def make_paths_relative(data: dict[str, Any], config_path: str | None = None) -> None:
134134
# Make base_prefix relative to the config_path directory
135135
if config_path:
136-
data['base_prefix'] = os.path.relpath(data['base_prefix'], os.path.dirname(config_path))
136+
data['base_prefix'] = relative_path(data['base_prefix'],
137+
os.path.dirname(config_path))
138+
base_prefix = data['base_prefix']
139+
137140
# Update path values to make them relative to base_prefix
138-
PATH_KEYS = [
141+
PATH_KEYS = (
139142
'base_interpreter',
140143
'libpython.dynamic',
141144
'libpython.dynamic_stableabi',
142145
'libpython.static',
143146
'c_api.headers',
144147
'c_api.pkgconfig_path',
145-
]
148+
)
146149
for entry in PATH_KEYS:
147-
parent, _, child = entry.rpartition('.')
150+
*parents, child = entry.split('.')
148151
# Get the key container object
149152
try:
150153
container = data
151-
for part in parent.split('.'):
154+
for part in parents:
152155
container = container[part]
156+
if child not in container:
157+
raise KeyError(child)
153158
current_path = container[child]
154159
except KeyError:
155160
continue
156161
# Get the relative path
157-
new_path = os.path.relpath(current_path, data['base_prefix'])
162+
new_path = relative_path(current_path, base_prefix)
158163
# Join '.' so that the path is formated as './path' instead of 'path'
159164
new_path = os.path.join('.', new_path)
160165
container[child] = new_path
161166

162167

168+
def relative_path(path: str, base: str) -> str:
169+
if os.name != 'nt':
170+
return os.path.relpath(path, base)
171+
172+
# There are no relative paths between drives on Windows.
173+
path_drv, _ = os.path.splitdrive(path)
174+
base_drv, _ = os.path.splitdrive(base)
175+
if path_drv.lower() == base_drv.lower():
176+
return os.path.relpath(path, base)
177+
178+
return path
179+
180+
163181
def main() -> None:
164182
parser = argparse.ArgumentParser(exit_on_error=False)
165183
parser.add_argument('location')
@@ -186,8 +204,9 @@ def main() -> None:
186204
make_paths_relative(data, args.config_file_path)
187205

188206
json_output = json.dumps(data, indent=2)
189-
with open(args.location, 'w') as f:
190-
print(json_output, file=f)
207+
with open(args.location, 'w', encoding='utf-8') as f:
208+
f.write(json_output)
209+
f.write('\n')
191210

192211

193212
if __name__ == '__main__':

0 commit comments

Comments
 (0)