diff --git a/components/polylith/check/__init__.py b/components/polylith/check/__init__.py index c2c6e1fb..5ce57b76 100644 --- a/components/polylith/check/__init__.py +++ b/components/polylith/check/__init__.py @@ -1,3 +1,3 @@ -from polylith.check import collect, grouping, report +from polylith.check import collect, report -__all__ = ["collect", "grouping", "report"] +__all__ = ["collect", "report"] diff --git a/components/polylith/check/collect.py b/components/polylith/check/collect.py index 38e974de..a7da8887 100644 --- a/components/polylith/check/collect.py +++ b/components/polylith/check/collect.py @@ -1,13 +1,13 @@ from pathlib import Path from typing import Set -from polylith import check, imports, workspace +from polylith import imports, workspace def extract_bricks(paths: Set[Path], ns: str) -> dict: all_imports = imports.fetch_all_imports(paths) - return check.grouping.extract_brick_imports(all_imports, ns) + return imports.extract_brick_imports(all_imports, ns) def with_unknown_components(root: Path, ns: str, brick_imports: dict) -> dict: diff --git a/components/polylith/check/report.py b/components/polylith/check/report.py index 1b9440f4..a07e76b2 100644 --- a/components/polylith/check/report.py +++ b/components/polylith/check/report.py @@ -2,7 +2,7 @@ from typing import Set from polylith import imports, libs, workspace -from polylith.check import collect, grouping +from polylith.check import collect from polylith.reporting import theme from rich.console import Console @@ -78,8 +78,8 @@ def extract_collected_imports( ns: str, imports_in_bases: dict, imports_in_components: dict ) -> dict: brick_imports = { - "bases": grouping.extract_brick_imports(imports_in_bases, ns), - "components": grouping.extract_brick_imports(imports_in_components, ns), + "bases": imports.grouping.extract_brick_imports(imports_in_bases, ns), + "components": imports.grouping.extract_brick_imports(imports_in_components, ns), } third_party_imports = { diff --git a/components/polylith/commands/deps.py b/components/polylith/commands/deps.py index 6c529815..67636289 100644 --- a/components/polylith/commands/deps.py +++ b/components/polylith/commands/deps.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import List, Set -from polylith import bricks, deps, info +from polylith import bricks, deps, info, interface def get_imports(root: Path, ns: str, bricks: dict) -> dict: @@ -30,6 +30,17 @@ def get_components(root: Path, ns: str, project_data: dict) -> Set[str]: return pick_name(bricks.get_components_data(root, ns)) +def used_by_as_bricks(bricks: dict, brick_deps: dict) -> dict: + bases = bricks["bases"] + components = bricks["components"] + + used_by = brick_deps["used_by"] + return { + "bases": {b for b in used_by if b in bases}, + "components": {b for b in used_by if b in components}, + } + + def run(root: Path, ns: str, options: dict): directory = options.get("directory") brick = options.get("brick") @@ -53,6 +64,8 @@ def run(root: Path, ns: str, options: dict): if brick and imports.get(brick): brick_deps = bricks_deps[brick] + used_bricks = used_by_as_bricks(bricks, brick_deps) + circular_deps = circular_bricks.get(brick) deps.print_brick_deps(brick, bricks, brick_deps, options) @@ -60,6 +73,8 @@ def run(root: Path, ns: str, options: dict): if circular_deps: deps.print_brick_with_circular_deps(brick, circular_deps, bricks) + interface.report.print_brick_interface_usage(root, ns, brick, used_bricks) + return deps.print_deps(bricks, imports, options) diff --git a/components/polylith/imports/__init__.py b/components/polylith/imports/__init__.py index 94143ff5..20863904 100644 --- a/components/polylith/imports/__init__.py +++ b/components/polylith/imports/__init__.py @@ -1,13 +1,23 @@ +from polylith.imports.grouping import ( + extract_brick_imports, + extract_brick_imports_with_namespaces, +) from polylith.imports.parser import ( extract_top_ns, fetch_all_imports, + fetch_api, + fetch_brick_import_usages, fetch_excluded_imports, list_imports, ) __all__ = [ + "extract_brick_imports", + "extract_brick_imports_with_namespaces", "extract_top_ns", "fetch_all_imports", + "fetch_api", + "fetch_brick_import_usages", "fetch_excluded_imports", "list_imports", ] diff --git a/components/polylith/check/grouping.py b/components/polylith/imports/grouping.py similarity index 85% rename from components/polylith/check/grouping.py rename to components/polylith/imports/grouping.py index 2e39a520..5dd304e7 100644 --- a/components/polylith/check/grouping.py +++ b/components/polylith/imports/grouping.py @@ -34,3 +34,9 @@ def extract_brick_imports(all_imports: dict, top_ns) -> dict: with_only_brick_names = only_brick_names(with_only_bricks) return exclude_empty(with_only_brick_names) + + +def extract_brick_imports_with_namespaces(all_imports: dict, top_ns) -> dict: + with_only_bricks = only_bricks(all_imports, top_ns) + + return exclude_empty(with_only_bricks) diff --git a/components/polylith/imports/parser.py b/components/polylith/imports/parser.py index 1a70a764..9790184e 100644 --- a/components/polylith/imports/parser.py +++ b/components/polylith/imports/parser.py @@ -2,7 +2,7 @@ from collections.abc import Iterable from functools import lru_cache from pathlib import Path -from typing import List, Set, Union +from typing import FrozenSet, List, Set, Union typing_ns = "typing" type_checking = "TYPE_CHECKING" @@ -68,6 +68,48 @@ def parse_node(node: ast.AST) -> Union[dict, None]: return None +def find_imported(node_id: str, imported: FrozenSet[str]) -> Union[str, None]: + return next((i for i in imported if str.endswith(i, f".{node_id}")), None) + + +def extract_api_part(path: str) -> str: + *_parts, api = str.split(path, ".") + + return api + + +def find_matching_node(expr: ast.expr, imported: FrozenSet[str]) -> Union[str, None]: + api = {extract_api_part(i) for i in imported} + + if isinstance(expr, ast.Name) and expr.id in api: + return find_imported(expr.id, imported) + + return None + + +def parse_import_usage(node: ast.AST, imported: FrozenSet[str]) -> Union[str, None]: + found = None + child = None + + wrapper_nodes = (ast.Await, ast.Expr, ast.NamedExpr, ast.Starred, ast.Subscript) + + if isinstance(node, ast.Attribute): + found = find_matching_node(node.value, imported) + child = node.value + elif isinstance(node, wrapper_nodes): + child = node.value + elif isinstance(node, ast.Call): + found = find_matching_node(node.func, imported) + child = node.func + elif isinstance(node, ast.UnaryOp): + child = node.operand + + if found: + return found + + return parse_import_usage(child, imported) if child is not None else None + + def parse_module(path: Path) -> ast.AST: with open(path.as_posix(), "r", encoding="utf-8", errors="ignore") as f: tree = ast.parse(f.read(), path.name) @@ -113,6 +155,35 @@ def fetch_all_imports(paths: Set[Path]) -> dict: return {k: v for row in rows for k, v in row.items()} +def fetch_import_usages_in_module(path: Path, imported: FrozenSet[str]) -> Set[str]: + tree = parse_module(path) + + nodes = (parse_import_usage(n, imported) for n in ast.walk(tree)) + + return {n for n in nodes if n is not None} + + +@lru_cache(maxsize=None) +def fetch_brick_import_usages(path: Path, imported: FrozenSet[str]) -> Set[str]: + py_modules = find_files(path) + + res = (fetch_import_usages_in_module(p, imported) for p in py_modules) + + return {i for n in res if n for i in n} + + +def extract_api(paths: Set[str]) -> Set[str]: + return {extract_api_part(p) for p in paths} + + +def fetch_api(paths: Set[Path]) -> dict: + interfaces = [Path(p / "__init__.py") for p in paths] + + rows = [{i.parent.name: extract_api(list_imports(i))} for i in interfaces] + + return {k: v for row in rows for k, v in row.items()} + + def should_exclude(path: Path, excludes: Set[str]): return any(path.match(pattern) for pattern in excludes) diff --git a/components/polylith/interface/__init__.py b/components/polylith/interface/__init__.py index 3774173f..3a6799ef 100644 --- a/components/polylith/interface/__init__.py +++ b/components/polylith/interface/__init__.py @@ -1,3 +1,4 @@ +from polylith.interface import report from polylith.interface.interfaces import create_interface -__all__ = ["create_interface"] +__all__ = ["create_interface", "report"] diff --git a/components/polylith/interface/report.py b/components/polylith/interface/report.py new file mode 100644 index 00000000..9144dfbe --- /dev/null +++ b/components/polylith/interface/report.py @@ -0,0 +1,130 @@ +from pathlib import Path +from typing import Set, Tuple + +from polylith import imports, workspace +from polylith.reporting import theme +from rich.console import Console +from rich.table import Table +from rich.tree import Tree + + +def get_brick_interface(root: Path, ns: str, brick: str, bricks: dict) -> set: + bases = bricks["bases"] + paths = {brick} + + if brick in bases: + brick_paths = workspace.paths.collect_bases_paths(root, ns, paths) + else: + brick_paths = workspace.paths.collect_components_paths(root, ns, paths) + + bricks_api = imports.fetch_api(brick_paths) + + brick_api = bricks_api.get(brick) or set() + + return {f"{ns}.{brick}.{a}" for a in brick_api} + + +def get_brick_imports(root: Path, ns: str, bases: set, components: set) -> dict: + bases_paths = workspace.paths.collect_bases_paths(root, ns, bases) + components_paths = workspace.paths.collect_components_paths(root, ns, components) + + in_bases = imports.fetch_all_imports(bases_paths) + in_components = imports.fetch_all_imports(components_paths) + + extracted_bases = imports.extract_brick_imports_with_namespaces(in_bases, ns) + extracted_components = imports.extract_brick_imports_with_namespaces( + in_components, ns + ) + + return {**extracted_bases, **extracted_components} + + +def to_imported_api(brick_imports: Set[str]) -> Set[str]: + return {imports.parser.extract_api_part(b) for b in brick_imports} + + +def filter_by_brick(brick_imports: Set[str], brick: str, ns: str) -> Set[str]: + brick_with_ns = f"{ns}.{brick}" + + return {b for b in brick_imports if str.startswith(b, brick_with_ns)} + + +def is_matching_namespace(using: str, endpoint: str) -> bool: + return str.startswith(endpoint, using) or str.startswith(using, endpoint) + + +def is_within_namespace(using: str, brick_interface: Set[str]) -> bool: + return any(is_matching_namespace(using, i) for i in brick_interface) + + +def check_usage(usings: Set[str], brick_interface: Set[str]) -> dict: + return {u: is_within_namespace(u, brick_interface) for u in usings} + + +def check_brick_interface_usage( + root: Path, ns: str, brick: str, bricks: dict +) -> Tuple[set, dict]: + brick_interface = get_brick_interface(root, ns, brick, bricks) + + bases = bricks["bases"] + components = bricks["components"] + + brick_imports = get_brick_imports(root, ns, bases, components) + filtered = {k: filter_by_brick(v, brick, ns) for k, v in brick_imports.items()} + + bases_paths = workspace.paths.collect_bases_paths(root, ns, bases) + comp_paths = workspace.paths.collect_components_paths(root, ns, components) + paths = bases_paths.union(comp_paths) + + usage = { + p.name: imports.fetch_brick_import_usages( + p, frozenset(filtered.get(p.name, set())) + ) + for p in paths + } + + collected = {k: {*v, *filtered.get(k, set())} for k, v in usage.items()} + + res = {k: check_usage(v, brick_interface) for k, v in collected.items()} + + return brick_interface, res + + +def has_valid_usage(checked_usage: dict) -> bool: + return all(v for v in checked_usage.values()) + + +def print_brick_interface_usage(root: Path, ns: str, brick: str, bricks: dict) -> None: + brick_interface, res = check_brick_interface_usage(root, ns, brick, bricks) + + invalid_usage = {k: v for k, v in res.items() if not has_valid_usage(v)} + + if not invalid_usage: + return + + console = Console(theme=theme.poly_theme) + + interface_table = Table(box=None) + tag = "base" if brick in bricks["bases"] else "comp" + interface_tree = Tree(f"[{tag}]{brick}[/] [data]interface[/]") + + for endpoint in sorted(brick_interface): + interface_tree.add(f"[data]{endpoint}[/]") + + interface_table.add_row(interface_tree) + + console.print(interface_table, overflow="ellipsis") + + table = Table(box=None) + + for using_brick, usages in invalid_usage.items(): + tag = "base" if using_brick in bricks["bases"] else "comp" + tree = Tree(f"[{tag}]{using_brick}[/] [data]using[/]") + usings = {k for k, v in usages.items() if v is False} + + for using in usings: + tree.add(f"[data]{using}[/]") + + table.add_row(tree) + + console.print(table, overflow="ellipsis") diff --git a/components/polylith/test/core.py b/components/polylith/test/core.py index 3115b326..8e464461 100644 --- a/components/polylith/test/core.py +++ b/components/polylith/test/core.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import List, Union -from polylith import check, diff, imports +from polylith import diff, imports def is_test(root: Path, ns: str, path: Path, theme: str) -> bool: @@ -34,4 +34,4 @@ def get_brick_imports_in_tests( all_imports = {k: v for k, v in enumerate(listed_imports)} - return check.grouping.extract_brick_imports(all_imports, ns) + return imports.extract_brick_imports(all_imports, ns)