From 5e9e9983301eff404d3f79f50f7297dcf0d51dc0 Mon Sep 17 00:00:00 2001 From: Itamar Oren Date: Mon, 19 Oct 2026 16:43:40 -0700 Subject: [PATCH 1/5] gh-159819: Fix python-config.sh for installation paths containing spaces The shell variant of python-config located its own installation prefix with unquoted command substitutions, so a prefix containing whitespace was word-split: the script printed empty --prefix and --exec-prefix values and bogus --includes/--ldflags paths while still exiting 0. Quote the substitutions, quote the argument loop, and exit with status 1 when the prefix cannot be determined. --- Misc/python-config.sh.in | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Misc/python-config.sh.in b/Misc/python-config.sh.in index 555b0cb6ba2a482..1a8327e73e256d7 100644 --- a/Misc/python-config.sh.in +++ b/Misc/python-config.sh.in @@ -19,18 +19,23 @@ 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 + echo "$RESULT" } -prefix_real=$(installed_prefix "$0") +prefix_real=$(installed_prefix "$0") || { + echo "$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 @@ -63,9 +68,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 ;; From 40d6b81c7be0165fc4a32549f160d16ae92671a3 Mon Sep 17 00:00:00 2001 From: Itamar Oren Date: Mon, 19 Oct 2026 16:43:40 -0700 Subject: [PATCH 2/5] gh-159819: Add test for python-config.sh with a prefix containing spaces --- Lib/test/test_sysconfig.py | 50 +++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_sysconfig.py b/Lib/test/test_sysconfig.py index 6b90f2960672b69..272933d48cc2552 100644 --- a/Lib/test/test_sysconfig.py +++ b/Lib/test/test_sysconfig.py @@ -21,7 +21,8 @@ ) from test.support.import_helper import import_module from test.support.os_helper import (TESTFN, unlink, skip_unless_symlink, - change_cwd, EnvironmentVarGuard) + change_cwd, temp_dir, + EnvironmentVarGuard) from test.support.venv import VirtualEnvironmentMixin import sysconfig @@ -767,6 +768,53 @@ 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_with_spaces(self): + # gh-159819: the shell variant of python-config must report the + # directory it was installed to even if that path contains spaces. + 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 temp_dir() as tmpdir: + prefix = os.path.realpath(os.path.join(tmpdir, 'path with spaces')) + bindir = os.path.join(prefix, 'bin') + os.mkdir(prefix) + os.mkdir(bindir) + copy = os.path.join(bindir, 'python-config') + shutil.copyfile(script, copy) + + def check(option, expected): + proc = subprocess.run(['/bin/sh', 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, '') + + check('--prefix', prefix) + check('--exec-prefix', prefix) + + # The script rewrites the configured include directory by + # replacing the configured prefix with the actual one. + makefile_vars = _parse_makefile(sysconfig.get_makefile_filename()) + includedir = makefile_vars['INCLUDEDIR'].replace( + makefile_vars['prefix'], prefix, 1) + incdir = os.path.join(includedir, 'python{VERSION}{ABIFLAGS}' + .format_map(makefile_vars)) + check('--includes', f'-I{incdir} -I{incdir}') + def test_parse_config_h(self): config = textwrap.dedent(''' #ifndef Py_PYCONFIG_H From 9893a04471c0e5c89674900b08dc9619e95ca419 Mon Sep 17 00:00:00 2001 From: Itamar Oren Date: Mon, 19 Oct 2026 16:43:41 -0700 Subject: [PATCH 3/5] gh-159819: Relocate paths in python-config.sh without sed Replace the configured prefix with the installed one using POSIX parameter expansion instead of sed, so that a prefix containing '#', '&', a backslash or a regular expression metacharacter is handled literally. --- Misc/python-config.sh.in | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Misc/python-config.sh.in b/Misc/python-config.sh.in index 1a8327e73e256d7..3d6de95ddf7587f 100644 --- a/Misc/python-config.sh.in +++ b/Misc/python-config.sh.in @@ -37,16 +37,28 @@ prefix_real=$(installed_prefix "$0") || { 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@" @@ -60,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}" From de30d4ae074af9636e0f79ecf6a502ccd58ff9bf Mon Sep 17 00:00:00 2001 From: Itamar Oren Date: Mon, 19 Oct 2026 16:43:41 -0700 Subject: [PATCH 4/5] gh-159819: Add NEWS entry --- .../Build/2026-10-19-17-05-00.gh-issue-159819.pyCfgSp.rst | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Misc/NEWS.d/next/Build/2026-10-19-17-05-00.gh-issue-159819.pyCfgSp.rst 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. From ced9169e9a411dbb8cb74d1a9dc3e579f11522ab Mon Sep 17 00:00:00 2001 From: Itamar Oren Date: Wed, 21 Oct 2026 17:25:28 -0700 Subject: [PATCH 5/5] Use printf instead of echo and test more special characters dash's echo interprets backslash escapes even in quoted arguments, so a path containing "\c" was truncated. Print every value with printf '%s\n'. Test "#", "&" and "\" in the install path and in the configured prefix. Co-Authored-By: Claude Opus 5.5 --- Lib/test/test_sysconfig.py | 77 ++++++++++++++++++++++++-------------- Misc/python-config.sh.in | 26 ++++++------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/Lib/test/test_sysconfig.py b/Lib/test/test_sysconfig.py index 272933d48cc2552..af642e25ac8c3df 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 @@ -773,9 +774,10 @@ def test_sysconfig_config_vars_no_prefix_cache(self): @unittest.skipIf(is_android or is_apple_mobile, 'Android and iOS do not ship python-config') @requires_subprocess() - def test_python_config_sh_prefix_with_spaces(self): + 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. + # 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: @@ -788,32 +790,51 @@ def test_python_config_sh_prefix_with_spaces(self): if f.readline().rstrip() != '#!/bin/sh': self.skipTest('python-config is not the shell script variant') - with temp_dir() as tmpdir: - prefix = os.path.realpath(os.path.join(tmpdir, 'path with spaces')) - bindir = os.path.join(prefix, 'bin') - os.mkdir(prefix) - os.mkdir(bindir) - copy = os.path.join(bindir, 'python-config') - shutil.copyfile(script, copy) - - def check(option, expected): - proc = subprocess.run(['/bin/sh', 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, '') - - check('--prefix', prefix) - check('--exec-prefix', prefix) - - # The script rewrites the configured include directory by - # replacing the configured prefix with the actual one. - makefile_vars = _parse_makefile(sysconfig.get_makefile_filename()) - includedir = makefile_vars['INCLUDEDIR'].replace( - makefile_vars['prefix'], prefix, 1) - incdir = os.path.join(includedir, 'python{VERSION}{ABIFLAGS}' - .format_map(makefile_vars)) - check('--includes', f'-I{incdir} -I{incdir}') + 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(''' diff --git a/Misc/python-config.sh.in b/Misc/python-config.sh.in index 3d6de95ddf7587f..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 } @@ -29,11 +29,11 @@ installed_prefix () RESULT=$(readlink -f "$RESULT") fi fi - echo "$RESULT" + printf '%s\n' "$RESULT" } prefix_real=$(installed_prefix "$0") || { - echo "$0: cannot determine the installation prefix" >&2 + printf '%s\n' "$0: cannot determine the installation prefix" >&2 exit 1 } @@ -105,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