Skip to content

Commit 2e8a273

Browse files
committed
pythonGH-107956: install build-details.json (PEP 739)
Signed-off-by: Filipe Laíns <[email protected]>
1 parent 6fb5138 commit 2e8a273

File tree

3 files changed

+323
-1
lines changed

3 files changed

+323
-1
lines changed

Lib/test/test_build_details.py

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import json
2+
import os
3+
import sys
4+
import sysconfig
5+
import string
6+
import unittest
7+
8+
9+
class FormatTestsBase:
10+
@property
11+
def contents(self):
12+
"""Install details file contents. Should be overriden by subclasses."""
13+
raise NotImplementedError
14+
15+
@property
16+
def data(self):
17+
"""Parsed install details file data, as a Python object."""
18+
return json.loads(self.contents)
19+
20+
def key(self, name):
21+
"""Helper to fetch subsection entries.
22+
23+
It takes the entry name, allowing the usage of a dot to separate the
24+
different subsection names (eg. specifying 'a.b.c' as the key will
25+
return the value of self.data['a']['b']['c']).
26+
"""
27+
value = self.data
28+
for part in name.split('.'):
29+
value = value[part]
30+
return value
31+
32+
def test_parse(self):
33+
self.data
34+
35+
def test_top_level_container(self):
36+
self.assertIsInstance(self.data, dict)
37+
for key, value in self.data.items():
38+
with self.subTest(key=key):
39+
if key in ('schema_version', 'base_prefix', 'base_interpreter', 'platform'):
40+
self.assertIsInstance(value, str)
41+
elif key in ('language', 'implementation', 'abi', 'suffixes', 'libpython', 'c_api', 'arbitrary_data'):
42+
self.assertIsInstance(value, dict)
43+
44+
def test_base_prefix(self):
45+
self.assertIsInstance(self.key('base_prefix'), str)
46+
47+
def test_base_interpreter(self):
48+
"""Test the base_interpreter entry.
49+
50+
The generic test wants the key to be missing. If your implementation
51+
provides a value for it, you should override this test.
52+
"""
53+
with self.assertRaises(KeyError):
54+
self.key('base_interpreter')
55+
56+
def test_platform(self):
57+
self.assertEqual(self.key('platform'), sysconfig.get_platform())
58+
59+
def test_language_version(self):
60+
allowed_characters = string.digits + string.ascii_letters + '.'
61+
value = self.key('language.version')
62+
63+
self.assertLessEqual(set(value), set(allowed_characters))
64+
self.assertTrue(sys.version.startswith(value))
65+
66+
def test_language_version_info(self):
67+
value = self.key('language.version_info')
68+
69+
self.assertEqual(len(value), sys.version_info.n_fields)
70+
for part_name, part_value in value.items():
71+
with self.subTest(part=part_name):
72+
self.assertEqual(part_value, getattr(sys.version_info, part_name))
73+
74+
def test_implementation(self):
75+
for key, value in self.key('implementation').items():
76+
with self.subTest(part=key):
77+
if key == 'version':
78+
# XXX: Actually compare the values.
79+
self.assertEqual(len(value), len(sys.implementation.version))
80+
for part_name, part_value in value.items():
81+
self.assertEqual(getattr(sys.implementation.version, part_name), part_value)
82+
else:
83+
self.assertEqual(getattr(sys.implementation, key), value)
84+
85+
86+
needs_installed_python = unittest.skipIf(
87+
sysconfig.is_python_build(),
88+
'This test can only run in an installed Python',
89+
)
90+
91+
92+
@unittest.skipIf(os.name != 'posix', 'Feature only implemented on POSIX right now')
93+
class CPythonBuildDetailsTests(unittest.TestCase, FormatTestsBase):
94+
"""Test CPython's install details file implementation."""
95+
96+
@property
97+
def location(self):
98+
if sysconfig.is_python_build():
99+
projectdir = sysconfig.get_config_var('projectbase')
100+
with open(os.path.join(projectdir, 'pybuilddir.txt')) as f:
101+
dirname = os.path.join(projectdir, f.read())
102+
else:
103+
dirname = sysconfig.get_path('stdlib')
104+
return os.path.join(dirname, 'build-details.json')
105+
106+
@property
107+
def contents(self):
108+
with open(self.location, 'r') as f:
109+
return f.read()
110+
111+
def setUp(self):
112+
if sys.platform in ('wasi', 'emscripten') and not os.path.isfile(self.location):
113+
self.skipTest(f'{sys.platform} build without a build-details.json file')
114+
115+
@needs_installed_python
116+
def test_location(self):
117+
self.assertTrue(os.path.isfile(self.location))
118+
119+
# Override generic format tests with tests for our specific implemenation.
120+
121+
@needs_installed_python
122+
def test_base_interpreter(self):
123+
value = self.key('base_interpreter')
124+
125+
self.assertEqual(os.path.realpath(value), os.path.realpath(sys.executable))
126+
127+
128+
if __name__ == '__main__':
129+
unittest.main()

Makefile.pre.in

+5-1
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,7 @@ list-targets:
728728

