GH-83417: Allow `venv` to add a `.gitignore` file to environments via a new `scm_ignore_file` parameter (GH-108125)

This feature is off by default via code but on by default via the CLI. The `.gitignore` file contains `*` which causes the entire directory to be ignored.

Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com>
Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>
This commit is contained in:
Brett Cannon 2023-09-15 15:38:08 -07:00 committed by GitHub
parent 6b179adb8c
commit e218e5022e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 229 additions and 91 deletions

View File

@ -143,7 +143,8 @@ creation according to their needs, the :class:`EnvBuilder` class.
.. class:: EnvBuilder(system_site_packages=False, clear=False, \ .. class:: EnvBuilder(system_site_packages=False, clear=False, \
symlinks=False, upgrade=False, with_pip=False, \ symlinks=False, upgrade=False, with_pip=False, \
prompt=None, upgrade_deps=False) prompt=None, upgrade_deps=False, \
*, scm_ignore_files=frozenset())
The :class:`EnvBuilder` class accepts the following keyword arguments on The :class:`EnvBuilder` class accepts the following keyword arguments on
instantiation: instantiation:
@ -172,6 +173,12 @@ creation according to their needs, the :class:`EnvBuilder` class.
* ``upgrade_deps`` -- Update the base venv modules to the latest on PyPI * ``upgrade_deps`` -- Update the base venv modules to the latest on PyPI
* ``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 .. versionchanged:: 3.4
Added the ``with_pip`` parameter Added the ``with_pip`` parameter
@ -181,6 +188,9 @@ creation according to their needs, the :class:`EnvBuilder` class.
.. versionadded:: 3.9 .. versionadded:: 3.9
Added the ``upgrade_deps`` parameter Added the ``upgrade_deps`` parameter
.. versionadded:: 3.13
Added the ``scm_ignore_files`` parameter
Creators of third-party virtual environment tools will be free to use the Creators of third-party virtual environment tools will be free to use the
provided :class:`EnvBuilder` class as a base class. provided :class:`EnvBuilder` class as a base class.
@ -339,11 +349,18 @@ creation according to their needs, the :class:`EnvBuilder` class.
The directories are allowed to exist (for when an existing environment The directories are allowed to exist (for when an existing environment
is being upgraded). is being upgraded).
.. method:: create_git_ignore_file(context)
Creates a ``.gitignore`` file within the virtual environment that causes
the entire directory to be ignored by the ``git`` source control manager.
.. versionadded:: 3.13
There is also a module-level convenience function: There is also a module-level convenience function:
.. function:: create(env_dir, system_site_packages=False, clear=False, \ .. function:: create(env_dir, system_site_packages=False, clear=False, \
symlinks=False, with_pip=False, prompt=None, \ symlinks=False, with_pip=False, prompt=None, \
upgrade_deps=False) upgrade_deps=False, *, scm_ignore_files=frozenset())
Create an :class:`EnvBuilder` with the given keyword arguments, and call its Create an :class:`EnvBuilder` with the given keyword arguments, and call its
:meth:`~EnvBuilder.create` method with the *env_dir* argument. :meth:`~EnvBuilder.create` method with the *env_dir* argument.
@ -359,6 +376,9 @@ There is also a module-level convenience function:
.. versionchanged:: 3.9 .. versionchanged:: 3.9
Added the ``upgrade_deps`` parameter Added the ``upgrade_deps`` parameter
.. versionchanged:: 3.13
Added the ``scm_ignore_files`` parameter
An example of extending ``EnvBuilder`` An example of extending ``EnvBuilder``
-------------------------------------- --------------------------------------

View File

