Repository: zyskarch/pytestarch

Issue:
Permit to exclude external libraries based on a regex
I'd like to filter out some libraries (for example the stdlib `logging`, `typing`, ... or some third-parties like `typing_extensions` ...) but not all of them.
Currently, the parameter `exclude_external_libraries` of `get_evaluable_architecture` is all-or-nothing. Also, (as documented), the parameter `exclusions` (and `regex_exclusions`) only applies to modules found in the `root_path`/`module_path`.

Would adding an `external_exclusions` be interesting ?

This would greatly help to reduce the noise when exporting the architecture graph (for manual review).
I'm currently doing :
```python
evaluable = get_evaluable_architecture(...)
import matplotlib.pyplot as plt
import networkx as nx
G: NetworkxGraph = evaluable._graph._graph
pos = nx.circular_layout(G)
plt.figure(3, figsize=(12, 12))
nx.draw(G, pos, with_labels=True)
plt.savefig("Graph.png", format="PNG", dpi=300)
```

Discussion:
@zyskarch: Definitely! I'll take a look at it, thanks for the suggestion! 
@Lenormju: Do you want me to contribute a PR for it ?@zyskarch: I'm the last person to stop you from contributing ;) However, I have started working on it already, and I'd say within the next two weeks should be a reasonable timeframe to complete it.
So if you do want to speed it up by opening a PR before then (or just generally want to work on it), let me know - otherwise I'll just keep working on it :)

