diff --git a/Lib/test/test_build_details.py b/Lib/test/test_build_details.py index 12b7664d83aae16..f738c004e471d71 100644 --- a/Lib/test/test_build_details.py +++ b/Lib/test/test_build_details.py @@ -5,11 +5,12 @@ import sys import sysconfig import string +import textwrap import unittest from pathlib import Path from unittest import mock -from test.support import is_android, is_apple_mobile, is_wasm32 +from test.support import is_android, is_apple_mobile, is_wasm32, os_helper BASE_PATH = Path( __file__, # Lib/test/test_build_details.py @@ -289,6 +290,44 @@ def check_relative_paths(self, data_abs_path, data_rel_path): self.assertEqual(data_abs_path[part][key], abs_rel_path) +@unittest.skipIf( + generate_build_details is None, + "Failed to import generate-build-details" +) +class BuildDetailsVersionTests(unittest.TestCase): + def test_native(self): + version_info = generate_build_details.get_version_info() + self.assertEqual(version_info, + generate_build_details.version_info_to_dict( + sys.version_info)) + self.assertEqual(generate_build_details.hexversion(version_info), + sys.hexversion) + + def test_patchlevel(self): + patchlevel_h = os.path.join(os_helper.TESTFN, 'patchlevel.h') + self.addCleanup(os_helper.rmtree, os_helper.TESTFN) + os.mkdir(os_helper.TESTFN) + with open(patchlevel_h, 'w', encoding='utf-8') as f: + f.write(textwrap.dedent('''\ + #define PY_RELEASE_LEVEL_GAMMA 0xC /* For release candidates */ + #define PY_MAJOR_VERSION 3 + #define PY_MINOR_VERSION 99 + #define PY_MICRO_VERSION 7 + #define PY_RELEASE_LEVEL PY_RELEASE_LEVEL_GAMMA + #define PY_RELEASE_SERIAL 2 + #define PY_VERSION "3.99.7rc2" + ''')) + with mock.patch.object(generate_build_details, 'PATCHLEVEL_H', + patchlevel_h): + version_info = generate_build_details.get_version_info() + self.assertEqual(version_info, { + 'major': 3, 'minor': 99, 'micro': 7, + 'releaselevel': 'candidate', 'serial': 2, + }) + self.assertEqual(generate_build_details.hexversion(version_info), + 0x036307c2) + + @unittest.skipIf( generate_build_details is None, "Failed to import generate-build-details" diff --git a/Misc/NEWS.d/next/Build/2026-11-06-12-00-02.gh-issue-136267.Vt4pLm.rst b/Misc/NEWS.d/next/Build/2026-11-06-12-00-02.gh-issue-136267.Vt4pLm.rst new file mode 100644 index 000000000000000..d99ae1c42133402 --- /dev/null +++ b/Misc/NEWS.d/next/Build/2026-11-06-12-00-02.gh-issue-136267.Vt4pLm.rst @@ -0,0 +1,4 @@ +``build-details.json`` now takes ``language.version_info``, +``implementation.version`` and ``implementation.hexversion`` from +:file:`Include/patchlevel.h` instead of the interpreter running the generator, +which can be a different patch release under :option:`--with-build-python`. diff --git a/Tools/build/generate-build-details.py b/Tools/build/generate-build-details.py index 4fb49bf139b225f..b21c22bfdba6790 100644 --- a/Tools/build/generate-build-details.py +++ b/Tools/build/generate-build-details.py @@ -10,6 +10,7 @@ import importlib.machinery import json import os +import re import sys import sysconfig @@ -18,11 +19,52 @@ from typing import Any +PATCHLEVEL_H = os.path.join( + os.path.dirname(__file__), '..', '..', 'Include', 'patchlevel.h', +) +RELEASE_LEVELS = {0xA: 'alpha', 0xB: 'beta', 0xC: 'candidate', 0xF: 'final'} + + def version_info_to_dict(obj: sys._version_info) -> dict[str, Any]: field_names = ('major', 'minor', 'micro', 'releaselevel', 'serial') return {field: getattr(obj, field) for field in field_names} +def get_version_info() -> dict[str, Any]: + """Return the target's version as a version_info dict. + + sys.version_info describes the running interpreter, which under + --with-build-python can differ from the target in micro version and + release level. Read Include/patchlevel.h instead when it is available. + """ + try: + with open(PATCHLEVEL_H, encoding='utf-8') as f: + patchlevel = f.read() + except FileNotFoundError: + return version_info_to_dict(sys.version_info) + defines = dict(re.findall(r'^#define\s+(PY_\w+)\s+(\S+)', patchlevel, re.M)) + # PY_RELEASE_LEVEL is defined as another macro, e.g. PY_RELEASE_LEVEL_ALPHA. + level = int(defines[defines['PY_RELEASE_LEVEL']], 16) + return { + 'major': int(defines['PY_MAJOR_VERSION']), + 'minor': int(defines['PY_MINOR_VERSION']), + 'micro': int(defines['PY_MICRO_VERSION']), + 'releaselevel': RELEASE_LEVELS[level], + 'serial': int(defines['PY_RELEASE_SERIAL']), + } + + +def hexversion(version_info: dict[str, Any]) -> int: + level = {name: value for value, name in RELEASE_LEVELS.items()} + return int( + version_info['major'] << 24 + | version_info['minor'] << 16 + | version_info['micro'] << 8 + | level[version_info['releaselevel']] << 4 + | version_info['serial'] + ) + + def get_dict_key(container: dict[str, Any], key: str) -> dict[str, Any]: for part in key.split('.'): container = container[part] @@ -54,11 +96,13 @@ def generate_data(schema_version: str) -> collections.defaultdict[str, Any]: ) data['platform'] = sysconfig.get_platform() + version_info = get_version_info() data['language']['version'] = sysconfig.get_python_version() - data['language']['version_info'] = version_info_to_dict(sys.version_info) + data['language']['version_info'] = version_info data['implementation'] = vars(sys.implementation).copy() - data['implementation']['version'] = version_info_to_dict(sys.implementation.version) + data['implementation']['version'] = version_info + data['implementation']['hexversion'] = hexversion(version_info) # Fix cross-compilation if '_multiarch' in data['implementation']: data['implementation']['_multiarch'] = sysconfig.get_config_var('MULTIARCH')