Skip to content

Commit 020d284

Browse files
committed
pytest-virtualenv: Modernize pytest-virtualenv
Related issues: #188, #185, #182, #163
1 parent 75e04eb commit 020d284

File tree

2 files changed

+164
-52
lines changed

2 files changed

+164
-52
lines changed

pytest-virtualenv/pytest_virtualenv.py

Lines changed: 105 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
""" Python virtual environment fixtures
22
"""
33
import os
4+
import pathlib
5+
import re
6+
import shutil
7+
import subprocess
48
import sys
9+
from enum import Enum
510

611
import importlib_metadata as metadata
12+
import pkg_resources
713
from pytest import yield_fixture
8-
try:
9-
from path import Path
10-
except ImportError:
11-
from path import path as Path
1214

1315
from pytest_shutil.workspace import Workspace
1416
from pytest_shutil import run, cmdline
1517
from pytest_fixture_config import Config, yield_requires_config
1618

1719

20+
class PackageVersion(Enum):
21+
LATEST = 1
22+
CURRENT = 2
23+
1824
class 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(";"))

pytest-virtualenv/tests/integration/test_tmpvirtualenv.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,62 @@ def test_installed_packages():
1414
with venv.VirtualEnv() as v:
1515
ips = v.installed_packages()
1616
assert len(ips) > 0
17-
check_member('pip', ips)
18-
check_member('virtualenv', ips)
17+
assert check_member("pip", ips)
18+
19+
20+
def test_install_version_from_current():
21+
with venv.VirtualEnv() as v:
22+
v.install_package("flask", "1.1.1")
23+
v.install_package("virtualenv", version=venv.PackageVersion.CURRENT)
24+
v.install_package("pytest-virtualenv", version=venv.PackageVersion.CURRENT)
25+
out = v.run([
26+
v.python,
27+
"-c",
28+
"""import pytest_virtualenv as venv
29+
with venv.VirtualEnv() as v:
30+
v.install_package("flask", version=venv.PackageVersion.CURRENT)
31+
print("The Flask version is", v.installed_packages()["Flask"].version)
32+
33+
"""
34+
], capture=True)
35+
assert "The Flask version is 1.1.1" in out.strip()
36+
37+
38+
def test_install_egg_link_from_current(tmp_path):
39+
with open(tmp_path / "setup.py", "w") as fp:
40+
fp.write("""from setuptools import setup
41+
setup(name="foo", version="1.2", description="none available", install_requires=["requests"], py_modules=["foo"])
42+
""")
43+
with open(tmp_path / "foo.py", "w") as fp:
44+
fp.write('print("hello")')
45+
46+
with venv.VirtualEnv() as v:
47+
v.install_package("pip")
48+
v.install_package("wheel")
49+
v.install_package("virtualenv", version=venv.PackageVersion.CURRENT)
50+
v.install_package("pytest-virtualenv", version=venv.PackageVersion.CURRENT)
51+
v.run([v.python, "-m", "pip", "install", "-e", str(tmp_path)])
52+
out = v.run([
53+
v.python,
54+
"-c",
55+
"""import pytest_virtualenv as venv
56+
with venv.VirtualEnv() as v:
57+
v.install_package("foo", version=venv.PackageVersion.CURRENT)
58+
print("The foo version is", v.installed_packages()["foo"].version)
59+
print("Requests installed:", "requests" in v.installed_packages())
60+
"""
61+
], capture=True)
62+
assert "The foo version is 1.2" in out
63+
assert "Requests installed: True"
64+
65+
66+
def test_install_pinned_version():
67+
with venv.VirtualEnv() as v:
68+
v.install_package("flask", "1.1.1")
69+
assert v.installed_packages()["Flask"].version == "1.1.1"
70+
71+
72+
def test_install_latest():
73+
with venv.VirtualEnv() as v:
74+
v.install_package("flask")
75+
assert v.installed_packages()["Flask"].version != "1.1.1"

0 commit comments

Comments
 (0)