https://github.com/python/cpython/commit/2eddd80c1067646138d6c0eb58f809f12dbab4b7
commit: 2eddd80c1067646138d6c0eb58f809f12dbab4b7
branch: 3.15
author: Miss Islington (bot) <[email protected]>
committer: itamaro <[email protected]>
date: 2026-10-22T06:35:33-07:00
summary:

[3.15] gh-159819: Fix python-config.sh for installation paths containing spaces 
(GH-159820) (#160184)

gh-159819: Fix python-config.sh for installation paths containing spaces 
(GH-159820)

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)

Co-authored-by: Itamar Oren <[email protected]>

files:
A Misc/NEWS.d/next/Build/2026-10-19-17-05-00.gh-issue-159819.pyCfgSp.rst
M Lib/test/test_sysconfig.py
M Misc/python-config.sh.in

diff --git a/Lib/test/test_sysconfig.py b/Lib/test/test_sysconfig.py
index 5263799a4c7088..ce3a3c6fe4513e 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
@@ -21,7 +22,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
@@ -760,6 +762,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&amp', 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 00000000000000..a7fcdaa05471eb
--- /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 555b0cb6ba2a48..39b0031b58b916 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

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to