11""" Python virtual environment fixtures
22"""
33import os
4+ import pathlib
5+ import re
6+ import shutil
7+ import subprocess
48import sys
9+ from enum import Enum
510
611import importlib_metadata as metadata
12+ import pkg_resources
713from pytest import yield_fixture
8- try :
9- from path import Path
10- except ImportError :
11- from path import path as Path
1214
1315from pytest_shutil .workspace import Workspace
1416from pytest_shutil import run , cmdline
1517from pytest_fixture_config import Config , yield_requires_config
1618
1719
20+ class PackageVersion (Enum ):
21+ LATEST = 1
22+ CURRENT = 2
23+
1824class FixtureConfig (Config ):
1925 __slots__ = ('virtualenv_executable' )
2026
@@ -43,7 +49,7 @@ def virtualenv():
4349 ----------
4450 virtualenv (`path.path`) : Path to this virtualenv's base directory
4551 python (`path.path`) : Path to this virtualenv's Python executable
46- easy_install (`path.path`) : Path to this virtualenv's easy_install executable
52+ pip (`path.path`) : Path to this virtualenv's pip executable
4753 .. also inherits all attributes from the `workspace` fixture
4854 """
4955 venv = VirtualEnv ()
@@ -102,21 +108,26 @@ class VirtualEnv(Workspace):
102108 path to the virtualenv base dir
103109 env : 'list'
104110 environment variables used in creation of virtualenv
105-
111+ delete_workspace: `None or bool`
112+ If True then the workspace will be deleted
113+ If False then the workspace will be kept
114+ If None (default) then the workspace will be deleted if workspace is also None, but it will be kept otherwise
106115 """
107116 # TODO: update to use pip, remove distribute
108- def __init__ (self , env = None , workspace = None , name = '.env' , python = None , args = None ):
109- Workspace .__init__ (self , workspace )
117+ def __init__ (self , env = None , workspace = None , name = '.env' , python = None , args = None , delete_workspace = None ):
118+ if delete_workspace is None :
119+ delete_workspace = workspace is None
120+ Workspace .__init__ (self , workspace , delete_workspace )
110121 self .virtualenv = self .workspace / name
111122 self .args = args or []
112123 if sys .platform == 'win32' :
113124 # In virtualenv on windows "Scripts" folder is used instead of "bin".
114125 self .python = self .virtualenv / 'Scripts' / 'python.exe'
115- self .easy_install = self .virtualenv / 'Scripts' / 'easy_install .exe'
126+ self .pip = self .virtualenv / 'Scripts' / 'pip .exe'
116127 self .coverage = self .virtualenv / 'Scripts' / 'coverage.exe'
117128 else :
118129 self .python = self .virtualenv / 'bin' / 'python'
119- self .easy_install = self .virtualenv / "bin" / "easy_install "
130+ self .pip = self .virtualenv / "bin" / "pip "
120131 self .coverage = self .virtualenv / 'bin' / 'coverage'
121132
122133 if env is None :
@@ -140,6 +151,7 @@ def __init__(self, env=None, workspace=None, name='.env', python=None, args=None
140151 cmd .extend (self .args )
141152 cmd .append (str (self .virtualenv ))
142153 self .run (cmd )
154+ self ._importlib_metadata_installed = False
143155
144156 def run (self , args , ** kwargs ):
145157 """
@@ -166,70 +178,118 @@ def run_with_coverage(self, *args, **kwargs):
166178 coverage = [str (self .python ), str (self .coverage )]
167179 return run .run_with_coverage (* args , coverage = coverage , ** kwargs )
168180
169- def install_package (self , pkg_name , installer = 'easy_install' , build_egg = None ):
181+ def install_package (self , pkg_name , version = PackageVersion . LATEST , installer = "pip" , installer_command = "install" ):
170182 """
171183 Install a given package name. If it's already setup in the
172184 test runtime environment, it will use that.
173- :param build_egg: `bool`
174- Only used when the package is installed as a source checkout, otherwise it
175- runs the installer to get it from PyPI.
176- True: builds an egg and installs it
177- False: Runs 'python setup.py develop'
178- None (default): installs the egg if available in dist/, otherwise develops it
185+ :param pkg_name: `str`
186+ Name of the package to be installed
187+ :param version: `str` or `PackageVersion`
188+ If PackageVersion.LATEST then installs the latest version of the package from upstream
189+ If PackageVersion.CURRENT then installs the same version that's installed in the current virtual environment
190+ that's running the tests If the package is an egg-link, then copy it over. If the
191+ package is not in the parent, then installs the latest version
192+ If the value is a string, then it will be used as the version to install
193+ :param installer: `str`
194+ The installer used to install packages, `pip` by default
195+ `param installer_command: `str`
196+ The command passed to the installed, `install` by default. So the resulting default install command is
197+ `<venv>/Scripts/pip.exe install` on windows and `<venv>/bin/pip install` elsewhere
179198 """
180- def location (dist ):
181- return dist .locate_file ('' )
182-
183- installed = [
184- dist for dist in metadata .distributions () if dist .name == pkg_name ]
185- if not installed or location (installed [0 ]).endswith ('.egg' ):
186- if sys .platform == 'win32' :
187- # In virtualenv on windows "Scripts" folder is used instead of "bin".
188- installer = str (self .virtualenv / 'Scripts' / installer + '.exe' )
189- else :
190- installer = str (self .virtualenv / 'bin' / installer )
191- if not self .debug :
192- installer += ' -q'
193- # Note we're running this as 'python easy_install foobar', instead of 'easy_install foobar'
194- # This is to circumvent #! line length limits :(
195- cmd = '%s %s %s' % (self .python , installer , pkg_name )
199+ if sys .platform == 'win32' :
200+ # In virtualenv on windows "Scripts" folder is used instead of "bin".
201+ installer = str (self .virtualenv / 'Scripts' / installer + '.exe' )
196202 else :
197- dist = installed [0 ]
198- d = {'python' : self .python ,
199- 'easy_install' : self .easy_install ,
200- 'src_dir' : location (dist ),
201- 'name' : dist .name ,
202- 'version' : dist .version ,
203- 'pyversion' : '{sys.version_info[0]}.{sys.version_info[1]}'
204- .format (** globals ()),
205- }
206-
207- d ['egg_file' ] = Path (location (dist )) / 'dist' / ('%(name)s-%(version)s-py%(pyversion)s.egg' % d )
208- if build_egg and not d ['egg_file' ].isfile ():
209- self .run ('cd %(src_dir)s; %(python)s setup.py -q bdist_egg' % d , capture = True )
210-
211- if build_egg or (build_egg is None and d ['egg_file' ].isfile ()):
212- cmd = '%(python)s %(easy_install)s %(egg_file)s' % d
203+ installer = str (self .virtualenv / 'bin' / installer )
204+ if not self .debug :
205+ installer += ' -q'
206+
207+ if version == PackageVersion .LATEST :
208+ self .run (
209+ "{python} {installer} {installer_command} {spec}" .format (
210+ python = self .python , installer = installer , installer_command = installer_command , spec = pkg_name
211+ )
212+ )
213+ elif version == PackageVersion .CURRENT :
214+ dist = next (
215+ iter ([dist for dist in metadata .distributions () if _normalize (dist .name ) == _normalize (pkg_name )]), None
216+ )
217+ if dist :
218+ egg_link = _get_egg_link (dist .name )
219+ if egg_link :
220+ self ._install_editable_package (egg_link , dist )
221+ else :
222+ spec = "{pkg_name}=={version}" .format (pkg_name = pkg_name , version = dist .version )
223+ self .run (
224+ "{python} {installer} {installer_command} {spec}" .format (
225+ python = self .python , installer = installer , installer_command = installer_command , spec = spec
226+ )
227+ )
213228 else :
214- cmd = 'cd %(src_dir)s; %(python)s setup.py -q develop' % d
215-
216- self .run (cmd , capture = False )
229+ self .run (
230+ "{python} {installer} {installer_command} {spec}" .format (
231+ python = self .python , installer = installer , installer_command = installer_command , spec = pkg_name
232+ )
233+ )
234+ else :
235+ spec = "{pkg_name}=={version}" .format (pkg_name = pkg_name , version = version )
236+ self .run (
237+ "{python} {installer} {installer_command} {spec}" .format (
238+ python = self .python , installer = installer , installer_command = installer_command , spec = spec
239+ )
240+ )
217241
218242 def installed_packages (self , package_type = None ):
219243 """
220244 Return a package dict with
221245 key = package name, value = version (or '')
222246 """
247+ # Lazily install importlib_metadata in the underlying virtual environment
248+ self ._install_importlib_metadata ()
223249 if package_type is None :
224250 package_type = PackageEntry .ANY
225251 elif package_type not in PackageEntry .PACKAGE_TYPES :
226252 raise ValueError ('invalid package_type parameter (%s)' % str (package_type ))
227253
228254 res = {}
229255 code = "import importlib_metadata as metadata\n " \
230- "for i in metadata.distributions(): print(i.name + ' ' + i.version + ' ' + i.locate_file(''))"
256+ "for i in metadata.distributions(): print(i.name + ' ' + i.version + ' ' + str( i.locate_file('') ))"
231257 lines = self .run ([self .python , "-c" , code ], capture = True ).split ('\n ' )
232258 for line in [i .strip () for i in lines if i .strip ()]:
233259 name , version , location = line .split ()
234260 res [name ] = PackageEntry (name , version , location )
235261 return res
262+
263+ def _install_importlib_metadata (self ):
264+ if not self ._importlib_metadata_installed :
265+ self .install_package ("importlib_metadata" , version = PackageVersion .CURRENT )
266+ self ._importlib_metadata_installed = True
267+
268+ def _install_editable_package (self , egg_link , package ):
269+ python_dir = "python{}.{}" .format (sys .version_info .major , sys .version_info .minor )
270+ shutil .copy (egg_link , self .virtualenv / "lib" / python_dir / "site-packages" / egg_link .name )
271+ easy_install_pth_path = self .virtualenv / "lib" / python_dir / "site-packages" / "easy-install.pth"
272+ with open (easy_install_pth_path , "a" ) as pth , open (egg_link ) as egg_link :
273+ pth .write (egg_link .read ())
274+ pth .write ("\n " )
275+ for spec in package .requires :
276+ if not _is_extra_requirement (spec ):
277+ dependency = next (pkg_resources .parse_requirements (spec ), None )
278+ if dependency and (not dependency .marker or dependency .marker .evaluate ()):
279+ self .install_package (dependency .name , version = PackageVersion .CURRENT )
280+
281+
282+ def _normalize (name ):
283+ return re .sub (r"[-_.]+" , "-" , name ).lower ()
284+
285+
286+ def _get_egg_link (pkg_name ):
287+ for path in sys .path :
288+ egg_link = pathlib .Path (path ) / (pkg_name + ".egg-link" )
289+ if egg_link .is_file ():
290+ return egg_link
291+ return None
292+
293+
294+ def _is_extra_requirement (spec ):
295+ return any (x .replace (" " , "" ).startswith ("extra==" ) for x in spec .split (";" ))
0 commit comments