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 --list-sources to CLI #346

Merged
merged 3 commits into from
Aug 11, 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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ one of these can be used at a time:
- `--check-unused`: Report only unused dependencies
- `--list-imports`: List third-party imports extracted from the project
- `--list-deps`: List declared dependencies extracted from the project
- `--list-sources`: List files/directories from which imports, declared
dependencies and installed packages would be extracted

When none of these are specified, the default action is `--check`.

Expand Down Expand Up @@ -111,6 +113,9 @@ To include both code from stdin (`import foo`) and a file path (`file.py`), use:
echo "import foo" | fawltydeps --list-imports --code - file.py
```

At any time, if you want to see where FawltyDeps is looking for Python code,
you can use the `--list-sources --detailed` options.

#### Where to find declared dependencies

The `--deps` option tells FawltyDeps where to look for your project's declared
Expand Down
10 changes: 10 additions & 0 deletions fawltydeps/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ def populate_parser_actions(parser: argparse._ActionsContainer) -> None:
const={Action.REPORT_UNUSED},
help="Report only unused dependencies",
)
parser.add_argument(
"--list-sources",
dest="actions",
action="store_const",
const={Action.LIST_SOURCES},
help=(
"List input paths used to extract imports, declared dependencies "
"and find installed packages"
),
)
parser.add_argument(
"--list-imports",
dest="actions",
Expand Down
86 changes: 60 additions & 26 deletions fawltydeps/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import sys
from functools import partial
from operator import attrgetter
from typing import Dict, List, Optional, Set, TextIO, Type
from typing import Dict, Iterator, List, Optional, Set, TextIO, Type

from pydantic.json import custom_pydantic_encoder # pylint: disable=no-name-in-module

Expand Down Expand Up @@ -82,7 +82,7 @@ def __init__(self, settings: Settings, stdin: Optional[TextIO] = None):

# The following members are calculated once, on-demand, by the
# @property @calculated_once methods below:
self._source: Optional[Set[Source]] = None
self._sources: Optional[Set[Source]] = None
self._imports: Optional[List[ParsedImport]] = None
self._declared_deps: Optional[List[DeclaredDependency]] = None
self._resolved_deps: Optional[Dict[str, Package]] = None
Expand All @@ -99,6 +99,7 @@ def sources(self) -> Set[Source]:
"""The input sources (code, deps, pyenv) found in this project."""
# What Source types are needed for which action?
source_types: Dict[Action, Set[Type[Source]]] = {
Action.LIST_SOURCES: {CodeSource, DepsSource, PyEnvSource},
Action.LIST_IMPORTS: {CodeSource},
Action.LIST_DEPS: {DepsSource},
Action.REPORT_UNDECLARED: {CodeSource, DepsSource, PyEnvSource},
Expand Down Expand Up @@ -177,6 +178,8 @@ def create(cls, settings: Settings, stdin: Optional[TextIO] = None) -> "Analysis
ret = cls(settings, stdin)

# Compute only the properties needed to satisfy settings.actions:
if ret.is_enabled(Action.LIST_SOURCES):
ret.sources # pylint: disable=pointless-statement
if ret.is_enabled(Action.LIST_IMPORTS):
ret.imports # pylint: disable=pointless-statement
if ret.is_enabled(Action.LIST_DEPS):
Expand All @@ -199,13 +202,15 @@ def print_json(self, out: TextIO) -> None:
frozenset: partial(sorted, key=str),
set: partial(sorted, key=str),
type(BasePackageResolver): lambda klass: klass.__name__,
type(Source): lambda klass: klass.__name__,
}
encoder = partial(custom_pydantic_encoder, custom_type_encoders)
json_dict = {
"settings": self.settings,
# Using properties with an underscore do not trigger computations.
# They are populated only if the computations were already required
# by settings.actions.
"sources": self._sources,
"imports": self._imports,
"declared_deps": self._declared_deps,
"resolved_deps": self._resolved_deps,
Expand All @@ -215,38 +220,67 @@ def print_json(self, out: TextIO) -> None:
}
json.dump(json_dict, out, indent=2, default=encoder)

def print_human_readable(self, out: TextIO, details: bool = True) -> None:
def print_human_readable(self, out: TextIO, detailed: bool = True) -> None:
"""Print a human-readable rendering of this analysis to 'out'."""
if self.is_enabled(Action.LIST_IMPORTS):
if details:

def render_sources() -> Iterator[str]:
if detailed:
# Sort sources by type, then by path
source_types = [
(CodeSource, "Sources of Python code:"),
(DepsSource, "Sources of declared dependencies:"),
(PyEnvSource, "Python environments:"),
]
for source_type, heading in source_types:
filtered = {s for s in self.sources if s.source_type is source_type}
if filtered:
yield "\n" + heading
yield from sorted([f" {src.render(True)}" for src in filtered])
else:
yield from sorted({src.render(False) for src in self.sources})

def render_imports() -> Iterator[str]:
if detailed:
# Sort imports by source, then by name
for imp in sorted(self.imports, key=attrgetter("source", "name")):
print(f"{imp.source}: {imp.name}", file=out)
yield f"{imp.source}: {imp.name}"
else:
unique_imports = {i.name for i in self.imports}
print("\n".join(sorted(unique_imports)), file=out)

if self.is_enabled(Action.LIST_DEPS):
if details:
# Sort dependencies by location, then by name
for dep in sorted(
set(self.declared_deps), key=attrgetter("source", "name")
):
print(f"{dep.source}: {dep.name}", file=out)
yield from sorted(unique_imports)

def render_declared_deps() -> Iterator[str]:
if detailed:
# Sort dependencies by source, then by name
unique_deps = set(self.declared_deps)
for dep in sorted(unique_deps, key=attrgetter("source", "name")):
yield f"{dep.source}: {dep.name}"
else:
print(
"\n".join(sorted(set(d.name for d in self.declared_deps))), file=out
)
yield from sorted(set(d.name for d in self.declared_deps))

if self.is_enabled(Action.REPORT_UNDECLARED) and self.undeclared_deps:
print("\nThese imports appear to be undeclared dependencies:", file=out)
def render_undeclared() -> Iterator[str]:
yield "\nThese imports appear to be undeclared dependencies:"
for undeclared in self.undeclared_deps:
print(f"- {undeclared.render(details)}", file=out)
yield f"- {undeclared.render(detailed)}"

if self.is_enabled(Action.REPORT_UNUSED) and self.unused_deps:
print(f"\n{UNUSED_DEPS_OUTPUT_PREFIX}:", file=out)
def render_unused() -> Iterator[str]:
yield f"\n{UNUSED_DEPS_OUTPUT_PREFIX}:"
for unused in sorted(self.unused_deps, key=lambda d: d.name):
print(f"- {unused.render(details)}", file=out)
yield f"- {unused.render(detailed)}"

def output(lines: Iterator[str]) -> None:
for line in lines:
print(line, file=out)

if self.is_enabled(Action.LIST_SOURCES):
output(render_sources())
if self.is_enabled(Action.LIST_IMPORTS):
output(render_imports())
if self.is_enabled(Action.LIST_DEPS):
output(render_declared_deps())
if self.is_enabled(Action.REPORT_UNDECLARED) and self.undeclared_deps:
output(render_undeclared())
if self.is_enabled(Action.REPORT_UNUSED) and self.unused_deps:
output(render_unused())
Nour-Mws marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def success_message(check_undeclared: bool, check_unused: bool) -> Optional[str]:
Expand Down Expand Up @@ -295,11 +329,11 @@ def print_output(
if analysis.settings.output_format == OutputFormat.JSON:
analysis.print_json(stdout)
elif analysis.settings.output_format == OutputFormat.HUMAN_DETAILED:
analysis.print_human_readable(stdout, details=True)
analysis.print_human_readable(stdout, detailed=True)
if exit_code == 0 and success_message:
print(f"\n{success_message}", file=stdout)
elif analysis.settings.output_format == OutputFormat.HUMAN_SUMMARY:
analysis.print_human_readable(stdout, details=False)
analysis.print_human_readable(stdout, detailed=False)
if exit_code == 0 and success_message:
print(f"\n{success_message}", file=stdout)
else:
Expand Down
1 change: 1 addition & 0 deletions fawltydeps/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def __lt__(self, other: object) -> bool:
class Action(OrderedEnum):
"""Actions provided by the FawltyDeps application."""

LIST_SOURCES = "list_sources"
LIST_IMPORTS = "list_imports"
LIST_DEPS = "list_deps"
REPORT_UNDECLARED = "check_undeclared"
Expand Down
46 changes: 40 additions & 6 deletions fawltydeps/types.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Common types used across FawltyDeps."""

import sys
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field, replace
from enum import Enum
from functools import total_ordering
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union

from fawltydeps.utils import hide_dataclass_fields

Expand Down Expand Up @@ -47,7 +48,25 @@ def __str__(self) -> str:


@dataclass(frozen=True, eq=True, order=True)
class CodeSource:
class Source(ABC):
"""Base class for some source of input to FawltyDeps.

This exists to inject the class name of the subclass into our JSON output.
"""

source_type: Type["Source"] = field(init=False)

def __post_init__(self) -> None:
object.__setattr__(self, "source_type", self.__class__)

@abstractmethod
def render(self, detailed: bool) -> str:
"""Return a human-readable string representation of this source."""
raise NotImplementedError
Nour-Mws marked this conversation as resolved.
Show resolved Hide resolved


@dataclass(frozen=True, eq=True, order=True)
class CodeSource(Source):
"""A Python code source to be parsed for import statements.

.path points to the .py or .ipynb file containing Python code, alternatively
Expand All @@ -63,6 +82,7 @@ class CodeSource:
base_dir: Optional[Path] = None

def __post_init__(self) -> None:
super().__post_init__()
if self.path != "<stdin>":
assert isinstance(self.path, Path)
if not self.path.is_file():
Expand All @@ -76,9 +96,14 @@ def __post_init__(self) -> None:
path=self.path,
)

def render(self, detailed: bool) -> str:
if detailed and self.base_dir is not None:
return f"{self.path} (using {self.base_dir}/ as base for 1st-party imports)"
return f"{self.path}"


@dataclass(frozen=True, eq=True, order=True)
class DepsSource:
class DepsSource(Source):
"""A source to be parsed for declared dependencies.

Also include which declared dependencies parser we have chosen to use for
Expand All @@ -96,11 +121,17 @@ class DepsSource:
parser_choice: ParserChoice

def __post_init__(self) -> None:
super().__post_init__()
assert self.path.is_file() # sanity check

def render(self, detailed: bool) -> str:
if detailed:
return f"{self.path} (parsed as a {self.parser_choice} file)"
return f"{self.path}"


@dataclass(frozen=True, eq=True, order=True)
class PyEnvSource:
class PyEnvSource(Source):
"""A source to be used for looking up installed Python packages.

.path points to a directory that directly contains Python packages, e.g. the
Expand All @@ -114,6 +145,7 @@ class PyEnvSource:
path: Path

def __post_init__(self) -> None:
super().__post_init__()
assert self.path.is_dir() # sanity check
# Support vitualenvs, poetry2nix envs, system-wide installs, etc.
if self.path.match("lib/python?.*/site-packages"):
Expand All @@ -125,8 +157,10 @@ def __post_init__(self) -> None:

raise ValueError(f"{self.path} is not a valid dir for Python packages!")


Source = Union[CodeSource, DepsSource, PyEnvSource]
def render(self, detailed: bool) -> str:
if detailed:
return f"{self.path} (as a source of Python packages)"
return f"{self.path}"


@total_ordering
Expand Down
Loading
Loading