Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add --venv option to CLI #219

Merged
merged 3 commits into from
Mar 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion fawltydeps/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ def create(cls, settings: Settings) -> "Analysis":
assert ret.imports is not None # convince Mypy that these cannot
assert ret.declared_deps is not None # be None at this time.
ret.resolved_deps = resolve_dependencies(
dep.name for dep in ret.declared_deps
(dep.name for dep in ret.declared_deps),
venv_path=settings.venv,
)

if ret.is_enabled(Action.REPORT_UNDECLARED):
Expand Down
12 changes: 9 additions & 3 deletions fawltydeps/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,24 @@ def lookup_package(self, package_name: str) -> Optional[Package]:
return self.packages.get(Package.normalize_name(package_name))


def resolve_dependencies(dep_names: Iterable[str]) -> Dict[str, Package]:
def resolve_dependencies(
dep_names: Iterable[str], venv_path: Optional[Path] = None
) -> Dict[str, Package]:
"""Associate dependencies with corresponding Package objects.

Use LocalPackageLookup to find Package objects for each of the given
dependencies. For dependencies that cannot be found with LocalPackageLookup,
dependencies inside the virtualenv given by 'venv_path'. When 'venv_path' is
None (the default), look for packages in the current Python environment
(i.e. equivalent to sys.path).

For dependencies that cannot be found with LocalPackageLookup,
fabricate an identity mapping (a pseudo-package making available an import
of the same name as the package, modulo normalization).

Return a dict mapping dependency names to the resolved Package objects.
"""
ret = {}
local_packages = LocalPackageLookup()
local_packages = LocalPackageLookup(venv_path)
for name in dep_names:
if name not in ret:
package = local_packages.lookup_package(name)
Expand Down
11 changes: 11 additions & 0 deletions fawltydeps/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class Settings(BaseSettings): # type: ignore
actions: Set[Action] = {Action.REPORT_UNDECLARED, Action.REPORT_UNUSED}
code: Set[PathOrSpecial] = {Path(".")}
deps: Set[Path] = {Path(".")}
venv: Optional[Path] = None
output_format: OutputFormat = OutputFormat.HUMAN_SUMMARY
ignore_undeclared: Set[str] = set()
ignore_unused: Set[str] = set()
Expand Down Expand Up @@ -325,6 +326,16 @@ def populate_parser_options(parser: argparse._ActionsContainer) -> None:
" to looking for supported files in the current directory)"
),
)
parser.add_argument(
"--venv",
type=Path,
metavar="VENV_DIR",
help=(
"Where to find a virtualenv that has the project dependencies"
jherland marked this conversation as resolved.
Show resolved Hide resolved
" installed, defaults to the Python environment where FawltyDeps is"
" installed."
),
)
parser.add_argument(
"--ignore-undeclared",
nargs="+",
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def lint(session):
session.run("pylint", "fawltydeps")
session.run(
"pylint",
"--disable=missing-function-docstring,invalid-name,redefined-outer-name",
"--disable=missing-function-docstring,invalid-name,redefined-outer-name,too-many-lines",
Nour-Mws marked this conversation as resolved.
Show resolved Hide resolved
"tests",
)

Expand Down
33 changes: 32 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Fixtures for tests"""
import sys
import venv
from pathlib import Path
from tempfile import mkdtemp
from textwrap import dedent
from typing import Dict, Iterable, Union
from typing import Dict, Iterable, Set, Union

import pytest

Expand All @@ -21,6 +24,34 @@ def _inner(file_contents: Dict[str, str]) -> Path:
return _inner


@pytest.fixture
def fake_venv(tmp_path):
def create_one_fake_venv(fake_packages: Dict[str, Set[str]]) -> Path:
venv_dir = Path(mkdtemp(prefix="fake_venv.", dir=tmp_path))
venv.create(venv_dir, with_pip=False)

# Create fake packages
major, minor = sys.version_info[:2]
site_dir = venv_dir / f"lib/python{major}.{minor}/site-packages"
assert site_dir.is_dir()
for package_name, import_names in fake_packages.items():
# Create just enough files under site_dir to fool importlib_metadata
# into believing these are genuine packages
dist_info_dir = site_dir / f"{package_name}-1.2.3.dist-info"
dist_info_dir.mkdir()
(dist_info_dir / "METADATA").write_text(
f"Name: {package_name}\nVersion: 1.2.3\n"
)
top_level = dist_info_dir / "top_level.txt"
top_level.write_text("".join(f"{name}\n" for name in sorted(import_names)))
for name in import_names:
(site_dir / f"{name}.py").touch()
Nour-Mws marked this conversation as resolved.
Show resolved Hide resolved

return venv_dir

return create_one_fake_venv


@pytest.fixture
def project_with_requirements(write_tmp_files):
return write_tmp_files(
Expand Down
23 changes: 23 additions & 0 deletions tests/test_cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def test_list_imports_json__from_py_file__prints_imports_from_file(write_tmp_fil
"actions": ["list_imports"],
"code": [f"{tmp_path}/myfile.py"],
"deps": ["."],
"venv": None,
"output_format": "json",
"ignore_undeclared": [],
"ignore_unused": [],
Expand Down Expand Up @@ -307,6 +308,7 @@ def test_list_deps_json__dir__prints_deps_from_requirements_txt(
"actions": ["list_deps"],
"code": ["."],
"deps": [f"{tmp_path}"],
"venv": None,
"output_format": "json",
"ignore_undeclared": [],
"ignore_unused": [],
Expand Down Expand Up @@ -570,6 +572,7 @@ def test_check_json__simple_project__can_report_both_undeclared_and_unused(
"actions": ["check_undeclared", "check_unused"],
"code": [f"{tmp_path}"],
"deps": [f"{tmp_path}"],
"venv": None,
"output_format": "json",
"ignore_undeclared": [],
"ignore_unused": [],
Expand Down Expand Up @@ -758,6 +761,25 @@ def test__quiet_check__writes_only_names_of_unused_and_undeclared(
assert returncode == 3


def test_check__simple_project_in_fake_venv__resolves_imports_vs_deps(
fake_venv, project_with_code_and_requirements_txt
):
tmp_path = project_with_code_and_requirements_txt(
imports=["requests"],
declares=["pandas"],
)
# A venv where the "pandas" package provides a "requests" import name
# should satisfy our comparison
venv_dir = fake_venv({"pandas": {"requests"}})

output, errors, returncode = run_fawltydeps(
"--detailed", f"--code={tmp_path}", f"--deps={tmp_path}", f"--venv={venv_dir}"
)
assert output.splitlines() == [SUCCESS_MESSAGE]
assert errors == ""
assert returncode == 0


@pytest.mark.parametrize(
"args,imports,dependencies,expected",
[
Expand Down Expand Up @@ -897,6 +919,7 @@ def test_cmdline_on_ignored_undeclared_option(
actions = ['list_imports']
# code = ['.']
deps = ['foobar']
# venv = None
output_format = 'human_detailed'
# ignore_undeclared = []
# ignore_unused = []
Expand Down
35 changes: 32 additions & 3 deletions tests/test_local_env.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Verify behavior of LocalPackageLookup looking at a given venv."""

"""Verify behavior of package module looking at a given venv."""
import venv

from fawltydeps.packages import DependenciesMapping, LocalPackageLookup
from fawltydeps.packages import (
DependenciesMapping,
LocalPackageLookup,
Package,
resolve_dependencies,
)


def test_local_env__empty_venv__has_no_packages(tmp_path):
Expand Down Expand Up @@ -45,3 +49,28 @@ def test_local_env__current_venv__contains_our_test_dependencies():
]
for package_name in expect_package_names:
assert package_name in lpl.packages


