From 8dbdc97bc4acef27bf163245d2e88c38fd92bf42 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Sun, 23 Nov 2025 11:32:22 +0100 Subject: [PATCH 01/14] wip --- components/polylith/imports/__init__.py | 2 ++ components/polylith/imports/parser.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/components/polylith/imports/__init__.py b/components/polylith/imports/__init__.py index 94143ff5..a4f6ded8 100644 --- a/components/polylith/imports/__init__.py +++ b/components/polylith/imports/__init__.py @@ -1,6 +1,7 @@ from polylith.imports.parser import ( extract_top_ns, fetch_all_imports, + fetch_api, fetch_excluded_imports, list_imports, ) @@ -8,6 +9,7 @@ __all__ = [ "extract_top_ns", "fetch_all_imports", + "fetch_api", "fetch_excluded_imports", "list_imports", ] diff --git a/components/polylith/imports/parser.py b/components/polylith/imports/parser.py index 1a70a764..2979df0e 100644 --- a/components/polylith/imports/parser.py +++ b/components/polylith/imports/parser.py @@ -113,6 +113,25 @@ def fetch_all_imports(paths: Set[Path]) -> dict: return {k: v for row in rows for k, v in row.items()} +def extract_api_part(path: str) -> str: + *_parts, api = str.split(path, ".") + + return api + + +def extract_api(paths: Set[str]) -> Set[str]: + return {extract_api_part(p) for p in paths} + + +def fetch_api(paths: Set[Path]) -> dict: + interface = "__init__.py" + interfaces = [Path(p / interface) 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) From 6330685da6bcb628268ea7f03857b1963b369b7c Mon Sep 17 00:00:00 2001 From: David Vujic Date: Fri, 2 Jan 2026 14:37:40 +0100 Subject: [PATCH 02/14] refactor(check): move grouping of brick imports to the 'imports' module --- components/polylith/check/__init__.py | 4 ++-- components/polylith/check/collect.py | 4 ++-- components/polylith/check/report.py | 6 +++--- components/polylith/imports/__init__.py | 2 ++ components/polylith/{check => imports}/grouping.py | 0 components/polylith/test/core.py | 4 ++-- 6 files changed, 11 insertions(+), 9 deletions(-) rename components/polylith/{check => imports}/grouping.py (100%) 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/imports/__init__.py b/components/polylith/imports/__init__.py index a4f6ded8..fecae14d 100644 --- a/components/polylith/imports/__init__.py +++ b/components/polylith/imports/__init__.py @@ -1,3 +1,4 @@ +from polylith.imports.grouping import extract_brick_imports from polylith.imports.parser import ( extract_top_ns, fetch_all_imports, @@ -7,6 +8,7 @@ ) __all__ = [ + "extract_brick_imports", "extract_top_ns", "fetch_all_imports", "fetch_api", diff --git a/components/polylith/check/grouping.py b/components/polylith/imports/grouping.py similarity index 100% rename from components/polylith/check/grouping.py rename to components/polylith/imports/grouping.py 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) From cdc1662d92d03919d218e60d48701af2f65ef49c Mon Sep 17 00:00:00 2001 From: David Vujic Date: Fri, 2 Jan 2026 17:00:25 +0100 Subject: [PATCH 03/14] wip: interface command --- components/polylith/imports/__init__.py | 6 ++- components/polylith/imports/grouping.py | 6 +++ components/polylith/imports/parser.py | 3 +- components/polylith/interface/__init__.py | 3 +- components/polylith/interface/report.py | 56 +++++++++++++++++++++++ 5 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 components/polylith/interface/report.py diff --git a/components/polylith/imports/__init__.py b/components/polylith/imports/__init__.py index fecae14d..051ce4a8 100644 --- a/components/polylith/imports/__init__.py +++ b/components/polylith/imports/__init__.py @@ -1,4 +1,7 @@ -from polylith.imports.grouping import extract_brick_imports +from polylith.imports.grouping import ( + extract_brick_imports, + extract_brick_imports_with_namespaces, +) from polylith.imports.parser import ( extract_top_ns, fetch_all_imports, @@ -9,6 +12,7 @@ __all__ = [ "extract_brick_imports", + "extract_brick_imports_with_namespaces", "extract_top_ns", "fetch_all_imports", "fetch_api", diff --git a/components/polylith/imports/grouping.py b/components/polylith/imports/grouping.py index 2e39a520..5dd304e7 100644 --- a/components/polylith/imports/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 2979df0e..6b24732a 100644 --- a/components/polylith/imports/parser.py +++ b/components/polylith/imports/parser.py @@ -124,8 +124,7 @@ def extract_api(paths: Set[str]) -> Set[str]: def fetch_api(paths: Set[Path]) -> dict: - interface = "__init__.py" - interfaces = [Path(p / interface) for p in paths] + interfaces = [Path(p / "__init__.py") for p in paths] rows = [{i.parent.name: extract_api(list_imports(i))} for i in interfaces] 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..80679d9e --- /dev/null +++ b/components/polylith/interface/report.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from polylith import imports, workspace +from polylith.reporting import theme +from rich import box +from rich.console import Console +from rich.table import Table + + +def get_brick_data(root: Path, ns: str, brick: str, brick_type: str) -> dict: + paths = {brick} + + if brick_type == "base": + brick_path = workspace.paths.collect_bases_paths(root, ns, paths) + else: + brick_path = workspace.paths.collect_components_paths(root, ns, paths) + + brick_api = imports.fetch_api(brick_path) + exposes = brick_api.get(brick) or set() + + return { + "name": brick, + "type": brick_type, + "exposes": sorted(exposes), + } + + +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) + + return { + "bases": imports.extract_brick_imports_with_namespaces(in_bases, ns), + "components": imports.extract_brick_imports_with_namespaces(in_components, ns), + } + + +def print_brick_interface(root: Path, ns: str, brick: str, bricks: dict) -> None: + bases = bricks["bases"] + tag = "base" if brick in bases else "comp" + + brick_data = get_brick_data(root, ns, brick, tag) + exposes = brick_data["exposes"] + + console = Console(theme=theme.poly_theme) + + table = Table(box=box.SIMPLE_HEAD) + table.add_column(f"[{tag}]{brick}[/] [data]brick interface[/]") + + for e in exposes: + table.add_row(f"[data]{e}[/]") + + console.print(table, overflow="ellipsis") From 871780c5fb9e0ba37652eaa5ab45d2dbbd2dfd65 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Tue, 6 Jan 2026 21:01:57 +0100 Subject: [PATCH 04/14] wip: parse brick usages in modules --- components/polylith/imports/__init__.py | 2 + components/polylith/imports/parser.py | 61 +++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/components/polylith/imports/__init__.py b/components/polylith/imports/__init__.py index 051ce4a8..20863904 100644 --- a/components/polylith/imports/__init__.py +++ b/components/polylith/imports/__init__.py @@ -6,6 +6,7 @@ extract_top_ns, fetch_all_imports, fetch_api, + fetch_brick_import_usages, fetch_excluded_imports, list_imports, ) @@ -16,6 +17,7 @@ "extract_top_ns", "fetch_all_imports", "fetch_api", + "fetch_brick_import_usages", "fetch_excluded_imports", "list_imports", ] diff --git a/components/polylith/imports/parser.py b/components/polylith/imports/parser.py index 6b24732a..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,10 +155,21 @@ def fetch_all_imports(paths: Set[Path]) -> dict: return {k: v for row in rows for k, v in row.items()} -def extract_api_part(path: str) -> str: - *_parts, api = str.split(path, ".") +def fetch_import_usages_in_module(path: Path, imported: FrozenSet[str]) -> Set[str]: + tree = parse_module(path) - return api + 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]: From b4cb60f765318d8df5ddd6a07f54a367bc4f7463 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Sun, 11 Jan 2026 20:36:32 +0100 Subject: [PATCH 05/14] wip(interface): print brick usage that is not matching the interface --- components/polylith/commands/deps.py | 17 +++- components/polylith/interface/report.py | 122 +++++++++++++++++++----- 2 files changed, 114 insertions(+), 25 deletions(-) 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/interface/report.py b/components/polylith/interface/report.py index 80679d9e..9144dfbe 100644 --- a/components/polylith/interface/report.py +++ b/components/polylith/interface/report.py @@ -1,28 +1,27 @@ from pathlib import Path +from typing import Set, Tuple from polylith import imports, workspace from polylith.reporting import theme -from rich import box from rich.console import Console from rich.table import Table +from rich.tree import Tree -def get_brick_data(root: Path, ns: str, brick: str, brick_type: str) -> dict: +def get_brick_interface(root: Path, ns: str, brick: str, bricks: dict) -> set: + bases = bricks["bases"] paths = {brick} - if brick_type == "base": - brick_path = workspace.paths.collect_bases_paths(root, ns, paths) + if brick in bases: + brick_paths = workspace.paths.collect_bases_paths(root, ns, paths) else: - brick_path = workspace.paths.collect_components_paths(root, ns, paths) + brick_paths = workspace.paths.collect_components_paths(root, ns, paths) - brick_api = imports.fetch_api(brick_path) - exposes = brick_api.get(brick) or set() + bricks_api = imports.fetch_api(brick_paths) - return { - "name": brick, - "type": brick_type, - "exposes": sorted(exposes), - } + 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: @@ -32,25 +31,100 @@ def get_brick_imports(root: Path, ns: str, bases: set, components: set) -> dict: in_bases = imports.fetch_all_imports(bases_paths) in_components = imports.fetch_all_imports(components_paths) - return { - "bases": imports.extract_brick_imports_with_namespaces(in_bases, ns), - "components": imports.extract_brick_imports_with_namespaces(in_components, ns), - } + 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) -def print_brick_interface(root: Path, ns: str, brick: str, bricks: dict) -> None: bases = bricks["bases"] - tag = "base" if brick in bases else "comp" + components = bricks["components"] - brick_data = get_brick_data(root, ns, brick, tag) - exposes = brick_data["exposes"] + 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) - table = Table(box=box.SIMPLE_HEAD) - table.add_column(f"[{tag}]{brick}[/] [data]brick interface[/]") + 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}[/]") - for e in exposes: - table.add_row(f"[data]{e}[/]") + table.add_row(tree) console.print(table, overflow="ellipsis") From 5cb6387ddc55ae4e19fca17d53289976842f83e4 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Thu, 15 Jan 2026 21:39:36 +0100 Subject: [PATCH 06/14] wip: parse brick usages in modules --- components/polylith/imports/parser.py | 134 ++++++++++++++---- components/polylith/interface/report.py | 115 ++++++++------- .../polylith/imports/test_parser.py | 132 +++++++++++++++++ 3 files changed, 300 insertions(+), 81 deletions(-) create mode 100644 test/components/polylith/imports/test_parser.py diff --git a/components/polylith/imports/parser.py b/components/polylith/imports/parser.py index 9790184e..2b5430a5 100644 --- a/components/polylith/imports/parser.py +++ b/components/polylith/imports/parser.py @@ -2,11 +2,14 @@ from collections.abc import Iterable from functools import lru_cache from pathlib import Path -from typing import FrozenSet, List, Set, Union +from typing import FrozenSet, List, Optional, Set, Tuple, Union typing_ns = "typing" type_checking = "TYPE_CHECKING" +WRAPPER_NODES = (ast.Await, ast.Expr, ast.NamedExpr, ast.Starred, ast.Subscript) +FN_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) + def parse_import(node: ast.Import) -> List[str]: return [name.name for name in node.names] @@ -68,46 +71,107 @@ 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: + return path.rsplit(".", 1)[-1] + +def find_import_root_and_path( + expr: ast.expr, parts: Tuple[str, ...] = () +) -> Tuple[ast.expr, str]: + """Builds a namespace when the expression is an Attribute or Name, otherwise empty.""" + if isinstance(expr, ast.Attribute): + return find_import_root_and_path(expr.value, (*parts, expr.attr)) + + namespace_parts = (*parts, expr.id) if isinstance(expr, ast.Name) else parts + + namespace = str.join(".", reversed(namespace_parts)) + + return expr, namespace -def extract_api_part(path: str) -> str: - *_parts, api = str.split(path, ".") - return api +def with_ns(usage: str, ns: str) -> str: + return usage if str.startswith(usage, ns + ".") else f"{ns}.{usage}" -def find_matching_node(expr: ast.expr, imported: FrozenSet[str]) -> Union[str, None]: - api = {extract_api_part(i) for i in imported} +def find_matching_usage(expr: ast.expr, options: dict) -> Optional[str]: + ns = options["ns"] + api_map = options["api_map"] + allowed_prefixes = options["allowed_prefixes"] + shadowed = options["shadowed"] - if isinstance(expr, ast.Name) and expr.id in api: - return find_imported(expr.id, imported) + root, usage = find_import_root_and_path(expr) + + if not isinstance(root, ast.Name): + return None + + if root.id in shadowed: + return None + + if root.id in api_map: + found = api_map[root.id] if usage == root.id else usage + + return with_ns(found, ns) + + if any(usage.startswith(p + ".") for p in allowed_prefixes): + return with_ns(usage, ns) return None -def parse_import_usage(node: ast.AST, imported: FrozenSet[str]) -> Union[str, None]: - found = None +def parse_import_usage(node: ast.AST, options: dict) -> Union[str, None]: + usage = 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) + usage = find_matching_usage(node, options) child = node.value - elif isinstance(node, wrapper_nodes): + elif isinstance(node, WRAPPER_NODES): child = node.value elif isinstance(node, ast.Call): - found = find_matching_node(node.func, imported) + usage = find_matching_usage(node.func, options) child = node.func elif isinstance(node, ast.UnaryOp): child = node.operand - if found: - return found + if usage: + return usage + + return parse_import_usage(child, options) if child is not None else None - return parse_import_usage(child, imported) if child is not None else None + +def collect_arg_names( + fn: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda], +) -> Set[str]: + args = fn.args + + names = {a.arg for a in args.posonlyargs + args.args + args.kwonlyargs} + + if args.vararg: + names.add(args.vararg.arg) + + if args.kwarg: + names.add(args.kwarg.arg) + + return names + + +def walk_usages(node: ast.AST, options: dict) -> Set[str]: + if isinstance(node, FN_NODES): + options = { + **options, + "shadowed": options["shadowed"] | frozenset(collect_arg_names(node)), + } + + out = set() + hit = parse_import_usage(node, options) + + if hit: + out.add(hit) + + for child in ast.iter_child_nodes(node): + out |= walk_usages(child, options) + + return out def parse_module(path: Path) -> ast.AST: @@ -130,7 +194,7 @@ def extract_imports(path: Path) -> List[str]: return [i for i in includes if i not in excludes] -def extract_and_flatten(py_modules: Iterable) -> Set[str]: +def extract_imports_and_flatten(py_modules: Iterable) -> Set[str]: return {i for m in py_modules for i in extract_imports(m)} @@ -146,7 +210,7 @@ def find_files(path: Path) -> Iterable: def list_imports(path: Path) -> Set[str]: py_modules = find_files(path) - return extract_and_flatten(py_modules) + return extract_imports_and_flatten(py_modules) def fetch_all_imports(paths: Set[Path]) -> dict: @@ -155,21 +219,31 @@ 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]: +def fetch_import_usages_in_module(path: Path, ns: str, imported: Set[str]) -> Set[str]: tree = parse_module(path) + api_map = {extract_api_part(p): p for p in imported} - nodes = (parse_import_usage(n, imported) for n in ast.walk(tree)) - - return {n for n in nodes if n is not None} + options = { + "ns": ns, + "api_map": api_map, + "allowed_prefixes": frozenset(api_map.values()), + "shadowed": frozenset(), + } + return walk_usages(tree, options) @lru_cache(maxsize=None) -def fetch_brick_import_usages(path: Path, imported: FrozenSet[str]) -> Set[str]: +def fetch_brick_import_usages( + path: Path, ns: str, imported: FrozenSet[str] +) -> Set[str]: py_modules = find_files(path) - res = (fetch_import_usages_in_module(p, imported) for p in py_modules) + found = {m: set(extract_imports(m)).intersection(imported) for m in py_modules} + filtered = {k: v for k, v in found.items() if v} + + fetched = (fetch_import_usages_in_module(k, ns, v) for k, v in filtered.items()) - return {i for n in res if n for i in n} + return {i for f in fetched if f for i in f} def extract_api(paths: Set[str]) -> Set[str]: @@ -193,7 +267,7 @@ def list_excluded_imports(path: Path, excludes: Set[str]) -> Set[str]: filtered = [p for p in py_modules if should_exclude(p, excludes)] - return extract_and_flatten(filtered) + return extract_imports_and_flatten(filtered) def fetch_excluded_imports(paths: Set[Path], excludes: Set[str]) -> dict: diff --git a/components/polylith/interface/report.py b/components/polylith/interface/report.py index 9144dfbe..54aaadf1 100644 --- a/components/polylith/interface/report.py +++ b/components/polylith/interface/report.py @@ -1,40 +1,37 @@ from pathlib import Path -from typing import Set, Tuple +from typing import Dict, FrozenSet, Set, Tuple -from polylith import imports, workspace +from polylith import imports from polylith.reporting import theme +from polylith.workspace.paths import collect_bases_paths, collect_components_paths from rich.console import Console +from rich.padding import Padding 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) + fn = collect_bases_paths if brick in bases else collect_components_paths + brick_paths = fn(root, ns, paths) bricks_api = imports.fetch_api(brick_paths) - brick_api = bricks_api.get(brick) or set() + brick_ns = f"{ns}.{brick}" - return {f"{ns}.{brick}.{a}" for a in brick_api} + return {f"{brick_ns}.{endpoint}" for endpoint 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) + bases_paths = collect_bases_paths(root, ns, bases) + components_paths = collect_components_paths(root, ns, components) in_bases = imports.fetch_all_imports(bases_paths) - in_components = imports.fetch_all_imports(components_paths) + in_comps = 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 - ) + extracted_components = imports.extract_brick_imports_with_namespaces(in_comps, ns) return {**extracted_bases, **extracted_components} @@ -49,82 +46,98 @@ def filter_by_brick(brick_imports: Set[str], brick: str, ns: str) -> Set[str]: 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(current: str, namespaces: Set[str]) -> bool: + return any(current.startswith(i) for i in namespaces) -def is_within_namespace(using: str, brick_interface: Set[str]) -> bool: - return any(is_matching_namespace(using, i) for i in brick_interface) +def starts_with(usages: Set[str], current: str) -> bool: + return any(usage.startswith(current + ".") for usage in usages) def check_usage(usings: Set[str], brick_interface: Set[str]) -> dict: return {u: is_within_namespace(u, brick_interface) for u in usings} +def frozen(data: Dict[str, Set[str]], key: str) -> FrozenSet[str]: + return frozenset(data.get(key) or set()) + + def check_brick_interface_usage( root: Path, ns: str, brick: str, bricks: dict -) -> Tuple[set, dict]: +) -> Tuple[dict, set]: 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()} + by_brick = {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) + bases_paths = collect_bases_paths(root, ns, bases) + comp_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())) - ) + p.name: imports.fetch_brick_import_usages(p, ns, frozen(by_brick, p.name)) for p in paths } - collected = {k: {*v, *filtered.get(k, set())} for k, v in usage.items()} + checked = {k: check_usage(v, brick_interface) for k, v in usage.items()} - res = {k: check_usage(v, brick_interface) for k, v in collected.items()} + return checked, brick_interface - return brick_interface, res +def print_brick_interface(brick: str, brick_interface: set, bricks: dict) -> None: + console = Console(theme=theme.poly_theme) -def has_valid_usage(checked_usage: dict) -> bool: - return all(v for v in checked_usage.values()) + tag = "base" if brick in bricks["bases"] else "comp" + table = Table(box=None) -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) + message = f"[{tag}]{brick}[/] exposes:" + table.add_column(Padding(message, (1, 0, 0, 0))) - invalid_usage = {k: v for k, v in res.items() if not has_valid_usage(v)} + for endpoint in sorted(brick_interface): + *_ns, exposes = str.split(endpoint, ".") + table.add_row(f"[data]{exposes}[/]") - if not invalid_usage: - return + console.print(table, overflow="ellipsis") - 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[/]") +def unified_usages(usages: dict) -> Set[str]: + filtered = {k for k, v in usages.items() if not v} - for endpoint in sorted(brick_interface): - interface_tree.add(f"[data]{endpoint}[/]") + return {f for f in filtered if starts_with(filtered, f)} - interface_table.add_row(interface_tree) - console.print(interface_table, overflow="ellipsis") +def print_brick_interface_usage(root: Path, ns: str, brick: str, bricks: dict) -> None: + res, brick_interface = check_brick_interface_usage(root, ns, brick, bricks) + + invalid_usage = { + brick: unified_usages(usages) + for brick, usages in res.items() + if not all(usages.values()) + } + + if not invalid_usage: + return + + console = Console(theme=theme.poly_theme) table = Table(box=None) + tag = "base" if brick in bricks["bases"] else "comp" 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} + using_tag = "base" if using_brick in bricks["bases"] else "comp" + + for using in usages: + used = str.replace(using, f"{ns}.{brick}.", "") + prefix = f"Found in [{using_tag}]{using_brick}[/]" + middle = f"[data]{used}[/] is not part of the public interface of [{tag}]{brick}[/]" - for using in usings: - tree.add(f"[data]{using}[/]") + message = f":information: {prefix}: {middle}." - table.add_row(tree) + table.add_row(f"{message}") console.print(table, overflow="ellipsis") + + print_brick_interface(brick, brick_interface, bricks) diff --git a/test/components/polylith/imports/test_parser.py b/test/components/polylith/imports/test_parser.py new file mode 100644 index 00000000..c5d56532 --- /dev/null +++ b/test/components/polylith/imports/test_parser.py @@ -0,0 +1,132 @@ +import ast +import io +from functools import partial +from pathlib import Path + +from polylith.imports import parser + +fake_path = Path.cwd() + + +top_ns = "top_namespace" +brick = "something" +imported = {f"{top_ns}.{brick}"} + + +ns_brick_import = f""" +from {top_ns} import {brick} + + +def first() -> str: + return {brick}.one() + + +def second() -> str: + return {brick}.two() +""" + +ns_brick_fn_import = f""" +from {top_ns}.{brick} import one, two + + +def first() -> str: + return one() + + +def second() -> str: + return two() +""" + + +ns_brick_import_with_shadowed = f""" +from {top_ns} import {brick} + + +def second({brick}: dict) -> str: + return {brick}.get("key") +""" + +ns_import = f""" +import {top_ns} + + +def first() -> str: + return {top_ns}.x.one() + + +def second() -> str: + return {top_ns}.{brick}.one() + +""" + +ns_import_star = f""" +from {top_ns} import * + + +def first() -> str: + return x.one() + + +def second() -> str: + return {brick}.one() + +""" + + +def fake_parse_module(contents: str, *args, **kwargs) -> ast.AST: + f = io.StringIO(contents) + + return ast.parse(f.read(), "unit_test") + + +def test_fetch_import_usages_in_module_ns_brick(monkeypatch) -> None: + fn = partial(fake_parse_module, ns_brick_import) + monkeypatch.setattr(parser, "parse_module", fn) + + expected = {f"{top_ns}.{brick}.one", f"{top_ns}.{brick}.two"} + + res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + + assert res == expected + + +def test_fetch_import_usages_in_module_ns_brick_fn(monkeypatch) -> None: + fn = partial(fake_parse_module, ns_brick_import) + monkeypatch.setattr(parser, "parse_module", fn) + + expected = {f"{top_ns}.{brick}.one", f"{top_ns}.{brick}.two"} + + res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + + assert res == expected + + +def test_fetch_import_usages_in_module_ns_brick_with_shadowed(monkeypatch) -> None: + fn = partial(fake_parse_module, ns_brick_import_with_shadowed) + monkeypatch.setattr(parser, "parse_module", fn) + + res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + + assert res == set() + + +def test_fetch_import_usages_in_module_ns(monkeypatch) -> None: + fn = partial(fake_parse_module, ns_import) + monkeypatch.setattr(parser, "parse_module", fn) + + expected = {f"{top_ns}.{brick}.one"} + + res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + + assert res == expected + + +def test_fetch_import_usages_in_module_ns_star(monkeypatch) -> None: + fn = partial(fake_parse_module, ns_import_star) + monkeypatch.setattr(parser, "parse_module", fn) + + expected = {f"{top_ns}.{brick}.one"} + + res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + + assert res == expected From cbc20af6023ae55cee1618256c547c759f26c332 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Mon, 2 Feb 2026 23:21:58 +0100 Subject: [PATCH 07/14] wip: parse brick interface --- components/polylith/imports/__init__.py | 8 +- components/polylith/imports/parser.py | 19 +--- components/polylith/interface/parser.py | 105 ++++++++++++++++++ components/polylith/interface/report.py | 3 +- .../polylith/interface/test_parse_api.py | 93 ++++++++++++++++ 5 files changed, 212 insertions(+), 16 deletions(-) create mode 100644 components/polylith/interface/parser.py create mode 100644 test/components/polylith/interface/test_parse_api.py diff --git a/components/polylith/imports/__init__.py b/components/polylith/imports/__init__.py index 20863904..99b530d3 100644 --- a/components/polylith/imports/__init__.py +++ b/components/polylith/imports/__init__.py @@ -3,21 +3,25 @@ extract_brick_imports_with_namespaces, ) from polylith.imports.parser import ( + SYMBOLS, + extract_api, extract_top_ns, fetch_all_imports, - fetch_api, fetch_brick_import_usages, fetch_excluded_imports, list_imports, + parse_module, ) __all__ = [ "extract_brick_imports", "extract_brick_imports_with_namespaces", + "SYMBOLS", + "extract_api", "extract_top_ns", "fetch_all_imports", - "fetch_api", "fetch_brick_import_usages", "fetch_excluded_imports", "list_imports", + "parse_module", ] diff --git a/components/polylith/imports/parser.py b/components/polylith/imports/parser.py index 2b5430a5..189956a1 100644 --- a/components/polylith/imports/parser.py +++ b/components/polylith/imports/parser.py @@ -9,6 +9,7 @@ WRAPPER_NODES = (ast.Await, ast.Expr, ast.NamedExpr, ast.Starred, ast.Subscript) FN_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) +SYMBOLS = (*FN_NODES, ast.ClassDef) def parse_import(node: ast.Import) -> List[str]: @@ -75,6 +76,10 @@ def extract_api_part(path: str) -> str: return path.rsplit(".", 1)[-1] +def extract_api(paths: Set[str]) -> Set[str]: + return {extract_api_part(p) for p in paths} + + def find_import_root_and_path( expr: ast.expr, parts: Tuple[str, ...] = () ) -> Tuple[ast.expr, str]: @@ -174,7 +179,7 @@ def walk_usages(node: ast.AST, options: dict) -> Set[str]: return out -def parse_module(path: Path) -> ast.AST: +def parse_module(path: Path) -> ast.Module: with open(path.as_posix(), "r", encoding="utf-8", errors="ignore") as f: tree = ast.parse(f.read(), path.name) @@ -246,18 +251,6 @@ def fetch_brick_import_usages( return {i for f in fetched if f for i in f} -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/parser.py b/components/polylith/interface/parser.py new file mode 100644 index 00000000..a62d1243 --- /dev/null +++ b/components/polylith/interface/parser.py @@ -0,0 +1,105 @@ +import ast +from functools import lru_cache +from pathlib import Path +from typing import Optional, Set + +from polylith.imports import SYMBOLS, extract_api, list_imports, parse_module + + +def target_names(t: ast.AST) -> Set[str]: + if isinstance(t, ast.Name): + return {t.id} + + if isinstance(t, (ast.Tuple, ast.List)): + return {n for e in t.elts for n in target_names(e)} + + return set() + + +def extract_variables(statement: ast.stmt) -> Set[str]: + if isinstance(statement, ast.Assign): + return {n for t in statement.targets for n in target_names(t)} + + if isinstance(statement, (ast.AnnAssign, ast.AugAssign)): + return target_names(statement.target) + + if hasattr(ast, "TypeAlias") and isinstance(statement, ast.TypeAlias): + return {statement.name.id} + + return set() + + +def is_private(symbol_name: str) -> bool: + return symbol_name.startswith("_") + + +@lru_cache(maxsize=None) +def parse(path: Path) -> ast.Module: + return parse_module(path) + + +def extract_symbols(path: Path) -> Set[str]: + tree = parse(path) + + return { + s.name for s in tree.body if isinstance(s, SYMBOLS) and not is_private(s.name) + } + + +def extract_public_variables(path: Path) -> Set[str]: + tree = parse(path) + + return {v for s in tree.body for v in extract_variables(s) if not is_private(v)} + + +def is_the_all_statement(target: ast.expr) -> bool: + return isinstance(target, ast.Name) and target.id == "__all__" + + +def is_string_constant(expression: ast.AST) -> bool: + return isinstance(expression, ast.Constant) and isinstance(expression.value, str) + + +def find_the_all_variable(statement: ast.stmt) -> Optional[Set[str]]: + if not isinstance(statement, ast.Assign): + return None + + if not any(is_the_all_statement(t) for t in statement.targets): + return None + + if not isinstance(statement.value, (ast.List, ast.Tuple)): + return None + + if not all(is_string_constant(e) for e in statement.value.elts): + return None + + return {e.value for e in statement.value.elts if isinstance(e, ast.Constant)} + + +def extract_the_all_variable(path: Path) -> Set[str]: + tree = parse(path) + + res = [find_the_all_variable(s) for s in tree.body] + + return next((r for r in res if r is not None), set()) + + +def extract_imported_api(path: Path) -> Set[str]: + return extract_api(list_imports(path)) + + +def fetch_api_for_path(path: Path) -> Set[str]: + imported_api = extract_imported_api(path) + symbols = extract_symbols(path) + variables = extract_public_variables(path) + the_all_variable = extract_the_all_variable(path) + + return imported_api | symbols | variables | the_all_variable + + +def fetch_api(paths: Set[Path]) -> dict: + interfaces = [Path(p / "__init__.py") for p in paths] + + rows = [{i.parent.name: fetch_api_for_path(i)} for i in interfaces] + + return {k: v for row in rows for k, v in row.items()} diff --git a/components/polylith/interface/report.py b/components/polylith/interface/report.py index 54aaadf1..6566c1b6 100644 --- a/components/polylith/interface/report.py +++ b/components/polylith/interface/report.py @@ -2,6 +2,7 @@ from typing import Dict, FrozenSet, Set, Tuple from polylith import imports +from polylith.interface.parser import fetch_api from polylith.reporting import theme from polylith.workspace.paths import collect_bases_paths, collect_components_paths from rich.console import Console @@ -16,7 +17,7 @@ def get_brick_interface(root: Path, ns: str, brick: str, bricks: dict) -> set: fn = collect_bases_paths if brick in bases else collect_components_paths brick_paths = fn(root, ns, paths) - bricks_api = imports.fetch_api(brick_paths) + bricks_api = fetch_api(brick_paths) brick_api = bricks_api.get(brick) or set() brick_ns = f"{ns}.{brick}" diff --git a/test/components/polylith/interface/test_parse_api.py b/test/components/polylith/interface/test_parse_api.py new file mode 100644 index 00000000..954e4733 --- /dev/null +++ b/test/components/polylith/interface/test_parse_api.py @@ -0,0 +1,93 @@ +import ast +import io +from functools import partial +from pathlib import Path + +from polylith.interface import parser + +fake_path = Path.cwd() + + +the_interface = """ +THE_DATA = {"a": "b"} + +_the_private_var = "should not extract this one" + + +class Hello: + pass + + +def helloworld(): + pass + + +def goodbye(): + pass + +__all__ = ["thing", "other", "message"] +""" + +extracted_imports = {"a.b.c", "d.e.f"} + + +def fake_parse(contents: str, *args, **kwargs) -> ast.AST: + f = io.StringIO(contents) + + return ast.parse(f.read(), "unit_test") + + +def test_extract_api(monkeypatch) -> None: + monkeypatch.setattr(parser, "list_imports", lambda *args: extracted_imports) + + res = parser.extract_imported_api(fake_path) + + assert res == {"c", "f"} + + +def test_extract_symbols(monkeypatch) -> None: + fn = partial(fake_parse, the_interface) + monkeypatch.setattr(parser, "parse", fn) + + res = parser.extract_symbols(fake_path) + + assert res == {"Hello", "helloworld", "goodbye"} + + +def test_extract_variables(monkeypatch) -> None: + fn = partial(fake_parse, the_interface) + monkeypatch.setattr(parser, "parse", fn) + + res = parser.extract_public_variables(fake_path) + + assert res == {"THE_DATA"} + + +def test_extract_the_all_variable(monkeypatch) -> None: + fn = partial(fake_parse, the_interface) + monkeypatch.setattr(parser, "parse", fn) + + res = parser.extract_the_all_variable(fake_path) + + assert res == {"thing", "other", "message"} + + +def test_fetch_api_for_path(monkeypatch) -> None: + fn = partial(fake_parse, the_interface) + + monkeypatch.setattr(parser, "parse", fn) + monkeypatch.setattr(parser, "list_imports", lambda *args: extracted_imports) + + res = parser.fetch_api_for_path(fake_path) + + assert res == { + "c", + "f", + "Hello", + "helloworld", + "goodbye", + "THE_DATA", + "thing", + "other", + "message", + } From 563f0ae451ae030bdd80391d65ceb90c17cc1397 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Thu, 5 Feb 2026 17:44:28 +0100 Subject: [PATCH 08/14] refactor(import): put usages feature in separate module --- components/polylith/imports/__init__.py | 6 +- components/polylith/imports/parser.py | 140 +---------------- components/polylith/imports/usages.py | 144 ++++++++++++++++++ .../polylith/imports/test_parser.py | 22 +-- 4 files changed, 158 insertions(+), 154 deletions(-) create mode 100644 components/polylith/imports/usages.py diff --git a/components/polylith/imports/__init__.py b/components/polylith/imports/__init__.py index 99b530d3..73ebec97 100644 --- a/components/polylith/imports/__init__.py +++ b/components/polylith/imports/__init__.py @@ -3,20 +3,17 @@ extract_brick_imports_with_namespaces, ) from polylith.imports.parser import ( - SYMBOLS, - extract_api, extract_top_ns, fetch_all_imports, - fetch_brick_import_usages, fetch_excluded_imports, list_imports, parse_module, ) +from polylith.imports.usages import SYMBOLS, extract_api, fetch_brick_import_usages __all__ = [ "extract_brick_imports", "extract_brick_imports_with_namespaces", - "SYMBOLS", "extract_api", "extract_top_ns", "fetch_all_imports", @@ -24,4 +21,5 @@ "fetch_excluded_imports", "list_imports", "parse_module", + "SYMBOLS", ] diff --git a/components/polylith/imports/parser.py b/components/polylith/imports/parser.py index 189956a1..3cf34414 100644 --- a/components/polylith/imports/parser.py +++ b/components/polylith/imports/parser.py @@ -2,15 +2,11 @@ from collections.abc import Iterable from functools import lru_cache from pathlib import Path -from typing import FrozenSet, List, Optional, Set, Tuple, Union +from typing import List, Set, Union typing_ns = "typing" type_checking = "TYPE_CHECKING" -WRAPPER_NODES = (ast.Await, ast.Expr, ast.NamedExpr, ast.Starred, ast.Subscript) -FN_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) -SYMBOLS = (*FN_NODES, ast.ClassDef) - def parse_import(node: ast.Import) -> List[str]: return [name.name for name in node.names] @@ -72,113 +68,6 @@ def parse_node(node: ast.AST) -> Union[dict, None]: return None -def extract_api_part(path: str) -> str: - return path.rsplit(".", 1)[-1] - - -def extract_api(paths: Set[str]) -> Set[str]: - return {extract_api_part(p) for p in paths} - - -def find_import_root_and_path( - expr: ast.expr, parts: Tuple[str, ...] = () -) -> Tuple[ast.expr, str]: - """Builds a namespace when the expression is an Attribute or Name, otherwise empty.""" - if isinstance(expr, ast.Attribute): - return find_import_root_and_path(expr.value, (*parts, expr.attr)) - - namespace_parts = (*parts, expr.id) if isinstance(expr, ast.Name) else parts - - namespace = str.join(".", reversed(namespace_parts)) - - return expr, namespace - - -def with_ns(usage: str, ns: str) -> str: - return usage if str.startswith(usage, ns + ".") else f"{ns}.{usage}" - - -def find_matching_usage(expr: ast.expr, options: dict) -> Optional[str]: - ns = options["ns"] - api_map = options["api_map"] - allowed_prefixes = options["allowed_prefixes"] - shadowed = options["shadowed"] - - root, usage = find_import_root_and_path(expr) - - if not isinstance(root, ast.Name): - return None - - if root.id in shadowed: - return None - - if root.id in api_map: - found = api_map[root.id] if usage == root.id else usage - - return with_ns(found, ns) - - if any(usage.startswith(p + ".") for p in allowed_prefixes): - return with_ns(usage, ns) - - return None - - -def parse_import_usage(node: ast.AST, options: dict) -> Union[str, None]: - usage = None - child = None - - if isinstance(node, ast.Attribute): - usage = find_matching_usage(node, options) - child = node.value - elif isinstance(node, WRAPPER_NODES): - child = node.value - elif isinstance(node, ast.Call): - usage = find_matching_usage(node.func, options) - child = node.func - elif isinstance(node, ast.UnaryOp): - child = node.operand - - if usage: - return usage - - return parse_import_usage(child, options) if child is not None else None - - -def collect_arg_names( - fn: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda], -) -> Set[str]: - args = fn.args - - names = {a.arg for a in args.posonlyargs + args.args + args.kwonlyargs} - - if args.vararg: - names.add(args.vararg.arg) - - if args.kwarg: - names.add(args.kwarg.arg) - - return names - - -def walk_usages(node: ast.AST, options: dict) -> Set[str]: - if isinstance(node, FN_NODES): - options = { - **options, - "shadowed": options["shadowed"] | frozenset(collect_arg_names(node)), - } - - out = set() - hit = parse_import_usage(node, options) - - if hit: - out.add(hit) - - for child in ast.iter_child_nodes(node): - out |= walk_usages(child, options) - - return out - - def parse_module(path: Path) -> ast.Module: with open(path.as_posix(), "r", encoding="utf-8", errors="ignore") as f: tree = ast.parse(f.read(), path.name) @@ -224,33 +113,6 @@ 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, ns: str, imported: Set[str]) -> Set[str]: - tree = parse_module(path) - api_map = {extract_api_part(p): p for p in imported} - - options = { - "ns": ns, - "api_map": api_map, - "allowed_prefixes": frozenset(api_map.values()), - "shadowed": frozenset(), - } - return walk_usages(tree, options) - - -@lru_cache(maxsize=None) -def fetch_brick_import_usages( - path: Path, ns: str, imported: FrozenSet[str] -) -> Set[str]: - py_modules = find_files(path) - - found = {m: set(extract_imports(m)).intersection(imported) for m in py_modules} - filtered = {k: v for k, v in found.items() if v} - - fetched = (fetch_import_usages_in_module(k, ns, v) for k, v in filtered.items()) - - return {i for f in fetched if f for i in f} - - def should_exclude(path: Path, excludes: Set[str]): return any(path.match(pattern) for pattern in excludes) diff --git a/components/polylith/imports/usages.py b/components/polylith/imports/usages.py new file mode 100644 index 00000000..9a5396ab --- /dev/null +++ b/components/polylith/imports/usages.py @@ -0,0 +1,144 @@ +import ast +from functools import lru_cache +from pathlib import Path +from typing import FrozenSet, Optional, Set, Tuple, Union + +from polylith.imports.parser import extract_imports, find_files, parse_module + +WRAPPER_NODES = (ast.Await, ast.Expr, ast.NamedExpr, ast.Starred, ast.Subscript) +FN_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) +SYMBOLS = (*FN_NODES, ast.ClassDef) + + +def extract_api_part(path: str) -> str: + return path.rsplit(".", 1)[-1] + + +def extract_api(paths: Set[str]) -> Set[str]: + return {extract_api_part(p) for p in paths} + + +def find_import_root_and_path( + expr: ast.expr, parts: Tuple[str, ...] = () +) -> Tuple[ast.expr, str]: + """Builds a namespace when the expression is an Attribute or Name, otherwise empty.""" + if isinstance(expr, ast.Attribute): + return find_import_root_and_path(expr.value, (*parts, expr.attr)) + + namespace_parts = (*parts, expr.id) if isinstance(expr, ast.Name) else parts + + namespace = str.join(".", reversed(namespace_parts)) + + return expr, namespace + + +def with_ns(usage: str, ns: str) -> str: + return usage if str.startswith(usage, ns + ".") else f"{ns}.{usage}" + + +def find_matching_usage(expr: ast.expr, options: dict) -> Optional[str]: + ns = options["ns"] + api_map = options["api_map"] + allowed_prefixes = options["allowed_prefixes"] + shadowed = options["shadowed"] + + root, usage = find_import_root_and_path(expr) + + if not isinstance(root, ast.Name): + return None + + if root.id in shadowed: + return None + + if root.id in api_map: + found = api_map[root.id] if usage == root.id else usage + + return with_ns(found, ns) + + if any(usage.startswith(p + ".") for p in allowed_prefixes): + return with_ns(usage, ns) + + return None + + +def parse_import_usage(node: ast.AST, options: dict) -> Union[str, None]: + usage = None + child = None + + if isinstance(node, ast.Attribute): + usage = find_matching_usage(node, options) + child = node.value + elif isinstance(node, WRAPPER_NODES): + child = node.value + elif isinstance(node, ast.Call): + usage = find_matching_usage(node.func, options) + child = node.func + elif isinstance(node, ast.UnaryOp): + child = node.operand + + if usage: + return usage + + return parse_import_usage(child, options) if child is not None else None + + +def collect_arg_names( + fn: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda], +) -> Set[str]: + args = fn.args + + names = {a.arg for a in args.posonlyargs + args.args + args.kwonlyargs} + + if args.vararg: + names.add(args.vararg.arg) + + if args.kwarg: + names.add(args.kwarg.arg) + + return names + + +def walk_usages(node: ast.AST, options: dict) -> Set[str]: + if isinstance(node, FN_NODES): + options = { + **options, + "shadowed": options["shadowed"] | frozenset(collect_arg_names(node)), + } + + out = set() + hit = parse_import_usage(node, options) + + if hit: + out.add(hit) + + for child in ast.iter_child_nodes(node): + out |= walk_usages(child, options) + + return out + + +def fetch_import_usages_in_module(path: Path, ns: str, imported: Set[str]) -> Set[str]: + tree = parse_module(path) + api_map = {extract_api_part(p): p for p in imported} + + options = { + "ns": ns, + "api_map": api_map, + "allowed_prefixes": frozenset(api_map.values()), + "shadowed": frozenset(), + } + return walk_usages(tree, options) + + +@lru_cache(maxsize=None) +def fetch_brick_import_usages( + path: Path, ns: str, imported: FrozenSet[str] +) -> Set[str]: + py_modules = find_files(path) + + found = {m: set(extract_imports(m)).intersection(imported) for m in py_modules} + filtered = {k: v for k, v in found.items() if v} + + fetched = (fetch_import_usages_in_module(k, ns, v) for k, v in filtered.items()) + + return {i for f in fetched if f for i in f} diff --git a/test/components/polylith/imports/test_parser.py b/test/components/polylith/imports/test_parser.py index c5d56532..5f519bc0 100644 --- a/test/components/polylith/imports/test_parser.py +++ b/test/components/polylith/imports/test_parser.py @@ -3,7 +3,7 @@ from functools import partial from pathlib import Path -from polylith.imports import parser +from polylith.imports import usages fake_path = Path.cwd() @@ -81,52 +81,52 @@ def fake_parse_module(contents: str, *args, **kwargs) -> ast.AST: def test_fetch_import_usages_in_module_ns_brick(monkeypatch) -> None: fn = partial(fake_parse_module, ns_brick_import) - monkeypatch.setattr(parser, "parse_module", fn) + monkeypatch.setattr(usages, "parse_module", fn) expected = {f"{top_ns}.{brick}.one", f"{top_ns}.{brick}.two"} - res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + res = usages.fetch_import_usages_in_module(fake_path, top_ns, imported) assert res == expected def test_fetch_import_usages_in_module_ns_brick_fn(monkeypatch) -> None: fn = partial(fake_parse_module, ns_brick_import) - monkeypatch.setattr(parser, "parse_module", fn) + monkeypatch.setattr(usages, "parse_module", fn) expected = {f"{top_ns}.{brick}.one", f"{top_ns}.{brick}.two"} - res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + res = usages.fetch_import_usages_in_module(fake_path, top_ns, imported) assert res == expected def test_fetch_import_usages_in_module_ns_brick_with_shadowed(monkeypatch) -> None: fn = partial(fake_parse_module, ns_brick_import_with_shadowed) - monkeypatch.setattr(parser, "parse_module", fn) + monkeypatch.setattr(usages, "parse_module", fn) - res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + res = usages.fetch_import_usages_in_module(fake_path, top_ns, imported) assert res == set() def test_fetch_import_usages_in_module_ns(monkeypatch) -> None: fn = partial(fake_parse_module, ns_import) - monkeypatch.setattr(parser, "parse_module", fn) + monkeypatch.setattr(usages, "parse_module", fn) expected = {f"{top_ns}.{brick}.one"} - res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + res = usages.fetch_import_usages_in_module(fake_path, top_ns, imported) assert res == expected def test_fetch_import_usages_in_module_ns_star(monkeypatch) -> None: fn = partial(fake_parse_module, ns_import_star) - monkeypatch.setattr(parser, "parse_module", fn) + monkeypatch.setattr(usages, "parse_module", fn) expected = {f"{top_ns}.{brick}.one"} - res = parser.fetch_import_usages_in_module(fake_path, top_ns, imported) + res = usages.fetch_import_usages_in_module(fake_path, top_ns, imported) assert res == expected From 2d0ae288ec60d8df301fd644f78c9870fe487d2b Mon Sep 17 00:00:00 2001 From: David Vujic Date: Thu, 5 Feb 2026 18:00:35 +0100 Subject: [PATCH 09/14] feat(interfaces): show interface and invalid usage when passing in a cli option --- bases/polylith/cli/core.py | 2 ++ bases/polylith/cli/options.py | 1 + components/polylith/commands/deps.py | 7 ++++++- components/polylith/interface/report.py | 22 ++++++++++----------- components/polylith/poetry/commands/deps.py | 7 +++++++ 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/bases/polylith/cli/core.py b/bases/polylith/cli/core.py index 6d39109f..4057f0b3 100644 --- a/bases/polylith/cli/core.py +++ b/bases/polylith/cli/core.py @@ -191,6 +191,7 @@ def sync_command( def deps_command( directory: Annotated[str, options.directory] = "", brick: Annotated[str, options.brick] = "", + interface: Annotated[bool, options.interface] = False, save: Annotated[bool, options.save] = False, ): """Visualize the dependencies between bricks.""" @@ -205,6 +206,7 @@ def deps_command( "brick": brick or None, "save": save, "output": output, + "show_interface": interface, } commands.deps.run(root, ns, cli_options) diff --git a/bases/polylith/cli/options.py b/bases/polylith/cli/options.py index b9cbcc20..45720831 100644 --- a/bases/polylith/cli/options.py +++ b/bases/polylith/cli/options.py @@ -19,3 +19,4 @@ brick = Option(help="Shows dependencies for selected brick.") save = Option(help="Store the contents of this command to file.") +interface = Option(help="Show the brick interface.") diff --git a/components/polylith/commands/deps.py b/components/polylith/commands/deps.py index 67636289..fa2f0630 100644 --- a/components/polylith/commands/deps.py +++ b/components/polylith/commands/deps.py @@ -44,6 +44,7 @@ def used_by_as_bricks(bricks: dict, brick_deps: dict) -> dict: def run(root: Path, ns: str, options: dict): directory = options.get("directory") brick = options.get("brick") + show_interface = options.get("show_interface") projects_data = info.get_projects_data(root, ns) if directory else [] project = next((p for p in projects_data if directory in p["path"].as_posix()), {}) @@ -73,7 +74,11 @@ 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) + if show_interface: + interface.report.print_brick_interface_invalid_usage( + root, ns, brick, used_bricks + ) + interface.report.print_brick_interface(root, ns, brick, used_bricks) return diff --git a/components/polylith/interface/report.py b/components/polylith/interface/report.py index 6566c1b6..4065840e 100644 --- a/components/polylith/interface/report.py +++ b/components/polylith/interface/report.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, FrozenSet, Set, Tuple +from typing import Dict, FrozenSet, Set from polylith import imports from polylith.interface.parser import fetch_api @@ -38,7 +38,7 @@ def get_brick_imports(root: Path, ns: str, bases: set, components: set) -> dict: def to_imported_api(brick_imports: Set[str]) -> Set[str]: - return {imports.parser.extract_api_part(b) for b in brick_imports} + return {imports.usages.extract_api_part(b) for b in brick_imports} def filter_by_brick(brick_imports: Set[str], brick: str, ns: str) -> Set[str]: @@ -63,9 +63,7 @@ def frozen(data: Dict[str, Set[str]], key: str) -> FrozenSet[str]: return frozenset(data.get(key) or set()) -def check_brick_interface_usage( - root: Path, ns: str, brick: str, bricks: dict -) -> Tuple[dict, set]: +def check_brick_interface_usage(root: Path, ns: str, brick: str, bricks: dict) -> dict: brick_interface = get_brick_interface(root, ns, brick, bricks) bases = bricks["bases"] components = bricks["components"] @@ -84,10 +82,12 @@ def check_brick_interface_usage( checked = {k: check_usage(v, brick_interface) for k, v in usage.items()} - return checked, brick_interface + return checked + +def print_brick_interface(root: Path, ns: str, brick: str, bricks: dict) -> None: + brick_interface = get_brick_interface(root, ns, brick, bricks) -def print_brick_interface(brick: str, brick_interface: set, bricks: dict) -> None: console = Console(theme=theme.poly_theme) tag = "base" if brick in bricks["bases"] else "comp" @@ -110,8 +110,10 @@ def unified_usages(usages: dict) -> Set[str]: return {f for f in filtered if starts_with(filtered, f)} -def print_brick_interface_usage(root: Path, ns: str, brick: str, bricks: dict) -> None: - res, brick_interface = check_brick_interface_usage(root, ns, brick, bricks) +def print_brick_interface_invalid_usage( + root: Path, ns: str, brick: str, bricks: dict +) -> None: + res = check_brick_interface_usage(root, ns, brick, bricks) invalid_usage = { brick: unified_usages(usages) @@ -140,5 +142,3 @@ def print_brick_interface_usage(root: Path, ns: str, brick: str, bricks: dict) - table.add_row(f"{message}") console.print(table, overflow="ellipsis") - - print_brick_interface(brick, brick_interface, bricks) diff --git a/components/polylith/poetry/commands/deps.py b/components/polylith/poetry/commands/deps.py index 1fd62d9a..5c01342a 100644 --- a/components/polylith/poetry/commands/deps.py +++ b/components/polylith/poetry/commands/deps.py @@ -16,6 +16,11 @@ class DepsCommand(Command): description="Shows dependencies for selected brick", flag=False, ), + option( + long_name="interface", + description="Show the brick interface", + flag=True, + ), command_options.save, ] @@ -23,6 +28,7 @@ def handle(self) -> int: directory = self.option("directory") brick = self.option("brick") save = self.option("save") + interface = self.option("interface") root = repo.get_workspace_root(Path.cwd()) ns = configuration.get_namespace_from_config(root) @@ -35,6 +41,7 @@ def handle(self) -> int: "brick": brick, "save": save, "output": output, + "show_interface": interface, } commands.deps.run(root, ns, options) From 8c2d9b5a8d87e8bda6614eeaed933e17ce62cb76 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Fri, 6 Feb 2026 08:51:18 +0100 Subject: [PATCH 10/14] bump Poetry plugin to 1.48.0 --- projects/poetry_polylith_plugin/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/poetry_polylith_plugin/pyproject.toml b/projects/poetry_polylith_plugin/pyproject.toml index 4ac00a57..d0043d93 100644 --- a/projects/poetry_polylith_plugin/pyproject.toml +++ b/projects/poetry_polylith_plugin/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "poetry-polylith-plugin" -version = "1.47.1" +version = "1.48.0" description = "A Poetry plugin that adds tooling support for the Polylith Architecture" authors = ["David Vujic"] homepage = "https://davidvujic.github.io/python-polylith-docs/" From dad0dfb2dfa8a92b61cdc693f05593e07edd3697 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Fri, 6 Feb 2026 08:51:38 +0100 Subject: [PATCH 11/14] bump CLI to 1.42.0 --- projects/polylith_cli/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/polylith_cli/pyproject.toml b/projects/polylith_cli/pyproject.toml index 854d98c6..1999f4d6 100644 --- a/projects/polylith_cli/pyproject.toml +++ b/projects/polylith_cli/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "polylith-cli" -version = "1.41.1" +version = "1.42.0" description = "Python tooling support for the Polylith Architecture" authors = ['David Vujic'] homepage = "https://davidvujic.github.io/python-polylith-docs/" From 584e3e64f28812eb89dba229d3a8806292504695 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Fri, 6 Feb 2026 10:44:50 +0100 Subject: [PATCH 12/14] refactor(typing): use union instead of optional --- components/polylith/imports/usages.py | 4 ++-- components/polylith/interface/parser.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/polylith/imports/usages.py b/components/polylith/imports/usages.py index 9a5396ab..3f68dec1 100644 --- a/components/polylith/imports/usages.py +++ b/components/polylith/imports/usages.py @@ -1,7 +1,7 @@ import ast from functools import lru_cache from pathlib import Path -from typing import FrozenSet, Optional, Set, Tuple, Union +from typing import FrozenSet, Set, Tuple, Union from polylith.imports.parser import extract_imports, find_files, parse_module @@ -36,7 +36,7 @@ def with_ns(usage: str, ns: str) -> str: return usage if str.startswith(usage, ns + ".") else f"{ns}.{usage}" -def find_matching_usage(expr: ast.expr, options: dict) -> Optional[str]: +def find_matching_usage(expr: ast.expr, options: dict) -> Union[str, None]: ns = options["ns"] api_map = options["api_map"] allowed_prefixes = options["allowed_prefixes"] diff --git a/components/polylith/interface/parser.py b/components/polylith/interface/parser.py index a62d1243..711c4c08 100644 --- a/components/polylith/interface/parser.py +++ b/components/polylith/interface/parser.py @@ -1,7 +1,7 @@ import ast from functools import lru_cache from pathlib import Path -from typing import Optional, Set +from typing import Set, Union from polylith.imports import SYMBOLS, extract_api, list_imports, parse_module @@ -60,7 +60,7 @@ def is_string_constant(expression: ast.AST) -> bool: return isinstance(expression, ast.Constant) and isinstance(expression.value, str) -def find_the_all_variable(statement: ast.stmt) -> Optional[Set[str]]: +def find_the_all_variable(statement: ast.stmt) -> Union[Set[str], None]: if not isinstance(statement, ast.Assign): return None From a681c03b2ca7daba276db11e511bb86749296f49 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Sat, 7 Feb 2026 09:17:05 +0100 Subject: [PATCH 13/14] fix(poly deps): reorganizing the ordering of the output for --brick and --interface --- components/polylith/commands/deps.py | 9 +++++---- components/polylith/deps/report.py | 3 ++- components/polylith/interface/report.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/components/polylith/commands/deps.py b/components/polylith/commands/deps.py index fa2f0630..d0382675 100644 --- a/components/polylith/commands/deps.py +++ b/components/polylith/commands/deps.py @@ -71,14 +71,15 @@ def run(root: Path, ns: str, options: dict): deps.print_brick_deps(brick, bricks, brick_deps, options) - if circular_deps: - deps.print_brick_with_circular_deps(brick, circular_deps, bricks) - if show_interface: + interface.report.print_brick_interface(root, ns, brick, used_bricks) + interface.report.print_brick_interface_invalid_usage( root, ns, brick, used_bricks ) - interface.report.print_brick_interface(root, ns, brick, used_bricks) + + if circular_deps: + deps.print_brick_with_circular_deps(brick, circular_deps, bricks) return diff --git a/components/polylith/deps/report.py b/components/polylith/deps/report.py index a3edefc2..72f41e95 100644 --- a/components/polylith/deps/report.py +++ b/components/polylith/deps/report.py @@ -6,6 +6,7 @@ from polylith.reporting import theme from rich import box from rich.console import Console +from rich.padding import Padding from rich.table import Table @@ -157,7 +158,7 @@ def print_brick_with_circular_deps(brick: str, deps: Set[str], bricks: dict) -> prefix = ":information:" message = f"[{tag}]{brick}[/] [data]is used by[/] {others} [data]and also uses[/] {others}[data].[/]" - console.print(f"{prefix} {message}", overflow="ellipsis") + console.print(Padding(f"{prefix} {message}", (0, 0, 0, 1)), overflow="ellipsis") def print_bricks_with_circular_deps(circular_bricks: dict, bricks: dict) -> None: diff --git a/components/polylith/interface/report.py b/components/polylith/interface/report.py index 4065840e..0151f189 100644 --- a/components/polylith/interface/report.py +++ b/components/polylith/interface/report.py @@ -95,7 +95,7 @@ def print_brick_interface(root: Path, ns: str, brick: str, bricks: dict) -> None table = Table(box=None) message = f"[{tag}]{brick}[/] exposes:" - table.add_column(Padding(message, (1, 0, 0, 0))) + table.add_column(message) for endpoint in sorted(brick_interface): *_ns, exposes = str.split(endpoint, ".") From 4e49c7535bbcb98863818c2e8268095df117a808 Mon Sep 17 00:00:00 2001 From: David Vujic Date: Sat, 7 Feb 2026 09:17:33 +0100 Subject: [PATCH 14/14] fix(poly deps): reorganizing the ordering of the output for --brick and --interface --- components/polylith/interface/report.py | 1 - 1 file changed, 1 deletion(-) diff --git a/components/polylith/interface/report.py b/components/polylith/interface/report.py index 0151f189..70853d72 100644 --- a/components/polylith/interface/report.py +++ b/components/polylith/interface/report.py @@ -6,7 +6,6 @@ from polylith.reporting import theme from polylith.workspace.paths import collect_bases_paths, collect_components_paths from rich.console import Console -from rich.padding import Padding from rich.table import Table