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 ()
@@ -112,11 +118,11 @@ def __init__(self, env=None, workspace=None, name='.env', python=None, args=None
112118 if sys .platform == 'win32' :
113119 # In virtualenv on windows "Scripts" folder is used instead of "bin".
114120 self .python = self .virtualenv / 'Scripts' / 'python.exe'
115- self .easy_install = self .virtualenv / 'Scripts' / 'easy_install .exe'
121+ self .pip = self .virtualenv / 'Scripts' / 'pip .exe'
116122 self .coverage = self .virtualenv / 'Scripts' / 'coverage.exe'
117123 else :
118124 self .python = self .virtualenv / 'bin' / 'python'
119- self .easy_install = self .virtualenv / "bin" / "easy_install "
125+ self .pip = self .virtualenv / "bin" / "pip "
120126 self .coverage = self .virtualenv / 'bin' / 'coverage'
121127
122128 if env is None :
@@ -140,6 +146,7 @@ def __init__(self, env=None, workspace=None, name='.env', python=None, args=None
140146 cmd .extend (self .args )
141147 cmd .append (str (self .virtualenv ))
142148 self .run (cmd )
149+ self ._importlib_metadata_installed = False
143150
144151 def run (self , args , ** kwargs ):
145152 """
@@ -166,70 +173,118 @@ def run_with_coverage(self, *args, **kwargs):
166173 coverage = [str (self .python ), str (self .coverage )]
167174 return run .run_with_coverage (* args , coverage = coverage , ** kwargs )
168175
169- def install_package (self , pkg_name , installer = 'easy_install' , build_egg = None ):
176+ def install_package (self , pkg_name , version = PackageVersion . LATEST , installer = "pip" , installer_command = "install" ):
170177 """
171178 Install a given package name. If it's already setup in the
172179 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
180+ :param pkg_name: `str`
181+ Name of the package to be installed
182+ :param version: `str` or `PackageVersion`
183+ If PackageVersion.LATEST then installs the latest version of the package from upstream
184+ If PackageVersion.CURRENT then installs the same version that's installed in the current virtual environment
185+ that's running the tests If the package is an egg-link, then copy it over. If the
186+ package is not in the parent, then installs the latest version
187+ If the value is a string, then it will be used as the version to install
188+ :param installer: `str`
189+ The installer used to install packages, `pip` by default
190+ `param installer_command: `str`
191+ The command passed to the installed, `install` by default. So the resulting default install command is
192+ `<venv>/Scripts/pip.exe install` on windows and `<venv>/bin/pip install` elsewhere
179193 """
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 )
194+ if sys .platform == 'win32' :
195+ # In virtualenv on windows "Scripts" folder is used instead of "bin".
196+ installer = str (self .virtualenv / 'Scripts' / installer + '.exe' )
196197 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
198+ installer = str (self .virtualenv / 'bin' / installer )
199+ if not self .debug :
200+ installer += ' -q'
201+
202+ if version == PackageVersion .LATEST :
203+ self .run (
204+ "{python} {installer} {installer_command} {spec}" .format (
205+ python = self .python , installer = installer , installer_command = installer_command , spec = pkg_name
206+ )
207+ )
208+ elif version == PackageVersion .CURRENT :
209+ dist = next (
210+ iter ([dist for dist in metadata .distributions () if _normalize (dist .name ) == _normalize (pkg_name )]), None
211+ )
212+ if dist :
213+ egg_link = _get_egg_link (dist .name )
214+ if egg_link :
215+ self ._install_editable_package (egg_link , dist )
216+ else :
217+ spec = "{pkg_name}=={version}" .format (pkg_name = pkg_name , version = dist .version )
218+ self .run (
219+ "{python} {installer} {installer_command} {spec}" .format (
220+ python = self .python , installer = installer , installer_command = installer_command , spec = spec
221+ )
222+ )
213223 else :
214- cmd = 'cd %(src_dir)s; %(python)s setup.py -q develop' % d
215-
216- self .run (cmd , capture = False )
224+ self .run (
225+ "{python} {installer} {installer_command} {spec}" .format (
226+ python = self .python , installer = installer , installer_command = installer_command , spec = pkg_name
227+ )
228+ )
229+ else :
230+ spec = "{pkg_name}=={version}" .format (pkg_name = pkg_name , version = version )
231+ self .run (
232+ "{python} {installer} {installer_command} {spec}" .format (
233+ python = self .python , installer = installer , installer_command = installer_command , spec = spec
234+ )
235+ )
217236
218237 def installed_packages (self , package_type = None ):
219238 """
220239 Return a package dict with
221240 key = package name, value = version (or '')
222241 """
242+ # Lazily install importlib_metadata in the underlying virtual environment
243+ self ._install_importlib_metadata ()
223244 if package_type is None :
224245 package_type = PackageEntry .ANY
225246 elif package_type not in PackageEntry .PACKAGE_TYPES :
226247 raise ValueError ('invalid package_type parameter (%s)' % str (package_type ))
227248
228249 res = {}
229250 code = "import importlib_metadata as metadata\n " \
230- "for i in metadata.distributions(): print(i.name + ' ' + i.version + ' ' + i.locate_file(''))"
251+ "for i in metadata.distributions(): print(i.name + ' ' + i.version + ' ' + str( i.locate_file('') ))"
231252 lines = self .run ([self .python , "-c" , code ], capture = True ).split ('\n ' )
232253 for line in [i .strip () for i in lines if i .strip ()]:
233254 name , version , location = line .split ()
234255 res [name ] = PackageEntry (name , version , location )
235256 return res
257+
258+ def _install_importlib_metadata (self ):
259+ if not self ._importlib_metadata_installed :
260+ self .install_package ("importlib_metadata" , version = PackageVersion .CURRENT )
261+ self ._importlib_metadata_installed = True
262+
263+ def _install_editable_package (self , egg_link , package ):
264+ python_dir = "python{}.{}" .format (sys .version_info .major , sys .version_info .minor )
265+ shutil .copy (egg_link , self .virtualenv / "lib" / python_dir / "site-packages" / egg_link .name )
266+ easy_install_pth_path = self .virtualenv / "lib" / python_dir / "site-packages" / "easy-install.pth"
267+ with open (easy_install_pth_path , "a" ) as pth , open (egg_link ) as egg_link :
268+ pth .write (egg_link .read ())
269+ pth .write ("\n " )
270+ for spec in package .requires :
271+ if not _is_extra_requirement (spec ):
272+ dependency = next (pkg_resources .parse_requirements (spec ), None )
273+ if dependency and (not dependency .marker or dependency .marker .evaluate ()):
274+ self .install_package (dependency .name , version = PackageVersion .CURRENT )
275+
276+
277+ def _normalize (name ):
278+ return re .sub (r"[-_.]+" , "-" , name ).lower ()
279+
280+
281+ def _get_egg_link (pkg_name ):
282+ for path in sys .path :
283+ egg_link = pathlib .Path (path ) / (pkg_name + ".egg-link" )
284+ if egg_link .is_file ():
285+ return egg_link
286+ return None
287+
288+
289+ def _is_extra_requirement (spec ):
290+ return any (x .replace (" " , "" ).startswith ("extra==" ) for x in spec .split (";" ))
0 commit comments