@ -35,37 +35,48 @@ your :ref:`Python installation <using-on-windows>`::
The command, if run with ``-h``, will show the available options:: The command, if run with ``-h``, will show the available options::
usage: venv [-h] [--system-site-packages] [--symlinks | --copies] [--clear] usage: venv [-h] [--system-site-packages] [--symlinks | --copies] [--clear]
[--upgrade] [--without-pip] [--prompt PROMPT] [--upgrade-deps] [--upgrade] [--without-pip] [--prompt PROMPT] [--upgrade-deps]
ENV_DIR [ENV_DIR ...] [--without-scm-ignore-file]
ENV_DIR [ENV_DIR ...]
Creates virtual Python environments in one or more target directories. Creates virtual Python environments in one or more target directories.
positional arguments: positional arguments:
ENV_DIR A directory to create the environment in. ENV_DIR A directory to create the environment in.
optional arguments: options:
-h, --help show this help message and exit -h, --help show this help message and exit
--system-site-packages --system-site-packages
Give the virtual environment access to the system Give the virtual environment access to the system
site-packages dir. site-packages dir.
--symlinks Try to use symlinks rather than copies, when symlinks --symlinks Try to use symlinks rather than copies, when
are not the default for the platform. symlinks are not the default for the platform.
--copies Try to use copies rather than symlinks, even when --copies Try to use copies rather than symlinks, even when
symlinks are the default for the platform. symlinks are the default for the platform.
--clear Delete the contents of the environment directory if it --clear Delete the contents of the environment directory if
already exists, before environment creation. it already exists, before environment creation.
--upgrade Upgrade the environment directory to use this version --upgrade Upgrade the environment directory to use this
of Python, assuming Python has been upgraded in-place. version of Python, assuming Python has been upgraded
--without-pip Skips installing or upgrading pip in the virtual in-place.
environment (pip is bootstrapped by default) --without-pip Skips installing or upgrading pip in the virtual
--prompt PROMPT Provides an alternative prompt prefix for this environment (pip is bootstrapped by default)
environment. --prompt PROMPT Provides an alternative prompt prefix for this
--upgrade-deps Upgrade core dependencies (pip) to the environment.
latest version in PyPI --upgrade-deps Upgrade core dependencies (pip) to the latest
version in PyPI
--without-scm-ignore-file
Skips adding the default SCM ignore file to the
environment directory (the default is a .gitignore
file).
Once an environment has been created, you may wish to activate it, e.g. by Once an environment has been created, you may wish to activate it, e.g. by
sourcing an activate script in its bin directory. sourcing an activate script in its bin directory.
.. versionchanged:: 3.13
``--without-scm-ignore-file`` was added along with creating an ignore file
for ``git`` by default.
.. versionchanged:: 3.12 .. versionchanged:: 3.12

View File

@ -220,6 +220,16 @@ typing
check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in
:gh:`104873`.) :gh:`104873`.)
venv
----
* Add support for adding source control management (SCM) ignore files to a
virtual environment's directory. By default, Git is supported. This is
implemented as opt-in via the API which can be extended to support other SCMs
(:class:`venv.EnvBuilder` and :func:`venv.create`), and opt-out via the CLI
(using ``--without-scm-ignore-files``). (Contributed by Brett Cannon in
:gh:`108125`.)
Optimizations Optimizations
============= =============

View File

