Skip to content

Commit

Permalink
Make scm_ignore_files accept an iterable
Browse files Browse the repository at this point in the history
  • Loading branch information
brettcannon committed Sep 10, 2023
1 parent 90eb700 commit c3b96e0
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 59 deletions.
17 changes: 9 additions & 8 deletions Doc/library/venv.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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``
--------------------------------------
Expand Down
118 changes: 84 additions & 34 deletions Lib/test/test_venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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),
Expand All @@ -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'),
Expand All @@ -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'),
Expand Down Expand Up @@ -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())
Expand All @@ -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())
Expand All @@ -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',
Expand All @@ -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)); '
Expand All @@ -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):
Expand All @@ -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())

Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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'])
Expand Down
33 changes: 16 additions & 17 deletions Lib/venv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
"""
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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.')
Expand All @@ -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)

Expand Down

0 comments on commit c3b96e0

Please sign in to comment.