Solution patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 05dcde1..b12e476 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,11 @@
 
 This project uses semantic versioning and follows [keep a changelog](https://keepachangelog.com).
 
+## [3.1.0] -- 2024-09-16
+### Added
+- Filter for external dependencies.
+
+
 ## [3.0.1] -- 2024-07-25
 ### Fixed
 - Regex syntax error in newer python versions
diff --git a/pyproject.toml b/pyproject.toml
index 2dc3053..ea18270 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "PyTestArch"
-version = "3.0.1"
+version = "3.1.0"
 description = "Test framework for software architecture based on imports between modules"
 authors = ["zyskarch <zyskarch@gmail.com>"]
 maintainers = ["zyskarch <zyskarch@gmail.com>"]
diff --git a/src/pytestarch/eval_structure_generation/file_import/config.py b/src/pytestarch/eval_structure_generation/file_import/config.py
index dc9d246..89966bc 100644
--- a/src/pytestarch/eval_structure_generation/file_import/config.py
+++ b/src/pytestarch/eval_structure_generation/file_import/config.py
@@ -10,6 +10,7 @@ class Config:
     Attributes:
         excluded_directories: Directory paths not to include in parsing specified via regex. Can be used to exclude
         e.g. test directories.
+        excluded_external_dependencies: All external dependencies matching these patterns shall be excluded.
     """
 
     excluded_directories: tuple[str, ...]
diff --git a/src/pytestarch/eval_structure_generation/file_import/file_filter.py b/src/pytestarch/eval_structure_generation/file_import/file_filter.py
index 2c1fc16..ea446e2 100644
--- a/src/pytestarch/eval_structure_generation/file_import/file_filter.py
+++ b/src/pytestarch/eval_structure_generation/file_import/file_filter.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import re
+from functools import singledispatchmethod
 from pathlib import Path
 
 from pytestarch.eval_structure_generation.file_import.config import Config
@@ -9,14 +10,7 @@
 
 
 class FileFilter:
-    """Uses a pseudo-regex pattern to determine whether a file or directory should be excluded.
-
-    Allowed patterns are:
-    - *A*: file or dir will be excluded, if 'A' is part of their name
-    - *A: file or dir will be excluded, if their name ends with 'A'
-    - A*: file or dir will be excluded, if their name starts with 'A'
-    - A: file or dir will be excluded, if their name is 'A'
-    """
+    """Uses a regex pattern to determine whether a file or directory should be excluded."""
 
     def __init__(self, config: Config) -> None:
         self._excluded_directories = [
@@ -24,11 +18,22 @@ def __init__(self, config: Config) -> None:
             for directory_pattern in config.excluded_directories
         ]
 
-    def is_excluded(self, path: Path) -> bool:
-        """Returns True if the path matches one of the pre-configured exclusion patterns."""
-        path_as_str = str(path)
+    @singledispatchmethod
+    def is_excluded(self, obj: str | Path) -> bool:
+        """Returns True if the object matches one of the pre-configured exclusion patterns."""
+        raise NotImplementedError
+
+    @is_excluded.register(Path)
+    def _(self, obj: Path) -> bool:
+        path_as_str = str(obj)
 
+        return self.is_excluded(path_as_str)
+
+    @is_excluded.register(str)
+    def _(self, obj: str) -> bool:
         return any(
-            re.match(pattern, path_as_str) is not None
-            for pattern in self._excluded_directories
+            re.match(pattern, obj) is not None for pattern in self._excluded_directories
         )
+
+    def has_filter(self) -> bool:
+        return len(self._excluded_directories) > 0
diff --git a/src/pytestarch/eval_structure_generation/file_import/import_filter.py b/src/pytestarch/eval_structure_generation/file_import/import_filter.py
index 9d7630a..8b4ef77 100644
--- a/src/pytestarch/eval_structure_generation/file_import/import_filter.py
+++ b/src/pytestarch/eval_structure_generation/file_import/import_filter.py
@@ -3,22 +3,33 @@
 from collections.abc import Sequence
 
 from pytestarch.eval_structure.types import Import
+from pytestarch.eval_structure_generation.file_import.config import Config
+from pytestarch.eval_structure_generation.file_import.file_filter import FileFilter
 
 
-class ImportFilter:
-    """Filters out imports of external modules from the list of all imports.
+class ExternalImportFilter:
+    """Filters out imports of (some) external modules from the list of all imports.
     External modules are all modules that are not submodules of the configured module to search for imports.
+
+    Two possibilities: either all external modules are filtered out, or only those matching given regex patterns.
     """
 
-    def __init__(self, exclude_external_libraries: bool, root_module_name: str) -> None:
+    def __init__(
+        self,
+        exclude_external_libraries: bool,
+        root_module_name: str,
+        external_exclusions: tuple[str, ...],
+    ) -> None:
         """
         Args:
             exclude_external_libraries: If True, external modules will be filtered out, otherwise this class implements a no-op.
             root_module_name: name of the module that determines which modules are considered external. If a module
                 is a submodule of this module, it is considered internal.
+            external_exclusions: regex pattern: all external modules that match it shall be filtered out.
         """
         self._exclude_external_libraries = exclude_external_libraries
         self._root_module_name = root_module_name
+        self._external_exclusion_filter = FileFilter(Config(external_exclusions))
 
     def filter(self, imports: Sequence[Import]) -> Sequence[Import]:
         """According to the configuration, imports will be filtered.
@@ -28,12 +39,34 @@ def filter(self, imports: Sequence[Import]) -> Sequence[Import]:
         Returns:
             filtered list of imports
         """
-        if not self._exclude_external_libraries:
+        if (
+            not self._exclude_external_libraries
+            and not self._external_exclusion_filter.has_filter()
+        ):
             return imports
 
+        # is this is set, then _exclude_external_libraries is False, as asserted by previously executed code
+        if self._external_exclusion_filter.has_filter():
+            return [
+                i for i in imports if self._is_internal_or_retained_external_import(i)
+            ]
+
         return [i for i in imports if self._is_internal_import(i)]
 
     def _is_internal_import(self, i: Import) -> bool:
         importee = i.importee()
 
         return importee.startswith(self._root_module_name)
+
+    def _is_internal_or_retained_external_import(self, i: Import) -> bool:
+        if self._is_internal_import(i):
+            return True
+
+        importee = i.importee()
+
+        excluded = self._external_exclusion_filter.is_excluded(importee)
+        any_parent_excluded = any(
+            self._external_exclusion_filter.is_excluded(parent)
+            for parent in i.importee_parent_modules()
+        )
+        return not (excluded or any_parent_excluded)
diff --git a/src/pytestarch/eval_structure_generation/graph_generation/graph_generator.py b/src/pytestarch/eval_structure_generation/graph_generation/graph_generator.py
index 7392a1d..9e95b4c 100644
--- a/src/pytestarch/eval_structure_generation/graph_generation/graph_generator.py
+++ b/src/pytestarch/eval_structure_generation/graph_generation/graph_generator.py
@@ -10,7 +10,9 @@
 from pytestarch.eval_structure_generation.file_import.config import Config
 from pytestarch.eval_structure_generation.file_import.converter import ImportConverter
 from pytestarch.eval_structure_generation.file_import.file_filter import FileFilter
-from pytestarch.eval_structure_generation.file_import.import_filter import ImportFilter
+from pytestarch.eval_structure_generation.file_import.import_filter import (
+    ExternalImportFilter,
+)
 from pytestarch.eval_structure_generation.file_import.import_types import NamedModule
 from pytestarch.eval_structure_generation.file_import.importee_module_calculator import (
     ImporteeModuleCalculator,
@@ -36,6 +38,7 @@ def generate_graph(
     exclusions: tuple[str, ...],
     exclude_external_libraries: bool,
     level_limit: int | None,
+    external_exclusions: tuple[str, ...] | None,
 ) -> EvaluableArchitectureGraph:
     level_limit = _add_extra_levels_to_limit_if_root_and_module_path_differ(
         level_limit,
@@ -56,12 +59,15 @@ def generate_graph(
         _get_all_internal_modules(all_modules, internal_module_prefix),
     )
 
+    if external_exclusions is None:
+        external_exclusions = ()
+
     imports = _remove_excluded_imports(
-        exclude_external_libraries, imports, internal_module_prefix
+        exclude_external_libraries, imports, internal_module_prefix, external_exclusions
     )
 
     all_modules = _append_external_modules_to_module_list(
-        all_modules, exclude_external_libraries, imports, root_path
+        all_modules, exclude_external_libraries, imports, root_path, external_exclusions
     )
     return EvaluableArchitectureGraph(NetworkxGraph(all_modules, imports, level_limit))
 
@@ -71,24 +77,36 @@ def _append_external_modules_to_module_list(
     exclude_external_libraries: bool,
     imports: Sequence[Import],
     root_path: Path,
+    external_exclusions: tuple[str, ...],
 ) -> list[Node]:
     """External modules are not detected as modules when importing the source folder - but they will of course show up
     in the imports. To ensure that all edges in the graph have nodes attached, the external modules need to be added to
     the list of modules/nodes."""
-    if not exclude_external_libraries:
-        all_modules = ImporteeModuleCalculator(root_path).calculate_importee_modules(
-            imports,
-            all_modules,
-        )
-    return all_modules
+    if exclude_external_libraries:
+        return all_modules
+
+    all_modules = ImporteeModuleCalculator(root_path).calculate_importee_modules(
+        imports,
+        all_modules,
+    )
+
+    file_filter = FileFilter(Config(external_exclusions))
+
+    if not file_filter.has_filter():
+        return all_modules
+
+    return [module for module in all_modules if not file_filter.is_excluded(module)]
 
 
 def _remove_excluded_imports(
     exclude_external_libraries: bool,
     imports: Sequence[Import],
     internal_module_prefix: str,
+    external_exclusions: tuple[str, ...],
 ) -> Sequence[Import]:
-    import_filter = ImportFilter(exclude_external_libraries, internal_module_prefix)
+    import_filter = ExternalImportFilter(
+        exclude_external_libraries, internal_module_prefix, external_exclusions
+    )
     return import_filter.filter(imports)
 
 
@@ -139,7 +157,9 @@ def _get_imports_from_ast(
 
 
 def _get_all_ast_modules(
-    module_path: Path, root_path: Path, exclusions: tuple[str, ...]
+    module_path: Path,
+    root_path: Path,
+    exclusions: tuple[str, ...],
 ) -> tuple[list[str], list[NamedModule]]:
     config = Config(exclusions)
     file_filter = FileFilter(config)
diff --git a/src/pytestarch/pytestarch.py b/src/pytestarch/pytestarch.py
index 1f28913..47ac503 100755
--- a/src/pytestarch/pytestarch.py
+++ b/src/pytestarch/pytestarch.py
@@ -28,32 +28,52 @@ def get_evaluable_architecture(
     exclude_external_libraries: bool = True,
     level_limit: int | None = None,
     regex_exclusions: tuple[str, ...] | None = None,
+    external_exclusions: tuple[str, ...] | None = None,
+    regex_external_exclusions: tuple[str, ...] | None = None,
 ) -> EvaluableArchitecture:
     """Constructs an evaluable object based on the given module.
 
     Args:
         root_path: root directory of the source code. Should not be set to a submodule of the top level module.
         module_path: path of module to generate the evaluable for. Must be a submodule of the root_path module.
-        exclusions: pseudo-regex to exclude files and directories from being integrated into the evaluable, e.g. *Test.py
+        exclusions: pseudo-regex to exclude files and directories from being integrated into the evaluable, e.g. *Test.py. Can only be specified if regex_exclusions is not specified.
+        external_exclusions: pseudo-regex version of 'external_exclusions' to exclude certain external dependencies from being integrated into the evaluable. Can only be specified if exclude_external_libraries is False and regex_external_exclusions is not specified. Note that child dependencies are not automatically excluded, e.g. if exclusion regex is 'logging', then 'logging.handler' will not be excluded. For exclude this as well, use 'logging.*'.
         exclude_external_libraries: if True, external dependencies (defined as all dependencies to module outside the
-            module_path, including Python built-in modules) will not be taken into account for the dependency analysis.
-            Can only be specified if regex_exclusions is not specified.
+            module_path, including Python built-in modules) will not be taken into account for the dependency analysis. If a parent module (e.g. 'logging') is excluded, so will be child modules (e.g. 'logging.handlers').
         level_limit: if not None, specifies the depth of the graph. For example, a limit of 1 will result in only the
             modules one level below the module path to be added as nodes. Note that this only applies to the final graph;
             all modules will be parsed, the graph will simply be flattened: if a submodule of X imports Y, this import is
             then assigned to X instead, if Y is above the level limit.
         regex_exclusions: Proper regex version of 'exclusions'. Can only be specified if regex_exclusions is not specified.
+        regex_external_exclusions: Proper regex version of 'external_exclusions' to exclude certain external dependencies from being integrated into the evaluable. Can only be specified if exclude_external_libraries is False and external_exclusions is not specified. If a parent module (e.g. 'logging') is excluded, so will be child modules (e.g. 'logging.handlers').
     """
     if regex_exclusions and exclusions:
         raise ImproperlyConfigured(
             "Partial match exclusions and regex exclusions cannot both be specified."
         )
 
+    if regex_external_exclusions and external_exclusions:
+        raise ImproperlyConfigured(
+            "Partial match external_exclusions and regex external_exclusions cannot both be specified."
+        )
+
+    if exclude_external_libraries and (
+        external_exclusions or regex_external_exclusions
+    ):
+        raise ImproperlyConfigured(
+            "If external libraries are excluded, no exclusion patterns can be defined for them."
+        )
+
     if exclusions:
         regex_exclusions = tuple(
             convert_partial_match_to_regex(pattern) for pattern in exclusions
         )
 
+    if external_exclusions:
+        regex_external_exclusions = tuple(
+            convert_partial_match_to_regex(pattern) for pattern in external_exclusions
+        )
+
     root_as_path = Path(root_path)
     module_as_path = Path(module_path)
 
@@ -68,6 +88,7 @@ def get_evaluable_architecture(
         regex_exclusions,  # type: ignore
         exclude_external_libraries,
         level_limit,
+        regex_external_exclusions,
     )
 
 
@@ -78,6 +99,8 @@ def get_evaluable_architecture_for_module_objects(
     exclude_external_libraries: bool = True,
     level_limit: int | None = None,
     regex_exclusions: tuple[str, ...] | None = None,
+    external_exclusions: tuple[str, ...] | None = None,
+    regex_external_exclusions: tuple[str, ...] | None = None,
 ) -> EvaluableArchitecture:
     """Same functionality as get_evaluable_architecture, but root module and module to evaluate are passed in as module objects
     instead of the absolute paths to them.
@@ -92,4 +115,6 @@ def get_evaluable_architecture_for_module_objects(
         exclude_external_libraries,
         level_limit,
         regex_exclusions,
+        external_exclusions,
+        regex_external_exclusions,
     )
diff --git a/src/pytestarch/utils/partial_match_to_regex_converter.py b/src/pytestarch/utils/partial_match_to_regex_converter.py
index 3a40849..23cd4c4 100644
--- a/src/pytestarch/utils/partial_match_to_regex_converter.py
+++ b/src/pytestarch/utils/partial_match_to_regex_converter.py
@@ -16,7 +16,7 @@ def convert_partial_match_to_regex(match: str) -> str:
     Returns:
         Regex version of the partial match string. This means that the partial match characters * have been replaced
         by their regex equivalent .* and the rest of the string has been escaped to not contain any characters that
-        have a special meaning in a regex. In addition, $ will be added at the end if no * was present their before.
+        have a special meaning in a regex. In addition, $ will be added at the end if no * was present there before.
         This is done because partial matches are used as full string matches, unless * is present at the end.
     """
     all_at_start = match.startswith(PARTIAL_MATCH_ALL_MARKER)


Test patch:
diff --git a/tests/eval_structure_generation/test_external_exclusions.py b/tests/eval_structure_generation/test_external_exclusions.py
new file mode 100644
index 0000000..7cab7db
--- /dev/null
+++ b/tests/eval_structure_generation/test_external_exclusions.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import resources
+from resources import project_with_nested_external_dependencies
+
+from pytestarch.pytestarch import get_evaluable_architecture_for_module_objects
+
+
+def test_external_dependencies_present() -> None:
+    evaluable = get_evaluable_architecture_for_module_objects(
+        resources,
+        project_with_nested_external_dependencies,
+        exclude_external_libraries=False,
+    )
+    graph = evaluable._graph  # type: ignore[attr-defined]
+
+    assert "logging" in graph
+    assert "logging.handlers" in graph
+
+
+def test_external_dependencies_removed_if_complete_match() -> None:
+    evaluable = get_evaluable_architecture_for_module_objects(
+        resources,
+        project_with_nested_external_dependencies,
+        exclude_external_libraries=False,
+        regex_external_exclusions=("logging",),
+    )
+    graph = evaluable._graph  # type: ignore[attr-defined]
+
+    assert "logging" not in graph
+    assert "logging.handlers" not in graph
+
+
+def test_external_dependencies_removed_if_not_complete_match() -> None:
+    evaluable = get_evaluable_architecture_for_module_objects(
+        resources,
+        project_with_nested_external_dependencies,
+        exclude_external_libraries=False,
+        regex_external_exclusions=("logging$",),
+    )
+    graph = evaluable._graph  # type: ignore[attr-defined]
+
+    assert "logging" not in graph
+    assert "logging.handlers" not in graph  # parent is excluded
+
+
+def test_external_dependencies_removed_if_partial_match() -> None:
+    evaluable = get_evaluable_architecture_for_module_objects(
+        resources,
+        project_with_nested_external_dependencies,
+        exclude_external_libraries=False,
+        regex_external_exclusions=("logging.*",),
+    )
+    graph = evaluable._graph  # type: ignore[attr-defined]
+
+    assert "logging" not in graph
+    assert "logging.handlers" not in graph
+
+
+def test_external_dependencies_removed_if_complete_pseudo_regex_match() -> None:
+    evaluable = get_evaluable_architecture_for_module_objects(
+        resources,
+        project_with_nested_external_dependencies,
+        exclude_external_libraries=False,
+        external_exclusions=("logging",),
+    )
+    graph = evaluable._graph  # type: ignore[attr-defined]
+
+    assert "logging" not in graph
+    assert "logging.handlers" not in graph  # parent is excluded
+
+
+def test_external_dependencies_removed_if_partial_pseudo_regex_match() -> None:
+    evaluable = get_evaluable_architecture_for_module_objects(
+        resources,
+        project_with_nested_external_dependencies,
+        exclude_external_libraries=False,
+        external_exclusions=("logging*",),
+    )
+    graph = evaluable._graph  # type: ignore[attr-defined]
+
+    assert "logging" not in graph
+    assert "logging.handlers" not in graph
diff --git a/tests/eval_structure_generation/test_import_filter.py b/tests/eval_structure_generation/test_import_filter.py
index 0f67105..e87911a 100644
--- a/tests/eval_structure_generation/test_import_filter.py
+++ b/tests/eval_structure_generation/test_import_filter.py
@@ -2,7 +2,9 @@
 
 from pathlib import Path
 
-from pytestarch.eval_structure_generation.file_import.import_filter import ImportFilter
+from pytestarch.eval_structure_generation.file_import.import_filter import (
+    ExternalImportFilter,
+)
 from pytestarch.eval_structure_generation.file_import.import_types import AbsoluteImport
 
 ROOT_PATH = Path("A.B")
@@ -14,7 +16,7 @@
 
 
 def test_import_filter_excludes_external_dependencies() -> None:
-    filter = ImportFilter(True, str(ROOT_PATH))
+    filter = ExternalImportFilter(True, str(ROOT_PATH), ())
     filtered_imports = filter.filter(imports)
 
     assert len(filtered_imports) == 1
@@ -22,7 +24,7 @@ def test_import_filter_excludes_external_dependencies() -> None:
 
 
 def test_import_filter_includes_internal_dependencies() -> None:
-    filter = ImportFilter(False, str(ROOT_PATH))
+    filter = ExternalImportFilter(False, str(ROOT_PATH), ())
     filtered_imports = filter.filter(imports)
 
     assert len(filtered_imports) == 2
diff --git a/tests/resources/project_with_nested_external_dependencies/__init__.py b/tests/resources/project_with_nested_external_dependencies/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/file.py b/tests/resources/project_with_nested_external_dependencies/file.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/DummyTest.py b/tests/resources/project_with_nested_external_dependencies/level0/DummyTest.py
new file mode 100644
index 0000000..e01e36c
--- /dev/null
+++ b/tests/resources/project_with_nested_external_dependencies/level0/DummyTest.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from urllib import parse  # noqa: F401 E401
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/__init__.py b/tests/resources/project_with_nested_external_dependencies/level0/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/level1/__init__.py b/tests/resources/project_with_nested_external_dependencies/level0/level1/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/__init__.py b/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/__init__.py b/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/level4/__init__.py b/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/level4/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/level4/level5/__init__.py b/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/level4/level5/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/level4/level5/module_level_5.py b/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/level4/level5/module_level_5.py
new file mode 100644
index 0000000..a1d133d
--- /dev/null
+++ b/tests/resources/project_with_nested_external_dependencies/level0/level1/level2/level3/level4/level5/module_level_5.py
@@ -0,0 +1,7 @@
+from __future__ import annotations
+
+from ......test_dummy import A
+
+
+def test_level5():
+    print(A())
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/test_dummy.py b/tests/resources/project_with_nested_external_dependencies/level0/test_dummy.py
new file mode 100644
index 0000000..5693c91
--- /dev/null
+++ b/tests/resources/project_with_nested_external_dependencies/level0/test_dummy.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+import io as io_import  # noqa: F401
+import sys  # noqa: F401
+import typing  # noqa: F401 E401
+from ast import *  # noqa: F401 F403
+from os import path as p  # noqa: F401
+from os import read  # noqa: F401
+from pathlib import Path
+
+import pytest  # noqa: F401 E401
+
+from pytestarch.pytestarch import get_evaluable_architecture
+
+from . import test_dummy_2, test_dummy_3  # noqa: F401
+
+
+class A:
+    pass
+
+
+ROOT_PATH = Path(__file__).parent.parent.parent.parent.parent.resolve()
+ROOT_PATH_STR: typing.Final[str] = str(ROOT_PATH)
+MODULE_PATH_STR: typing.Final[str] = str(ROOT_PATH / "tests/resources")
+
+
+def dummy_test() -> None:
+    get_evaluable_architecture(ROOT_PATH_STR, MODULE_PATH_STR)
+    import itertools  # noqa: F401
+    import logging.handlers as handlers  # noqa: F401
+
+    assert True
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/test_dummy_2.py b/tests/resources/project_with_nested_external_dependencies/level0/test_dummy_2.py
new file mode 100644
index 0000000..6e43559
--- /dev/null
+++ b/tests/resources/project_with_nested_external_dependencies/level0/test_dummy_2.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+
+def dummy_method_2() -> None:
+    pass
diff --git a/tests/resources/project_with_nested_external_dependencies/level0/test_dummy_3.py b/tests/resources/project_with_nested_external_dependencies/level0/test_dummy_3.py
new file mode 100644
index 0000000..ae3e6f1
--- /dev/null
+++ b/tests/resources/project_with_nested_external_dependencies/level0/test_dummy_3.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+
+def dummy_method_3() -> None:
+    pass
diff --git a/tests/resources/project_with_nested_external_dependencies/sub_dir/__init__.py b/tests/resources/project_with_nested_external_dependencies/sub_dir/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/util.py b/tests/util.py
index 15cda0e..8225f09 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -10,5 +10,5 @@ class MockFileFilter(FileFilter):
     def __init__(self):
         super().__init__(Config(tuple()))
 
-    def is_excluded(self, path: Path) -> bool:
+    def is_excluded(self, obj: Path | str) -> bool:  # type: ignore
         return False