https://github.com/python/cpython/commit/3e134077631fec203e4cdcb44133176d5abbea19
commit: 3e134077631fec203e4cdcb44133176d5abbea19
branch: main
author: Itamar Oren <[email protected]>
committer: itamaro <[email protected]>
date: 2026-10-21T21:29:37-07:00
summary:
gh-159819: Fix python-config.sh for installation paths containing spaces
(#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
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 6b90f2960672b6..af642e25ac8c3d 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
@@ -767,6 +769,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 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]