Repository: DavidVujic/python-polylith

Issue:
Get a list of changed components plus all their transitive dependants for testing
### Discussed in https://github.com/DavidVujic/python-polylith/discussions/236

<div type='discussions-op-text'>

<sup>Originally posted by **jamesrobson-secondmind** July 30, 2024</sup>
What I would like is a way to find any component that has changed and walk the tree of imports to find any component that could be affected by the change. This would let you test all code that might have been broken without needing to run everything. 
You can almost do this by combining `poly diff` and `poly deps` except that `poly deps` doesn't provide output in a format that's easy to parse.
</div>

Discussion:
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DavidVujic_python-polylith&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DavidVujic_python-polylith&pullRequest=243&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=DavidVujic_python-polylith&pullRequest=243)



Patch:
diff --git a/bases/polylith/cli/core.py b/bases/polylith/cli/core.py
index 56f9cfc..72634bf 100644
--- a/bases/polylith/cli/core.py
+++ b/bases/polylith/cli/core.py
@@ -86,9 +86,14 @@ def diff_command(
     since: Annotated[str, Option(help="Changed since a specific tag.")] = "",
     short: Annotated[bool, options.short] = False,
     bricks: Annotated[bool, Option(help="Print changed bricks.")] = False,
+    deps: Annotated[
+        bool, Option(help="Print bricks that depend on changes. Use with --bricks.")
+    ] = False,
 ):
     """Shows changed bricks compared to the latest git tag."""
-    commands.diff.run(since, short, bricks)
+    options = {"short": short, "bricks": bricks, "deps": deps}
+
+    commands.diff.run(since, options)
 
 
 @app.command("libs")
diff --git a/components/polylith/commands/diff.py b/components/polylith/commands/diff.py
index a621592..501472b 100644
--- a/components/polylith/commands/diff.py
+++ b/components/polylith/commands/diff.py
@@ -1,39 +1,102 @@
 from pathlib import Path
-from typing import Union
+from typing import List, Set, Union
 
-from polylith import configuration, diff, info, repo
+from polylith import configuration, deps, diff, info, repo
 
 
-def print_views(root: Path, tag: str, short: bool, only_bricks: bool) -> None:
+def get_imports(root: Path, ns: str, bases: Set[str], components: Set[str]) -> dict:
+    brick_imports = deps.get_brick_imports(root, ns, bases, components)
+
+    return {**brick_imports["bases"], **brick_imports["components"]}
+
+
+def get_projects_data(root: Path, ns: str) -> List[dict]:
+    data = info.get_projects_data(root, ns)
+
+    return [p for p in data if info.is_project(p)]
+
+
+def flatten_bricks(projects_data: List[dict], brick_type: str) -> Set[str]:
+    matrix = [p[brick_type] for p in projects_data]
+
+    flattened: List[str] = sum(matrix, [])
+
+    return set(flattened)
+
+
+def flatten_dependent_bricks(
+    changed_bricks: Set[str], bases: Set[str], components: Set[str], import_data: dict
+) -> Set[str]:
+    matrix = [
+        deps.report.sorted_used_by(brick, bases, components, import_data)
+        for brick in changed_bricks
+    ]
+
+    flattened: List[str] = sum(matrix, [])
+    filtered = set(flattened).difference(changed_bricks)
+
+    return filtered
+
+
+def calculate_dependent_bricks(
+    root: Path, ns: str, projects_data: List[dict], changed_bricks: Set[str]
+) -> dict:
+    bases = flatten_bricks(projects_data, "bases")
+    components = flatten_bricks(projects_data, "components")
+    import_data = get_imports(root, ns, bases, components)
+
+    dependent_bricks = flatten_dependent_bricks(
+        changed_bricks, bases, components, import_data
+    )
+
+    dependent_bases = {b for b in dependent_bricks if b in bases}
+    dependent_components = {c for c in dependent_bricks if c in components}
+
+    return {"bases": dependent_bases, "components": dependent_components}
+
+
+def print_views(root: Path, tag: str, options: dict) -> None:
+    short = options.get("short", False)
+    only_bricks = options.get("bricks", False)
+    with_deps = options.get("deps", False)
+
     ns = configuration.get_namespace_from_config(root)
     files = diff.collect.get_files(tag)