@ -82,6 +82,13 @@ def setUp(self):
def tearDown(self): def tearDown(self):
rmtree(self.env_dir) 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): def run_with_capture(self, func, *args, **kwargs):
with captured_stdout() as output: with captured_stdout() as output:
with captured_stderr() as error: with captured_stderr() as error:
@ -138,7 +145,8 @@ def _check_output_of_default_create(self):
self.assertIn('executable = %s' % self.assertIn('executable = %s' %
os.path.realpath(sys.executable), data) os.path.realpath(sys.executable), data)
copies = '' if os.name=='nt' else ' --copies' copies = '' if os.name=='nt' else ' --copies'
cmd = f'command = {sys.executable} -m venv{copies} --without-pip {self.env_dir}' cmd = (f'command = {sys.executable} -m venv{copies} --without-pip '
f'--without-scm-ignore-files {self.env_dir}')
self.assertIn(cmd, data) self.assertIn(cmd, data)
fn = self.get_env_file(self.bindir, self.exe) fn = self.get_env_file(self.bindir, self.exe)
if not os.path.exists(fn): # diagnostics for Windows buildbot failures if not os.path.exists(fn): # diagnostics for Windows buildbot failures
@ -148,35 +156,37 @@ def _check_output_of_default_create(self):
self.assertTrue(os.path.exists(fn), 'File %r should exist.' % fn) self.assertTrue(os.path.exists(fn), 'File %r should exist.' % fn)
def test_config_file_command_key(self): def test_config_file_command_key(self):
attrs = [ options = [
(None, None), (None, None, None), # Default case.
('symlinks', '--copies'), ('--copies', 'symlinks', False),
('with_pip', '--without-pip'), ('--without-pip', 'with_pip', False),
('system_site_packages', '--system-site-packages'), ('--system-site-packages', 'system_site_packages', True),
('clear', '--clear'), ('--clear', 'clear', True),
('upgrade', '--upgrade'), ('--upgrade', 'upgrade', True),
('upgrade_deps', '--upgrade-deps'), ('--upgrade-deps', 'upgrade_deps', True),
('prompt', '--prompt'), ('--prompt', 'prompt', True),
('--without-scm-ignore-files', 'scm_ignore_files', frozenset()),
] ]
for attr, opt in attrs: for opt, attr, value in options:
rmtree(self.env_dir) with self.subTest(opt=opt, attr=attr, value=value):
if not attr: rmtree(self.env_dir)
b = venv.EnvBuilder() if not attr:
else: kwargs = {}
b = venv.EnvBuilder( else:
**{attr: False if attr in ('with_pip', 'symlinks') else True}) kwargs = {attr: value}
b.upgrade_dependencies = Mock() # avoid pip command to upgrade deps b = venv.EnvBuilder(**kwargs)
b._setup_pip = Mock() # avoid pip setup b.upgrade_dependencies = Mock() # avoid pip command to upgrade deps
self.run_with_capture(b.create, self.env_dir) b._setup_pip = Mock() # avoid pip setup
data = self.get_text_file_contents('pyvenv.cfg') self.run_with_capture(b.create, self.env_dir)
if not attr: data = self.get_text_file_contents('pyvenv.cfg')
for opt in ('--system-site-packages', '--clear', '--upgrade', if not attr or opt.endswith('git'):
'--upgrade-deps', '--prompt'): for opt in ('--system-site-packages', '--clear', '--upgrade',
self.assertNotRegex(data, rf'command = .* {opt}') '--upgrade-deps', '--prompt'):
elif os.name=='nt' and attr=='symlinks': self.assertNotRegex(data, rf'command = .* {opt}')
pass elif os.name=='nt' and attr=='symlinks':
else: pass
self.assertRegex(data, rf'command = .* {opt}') else:
self.assertRegex(data, rf'command = .* {opt}')
def test_prompt(self): def test_prompt(self):
env_name = os.path.split(self.env_dir)[1] env_name = os.path.split(self.env_dir)[1]
@ -243,8 +253,7 @@ def test_prefixes(self):
# check a venv's prefixes # check a venv's prefixes
rmtree(self.env_dir) rmtree(self.env_dir)
self.run_with_capture(venv.create, self.env_dir) self.run_with_capture(venv.create, self.env_dir)
envpy = os.path.join(self.env_dir, self.bindir, self.exe) cmd = [self.envpy(), '-c', None]
cmd = [envpy, '-c', None]
for prefix, expected in ( for prefix, expected in (
('prefix', self.env_dir), ('prefix', self.env_dir),
('exec_prefix', self.env_dir), ('exec_prefix', self.env_dir),
@ -261,8 +270,7 @@ def test_sysconfig(self):
""" """
rmtree(self.env_dir) rmtree(self.env_dir)
self.run_with_capture(venv.create, self.env_dir, symlinks=False) self.run_with_capture(venv.create, self.env_dir, symlinks=False)
envpy = os.path.join(self.env_dir, self.bindir, self.exe) cmd = [self.envpy(), '-c', None]
cmd = [envpy, '-c', None]
for call, expected in ( for call, expected in (
# installation scheme # installation scheme
('get_preferred_scheme("prefix")', 'venv'), ('get_preferred_scheme("prefix")', 'venv'),
@ -284,8 +292,7 @@ def test_sysconfig_symlinks(self):
""" """
rmtree(self.env_dir) rmtree(self.env_dir)
self.run_with_capture(venv.create, self.env_dir, symlinks=True) self.run_with_capture(venv.create, self.env_dir, symlinks=True)
envpy = os.path.join(self.env_dir, self.bindir, self.exe) cmd = [self.envpy(), '-c', None]
cmd = [envpy, '-c', None]
for call, expected in ( for call, expected in (
# installation scheme # installation scheme
('get_preferred_scheme("prefix")', 'venv'), ('get_preferred_scheme("prefix")', 'venv'),
@ -424,8 +431,7 @@ def test_executable(self):
""" """
rmtree(self.env_dir) rmtree(self.env_dir)
self.run_with_capture(venv.create, self.env_dir) self.run_with_capture(venv.create, self.env_dir)
envpy = os.path.join(os.path.realpath(self.env_dir), envpy = self.envpy(real_env_dir=True)
self.bindir, self.exe)
out, err = check_output([envpy, '-c', out, err = check_output([envpy, '-c',
'import sys; print(sys.executable)']) 'import sys; print(sys.executable)'])
self.assertEqual(out.strip(), envpy.encode()) self.assertEqual(out.strip(), envpy.encode())
@ -438,8 +444,7 @@ def test_executable_symlinks(self):
rmtree(self.env_dir) rmtree(self.env_dir)
builder = venv.EnvBuilder(clear=True, symlinks=True) builder = venv.EnvBuilder(clear=True, symlinks=True)
builder.create(self.env_dir) builder.create(self.env_dir)
envpy = os.path.join(os.path.realpath(self.env_dir), envpy = self.envpy(real_env_dir=True)
self.bindir, self.exe)
out, err = check_output([envpy, '-c', out, err = check_output([envpy, '-c',
'import sys; print(sys.executable)']) 'import sys; print(sys.executable)'])
self.assertEqual(out.strip(), envpy.encode()) self.assertEqual(out.strip(), envpy.encode())
@ -454,7 +459,6 @@ def test_unicode_in_batch_file(self):
builder = venv.EnvBuilder(clear=True) builder = venv.EnvBuilder(clear=True)
builder.create(env_dir) builder.create(env_dir)
activate = os.path.join(env_dir, self.bindir, 'activate.bat') activate = os.path.join(env_dir, self.bindir, 'activate.bat')
envpy = os.path.join(env_dir, self.bindir, self.exe)
out, err = check_output( out, err = check_output(
[activate, '&', self.exe, '-c', 'print(0)'], [activate, '&', self.exe, '-c', 'print(0)'],
encoding='oem', encoding='oem',
@ -473,9 +477,7 @@ def test_multiprocessing(self):
rmtree(self.env_dir) rmtree(self.env_dir)
self.run_with_capture(venv.create, self.env_dir) self.run_with_capture(venv.create, self.env_dir)
envpy = os.path.join(os.path.realpath(self.env_dir), out, err = check_output([self.envpy(real_env_dir=True), '-c',
self.bindir, self.exe)
out, err = check_output([envpy, '-c',
'from multiprocessing import Pool; ' 'from multiprocessing import Pool; '
'pool = Pool(1); ' 'pool = Pool(1); '
'print(pool.apply_async("Python".lower).get(3)); ' 'print(pool.apply_async("Python".lower).get(3)); '
@ -491,10 +493,8 @@ def test_multiprocessing_recursion(self):
rmtree(self.env_dir) rmtree(self.env_dir)
self.run_with_capture(venv.create, 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') 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') @unittest.skipIf(os.name == 'nt', 'not relevant on Windows')
def test_deactivate_with_strict_bash_opts(self): def test_deactivate_with_strict_bash_opts(self):
@ -521,9 +521,7 @@ def test_macos_env(self):
builder = venv.EnvBuilder() builder = venv.EnvBuilder()
builder.create(self.env_dir) builder.create(self.env_dir)
envpy = os.path.join(os.path.realpath(self.env_dir), out, err = check_output([self.envpy(real_env_dir=True), '-c',
self.bindir, self.exe)
out, err = check_output([envpy, '-c',
'import os; print("__PYVENV_LAUNCHER__" in os.environ)']) 'import os; print("__PYVENV_LAUNCHER__" in os.environ)'])
self.assertEqual(out.strip(), 'False'.encode()) self.assertEqual(out.strip(), 'False'.encode())
@ -585,6 +583,7 @@ def test_zippath_from_non_installed_posix(self):
"-m", "-m",
"venv", "venv",
"--without-pip", "--without-pip",
"--without-scm-ignore-files",
self.env_dir] self.env_dir]
# Our fake non-installed python is not fully functional because # Our fake non-installed python is not fully functional because
# it cannot find the extensions. Set PYTHONPATH so it can run the # it cannot find the extensions. Set PYTHONPATH so it can run the
@ -609,13 +608,13 @@ def test_zippath_from_non_installed_posix(self):
# prevent https://github.com/python/cpython/issues/104839 # prevent https://github.com/python/cpython/issues/104839
child_env["ASAN_OPTIONS"] = asan_options child_env["ASAN_OPTIONS"] = asan_options
subprocess.check_call(cmd, env=child_env) 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 # Now check the venv created from the non-installed python has
# correct zip path in pythonpath. # 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) out, err = check_output(cmd)
self.assertTrue(zip_landmark.encode() in out) self.assertTrue(zip_landmark.encode() in out)
@requireVenvCreate
def test_activate_shell_script_has_no_dos_newlines(self): def test_activate_shell_script_has_no_dos_newlines(self):
""" """
Test that the `activate` shell script contains no CR LF. Test that the `activate` shell script contains no CR LF.
@ -632,13 +631,80 @@ def test_activate_shell_script_has_no_dos_newlines(self):
error_message = f"CR LF found in line {i}" error_message = f"CR LF found in line {i}"
self.assertFalse(line.endswith(b'\r\n'), error_message) self.assertFalse(line.endswith(b'\r\n'), error_message)
@requireVenvCreate
def test_scm_ignore_files_git(self):
"""
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_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 @requireVenvCreate
class EnsurePipTest(BaseTest): class EnsurePipTest(BaseTest):
"""Test venv module installation of pip.""" """Test venv module installation of pip."""
def assert_pip_not_installed(self): def assert_pip_not_installed(self):
envpy = os.path.join(os.path.realpath(self.env_dir), out, err = check_output([self.envpy(real_env_dir=True), '-c',
self.bindir, self.exe)
out, err = check_output([envpy, '-c',
'try:\n import pip\nexcept ImportError:\n print("OK")']) 'try:\n import pip\nexcept ImportError:\n print("OK")'])
# We force everything to text, so unittest gives the detailed diff # We force everything to text, so unittest gives the detailed diff
# if we get unexpected results # if we get unexpected results
@ -705,9 +771,9 @@ def do_test_with_pip(self, system_site_packages):
system_site_packages=system_site_packages, system_site_packages=system_site_packages,
with_pip=True) with_pip=True)
# Ensure pip is available in the virtual environment # 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 # 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', '-W', 'ignore::ImportWarning', '-I',
'-m', 'pip', '--version']) '-m', 'pip', '--version'])
# We force everything to text, so unittest gives the detailed diff # We force everything to text, so unittest gives the detailed diff
@ -728,7 +794,7 @@ def do_test_with_pip(self, system_site_packages):
# It seems ensurepip._uninstall calls subprocesses which do not # It seems ensurepip._uninstall calls subprocesses which do not
# inherit the interpreter settings. # inherit the interpreter settings.
envvars["PYTHONWARNINGS"] = "ignore" envvars["PYTHONWARNINGS"] = "ignore"
out, err = check_output([envpy, out, err = check_output([self.envpy(real_env_dir=True),
'-W', 'ignore::DeprecationWarning', '-W', 'ignore::DeprecationWarning',
'-W', 'ignore::ImportWarning', '-I', '-W', 'ignore::ImportWarning', '-I',
'-m', 'ensurepip._uninstall']) '-m', 'ensurepip._uninstall'])

View File

@ -41,11 +41,13 @@ class EnvBuilder:
environment environment
:param prompt: Alternative terminal prefix for the environment. :param prompt: Alternative terminal prefix for the environment.
:param upgrade_deps: Update the base venv modules to the latest on PyPI :param upgrade_deps: Update the base venv modules to the latest on PyPI
:param scm_ignore_files: Create ignore files for the SCMs specified by the
iterable.
""" """
def __init__(self, system_site_packages=False, clear=False, def __init__(self, system_site_packages=False, clear=False,
symlinks=False, upgrade=False, with_pip=False, prompt=None, symlinks=False, upgrade=False, with_pip=False, prompt=None,
upgrade_deps=False): upgrade_deps=False, *, scm_ignore_files=frozenset()):
self.system_site_packages = system_site_packages self.system_site_packages = system_site_packages
self.clear = clear self.clear = clear
self.symlinks = symlinks self.symlinks = symlinks
@ -56,6 +58,7 @@ def __init__(self, system_site_packages=False, clear=False,
prompt = os.path.basename(os.getcwd()) prompt = os.path.basename(os.getcwd())
self.prompt = prompt self.prompt = prompt
self.upgrade_deps = upgrade_deps self.upgrade_deps = upgrade_deps
self.scm_ignore_files = frozenset(map(str.lower, scm_ignore_files))
def create(self, env_dir): def create(self, env_dir):
""" """
@ -66,6 +69,8 @@ def create(self, env_dir):
""" """
env_dir = os.path.abspath(env_dir) env_dir = os.path.abspath(env_dir)
context = self.ensure_directories(env_dir) context = self.ensure_directories(env_dir)
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 # See issue 24875. We need system_site_packages to be False
# until after pip is installed. # until after pip is installed.
true_system_site_packages = self.system_site_packages true_system_site_packages = self.system_site_packages
@ -210,6 +215,8 @@ def create_configuration(self, context):
args.append('--upgrade-deps') args.append('--upgrade-deps')
if self.orig_prompt is not None: if self.orig_prompt is not None:
args.append(f'--prompt="{self.orig_prompt}"') args.append(f'--prompt="{self.orig_prompt}"')
if not self.scm_ignore_files:
args.append('--without-scm-ignore-files')
args.append(context.env_dir) args.append(context.env_dir)
args = ' '.join(args) args = ' '.join(args)
@ -278,6 +285,19 @@ def symlink_or_copy(self, src, dst, relative_symlinks_ok=False):
shutil.copyfile(src, dst) shutil.copyfile(src, dst)
def create_git_ignore_file(self, context):
"""
Create a .gitignore file in the environment directory.
The contents of the file cause the entire environment directory to be
ignored by git.
"""
gitignore_path = os.path.join(context.env_dir, '.gitignore')
with open(gitignore_path, 'w', encoding='utf-8') as file:
file.write('# Created by venv; '
'see https://docs.python.org/3/library/venv.html\n')
file.write('*\n')
def setup_python(self, context): def setup_python(self, context):
""" """
Set up a Python executable in the environment. Set up a Python executable in the environment.
@ -461,11 +481,13 @@ def upgrade_dependencies(self, context):
def create(env_dir, system_site_packages=False, clear=False, def create(env_dir, system_site_packages=False, clear=False,
symlinks=False, with_pip=False, prompt=None, upgrade_deps=False): symlinks=False, with_pip=False, prompt=None, upgrade_deps=False,
*, scm_ignore_files=frozenset()):
"""Create a virtual environment in a directory.""" """Create a virtual environment in a directory."""
builder = EnvBuilder(system_site_packages=system_site_packages, builder = EnvBuilder(system_site_packages=system_site_packages,
clear=clear, symlinks=symlinks, with_pip=with_pip, clear=clear, symlinks=symlinks, with_pip=with_pip,
prompt=prompt, upgrade_deps=upgrade_deps) prompt=prompt, upgrade_deps=upgrade_deps,
scm_ignore_files=scm_ignore_files)
builder.create(env_dir) builder.create(env_dir)
@ -525,6 +547,11 @@ def main(args=None):
dest='upgrade_deps', dest='upgrade_deps',
help=f'Upgrade core dependencies ({", ".join(CORE_VENV_DEPS)}) ' help=f'Upgrade core dependencies ({", ".join(CORE_VENV_DEPS)}) '
'to the latest version in PyPI') 'to the latest version in PyPI')
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) options = parser.parse_args(args)
if options.upgrade and options.clear: if options.upgrade and options.clear:
raise ValueError('you cannot supply --upgrade and --clear together.') raise ValueError('you cannot supply --upgrade and --clear together.')
@ -534,7 +561,8 @@ def main(args=None):
upgrade=options.upgrade, upgrade=options.upgrade,
with_pip=options.with_pip, with_pip=options.with_pip,
prompt=options.prompt, prompt=options.prompt,
upgrade_deps=options.upgrade_deps) upgrade_deps=options.upgrade_deps,
scm_ignore_files=options.scm_ignore_files)
for d in options.dirs: for d in options.dirs:
builder.create(d) builder.create(d)

View File

@ -6,5 +6,5 @@
main() main()
rc = 0 rc = 0
except Exception as e: except Exception as e:
print('Error: %s' % e, file=sys.stderr) print('Error:', e, file=sys.stderr)
sys.exit(rc) sys.exit(rc)

View File

@ -0,0 +1,3 @@
Add the ability for venv to create a ``.gitignore`` file which causes the
created environment to be ignored by Git. It is on by default when venv is
called via its CLI.