729729
.PHONY: build_all
730730
build_all: check-clean-src check-app-store-compliance $(BUILDPYTHON) platform sharedmods \
731-
gdbhooks Programs/_testembed scripts checksharedmods rundsymutil
731+
gdbhooks Programs/_testembed scripts checksharedmods rundsymutil build-details.json
732732

733733
.PHONY: build_wasm
734734
build_wasm: check-clean-src $(BUILDPYTHON) platform sharedmods \
@@ -934,6 +934,9 @@ pybuilddir.txt: $(PYTHON_FOR_BUILD_DEPS)
934934
exit 1 ; \
935935
fi
936936

937+
build-details.json: pybuilddir.txt
938+
$(RUNSHARED) $(PYTHON_FOR_BUILD) $(srcdir)/Tools/build/generate-build-details.py `cat pybuilddir.txt`/build-details.json
939+
937940
# Build static library
938941
$(LIBRARY): $(LIBRARY_OBJS)
939942
-rm -f $@
@@ -2644,6 +2647,7 @@ libinstall: all $(srcdir)/Modules/xxmodule.c
26442647
done
26452648
$(INSTALL_DATA) `cat pybuilddir.txt`/_sysconfigdata_$(ABIFLAGS)_$(MACHDEP)_$(MULTIARCH).py $(DESTDIR)$(LIBDEST); \
26462649
$(INSTALL_DATA) `cat pybuilddir.txt`/_sysconfig_vars_$(ABIFLAGS)_$(MACHDEP)_$(MULTIARCH).json $(DESTDIR)$(LIBDEST); \
2650+
$(INSTALL_DATA) `cat pybuilddir.txt`/build-details.json $(DESTDIR)$(LIBDEST); \
26472651
$(INSTALL_DATA) $(srcdir)/LICENSE $(DESTDIR)$(LIBDEST)/LICENSE.txt
26482652
@ # If app store compliance has been configured, apply the patch to the
26492653
@ # installed library code. The patch has been previously validated against