-    bases = diff.collect.get_changed_bases(root, files, ns)
-    components = diff.collect.get_changed_components(root, files, ns)
 
-    projects = diff.collect.get_changed_projects(files)
-    all_projects_data = info.get_bricks_in_projects(root, components, bases, ns)
-    projects_data = [p for p in all_projects_data if info.is_project(p)]
+    changed_bases = diff.collect.get_changed_bases(root, files, ns)
+    changed_components = diff.collect.get_changed_components(root, files, ns)
+    changed_bricks = set(changed_bases + changed_components)
+    changed_projects = diff.collect.get_changed_projects(files)
 
-    affected_projects = diff.collect.get_projects_affected_by_changes(
-        projects_data, projects, bases, components
-    )
+    projects_data = get_projects_data(root, ns)
 
     if not short and not only_bricks:
-        diff.report.print_diff_summary(tag, bases, components)
-        diff.report.print_detected_changes_in_projects(projects, short)
-        diff.report.print_diff_details(projects_data, bases, components)
+        diff.report.print_diff_summary(tag, changed_bases, changed_components)
+        diff.report.print_detected_changes_in_projects(changed_projects, short)
+        diff.report.print_diff_details(projects_data, changed_bases, changed_components)
 
         return
 
     if short and not only_bricks:
+        affected_projects = diff.collect.get_projects_affected_by_changes(
+            projects_data, changed_projects, changed_bases, changed_components
+        )
         diff.report.print_projects_affected_by_changes(affected_projects, short)
 
         return
 
-    diff.report.print_detected_changes_in_bricks(bases, components, short)
+    dependent_bricks = (
+        calculate_dependent_bricks(root, ns, projects_data, changed_bricks)
+        if with_deps
+        else {}
+    )
+
+    diff.report.print_detected_changes_in_bricks(
+        changed_bases, changed_components, dependent_bricks, options
+    )
 
 
-def run(tag_name: Union[str, None], short: bool, only_bricks: bool):
+def run(tag_name: Union[str, None], options: dict):
     root = repo.get_workspace_root(Path.cwd())
 
     tag = diff.collect.get_latest_tag(root, tag_name)
@@ -42,4 +105,4 @@ def run(tag_name: Union[str, None], short: bool, only_bricks: bool):
         print("No tags found in repository.")
         return
 
-    print_views(root, tag, short, only_bricks)
+    print_views(root, tag, options)
diff --git a/components/polylith/diff/report.py b/components/polylith/diff/report.py
index e4eef36..5975e56 100644
--- a/components/polylith/diff/report.py
+++ b/components/polylith/diff/report.py
@@ -36,17 +36,41 @@ def print_detected_changes(changes: List[str], markup: str, short: bool) -> None
         console.print(f"[data]:gear: Changes found in [/][{markup}]{brick}[/]")
 
 
+def print_detected_dependent(bases: List[str], components: List[str]) -> None:
+    console = Console(theme=theme.poly_theme)
+
+    printable_bases = [f"[base]{b}[/]" for b in bases]
+    printable_components = [f"[comp]{c}[/]" for c in components]
+
+    printable_bricks = printable_components + printable_bases
+    joined = ", ".join(printable_bricks) or "-"
+
+    console.print(f"[data]:gear: Used by: [/]{joined}")
+
+
 def print_detected_changes_in_bricks(
-    bases: List[str], components: List[str], short: bool
+    changed_bases: List[str],
+    changed_components: List[str],
+    dependent_bricks: dict,
+    options: dict,
 ) -> None:
