Skip to content

Commit

Permalink
Modify CLI options
Browse files Browse the repository at this point in the history
  • Loading branch information
zz1874 committed Oct 13, 2023
1 parent e8943b1 commit 1958dcd
Show file tree
Hide file tree
Showing 3 changed files with 64 additions and 91 deletions.
28 changes: 4 additions & 24 deletions PyPI_analysis/cli_parser.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""Declare command line options.
"""Declare PyPI analysis command line options.
Part of the options are strictly related to `Settings` object
and part is for general purpose.
Reuse of Fawltydeps command line options.
"""

import argparse
from pathlib import Path
from typing import Any, Optional, Sequence

from fawltydeps.settings import Action, parse_path_or_stdin
from fawltydeps.settings import parse_path_or_stdin
from fawltydeps.utils import version


Expand All @@ -26,22 +28,6 @@ def __call__( # type: ignore
setattr(namespace, self.dest, set(items) | set(values))


def populate_parser_actions(parser: argparse._ActionsContainer) -> None:
"""Add the Actions-related arguments to the command-line parser.
These are mutually exclusive options that each will set the .actions
member to a set of 'Action's. If not given, the .actions member will
remain unset, to allow the underlying default to come through.
"""
parser.add_argument(
"--list-imports",
dest="actions",
action="store_const",
const={Action.LIST_IMPORTS},
help="List third-party imports extracted from code",
)


def populate_output_formats(parser: argparse._ActionsContainer) -> None:
"""Add arguments related to output format to the command-line parser.
Expand Down Expand Up @@ -165,12 +151,6 @@ def build_parser(

parser.register("action", "union", ArgparseUnionAction)

# A mutually exclusive group for arguments specifying .actions
action_group = parser.add_argument_group(
title="Actions (choose one)"
).add_mutually_exclusive_group()
populate_parser_actions(action_group)

# A mutually exclusive group for arguments specifying .output_format
output_format_group = parser.add_argument_group(
title="Output format (choose one)"
Expand Down
105 changes: 57 additions & 48 deletions PyPI_analysis/detect_imports.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
"""Detect different types of imports for automating PyPI analysis.
Reuse of Fawltydeps extract_imports code.
"""

import ast
import json
import logging
Expand Down Expand Up @@ -37,70 +42,74 @@ def make_isort_config(path: Path, src_paths: Tuple[Path, ...] = ()) -> isort.Con
def parse_code(
code: str, *, source: Location, local_context: isort.Config = ISORT_FALLBACK_CONFIG
) -> Iterator[Dict[str, ParsedImport]]:
"""Extract import statements from a string containing Python code.
"""Extract conditional import statements from a string containing Python code.
Generate (i.e. yield) the module names that are imported in the order
Generate (i.e. yield) the import category module names that are imported in the order
they appear in the code.
"""

def is_external_import(name: str) -> bool:
return isort.place_module(name, config=local_context) == "THIRDPARTY"

def conditional_imports(parsed_code: ast.Module):
for node in ast.walk(parsed_code):
if isinstance(node, ast.Try):
if isinstance(node.handlers, list) and len(node.handlers) == 1:
handler = node.handlers[0]
if (
isinstance(handler.type, ast.Name)
and handler.type.id == "ImportError"
and isinstance(handler.body, list)
and len(handler.body) == 1
and isinstance(handler.body[0], ast.Pass)
):
if isinstance(node.body, list):
for node_import in node.body:
if isinstance(node_import, ast.Import):
for alias in node_import.names:
name = alias.name.split(".", 1)[0]
if is_external_import(name):
yield {
"Conditional imports": ParsedImport(
name=name,
source=source.supply(
lineno=node.lineno
),
)
}
elif isinstance(node_import, ast.ImportFrom):
# Relative imports are always relative to the current package, and
# will therefore not resolve to a third-party package.
# They are therefore uninteresting to us.
if (
node_import.level == 0
and node_import.module is not None
):
name = node_import.module.split(".", 1)[
0
]
if is_external_import(name):
yield {
"Conditional imports": ParsedImport(
name=name,
source=source.supply(
lineno=node.lineno
),
)
}

try:
parsed_code = ast.parse(code, filename=str(source.path))
except SyntaxError as exc:
logger.error(f"Could not parse code from {source}: {exc}")
return

for node in ast.walk(parsed_code):
if isinstance(node, ast.Try):
if isinstance(node.handlers, list) and len(node.handlers) == 1:
handler = node.handlers[0]
if (
isinstance(handler.type, ast.Name)
and handler.type.id == "ImportError"
and isinstance(handler.body, list)
and len(handler.body) == 1
and isinstance(handler.body[0], ast.Pass)
):
if isinstance(node.body, list):
for node_import in node.body:
if isinstance(node_import, ast.Import):
for alias in node_import.names:
name = alias.name.split(".", 1)[0]
if is_external_import(name):
yield {
"Conditional imports": ParsedImport(
name=name,
source=source.supply(
lineno=node.lineno
),
)
}
elif isinstance(node_import, ast.ImportFrom):
# Relative imports are always relative to the current package, and
# will therefore not resolve to a third-party package.
# They are therefore uninteresting to us.
if (
node_import.level == 0
and node_import.module is not None
):
name = node_import.module.split(".", 1)[0]
if is_external_import(name):
yield {
"Conditional imports": ParsedImport(
name=name,
source=source.supply(
lineno=node.lineno
),
)
}
yield from conditional_imports(parsed_code)


def parse_notebook_file(
path: Path, local_context: Optional[isort.Config] = None
) -> Iterator[ParsedImport]:
"""Extract import statements from an ipynb notebook.
"""Extract conditional import statements from an ipynb notebook.
Generate (i.e. yield) the module names that are imported in the order
they appear in the file.
Expand Down Expand Up @@ -164,7 +173,7 @@ def filter_out_magic_commands(
def parse_python_file(
path: Path, local_context: Optional[isort.Config] = None
) -> Iterator[ParsedImport]:
"""Extract import statements from a file containing Python code.
"""Extract conditional import statements from a file containing Python code.
Generate (i.e. yield) the module names that are imported in the order
they appear in the file.
Expand Down
22 changes: 3 additions & 19 deletions PyPI_analysis/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
logger = logging.getLogger(__name__)

VERBOSE_PROMPT = "For a more verbose report re-run with the `--detailed` option."
UNUSED_DEPS_OUTPUT_PREFIX = "These dependencies appear to be unused (i.e. not imported)"


class Analysis: # pylint: disable=too-many-instance-attributes
Expand All @@ -44,16 +43,6 @@ class Analysis: # pylint: disable=too-many-instance-attributes
- .sources (a set of CodeSource, DepsSource and/or PyEnvSource objects)
reflect the result of traversing the project structure.
- .imports contains the imports found by parsing the CodeSources.
- .declared_deps contains the declared dependencies found by parsing the
DepsSources.
- .resolved_deps contains the mapping from .declared_deps to the Python
package that expose the corresponding imports. This package is found
within one of the PyEnvSources.
- .undeclared_deps is calculated by finding the .imports that are not
present in any of the .resolved_deps.
- .unused_deps is the subset of .declared_deps whose corresponding packages
only provide imports that are never actually imported (i.e. present in
.imports).
"""

def __init__(self, settings: Settings, stdin: Optional[TextIO] = None):
Expand Down Expand Up @@ -82,7 +71,7 @@ def sources(self) -> Set[Source]:
return set(
find_sources(
self.settings,
set.union(*[source_types[action] for action in self.settings.actions]),
set.union(*[source_types[action] for action in {Action.LIST_IMPORTS}]),
)
)

Expand Down Expand Up @@ -111,11 +100,7 @@ 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
ret.imports

return ret

Expand Down Expand Up @@ -175,8 +160,7 @@ def output(lines: Iterator[str]) -> None:
for line in lines:
print(line, file=out)

if self.is_enabled(Action.LIST_IMPORTS):
output(render_imports())
output(render_imports())

@staticmethod
def success_message(check_undeclared: bool, check_unused: bool) -> Optional[str]:
Expand Down

0 comments on commit 1958dcd

Please sign in to comment.