Skip to content

Commit 11ef514

Browse files
committed
conftest: Partially isolate the default LocalPackageResolver
LocalPackageResolver without a given pyenv_path defaults to looking up packages in sys.path. This is problematic in tests, as we do not fully control the Python environment in which the tests are run (e.g. consider a developer running pytest directly in their dev environment where they happen to have installed some packages that we don't expect). Until now, we've skirted around the problem by adding a couple of TODO comments near the relevant tests, but it's now time to solve this in a better way. However, short of running the tests in a subprocess, we cannot _fully_ control the sys.path. What we can do, is to use a combination of our fake_venv fixture and pytest's monkeypatch.syspath_prepend() function to insert an environment that we _do_ control at the start of sys.path. This will allow us to place entries into that fake_venv that we can then expect to be resolved exactly as we expect in tests. We introduce a new isolate_default_resolver fixture to apply this partial isolation for a given dict of package-to-imports mappings. At the same time we add a default_sys_path_env_for_tests dict that encodes what we previously considered the minimal/default package mappings that we expected (but could not guarantee) to be present in the test environment. With this, we can now call isolate_default_resolver(default_sys_path_env_for_tests) from tests that previously relied on the surrounding test environment, and their expectation is now guarateed by this new fake_venv inserted at the start of sys.path. Note that for tests that rely on packages NOT being installed into the test environment (e.g. testing the fallback to the identity mapping), we must still take care to use package names that we can ~guarantee never to be present in the environment in which the tests are run. Thus, we are only _partially_ isolated from the surrounding test environment.
1 parent 805f293 commit 11ef514

File tree

5 files changed

+85
-62
lines changed

5 files changed

+85
-62
lines changed

tests/conftest.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55
from tempfile import mkdtemp
66
from textwrap import dedent
7-
from typing import Dict, Iterable, Set, Tuple, Union
7+
from typing import Callable, Dict, Iterable, Set, Tuple, Union
88

99
import pytest
1010

@@ -52,6 +52,40 @@ def create_one_fake_venv(fake_packages: Dict[str, Set[str]]) -> Tuple[Path, Path
5252
return create_one_fake_venv
5353

5454

55+
@pytest.fixture
56+
def isolate_default_resolver(
57+
fake_venv: Callable[[Dict[str, Set[str]]], Tuple[Path, Path]], monkeypatch
58+
):
59+
"""Put a fake_venv at the start of sys.path to yield predictable Packages.
60+
61+
Call the returned function to place a fake venv with the specified package
62+
mappings at the start of sys.path.
63+
64+
Rationale:
65+
When testing resolve_dependencies() or anything that depends on
66+
LocalPackageResolver() with default/empty pyenv, it is important to realize
67+
that local packages will be resolved via sys.path. This is hard to fully
68+
isolate/mock in tests, but we can do the following to approximate isolation:
69+
- Use fake_venv() and pytest.monkeypatch.syspath_prepend(path) to make sure
70+
packages that we expect to find in the default environment are always
71+
found in this fake venv. This is achieved by using this fixture.
72+
- Populate this fake_venv with package that we expect to find in the default
73+
environment. These will then be resolved through the fake_venv to yield
74+
predictable import names and mapping descriptions.
75+
- Tests must make sure packages that they expect NOT to find in the default
76+
environment are chosen/spelled in ways to ensure they are indeed never
77+
found elsewhere in sys.path, as we are not able to isolate the resolver
78+
from sys.path.
79+
"""
80+
81+
def inner(fake_packages: Dict[str, Set[str]]) -> Path:
82+
_venv_dir, package_dir = fake_venv(fake_packages)
83+
monkeypatch.syspath_prepend(package_dir)
84+
return package_dir
85+
86+
return inner
87+
88+
5589
@pytest.fixture
5690
def project_with_requirements(write_tmp_files):
5791
return write_tmp_files(

tests/test_local_env.py

+11-12
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,18 @@ def test_local_env__default_venv__contains_pip_and_setuptools(tmp_path):
105105
assert import_names.issubset(p.mappings[DependenciesMapping.LOCAL_ENV])
106106

107107

108-
def test_local_env__current_venv__contains_our_test_dependencies():
108+
def test_local_env__current_venv__contains_prepared_packages(isolate_default_resolver):
109+
isolate_default_resolver(
110+
{
111+
"pip": {"pip"},
112+
"setuptools": {"setuptools", "pkg_resources"},
113+
"isort": {"isort"},
114+
"pydantic": {"pydantic"},
115+
"pytest": {"pytest"},
116+
}
117+
)
109118
lpl = LocalPackageResolver()
110-
expect_package_names = [
111-
# Present in ~all venvs:
112-
"pip",
113-
"setuptools",
114-
# FawltyDeps main deps
115-
"isort",
116-
"pydantic",
117-
# Test dependencies
118-
"hypothesis",
119-
"pytest",
120-
]
119+
expect_package_names = ["pip", "setuptools", "isort", "pydantic", "pytest"]
121120
for package_name in expect_package_names:
122121
assert package_name in lpl.packages
123122

tests/test_packages.py

+11-12
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
)
1616
from fawltydeps.types import UnparseablePathException
1717

18-
from .utils import SAMPLE_PROJECTS_DIR, test_vectors
18+
from .utils import SAMPLE_PROJECTS_DIR, default_sys_path_env_for_tests, test_vectors
1919

2020

2121
def test_package__empty_package__matches_nothing():
@@ -229,14 +229,6 @@ def test_user_defined_mapping__no_input__returns_empty_mapping():
229229
assert len(udm.packages) == 0
230230

231231

232-
# TODO: These tests are not fully isolated, i.e. they do not control the
233-
# virtualenv in which they run. For now, we assume that we are running in an
234-
# environment where at least these packages are available:
235-
# - setuptools (exposes multiple import names, including pkg_resources)
236-
# - pip (exposes a single import name: pip)
237-
# - isort (exposes no top_level.txt, but 'isort' import name can be inferred)
238-
239-
240232
@pytest.mark.parametrize(
241233
"dep_name,expect_import_names",
242234
[
@@ -272,7 +264,10 @@ def test_user_defined_mapping__no_input__returns_empty_mapping():
272264
),
273265
],
274266
)
275-
def test_LocalPackageResolver_lookup_packages(dep_name, expect_import_names):
267+
def test_LocalPackageResolver_lookup_packages(
268+
isolate_default_resolver, dep_name, expect_import_names
269+
):
270+
isolate_default_resolver(default_sys_path_env_for_tests)
276271
lpl = LocalPackageResolver()
277272
actual = lpl.lookup_packages({dep_name})
278273
if expect_import_names is None:
@@ -283,13 +278,17 @@ def test_LocalPackageResolver_lookup_packages(dep_name, expect_import_names):
283278

284279

285280
@pytest.mark.parametrize("vector", [pytest.param(v, id=v.id) for v in test_vectors])
286-
def test_resolve_dependencies(vector):
281+
def test_resolve_dependencies(vector, isolate_default_resolver):
287282
dep_names = [dd.name for dd in vector.declared_deps]
283+
isolate_default_resolver(default_sys_path_env_for_tests)
288284
assert resolve_dependencies(dep_names) == vector.expect_resolved_deps
289285

290286

291-
def test_resolve_dependencies__informs_once_when_id_mapping_is_used(caplog):
287+
def test_resolve_dependencies__informs_once_when_id_mapping_is_used(
288+
caplog, isolate_default_resolver
289+
):
292290
dep_names = ["some-foo", "pip", "some-foo"]
291+
isolate_default_resolver(default_sys_path_env_for_tests)
293292
expect = {
294293
"pip": Package("pip", {DependenciesMapping.LOCAL_ENV: {"pip"}}),
295294
"some-foo": Package("some-foo", {DependenciesMapping.IDENTITY: {"some_foo"}}),

tests/test_resolver.py

+8-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Verify behavior of packages resolver"""
22

33
from pathlib import Path
4-
from typing import Dict, List, Optional
4+
from typing import Dict, List, Optional, Set
55

66
from hypothesis import HealthCheck, given, settings
77
from hypothesis import strategies as st
@@ -13,22 +13,15 @@
1313
resolve_dependencies,
1414
)
1515

16+
from .utils import default_sys_path_env_for_tests
17+
1618
# The deps in each category should be disjoint
1719
other_deps = ["pandas", "numpy", "other"]
18-
locally_installed_deps = {
19-
"setuptools": [
20-
"_distutils_hack",
21-
"pkg_resources",
22-
"setuptools",
23-
],
24-
"pip": ["pip"],
25-
"isort": ["isort"],
26-
}
2720
user_defined_mapping = {"apache-airflow": ["airflow", "foo", "bar"]}
2821

2922

3023
@st.composite
31-
def dict_subset_strategy(draw, input_dict: Dict[str, List[str]]):
24+
def dict_subset_strategy(draw, input_dict: Dict[str, Set[str]]):
3225
"""Returns a hypothesis strategy to choose items from a dict."""
3326
if not input_dict:
3427
return {}
@@ -89,7 +82,7 @@ def generate_expected_resolved_deps(
8982
ret.update(
9083
{
9184
dep: Package(
92-
dep,
85+
Package.normalize_name(dep),
9386
{DependenciesMapping.LOCAL_ENV: set(imports)},
9487
)
9588
for dep, imports in locally_installed_deps.items()
@@ -118,14 +111,15 @@ def generate_expected_resolved_deps(
118111
# The test function only reads the file content and filters needed input.
119112
@given(
120113
user_mapping=user_mapping_strategy(user_defined_mapping),
121-
installed_deps=dict_subset_strategy(locally_installed_deps),
114+
installed_deps=dict_subset_strategy(default_sys_path_env_for_tests),
122115
other_deps=st.lists(st.sampled_from(other_deps), unique=True),
123116
)
124117
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
125118
def test_resolve_dependencies__generates_expected_mappings(
126119
installed_deps,
127120
other_deps,
128121
user_mapping,
122+
isolate_default_resolver,
129123
tmp_path,
130124
):
131125

@@ -163,6 +157,7 @@ def test_resolve_dependencies__generates_expected_mappings(
163157
other_deps=other_deps,
164158
)
165159

160+
isolate_default_resolver(installed_deps)
166161
obtained = resolve_dependencies(
167162
dep_names,
168163
custom_mapping_files=set([custom_mapping_file])

tests/utils.py

+20-24
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@
88
from typing import Any, Dict, Iterable, List, Optional, Tuple
99

1010
from fawltydeps.main import main
11-
from fawltydeps.packages import (
12-
DependenciesMapping,
13-
IdentityMapping,
14-
LocalPackageResolver,
15-
Package,
16-
)
11+
from fawltydeps.packages import DependenciesMapping, Package
1712
from fawltydeps.types import (
1813
DeclaredDependency,
1914
Location,
@@ -35,16 +30,6 @@ def collect_dep_names(deps: Iterable[DeclaredDependency]) -> Iterable[str]:
3530
return (dep.name for dep in deps)
3631

3732

38-
# TODO: These tests are not fully isolated, i.e. they do not control the
39-
# virtualenv in which they run. For now, we assume that we are running in an
40-
# environment where at least these packages are available:
41-
# - setuptools (exposes multiple import names, including pkg_resources)
42-
# - pip (exposes a single import name: pip)
43-
# - isort (exposes no top_level.txt, but 'isort' import name can be inferred)
44-
45-
local_env = LocalPackageResolver()
46-
47-
4833
def imports_factory(*imports: str) -> List[ParsedImport]:
4934
return [ParsedImport(imp, Location("<stdin>")) for imp in imports]
5035

@@ -54,15 +39,26 @@ def deps_factory(*deps: str, path: str = "foo") -> List[DeclaredDependency]:
5439
return [DeclaredDependency(name=dep, source=Location(Path(path))) for dep in deps]
5540

5641

42+
# There are the packages/imports we expect to be able to resolve via the default
43+
# LocalPackageResolver. See the isolate_default_resolver() fixture for how we
44+
# make this come true.
45+
default_sys_path_env_for_tests = {
46+
"pip": {"pip"},
47+
"setuptools": {"setuptools", "pkg_resources", "_distutils_hack"},
48+
"isort": {"isort"},
49+
"typing-extensions": {"typing_extensions"},
50+
}
51+
52+
5753
def resolved_factory(*deps: str) -> Dict[str, Package]:
58-
unresolved = set(deps)
59-
resolved = local_env.lookup_packages(unresolved)
60-
unresolved -= resolved.keys()
61-
id_mapping = IdentityMapping()
62-
resolved.update(id_mapping.lookup_packages(unresolved))
63-
unresolved -= resolved.keys()
64-
assert not unresolved
65-
return resolved
54+
def make_package(dep: str) -> Package:
55+
imports = default_sys_path_env_for_tests.get(dep, None)
56+
if imports is not None: # exists in local env
57+
return Package(dep, {DependenciesMapping.LOCAL_ENV: imports})
58+
# fall back to identity mapping
59+
return Package(dep, {DependenciesMapping.IDENTITY: {dep}})
60+
61+
return {dep: make_package(dep) for dep in deps}
6662

6763

6864
def undeclared_factory(*deps: str) -> List[UndeclaredDependency]:

0 commit comments

Comments
 (0)