From c3b96e095d79e2862c2ea83b63fe7e0820d3e6b3 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Sat, 9 Sep 2023 17:03:02 -0700 Subject: [PATCH] Make `scm_ignore_files` accept an iterable --- Doc/library/venv.rst | 17 +++--- Lib/test/test_venv.py | 118 ++++++++++++++++++++++++++++++------------ Lib/venv/__init__.py | 33 ++++++------ 3 files changed, 109 insertions(+), 59 deletions(-) diff --git a/Doc/library/venv.rst b/Doc/library/venv.rst index 02d9dc51fb9924..3321a3c452e7db 100644 --- a/Doc/library/venv.rst +++ b/Doc/library/venv.rst @@ -143,7 +143,8 @@ creation according to their needs, the :class:`EnvBuilder` class. .. class:: EnvBuilder(system_site_packages=False, clear=False, \ symlinks=False, upgrade=False, with_pip=False, \ - prompt=None, upgrade_deps=False, *, scm_ignore_file=None) + prompt=None, upgrade_deps=False, + *, scm_ignore_files=frozenset()) The :class:`EnvBuilder` class accepts the following keyword arguments on instantiation: @@ -172,10 +173,10 @@ creation according to their needs, the :class:`EnvBuilder` class. * ``upgrade_deps`` -- Update the base venv modules to the latest on PyPI - * ``scm_ignore_file`` -- Create an ignore file for the specified source - control manager (SCM). Support is defined by having a method named - ``create_{scm}_ignore_file``. The only value currently supported is - ``"git"`` via :meth:`create_git_ignore_file`. + * ``scm_ignore_files`` -- Create ignore files based for the specified source + control managers (SCM) in the iterable. Support is defined by having a + method named ``create_{scm}_ignore_file``. The only value supported by + default is ``"git"`` via :meth:`create_git_ignore_file`. .. versionchanged:: 3.4 @@ -188,7 +189,7 @@ creation according to their needs, the :class:`EnvBuilder` class. Added the ``upgrade_deps`` parameter .. versionadded:: 3.13 - Added the ``scm_ignore_file`` parameter + Added the ``scm_ignore_files`` parameter Creators of third-party virtual environment tools will be free to use the provided :class:`EnvBuilder` class as a base class. @@ -359,7 +360,7 @@ There is also a module-level convenience function: .. function:: create(env_dir, system_site_packages=False, clear=False, \ symlinks=False, with_pip=False, prompt=None, \ - upgrade_deps=False, *, scm_ignore_file=None) + upgrade_deps=False, *, scm_ignore_files=frozenset()) Create an :class:`EnvBuilder` with the given keyword arguments, and call its :meth:`~EnvBuilder.create` method with the *env_dir* argument. @@ -376,7 +377,7 @@ There is also a module-level convenience function: Added the ``upgrade_deps`` parameter .. versionchanged:: 3.13 - Added the ``scm_ignore_file`` parameter + Added the ``scm_ignore_files`` parameter An example of extending ``EnvBuilder`` -------------------------------------- diff --git a/Lib/test/test_venv.py b/Lib/test/test_venv.py index dfddae53b3006e..a894bb10bd04da 100644 --- a/Lib/test/test_venv.py +++ b/Lib/test/test_venv.py @@ -82,6 +82,13 @@ def setUp(self): def tearDown(self): rmtree(self.env_dir) + def envpy(self, *, real_env_dir=False): + if real_env_dir: + env_dir = os.path.realpath(self.env_dir) + else: + env_dir = self.env_dir + return os.path.join(env_dir, self.bindir, self.exe) + def run_with_capture(self, func, *args, **kwargs): with captured_stdout() as output: with captured_stderr() as error: @@ -139,7 +146,7 @@ def _check_output_of_default_create(self): os.path.realpath(sys.executable), data) copies = '' if os.name=='nt' else ' --copies' cmd = (f'command = {sys.executable} -m venv{copies} --without-pip ' - f'--without-scm-ignore-file {self.env_dir}') + f'--without-scm-ignore-files {self.env_dir}') self.assertIn(cmd, data) fn = self.get_env_file(self.bindir, self.exe) if not os.path.exists(fn): # diagnostics for Windows buildbot failures @@ -158,7 +165,7 @@ def test_config_file_command_key(self): ('--upgrade', 'upgrade', True), ('--upgrade-deps', 'upgrade_deps', True), ('--prompt', 'prompt', True), - ('--without-scm-ignore-file', 'scm_ignore_file', None), + ('--without-scm-ignore-files', 'scm_ignore_files', frozenset()), ] for opt, attr, value in options: with self.subTest(opt=opt, attr=attr, value=value): @@ -246,8 +253,7 @@ def test_prefixes(self): # check a venv's prefixes rmtree(self.env_dir) self.run_with_capture(venv.create, self.env_dir) - envpy = os.path.join(self.env_dir, self.bindir, self.exe) - cmd = [envpy, '-c', None] + cmd = [self.envpy(), '-c', None] for prefix, expected in ( ('prefix', self.env_dir), ('exec_prefix', self.env_dir), @@ -264,8 +270,7 @@ def test_sysconfig(self): """ rmtree(self.env_dir) self.run_with_capture(venv.create, self.env_dir, symlinks=False) - envpy = os.path.join(self.env_dir, self.bindir, self.exe) - cmd = [envpy, '-c', None] + cmd = [self.envpy(), '-c', None] for call, expected in ( # installation scheme ('get_preferred_scheme("prefix")', 'venv'), @@ -287,8 +292,7 @@ def test_sysconfig_symlinks(self): """ rmtree(self.env_dir) self.run_with_capture(venv.create, self.env_dir, symlinks=True) - envpy = os.path.join(self.env_dir, self.bindir, self.exe) - cmd = [envpy, '-c', None] + cmd = [self.envpy(), '-c', None] for call, expected in ( # installation scheme ('get_preferred_scheme("prefix")', 'venv'), @@ -427,8 +431,7 @@ def test_executable(self): """ rmtree(self.env_dir) self.run_with_capture(venv.create, self.env_dir) - envpy = os.path.join(os.path.realpath(self.env_dir), - self.bindir, self.exe) + envpy = self.envpy(real_env_dir=True) out, err = check_output([envpy, '-c', 'import sys; print(sys.executable)']) self.assertEqual(out.strip(), envpy.encode()) @@ -441,8 +444,7 @@ def test_executable_symlinks(self): rmtree(self.env_dir) builder = venv.EnvBuilder(clear=True, symlinks=True) builder.create(self.env_dir) - envpy = os.path.join(os.path.realpath(self.env_dir), - self.bindir, self.exe) + envpy = self.envpy(real_env_dir=True) out, err = check_output([envpy, '-c', 'import sys; print(sys.executable)']) self.assertEqual(out.strip(), envpy.encode()) @@ -457,7 +459,6 @@ def test_unicode_in_batch_file(self): builder = venv.EnvBuilder(clear=True) builder.create(env_dir) activate = os.path.join(env_dir, self.bindir, 'activate.bat') - envpy = os.path.join(env_dir, self.bindir, self.exe) out, err = check_output( [activate, '&', self.exe, '-c', 'print(0)'], encoding='oem', @@ -476,9 +477,7 @@ def test_multiprocessing(self): rmtree(self.env_dir) self.run_with_capture(venv.create, self.env_dir) - envpy = os.path.join(os.path.realpath(self.env_dir), - self.bindir, self.exe) - out, err = check_output([envpy, '-c', + out, err = check_output([self.envpy(real_env_dir=True), '-c', 'from multiprocessing import Pool; ' 'pool = Pool(1); ' 'print(pool.apply_async("Python".lower).get(3)); ' @@ -494,10 +493,8 @@ def test_multiprocessing_recursion(self): rmtree(self.env_dir) self.run_with_capture(venv.create, self.env_dir) - envpy = os.path.join(os.path.realpath(self.env_dir), - self.bindir, self.exe) script = os.path.join(TEST_HOME_DIR, '_test_venv_multiprocessing.py') - subprocess.check_call([envpy, script]) + subprocess.check_call([self.envpy(real_env_dir=True), script]) @unittest.skipIf(os.name == 'nt', 'not relevant on Windows') def test_deactivate_with_strict_bash_opts(self): @@ -524,9 +521,7 @@ def test_macos_env(self): builder = venv.EnvBuilder() builder.create(self.env_dir) - envpy = os.path.join(os.path.realpath(self.env_dir), - self.bindir, self.exe) - out, err = check_output([envpy, '-c', + out, err = check_output([self.envpy(real_env_dir=True), '-c', 'import os; print("__PYVENV_LAUNCHER__" in os.environ)']) self.assertEqual(out.strip(), 'False'.encode()) @@ -588,7 +583,7 @@ def test_zippath_from_non_installed_posix(self): "-m", "venv", "--without-pip", - "--without-scm-ignore-file", + "--without-scm-ignore-files", self.env_dir] # Our fake non-installed python is not fully functional because # it cannot find the extensions. Set PYTHONPATH so it can run the @@ -613,10 +608,9 @@ def test_zippath_from_non_installed_posix(self): # prevent https://github.com/python/cpython/issues/104839 child_env["ASAN_OPTIONS"] = asan_options subprocess.check_call(cmd, env=child_env) - envpy = os.path.join(self.env_dir, self.bindir, self.exe) # Now check the venv created from the non-installed python has # correct zip path in pythonpath. - cmd = [envpy, '-S', '-c', 'import sys; print(sys.path)'] + cmd = [self.envpy(), '-S', '-c', 'import sys; print(sys.path)'] out, err = check_output(cmd) self.assertTrue(zip_landmark.encode() in out) @@ -638,23 +632,79 @@ def test_activate_shell_script_has_no_dos_newlines(self): self.assertFalse(line.endswith(b'\r\n'), error_message) @requireVenvCreate - def test_create_git_ignore_file(self): + def test_scm_ignore_files_git(self): """ - Test that a .gitignore file is created. + Test that a .gitignore file is created when "git" is specified. The file should contain a `*\n` line. """ - self.run_with_capture(venv.create, self.env_dir, scm_ignore_file='git') + self.run_with_capture(venv.create, self.env_dir, + scm_ignore_files={'git'}) file_lines = self.get_text_file_contents('.gitignore').splitlines() self.assertIn('*', file_lines) + @requireVenvCreate + def test_create_scm_ignore_files_multiple(self): + """ + Test that ``scm_ignore_files`` can work with multiple SCMs. + """ + bzrignore_name = ".bzrignore" + contents = "# For Bazaar.\n*\n" + + class BzrEnvBuilder(venv.EnvBuilder): + def create_bzr_ignore_file(self, context): + gitignore_path = os.path.join(context.env_dir, bzrignore_name) + with open(gitignore_path, 'w', encoding='utf-8') as file: + file.write(contents) + + builder = BzrEnvBuilder(scm_ignore_files={'git', 'bzr'}) + self.run_with_capture(builder.create, self.env_dir) + + gitignore_lines = self.get_text_file_contents('.gitignore').splitlines() + self.assertIn('*', gitignore_lines) + + bzrignore = self.get_text_file_contents(bzrignore_name) + self.assertEqual(bzrignore, contents) + + @requireVenvCreate + def test_create_scm_ignore_files_empty(self): + """ + Test that no default ignore files are created when ``scm_ignore_files`` + is empty. + """ + # scm_ignore_files is set to frozenset() by default. + self.run_with_capture(venv.create, self.env_dir) + with self.assertRaises(FileNotFoundError): + self.get_text_file_contents('.gitignore') + + self.assertIn("--without-scm-ignore-files", + self.get_text_file_contents('pyvenv.cfg')) + + @requireVenvCreate + def test_cli_with_scm_ignore_files(self): + """ + Test that default SCM ignore files are created by default via the CLI. + """ + self.run_with_capture(venv.main, ['--without-pip', self.env_dir]) + + gitignore_lines = self.get_text_file_contents('.gitignore').splitlines() + self.assertIn('*', gitignore_lines) + + @requireVenvCreate + def test_cli_without_scm_ignore_files(self): + """ + Test that ``--without-scm-ignore-files`` doesn't create SCM ignore files. + """ + args = ['--without-pip', '--without-scm-ignore-files', self.env_dir] + self.run_with_capture(venv.main, args) + + with self.assertRaises(FileNotFoundError): + self.get_text_file_contents('.gitignore') @requireVenvCreate class EnsurePipTest(BaseTest): """Test venv module installation of pip.""" def assert_pip_not_installed(self): - envpy = os.path.join(os.path.realpath(self.env_dir), - self.bindir, self.exe) - out, err = check_output([envpy, '-c', + out, err = check_output([self.envpy(real_env_dir=True), '-c', 'try:\n import pip\nexcept ImportError:\n print("OK")']) # We force everything to text, so unittest gives the detailed diff # if we get unexpected results @@ -721,9 +771,9 @@ def do_test_with_pip(self, system_site_packages): system_site_packages=system_site_packages, with_pip=True) # Ensure pip is available in the virtual environment - envpy = os.path.join(os.path.realpath(self.env_dir), self.bindir, self.exe) # Ignore DeprecationWarning since pip code is not part of Python - out, err = check_output([envpy, '-W', 'ignore::DeprecationWarning', + out, err = check_output([self.envpy(real_env_dir=True), + '-W', 'ignore::DeprecationWarning', '-W', 'ignore::ImportWarning', '-I', '-m', 'pip', '--version']) # We force everything to text, so unittest gives the detailed diff @@ -744,7 +794,7 @@ def do_test_with_pip(self, system_site_packages): # It seems ensurepip._uninstall calls subprocesses which do not # inherit the interpreter settings. envvars["PYTHONWARNINGS"] = "ignore" - out, err = check_output([envpy, + out, err = check_output([self.envpy(real_env_dir=True), '-W', 'ignore::DeprecationWarning', '-W', 'ignore::ImportWarning', '-I', '-m', 'ensurepip._uninstall']) diff --git a/Lib/venv/__init__.py b/Lib/venv/__init__.py index e4166a6ac79e3d..34d8c2cd7ff8e1 100644 --- a/Lib/venv/__init__.py +++ b/Lib/venv/__init__.py @@ -41,12 +41,13 @@ class EnvBuilder: environment :param prompt: Alternative terminal prefix for the environment. :param upgrade_deps: Update the base venv modules to the latest on PyPI - :param scm_ignore_file: Create an ignore file for the specified SCM. + :param scm_ignore_files: Create ignore files for the SCMs specified by the + iterable. """ def __init__(self, system_site_packages=False, clear=False, symlinks=False, upgrade=False, with_pip=False, prompt=None, - upgrade_deps=False, *, scm_ignore_file=None): + upgrade_deps=False, *, scm_ignore_files=frozenset()): self.system_site_packages = system_site_packages self.clear = clear self.symlinks = symlinks @@ -57,9 +58,7 @@ def __init__(self, system_site_packages=False, clear=False, prompt = os.path.basename(os.getcwd()) self.prompt = prompt self.upgrade_deps = upgrade_deps - if scm_ignore_file: - scm_ignore_file = scm_ignore_file.lower() - self.scm_ignore_file = scm_ignore_file + self.scm_ignore_files = frozenset(map(str.lower, scm_ignore_files)) def create(self, env_dir): """ @@ -70,8 +69,8 @@ def create(self, env_dir): """ env_dir = os.path.abspath(env_dir) context = self.ensure_directories(env_dir) - if self.scm_ignore_file: - getattr(self, f"create_{self.scm_ignore_file}_ignore_file")(context) + for scm in self.scm_ignore_files: + getattr(self, f"create_{scm}_ignore_file")(context) # See issue 24875. We need system_site_packages to be False # until after pip is installed. true_system_site_packages = self.system_site_packages @@ -216,8 +215,8 @@ def create_configuration(self, context): args.append('--upgrade-deps') if self.orig_prompt is not None: args.append(f'--prompt="{self.orig_prompt}"') - if not self.scm_ignore_file: - args.append('--without-scm-ignore-file') + if not self.scm_ignore_files: + args.append('--without-scm-ignore-files') args.append(context.env_dir) args = ' '.join(args) @@ -483,12 +482,12 @@ def upgrade_dependencies(self, context): def create(env_dir, system_site_packages=False, clear=False, symlinks=False, with_pip=False, prompt=None, upgrade_deps=False, - *, scm_ignore_file=None): + *, scm_ignore_files=frozenset()): """Create a virtual environment in a directory.""" builder = EnvBuilder(system_site_packages=system_site_packages, clear=clear, symlinks=symlinks, with_pip=with_pip, prompt=prompt, upgrade_deps=upgrade_deps, - scm_ignore_file=scm_ignore_file) + scm_ignore_files=scm_ignore_files) builder.create(env_dir) @@ -548,11 +547,11 @@ def main(args=None): dest='upgrade_deps', help=f'Upgrade core dependencies ({", ".join(CORE_VENV_DEPS)}) ' 'to the latest version in PyPI') - parser.add_argument('--without-scm-ignore-file', dest='scm_ignore_file', - action='store_const', const=None, default='git', - help='Skips adding the default SCM ignore file to the ' - 'environment directory (the default is a ' - '.gitignore file).') + parser.add_argument('--without-scm-ignore-files', dest='scm_ignore_files', + action='store_const', const=frozenset(), + default=frozenset(['git']), + help='Skips adding SCM ignore files to the environment ' + 'directory (git is supported by default).') options = parser.parse_args(args) if options.upgrade and options.clear: raise ValueError('you cannot supply --upgrade and --clear together.') @@ -563,7 +562,7 @@ def main(args=None): with_pip=options.with_pip, prompt=options.prompt, upgrade_deps=options.upgrade_deps, - scm_ignore_file=options.scm_ignore_file) + scm_ignore_files=options.scm_ignore_files) for d in options.dirs: builder.create(d)