Tools/build/generate-build-details.py

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Generate build-details.json (see PEP 739)."""
2+
3+
# Script initially imported from:
4+
# https://github.com/FFY00/python-instrospection/blob/main/python_introspection/scripts/generate-build-details.py
5+
6+
import argparse
7+
import collections
8+
import importlib.machinery
9+
import json
10+
import os
11+
import sys
12+
import sysconfig
13+
import traceback
14+
import warnings
15+
16+
17+
if False: # TYPE_CHECKING
18+
pass
19+
20+
21+
def version_info_to_dict(obj): # (object) -> dict[str, Any]
22+
field_names = ('major', 'minor', 'micro', 'releaselevel', 'serial')
23+
return {field: getattr(obj, field) for field in field_names}
24+
25+
26+
def get_dict_key(container, key): # (dict[str, Any], str) -> dict[str, Any]
27+
for part in key.split('.'):
28+
container = container[part]
29+
return container
30+
31+
32+
def generate_data(schema_version):
33+
"""Generate the build-details.json data (PEP 739).
34+
35+
:param schema_version: The schema version of the data we want to generate.
36+
"""
37+
38+
if schema_version != '1.0':
39+
raise ValueError(f'Unsupported schema_version: {schema_version}')
40+
41+
data = collections.defaultdict(lambda: collections.defaultdict(dict))
42+
43+
data['schema_version'] = schema_version
44+
45+
data['base_prefix'] = sysconfig.get_config_var('installed_base')
46+
#data['base_interpreter'] = sys._base_executable
47+
data['base_interpreter'] = os.path.join(
48+
sysconfig.get_path('scripts'),
49+
'python' + sysconfig.get_config_var('VERSION'),
50+
)
51+
data['platform'] = sysconfig.get_platform()
52+
53+
data['language']['version'] = sysconfig.get_python_version()
54+
data['language']['version_info'] = version_info_to_dict(sys.version_info)
55+
56+
data['implementation'] = vars(sys.implementation)
57+
data['implementation']['version'] = version_info_to_dict(sys.implementation.version)
58+
59+
data['abi']['flags'] = list(sys.abiflags)
60+
61+
data['suffixes']['source'] = importlib.machinery.SOURCE_SUFFIXES
62+
data['suffixes']['bytecode'] = importlib.machinery.BYTECODE_SUFFIXES
63+
#data['suffixes']['optimized_bytecode'] = importlib.machinery.OPTIMIZED_BYTECODE_SUFFIXES
64+
#data['suffixes']['debug_bytecode'] = importlib.machinery.DEBUG_BYTECODE_SUFFIXES
65+
data['suffixes']['extensions'] = importlib.machinery.EXTENSION_SUFFIXES
66+
67+
LIBDIR = sysconfig.get_config_var('LIBDIR')
68+
LDLIBRARY = sysconfig.get_config_var('LDLIBRARY')
69+
LIBRARY = sysconfig.get_config_var('LIBRARY')
70+
PY3LIBRARY = sysconfig.get_config_var('PY3LIBRARY')
71+
LIBPYTHON = sysconfig.get_config_var('LIBPYTHON')
72+
LIBPC = sysconfig.get_config_var('LIBPC')
73+
INCLUDEDIR = sysconfig.get_config_var('INCLUDEDIR')
74+
75+
if os.name == 'posix':
76+
# On POSIX, LIBRARY is always the static library, while LDLIBRARY is the
77+
# dynamic library if enabled, otherwise it's the static library.
78+
# If LIBRARY != LDLIBRARY, support for the dynamic library is enabled.
79+
has_dynamic_library = LDLIBRARY != LIBRARY
80+
has_static_library = sysconfig.get_config_var('STATIC_LIBPYTHON')
81+
elif os.name == 'nt':
82+
# Windows can only use a dynamic library or a static library.
83+
# If it's using a dynamic library, sys.dllhandle will be set.
84+
# Static builds on Windows are not really well supported, though.
85+
# More context: https://github.com/python/cpython/issues/110234
86+
has_dynamic_library = hasattr(sys, 'dllhandle')
87+
has_static_library = not has_dynamic_library
88+
else:
89+
raise NotADirectoryError(f'Unknown platform: {os.name}')
90+
91+
# On POSIX, EXT_SUFFIX is set regardless if extension modules are supported
92+
# or not, and on Windows older versions of CPython only set EXT_SUFFIX when
93+
# extension modules are supported, but newer versions of CPython set it
94+
# regardless.
95+
#
96+
# We only want to set abi.extension_suffix and stable_abi_suffix if
97+
# extension modules are supported.
98+
if has_dynamic_library:
99+
data['abi']['extension_suffix'] = sysconfig.get_config_var('EXT_SUFFIX')
100+
101+
# EXTENSION_SUFFIXES has been constant for a long time, and currently we
102+
# don't have a better information source to find the stable ABI suffix.
103+
for suffix in importlib.machinery.EXTENSION_SUFFIXES:
104+
if suffix.startswith('.abi'):
105+
data['abi']['stable_abi_suffix'] = suffix
106+
break
107+
108+
data['libpython']['dynamic'] = os.path.join(LIBDIR, LDLIBRARY)
109+
# FIXME: Not sure if windows has a different dll for the stable ABI, and
110+
# even if it does, currently we don't have a way to get its name.
111+
if PY3LIBRARY:
112+
data['libpython']['dynamic_stableabi'] = os.path.join(LIBDIR, PY3LIBRARY)
113+
114+
# Os POSIX, this is defined by the LIBPYTHON Makefile variable not being
115+
# empty. On Windows, don't link extensions — LIBPYTHON won't be defined,
116+
data['libpython']['link_extensions'] = bool(LIBPYTHON)
117+
118+
if has_static_library:
119+
data['libpython']['static'] = os.path.join(LIBDIR, LIBRARY)
120+
121+
data['c_api']['include'] = INCLUDEDIR
122+
if LIBPC:
123+
data['c_api']['pkgconfig_path'] = LIBPC
124+
125+
return data
126+
127+
128+
def make_paths_relative(data, config_path=None): # (dict[str, Any], str | None) -> None
129+
# Make base_prefix relative to the config_path directory
130+
if config_path:
131+
data['base_prefix'] = os.path.relpath(data['base_prefix'], os.path.dirname(config_path))
132+
# Update path values to make them relative to base_prefix
133+
PATH_KEYS = [
134+
'base_interpreter',
135+
'libpython.dynamic',
136+
'libpython.dynamic_stableabi',
137+
'libpython.static',
138+
'c_api.headers',
139+
'c_api.pkgconfig_path',
140+
]
141+
for entry in PATH_KEYS:
142+
parent, _, child = entry.rpartition('.')
143+
# Get the key container object
144+
try:
145+
container = data
146+
for part in parent.split('.'):
147+
container = container[part]
148+
current_path = container[child]
149+
except KeyError:
150+
continue
151+
# Get the relative path
152+
new_path = os.path.relpath(current_path, data['base_prefix'])
153+
# Join '.' so that the path is formated as './path' instead of 'path'
154+
new_path = os.path.join('.', new_path)
155+
container[child] = new_path
156+
157+
158+
def main(): # () -> None
159+
parser = argparse.ArgumentParser(exit_on_error=False)
160+
parser.add_argument('location')
161+
parser.add_argument(
162+
'--schema-version',
163+
default='1.0',
164+
help='Schema version of the build-details.json file to generate.',
165+
)
166+
parser.add_argument(
167+
'--relative-paths',
168+
action='store_true',
169+
help='Whether to specify paths as absolute, or as relative paths to ``base_prefix``.',
170+
)
171+
parser.add_argument(
172+
'--config-file-path',
173+
default=None,
174+
help='If specified, ``base_prefix`` will be set as a relative path to the given config file path.',
175+
)
176+
177+
args = parser.parse_args()
178+
179+
data = generate_data(args.schema_version)
180+
if args.relative_paths:
181+
make_paths_relative(data, args.config_file_path)
182+
183+
json_output = json.dumps(data, indent=2)
184+
with open(args.location, 'w') as f:
185+
print(json_output, file=f)
186+
187+
188+
if __name__ == '__main__':
189+
main()

0 commit comments

Comments
 (0)