Skip to content

Commit e07b520

Browse files
authored
Merge pull request #200 from man-group/pytest-virtualenv-code-jam
Pytest virtualenv code jam
2 parents ef7faeb + b0b4c12 commit e07b520

File tree

2 files changed

+215
-55
lines changed

2 files changed

+215
-55
lines changed

pytest-virtualenv/pytest_virtualenv.py

Lines changed: 113 additions & 53 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()
@@ -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(";"))

pytest-virtualenv/tests/integration/test_tmpvirtualenv.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import pathlib
23
import subprocess
34
import sys
45
import textwrap
@@ -14,5 +15,104 @@ def test_installed_packages():
1415
with venv.VirtualEnv() as v:
1516
ips = v.installed_packages()
1617
assert len(ips) > 0
17-
check_member('pip', ips)
18-
check_member('virtualenv', ips)
18+
assert check_member("pip", ips)
19+
20+
21+
def test_install_version_from_current():
22+
with venv.VirtualEnv() as v:
23+
v.install_package("flask", "1.1.1")
24+
v.install_package("virtualenv", version=venv.PackageVersion.CURRENT)
25+
v.install_package("pytest-virtualenv", version=venv.PackageVersion.CURRENT)
26+
out = v.run([
27+
v.python,
28+
"-c",
29+
"""import pytest_virtualenv as venv
30+
with venv.VirtualEnv() as v:
31+
v.install_package("flask", version=venv.PackageVersion.CURRENT)
32+
print("The Flask version is", v.installed_packages()["Flask"].version)
33+
34+
"""
35+
], capture=True)
36+
assert "The Flask version is 1.1.1" in out.strip()
37+
38+
39+
def test_install_egg_link_from_current(tmp_path):
40+
with open(tmp_path / "setup.py", "w") as fp:
41+
fp.write("""from setuptools import setup
42+
setup(name="foo", version="1.2", description="none available", install_requires=["requests"], py_modules=["foo"])
43+
""")
44+
with open(tmp_path / "foo.py", "w") as fp:
45+
fp.write('print("hello")')
46+
47+
with venv.VirtualEnv() as v:
48+
v.install_package("pip")
49+
v.install_package("wheel")
50+
v.install_package("virtualenv", version=venv.PackageVersion.CURRENT)
51+
v.install_package("pytest-virtualenv", version=venv.PackageVersion.CURRENT)
52+
v.run([v.python, "-m", "pip", "install", "-e", str(tmp_path)])
53+
out = v.run([
54+
v.python,
55+
"-c",
56+
"""import pytest_virtualenv as venv
57+
with venv.VirtualEnv() as v:
58+
v.install_package("foo", version=venv.PackageVersion.CURRENT)
59+
print("The foo version is", v.installed_packages()["foo"].version)
60+
print("Requests installed:", "requests" in v.installed_packages())
61+
"""
62+
], capture=True)
63+
assert "The foo version is 1.2" in out
64+
assert "Requests installed: True"
65+
66+
67+
def test_install_pinned_version():
68+
with venv.VirtualEnv() as v:
69+
v.install_package("flask", "1.1.1")
70+
assert v.installed_packages()["Flask"].version == "1.1.1"
71+
72+
73+
def test_install_latest():
74+
with venv.VirtualEnv() as v:
75+
v.install_package("flask")
76+
assert v.installed_packages()["Flask"].version != "1.1.1"
77+
78+
79+
def test_keep_named_workspace(tmp_path):
80+
workspace = tmp_path / "new-workspace"
81+
workspace.mkdir()
82+
with venv.VirtualEnv(workspace=str(workspace)) as v:
83+
pass
84+
assert workspace.exists()
85+
86+
87+
def test_really_keep_named_workspace(tmp_path):
88+
workspace = tmp_path / "new-workspace"
89+
workspace.mkdir()
90+
with venv.VirtualEnv(workspace=str(workspace), delete_workspace=False) as v:
91+
pass
92+
assert workspace.exists()
93+
94+
95+
def test_delete_named_workspace(tmp_path):
96+
workspace = tmp_path / "new-workspace"
97+
workspace.mkdir()
98+
with venv.VirtualEnv(workspace=str(workspace), delete_workspace=True) as v:
99+
pass
100+
assert not workspace.exists()
101+
102+
103+
def test_delete_unamed_workspace():
104+
with venv.VirtualEnv() as v:
105+
workspace = pathlib.Path(v.workspace)
106+
assert not workspace.exists()
107+
108+
109+
def test_really_delete_unamed_workspace():
110+
with venv.VirtualEnv(delete_workspace=True) as v:
111+
workspace = pathlib.Path(v.workspace)
112+
assert not workspace.exists()
113+
114+
115+
def test_keep_unamed_workspace():
116+
with venv.VirtualEnv(delete_workspace=False) as v:
117+
workspace = pathlib.Path(v.workspace)
118+
assert workspace.exists()

0 commit comments

Comments
 (0)