def test_resolve_dependencies__in_empty_venv__reverts_to_id_mapping(tmp_path):
venv.create(tmp_path, with_pip=False)
actual = resolve_dependencies(["pip", "setuptools"], venv_path=tmp_path)
assert actual == {
"pip": Package.identity_mapping("pip"),
"setuptools": Package.identity_mapping("setuptools"),
}


def test_resolve_dependencies__in_fake_venv__returns_local_and_id_deps(fake_venv):
venv_dir = fake_venv(
{
"pip": {"pip"},
"setuptools": {"setuptools", "pkg_resources"},
"empty_pkg": set(),
}
)
actual = resolve_dependencies(["PIP", "pandas", "empty-pkg"], venv_path=venv_dir)
assert actual == {
"PIP": Package("pip", {DependenciesMapping.LOCAL_ENV: {"pip"}}),
"pandas": Package.identity_mapping("pandas"),
"empty-pkg": Package("empty_pkg", {DependenciesMapping.LOCAL_ENV: set()}),
}
43 changes: 11 additions & 32 deletions tests/test_real_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import subprocess
import sys
import tarfile
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple
from urllib.parse import urlparse
Expand Down Expand Up @@ -53,11 +52,11 @@ def verify_requirements(venv_path: Path, requirements: List[str]) -> None:
def run_fawltydeps_json(
*args: str, venv_dir: Optional[Path], cwd: Optional[Path] = None
) -> JsonData:
cmd = ["fawltydeps"]
if venv_dir:
cmd = [f"{venv_dir}/bin/fawltydeps"]
argv = ["fawltydeps", "--config-file=/dev/null", "--json"]
jherland marked this conversation as resolved.
Show resolved Hide resolved
if venv_dir is not None:
argv += [f"--venv={venv_dir}"]
proc = subprocess.run(
cmd + ["--config-file=/dev/null"] + list(args) + ["--json"],
argv + list(args),
stdout=subprocess.PIPE,
check=False,
cwd=cwd,
Expand Down Expand Up @@ -203,26 +202,6 @@ def get_venv_dir(self, cache: pytest.Cache) -> Path:
cache.set(f"fawltydeps/{self.venv_hash()}", str(venv_dir))
return venv_dir

@contextmanager
def venv_with_fawltydeps(self, cache: pytest.Cache) -> Iterator[Path]:
"""Provide this experiments's venv with FawltyDeps installed within.
Provide a context in which the FawltyDeps version located in the current
working directory is installed in editable mode. Uninstall FawltyDeps
upon exiting the context, so that the venv_dir is ready for the next
test (which may be run from a different current working directory).
"""
venv_dir = self.get_venv_dir(cache)
# setup: install editable fawltydeps
subprocess.run([f"{venv_dir}/bin/pip", "install", "-e", "./"], check=True)
try:
yield venv_dir
finally:
# teardown: uninstall fawltydeps
subprocess.run(
[f"{venv_dir}/bin/pip", "uninstall", "-y", "fawltydeps"], check=True
)


class ThirdPartyProject(NamedTuple):
"""Encapsulate a 3rd-party project to be tested with FawltyDeps.
Expand Down Expand Up @@ -375,13 +354,13 @@ def get_project_dir(self, cache: pytest.Cache) -> Path:
)
def test_real_project(request, project, experiment):
project_dir = project.get_project_dir(request.config.cache)
with experiment.venv_with_fawltydeps(request.config.cache) as venv_dir:
verify_requirements(venv_dir, experiment.requirements)
analysis = run_fawltydeps_json(
*experiment.args,
venv_dir=venv_dir,
cwd=project_dir,
)
venv_dir = experiment.get_venv_dir(request.config.cache)
verify_requirements(venv_dir, experiment.requirements)
analysis = run_fawltydeps_json(
*experiment.args,
venv_dir=venv_dir,
cwd=project_dir,
)

print(f"Checking experiment {experiment.name} for project under {project_dir}...")
experiment.verify_analysis_json(analysis)
1 change: 1 addition & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
actions={Action.REPORT_UNDECLARED, Action.REPORT_UNUSED},
code={Path(".")},
deps={Path(".")},
venv=None,
output_format=OutputFormat.HUMAN_SUMMARY,
ignore_undeclared=set(),
ignore_unused=set(),
Expand Down