diff --git a/bpython/importcompletion.py b/bpython/importcompletion.py index e22b61f6..e9bff7fd 100644 --- a/bpython/importcompletion.py +++ b/bpython/importcompletion.py @@ -162,12 +162,24 @@ def complete(self, cursor_offset: int, line: str) -> set[str] | None: else: return None + def _is_skipped(self, path: Path) -> bool: + for pattern in self.skiplist: + if Path(pattern).is_absolute(): + if any( + fnmatch.fnmatch(str(parent), pattern) + for parent in (path, *path.parents) + ): + return True + elif fnmatch.fnmatch(path.name, pattern): + return True + return False + def find_modules(self, path: Path) -> Generator[str | None, None, None]: """Find all modules (and packages) for a given directory.""" if not path.is_dir(): # Perhaps a zip file return - if any(fnmatch.fnmatch(path.name, entry) for entry in self.skiplist): + if self._is_skipped(path): # Path is on skiplist return @@ -177,9 +189,7 @@ def find_modules(self, path: Path) -> Generator[str | None, None, None]: if p.name.startswith(".") or p.name == "__pycache__": # Impossible to import from names starting with . and we can skip __pycache__ continue - elif any( - fnmatch.fnmatch(p.name, entry) for entry in self.skiplist - ): + elif self._is_skipped(p): # Path is on skiplist continue elif not any(p.name.endswith(suffix) for suffix in SUFFIXES): diff --git a/bpython/test/test_importcompletion.py b/bpython/test/test_importcompletion.py index 814d3c31..bc513a5c 100644 --- a/bpython/test/test_importcompletion.py +++ b/bpython/test/test_importcompletion.py @@ -192,6 +192,40 @@ def test_simple_symbolic_link_loop(self): self.assertFalse(filepaths) +class TestSkiplist(unittest.TestCase): + def test_absolute_paths(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + for name in ("first", "second"): + (root / name).mkdir() + (root / name / "__init__.py").touch() + (root / name / "inside.py").touch() + + cases = ( + ([root], str(root), set()), + ([root], str(root / "first"), {"second", "second.inside"}), + ([root / "first"], str(root), set()), + ) + for paths, skip, expected in cases: + with self.subTest(paths=paths, skip=skip): + gatherer = ModuleGatherer(paths, [skip]) + while gatherer.find_coroutine(): + pass + self.assertEqual(gatherer.modules, expected) + + def test_relative_name_pattern_remains_supported(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + for name in ("first", "second"): + (root / name).mkdir() + (root / name / "__init__.py").touch() + (root / name / "inside.py").touch() + gatherer = ModuleGatherer([root], ["f*"]) + while gatherer.find_coroutine(): + pass + self.assertEqual(gatherer.modules, {"second", "second.inside"}) + + class TestBugReports(unittest.TestCase): def test_issue_847(self): with tempfile.TemporaryDirectory() as import_test_folder: diff --git a/doc/sphinx/source/configuration-options.rst b/doc/sphinx/source/configuration-options.rst index 1521542e..3e8fb0cf 100644 --- a/doc/sphinx/source/configuration-options.rst +++ b/doc/sphinx/source/configuration-options.rst @@ -186,7 +186,13 @@ Whether to use Unicode characters to draw boxes (default: True). import_completion_skiplist ^^^^^^^^^^^^^^^^^^^^^^^^^^ -A `:`-seperated list of patterns to skip when processing modules for import completion. +A ``:``-separated list of patterns to skip when processing modules for import +completion. A name pattern such as ``node_modules`` or ``build*`` matches names +at any level. On POSIX systems, an absolute path such as ``/home/user/projects`` +skips that directory and its descendants, including when a descendant is itself +on the module search path. Paths are matched against resolved filesystem paths. +The colon separator means that Windows drive-letter paths cannot be specified +in this option. .. versionadded:: 0.21