diff --git a/Lib/test/test_venv.py b/Lib/test/test_venv.py index 58ae85fb268042e..f44292c33affca6 100644 --- a/Lib/test/test_venv.py +++ b/Lib/test/test_venv.py @@ -773,6 +773,30 @@ def test_special_chars_csh(self): self.assertTrue(env_name.encode() in lines[0]) self.assertEndsWith(lines[1], env_name.encode()) + # gh-152686: '!' triggers csh history expansion even inside single quotes + @unittest.skipIf(os.name == 'nt', 'contains invalid characters on Windows') + @unittest.skipIf(sys.platform.startswith('netbsd'), + "NetBSD csh fails with quoted special chars; see gh-139308") + def test_special_chars_csh_prompt(self): + """ + Test that a '!' in the prompt is quoted properly (csh) + """ + rmtree(self.env_dir) + csh = shutil.which('tcsh') or shutil.which('csh') + if csh is None: + self.skipTest('csh required for this test') + prompt = 'py!env' + builder = venv.EnvBuilder(clear=True, prompt=prompt) + builder.create(self.env_dir) + activate = os.path.join(self.env_dir, self.bindir, 'activate.csh') + test_script = os.path.join(self.env_dir, 'test_special_chars_prompt.csh') + with open(test_script, "w") as f: + f.write(f'source {shlex.quote(activate)}\n' + 'python -c \'import os; print(os.environ["VIRTUAL_ENV_PROMPT"])\'\n' + 'deactivate\n') + out, err = check_output([csh, test_script]) + self.assertEndsWith(out.splitlines()[-1], prompt.encode()) + # gh-140006: the fish prompt override must keep working when a user # function shadows a builtin it relies on. @unittest.skipIf(os.name == 'nt', 'fish is not available on Windows') diff --git a/Lib/venv/__init__.py b/Lib/venv/__init__.py index bd2762d55ef6961..c9c592ad0a6420e 100644 --- a/Lib/venv/__init__.py +++ b/Lib/venv/__init__.py @@ -519,6 +519,11 @@ def quote_ps1(s): def quote_bat(s): return s + def quote_csh(s): + # (t)csh history-expands '!' even inside single quotes, so POSIX + # quoting is not enough; escape each '!' explicitly. + return shlex.quote(s).replace('!', "'\\!'") + # gh-124651: need to quote the template strings properly quote = shlex.quote script_path = context.script_path @@ -526,6 +531,8 @@ def quote_bat(s): quote = quote_ps1 elif script_path.endswith('.bat'): quote = quote_bat + elif script_path.endswith('.csh'): + quote = quote_csh else: # fallbacks to POSIX shell compliant quote quote = shlex.quote diff --git a/Misc/NEWS.d/next/Library/2026-06-30-13-00-00.gh-issue-152686.vcsh01.rst b/Misc/NEWS.d/next/Library/2026-06-30-13-00-00.gh-issue-152686.vcsh01.rst new file mode 100644 index 000000000000000..78579c6639dad8e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-30-13-00-00.gh-issue-152686.vcsh01.rst @@ -0,0 +1,3 @@ +Fix :mod:`venv` activation under (t)csh for environments whose path or +``--prompt`` contains ``!``: ``activate.csh`` is now quoted so that csh does +not perform history expansion on it.