-    sorted_bases = sorted(bases)
-    sorted_components = sorted(components)
+    short = options.get("short", False)
+    with_deps = options.get("deps", False)
+
+    dependent_bases = sorted(dependent_bricks.get("bases", set()))
+    dependent_components = sorted(dependent_bricks.get("components", set()))
+
+    bricks = changed_components + changed_bases + dependent_components + dependent_bases
 
     if short:
-        print_detected_changes(sorted_components + sorted_bases, "data", short)
-    else:
-        print_detected_changes(sorted_components, "comp", short)
-        print_detected_changes(sorted_bases, "base", short)
+        print_detected_changes(bricks, "data", short)
+        return
+
+    print_detected_changes(changed_components, "comp", short)
+    print_detected_changes(changed_bases, "base", short)
+
+    if with_deps:
+        print_detected_dependent(dependent_bases, dependent_components)
 
 
 def print_detected_changes_in_projects(projects: List[str], short: bool) -> None:
diff --git a/components/polylith/poetry/commands/diff.py b/components/polylith/poetry/commands/diff.py
index ec4ac0c..aedb9b8 100644
--- a/components/polylith/poetry/commands/diff.py
+++ b/components/polylith/poetry/commands/diff.py
@@ -19,6 +19,11 @@ class DiffCommand(Command):
             description="Print changed bricks",
             flag=True,
         ),
+        option(
+            long_name="deps",
+            description="Print bricks that depend on the changes. Use with --bricks.",
+            flag=True,
+        ),
         option(
             long_name="since",
             description="Changed since a specific tag",
@@ -28,9 +33,13 @@ class DiffCommand(Command):
 
     def handle(self) -> int:
         since = self.option("since")
-        short = True if self.option("short") else False
-        bricks = True if self.option("bricks") else False
 
-        commands.diff.run(since, short, bricks)
+        options = {
+            "short": self.option("short"),
+            "bricks": self.option("bricks"),
+            "deps": self.option("deps"),
+        }
+
+        commands.diff.run(since, options)
 
         return 0
diff --git a/projects/poetry_polylith_plugin/pyproject.toml b/projects/poetry_polylith_plugin/pyproject.toml
index 2e3a82c..cc4cfda 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.26.2"
+version = "1.27.0"
 description = "A Poetry plugin that adds tooling support for the Polylith Architecture"
 authors = ["David Vujic"]
 homepage = "https://davidvujic.github.io/python-polylith-docs/"
diff --git a/projects/polylith_cli/pyproject.toml b/projects/polylith_cli/pyproject.toml
index ebb1768..50a19a0 100644
--- a/projects/polylith_cli/pyproject.toml
+++ b/projects/polylith_cli/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "polylith-cli"
-version = "1.13.2"
+version = "1.14.0"
 description = "Python tooling support for the Polylith Architecture"
 authors = ['David Vujic']
 homepage = "https://davidvujic.github.io/python-polylith-docs/"


Test patch:
diff --git a/test/components/polylith/commands/test_diff.py b/test/components/polylith/commands/test_diff.py
new file mode 100644
index 0000000..ffd00e4
--- /dev/null
+++ b/test/components/polylith/commands/test_diff.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+
+from polylith import commands
+
+
+def test_calculate_dependent_bricks(monkeypatch):
+    fake_imports = {
+        "one": {"three"},
+        "two": {"three"},
+        "three": {"four"},
+        "four": {},
+        "base_one": {"one", "four"},
+        "base_two": {"two", "four"},
+    }
+    monkeypatch.setattr(
+        commands.diff, "get_imports", lambda *args, **kwargs: fake_imports
+    )
+
+    projects_data = [
+        {"bases": ["base_one", "base_two"], "components": ["one", "two", "three"]}
+    ]
+
+    changed_bricks = {"one", "three"}
+
+    res = commands.diff.calculate_dependent_bricks(
+        Path.cwd(), "test", projects_data, changed_bricks
+    )
+
+    assert res["bases"] == {"base_one"}
+    assert res["components"] == {"two"}