From 3a29da5d9386da6cf8637d567c5978c97e94c199 Mon Sep 17 00:00:00 2001 From: Itamar Oren Date: Wed, 21 Oct 2026 21:29:37 -0700 Subject: [PATCH] [3.14] gh-159819: Fix python-config.sh for installation paths containing spaces (GH-159820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell `python-config` located its own prefix with unquoted command substitutions, so any whitespace in the installation path made it print an empty `--prefix` and `-I/include/...`-style paths while exiting 0. - Quote the substitutions and the argument loop (POSIX `sh`, no bashisms). - Exit 1 with a message when the script's directory cannot be resolved, instead of silently printing empty paths. - Replace `sed "s#$prefix#$prefix_real#"` with a `relocate` helper using parameter expansion, so `#`, `&`, `\` and regex metacharacters in either prefix are handled literally. Same first-occurrence semantics. - Add `test_python_config_sh_prefix_with_spaces`: runs the generated script from `…/path with spaces/bin/` and checks `--prefix`, `--exec-prefix`, `--includes` and empty stderr (cherry picked from commit 3e134077631fec203e4cdcb44133176d5abbea19) --- Lib/test/test_sysconfig.py | 70 ++++++++++++++++++- ...10-19-17-05-00.gh-issue-159819.pyCfgSp.rst | 6 ++ Misc/python-config.sh.in | 61 ++++++++++------ 3 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 Misc/NEWS.d/next/Build/2026-10-19-17-05-00.gh-issue-159819.pyCfgSp.rst diff --git a/Lib/test/test_sysconfig.py b/Lib/test/test_sysconfig.py index 5a5f48af1998b13..1a9635323d23384 100644 --- a/Lib/test/test_sysconfig.py +++ b/Lib/test/test_sysconfig.py @@ -1,3 +1,4 @@ +import itertools import platform import re import unittest @@ -20,7 +21,7 @@ ) from test.support.import_helper import import_module from test.support.os_helper import (TESTFN, unlink, skip_unless_symlink, - change_cwd) + change_cwd, temp_dir) from test.support.venv import VirtualEnvironmentMixin import sysconfig @@ -749,6 +750,73 @@ def test_sysconfig_config_vars_no_prefix_cache(self): self.assertEqual(config_vars['exec_prefix'], sys.exec_prefix) self.assertEqual(config_vars['platbase'], sys.exec_prefix) + @unittest.skipUnless(os.name == 'posix', 'python-config.sh is POSIX only') + @unittest.skipIf(is_wasi, "Incompatible with WASI mapdir and OOT builds") + @unittest.skipIf(is_android or is_apple_mobile, + 'Android and iOS do not ship python-config') + @requires_subprocess() + def test_python_config_sh_prefix_special_characters(self): + # gh-159819: the shell variant of python-config must report the + # directory it was installed to even if that path contains spaces or + # characters special to the shell, sed or echo. + if is_python_build(): + script = os.path.join(_PROJECT_BASE, 'python-config') + else: + ldversion = get_config_var('LDVERSION') + script = os.path.join(os.path.dirname(sys.executable), + f'python{ldversion}-config') + if not os.path.isfile(script): + self.skipTest(f'{script!r} does not exist') + with open(script, encoding='utf-8') as f: + if f.readline().rstrip() != '#!/bin/sh': + self.skipTest('python-config is not the shell script variant') + + with open(script, encoding='utf-8') as f: + script_text = f.read() + makefile_vars = _parse_makefile(sysconfig.get_makefile_filename()) + configured_prefix = makefile_vars['prefix'] + + def check(script_copy, option, expected): + proc = subprocess.run(['/bin/sh', script_copy, option], + capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout.rstrip('\n'), expected) + self.assertEqual(proc.stderr, '') + + # '#', '&' and '\' were special to the sed replacement, and '\c' makes + # some echo implementations stop printing. + names = ['path with spaces', 'path#with&', r'back\slash\cdir'] + # The configured prefix is also matched literally. Simulate a build + # configured with special characters in its prefix by substituting + # them into a copy of the script. + fake_prefixes = [None] + if configured_prefix != '/': + fake_prefixes.append('/conf#with&special\\chars') + for name, fake_prefix in itertools.product(names, fake_prefixes): + with (self.subTest(name=name, configured_prefix=fake_prefix), + temp_dir() as tmpdir): + prefix = os.path.realpath(os.path.join(tmpdir, name)) + bindir = os.path.join(prefix, 'bin') + os.mkdir(prefix) + os.mkdir(bindir) + script_copy = os.path.join(bindir, 'python-config') + text = script_text + if fake_prefix is not None: + text = text.replace(configured_prefix, fake_prefix) + with open(script_copy, 'w', encoding='utf-8') as f: + f.write(text) + + check(script_copy, '--prefix', prefix) + check(script_copy, '--exec-prefix', prefix) + + # The script rewrites the configured include directory by + # replacing the configured prefix with the actual one. + includedir = makefile_vars['INCLUDEDIR'].replace( + configured_prefix, prefix, 1) + incdir = os.path.join(includedir, 'python{VERSION}{ABIFLAGS}' + .format_map(makefile_vars)) + check(script_copy, '--includes', f'-I{incdir} -I{incdir}') + def test_parse_config_h(self): config = textwrap.dedent(''' #ifndef Py_PYCONFIG_H diff --git a/Misc/NEWS.d/next/Build/2026-10-19-17-05-00.gh-issue-159819.pyCfgSp.rst b/Misc/NEWS.d/next/Build/2026-10-19-17-05-00.gh-issue-159819.pyCfgSp.rst new file mode 100644 index 000000000000000..a7fcdaa05471eb4 --- /dev/null +++ b/Misc/NEWS.d/next/Build/2026-10-19-17-05-00.gh-issue-159819.pyCfgSp.rst @@ -0,0 +1,6 @@ +Fix the shell version of :program:`python3-config` (used on all POSIX +platforms except macOS) when the installation prefix contains spaces or +other characters special to the shell or :program:`sed`: it printed an +empty ``--prefix`` and wrong ``--includes``/``--ldflags`` paths while +exiting successfully. The script now also exits with status 1 if it cannot +determine its installation prefix. diff --git a/Misc/python-config.sh.in b/Misc/python-config.sh.in index 555b0cb6ba2a482..39b0031b58b916b 100644 --- a/Misc/python-config.sh.in +++ b/Misc/python-config.sh.in @@ -7,9 +7,9 @@ exit_with_usage () local usage usage="Usage: $0 --prefix|--exec-prefix|--includes|--libs|--cflags|--ldflags|--extension-suffix|--help|--abiflags|--configdir|--embed" if [ "$1" -eq 0 ]; then - echo "$usage" + printf '%s\n' "$usage" else - echo "$usage" >&2 + printf '%s\n' "$usage" >&2 fi exit $1 } @@ -19,29 +19,46 @@ if [ "$1" = "" ] ; then fi # Returns the actual prefix where this script was installed to. +# Fails if the directory containing this script cannot be resolved. installed_prefix () { - RESULT=$(dirname $(cd $(dirname "$1") && pwd -P)) + RESULT=$(cd "$(dirname "$1")" && pwd -P) || return 1 + RESULT=$(dirname "$RESULT") if which readlink >/dev/null 2>&1 ; then if readlink -f "$RESULT" >/dev/null 2>&1; then RESULT=$(readlink -f "$RESULT") fi fi - echo $RESULT + printf '%s\n' "$RESULT" } -prefix_real=$(installed_prefix "$0") +prefix_real=$(installed_prefix "$0") || { + printf '%s\n' "$0: cannot determine the installation prefix" >&2 + exit 1 +} -# Use sed to fix paths from their built-to locations to their installed-to -# locations. Keep prefix & exec_prefix using their original values in case +# Fix paths from their built-to locations to their installed-to locations +# by replacing the first occurrence of the configured prefix with the actual +# one. Keep prefix & exec_prefix using their original values in case # they are referenced in other configure variables, to prevent double # substitution, issue #22140. prefix="@prefix@" exec_prefix="@exec_prefix@" exec_prefix_real=${prefix_real} -includedir=$(echo "@includedir@" | sed "s#$prefix#$prefix_real#") -libdir=$(echo "@libdir@" | sed "s#$prefix#$prefix_real#") -CFLAGS=$(echo "@CFLAGS@" | sed "s#$prefix#$prefix_real#") +relocate () +{ + case "$1" in + *"$prefix"*) + printf '%s\n' "${1%%"$prefix"*}${prefix_real}${1#*"$prefix"}" + ;; + *) + printf '%s\n' "$1" + ;; + esac +} +includedir=$(relocate "@includedir@") +libdir=$(relocate "@libdir@") +CFLAGS=$(relocate "@CFLAGS@") VERSION="@VERSION@" LIBM="@LIBM@" LIBC="@LIBC@" @@ -55,7 +72,7 @@ OPT="@OPT@" PY_ENABLE_SHARED="@PY_ENABLE_SHARED@" LDVERSION="@LDVERSION@" LIBDEST=${prefix_real}/lib/python${VERSION} -LIBPL=$(echo "@LIBPL@" | sed "s#$prefix#$prefix_real#") +LIBPL=$(relocate "@LIBPL@") SO="@EXT_SUFFIX@" PYTHONFRAMEWORK="@PYTHONFRAMEWORK@" INCDIR="-I$includedir/python${VERSION}${ABIFLAGS}" @@ -63,9 +80,9 @@ PLATINCDIR="-I$includedir/python${VERSION}${ABIFLAGS}" PY_EMBED=0 # Scan for --help or unknown argument. -for ARG in $* +for ARG in "$@" do - case $ARG in + case "$ARG" in --help) exit_with_usage 0 ;; @@ -88,35 +105,35 @@ for ARG in "$@" do case "$ARG" in --prefix) - echo "$prefix_real" + printf '%s\n' "$prefix_real" ;; --exec-prefix) - echo "$exec_prefix_real" + printf '%s\n' "$exec_prefix_real" ;; --includes) - echo "$INCDIR $PLATINCDIR" + printf '%s\n' "$INCDIR $PLATINCDIR" ;; --cflags) - echo "$INCDIR $PLATINCDIR $BASECFLAGS $CFLAGS $OPT" + printf '%s\n' "$INCDIR $PLATINCDIR $BASECFLAGS $CFLAGS $OPT" ;; --libs) - echo "$LIBS" + printf '%s\n' "$LIBS" ;; --ldflags) LIBPLUSED= if [ "$PY_ENABLE_SHARED" = "0" ] ; then LIBPLUSED="-L$LIBPL" fi - echo "$LIBPLUSED -L$libdir $LIBS" + printf '%s\n' "$LIBPLUSED -L$libdir $LIBS" ;; --extension-suffix) - echo "$SO" + printf '%s\n' "$SO" ;; --abiflags) - echo "$ABIFLAGS" + printf '%s\n' "$ABIFLAGS" ;; --configdir) - echo "$LIBPL" + printf '%s\n' "$LIBPL" ;; esac done