Skip to content

Commit

Permalink
feat: reworked poly check command (#60)
Browse files Browse the repository at this point in the history
* wip: poly check v2

* fix: accidental debug print statement

* fix(parse imports): extract node names when parsing 'from x import x, y z'

* docs: add docs about the poetry poly check command

* bump version to 1.4.0

* docs: fix typo in README
  • Loading branch information
DavidVujic authored Mar 5, 2023
1 parent 23a8021 commit 77fa7e4
Show file tree
Hide file tree
Showing 11 changed files with 174 additions and 59 deletions.
25 changes: 0 additions & 25 deletions components/polylith/check/core.py

This file was deleted.

36 changes: 36 additions & 0 deletions components/polylith/check/grouping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Set, Union


def only_brick_imports(imports: Set[str], top_ns: str) -> Set[str]:
return {i for i in imports if i.startswith(top_ns)}


def only_bricks(import_data: dict, top_ns: str) -> dict:
return {k: only_brick_imports(v, top_ns) for k, v in import_data.items()}


def brick_import_to_name(brick_import: str) -> Union[str, None]:
parts = brick_import.split(".")

return parts[1] if len(parts) > 1 else None


def only_brick_name(brick_imports: Set[str]) -> Set:
res = {brick_import_to_name(i) for i in brick_imports}

return {i for i in res if i}


def only_brick_names(import_data: dict) -> dict:
return {k: only_brick_name(v) for k, v in import_data.items() if v}


def exclude_empty(import_data: dict) -> dict:
return {k: v for k, v in import_data.items() if v}


def extract_brick_imports(all_imports: dict, top_ns) -> dict:
with_only_bricks = only_bricks(all_imports, top_ns)
with_only_brick_names = only_brick_names(with_only_bricks)

return exclude_empty(with_only_brick_names)
55 changes: 42 additions & 13 deletions components/polylith/check/report.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from polylith.check.core import run_command
from pathlib import Path
from typing import Set

from polylith import libs, workspace
from polylith.check import grouping
from rich.console import Console
from rich.theme import Theme

Expand All @@ -12,22 +16,47 @@
)


def run(project_data: dict) -> bool:
def print_missing_deps(brick_imports: dict, deps: Set[str], project_name: str) -> bool:
diff = libs.report.calculate_diff(brick_imports, deps)

if not diff:
return True

console = Console(theme=info_theme)

project_name = project_data["name"]
project_path = project_data["path"]
missing = ", ".join(sorted(diff))

console.print(f":thinking_face: Cannot locate {missing} in {project_name}")
return False


def print_report(
root: Path, ns: str, project_data: dict, third_party_libs: Set
) -> bool:
name = project_data["name"]

with console.status(f"checking [proj]{project_name}[/]", spinner="monkey"):
result = run_command(project_path)
bases = {b for b in project_data.get("bases", [])}
components = {c for c in project_data.get("components", [])}

message = ["[proj]", project_name, "[/]", " "]
extra = [":warning:"] if result else [":heavy_check_mark:"]
bases_paths = workspace.paths.collect_bases_paths(root, ns, bases)
components_paths = workspace.paths.collect_components_paths(root, ns, components)

all_imports_in_bases = libs.fetch_all_imports(bases_paths)
all_imports_in_components = libs.fetch_all_imports(components_paths)

brick_imports = {
"bases": grouping.extract_brick_imports(all_imports_in_bases, ns),
"components": grouping.extract_brick_imports(all_imports_in_components, ns),
}

third_party_imports = {
"bases": libs.extract_third_party_imports(all_imports_in_bases, ns),
"components": libs.extract_third_party_imports(all_imports_in_components, ns),
}

output = "".join(message + extra)
console.print(output)
packages = set().union(bases, components)

for row in result:
console.print(f"[data]{row}[/]")
brick_result = print_missing_deps(brick_imports, packages, name)
libs_result = print_missing_deps(third_party_imports, third_party_libs, name)

return True if not result else False
return all([brick_result, libs_result])
13 changes: 11 additions & 2 deletions components/polylith/libs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
from polylith.libs.grouping import get_third_party_imports
from polylith.libs import report
from polylith.libs.grouping import (
extract_third_party_imports,
fetch_all_imports,
get_third_party_imports,
)

__all__ = ["get_third_party_imports", "report"]
__all__ = [
"report",
"extract_third_party_imports",
"fetch_all_imports",
"get_third_party_imports",
]
13 changes: 9 additions & 4 deletions components/polylith/libs/grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,19 @@ def exclude_empty(import_data: dict) -> dict:
return {k: v for k, v in import_data.items() if v}


def get_third_party_imports(root: Path, paths: Set[Path]) -> dict:
def extract_third_party_imports(all_imports: dict, top_ns: str) -> dict:
python_version = get_python_version()
std_libs = get_standard_libs(python_version)
top_ns = workspace.parser.get_namespace_from_config(root)

all_imports = fetch_all_imports(paths)
top_level_imports = extract_top_ns(all_imports)

with_third_party = exclude_libs(top_level_imports, std_libs.union({top_ns}))

return exclude_empty(with_third_party)


def get_third_party_imports(root: Path, paths: Set[Path]) -> dict:
top_ns = workspace.parser.get_namespace_from_config(root)

all_imports = fetch_all_imports(paths)

return extract_third_party_imports(all_imports, top_ns)
10 changes: 9 additions & 1 deletion components/polylith/libs/imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ def parse_import(node: ast.Import) -> List[str]:
return [name.name for name in node.names]


def extract_import_from(node: ast.ImportFrom) -> List:
return (
[f"{node.module}.{alias.name}" for alias in node.names]
if node.names
else [node.module]
)


def parse_import_from(node: ast.ImportFrom) -> List[str]:
return [node.module] if node.module and node.level == 0 else []
return extract_import_from(node) if node.module and node.level == 0 else []


def parse_imports(node: ast.AST) -> List[str]:
Expand Down
6 changes: 3 additions & 3 deletions components/polylith/libs/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ def flatten_imports(brick_imports: dict, brick: str) -> Set[str]:
return set().union(*brick_imports.get(brick, {}).values())


def calculate_diff(brick_imports: dict, third_party_libs: Set[str]) -> Set[str]:
def calculate_diff(brick_imports: dict, deps: Set[str]) -> Set[str]:
bases_imports = flatten_imports(brick_imports, "bases")
components_imports = flatten_imports(brick_imports, "components")

normalized_libs = {t.replace("-", "_") for t in third_party_libs}
normalized_deps = {t.replace("-", "_") for t in deps}

return set().union(bases_imports, components_imports).difference(normalized_libs)
return set().union(bases_imports, components_imports).difference(normalized_deps)


def print_libs_summary(brick_imports: dict, project_name: str) -> None:
Expand Down
54 changes: 50 additions & 4 deletions components/polylith/poetry/commands/check.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,68 @@
from pathlib import Path
from typing import List, Set, Union

from poetry.console.commands.command import Command
from polylith import check, project, repo
from poetry.factory import Factory
from polylith import check, info, project, repo, workspace


def get_projects_data(root: Path, ns: str) -> List[dict]:
bases = info.get_bases(root, ns)
components = info.get_components(root, ns)

return info.get_bricks_in_projects(root, components, bases, ns)


class CheckCommand(Command):
name = "poly check"
description = "Validates the <comment>Polylith</> workspace."

def find_third_party_libs(self, path: Union[Path, None]) -> Set:
project_poetry = Factory().create_poetry(path) if path else self.poetry

if not project_poetry.locker.is_locked():
raise ValueError("poetry.lock not found. Run `poetry lock` to create it.")

packages = project_poetry.locker.locked_repository().packages

return {p.name for p in packages}

def print_report(self, root: Path, ns: str, project_data: dict) -> bool:
path = project_data["path"]
name = project_data["name"]

try:
third_party_libs = self.find_third_party_libs(path)
return check.report.print_report(root, ns, project_data, third_party_libs)
except ValueError as e:
self.line_error(f"{name}: <error>{e}</error>")
return False

def handle(self) -> int:
root = repo.find_workspace_root(Path.cwd())

if not root:
raise ValueError(
"Didn't find the workspace root. Expected to find a workspace.toml file."
)

projects = project.get_project_names_and_paths(root)
ns = workspace.parser.get_namespace_from_config(root)

projects_data = get_projects_data(root, ns)

if self.option("directory"):
project_name = project.get_project_name(self.poetry.pyproject.data)

data = next((p for p in projects_data if p["name"] == project_name), None)

if not data:
raise ValueError(f"Didn't find project in {self.option('directory')}")

res = self.print_report(root, ns, data)
result_code = 0 if res else 1
else:
results = {self.print_report(root, ns, data) for data in projects_data}

res = [check.report.run(proj) for proj in projects]
result_code = 0 if all(results) else 1

return 0 if all(res) else 1
return result_code
1 change: 0 additions & 1 deletion components/polylith/poetry/commands/libs.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ def handle(self) -> int:
projects_data = get_projects_data(root, ns)

if self.option("directory"):
self.line("in directory!")
project_name = project.get_project_name(self.poetry.pyproject.data)

data = next((p for p in projects_data if p["name"] == project_name), None)
Expand Down
18 changes: 13 additions & 5 deletions projects/poetry_polylith_plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Useful for CI:
poetry poly diff --short
```


#### Libs
Show info about the third-party libraries used in the workspace:

Expand All @@ -117,19 +118,26 @@ The very nice dependency lookup features of `Poetry` is used behind the scenes b
Show info about libraries used in a specific project.



#### Check
Validates the Polylith workspace:
Validates the Polylith workspace, checking for any missing dependencies (bricks and third-party libraries):

``` shell
poetry poly check
```

**NOTE**: this feature is built on top of the `poetry check-project` command from the [Multiproject](https://github.com/DavidVujic/poetry-multiproject-plugin) plugin.
Make sure that you have the latest version of poetry-multiproject-plugin installed to be able to use the `poly check` command.
**NOTE**: this feature is built on top of the `poetry poly libs` command,
and (just like the `poetry poly libs` command) it expects a `poetry.lock` of a project to be present.
If missing, there is a Poetry command available: `poetry lock --directory path/to-project`.


##### Options
`--directory` or `-C`

Show info about libraries used in a specific project.


#### Testing
The `create` commands will also create corresponding unit tests. It is possible to disable thi behaviour
The `create` commands will also create corresponding unit tests. It is possible to disable this behaviour
by setting `enabled = false` in the `workspace.toml` file.


Expand Down
2 changes: 1 addition & 1 deletion projects/poetry_polylith_plugin/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "poetry-polylith-plugin"
version = "1.3.1"
version = "1.4.0"
description = "A Poetry plugin that adds tooling support for the Polylith Architecture"
authors = ["David Vujic"]
homepage = "https://github.com/davidvujic/python-polylith"
Expand Down

0 comments on commit 77fa7e4

Please sign in to comment.