Skip to content

Commit

Permalink
Merge branch 'main' into feature/packages-multiple-paths
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg authored Mar 25, 2024
2 parents 3db9a11 + bd1b80c commit 17d5993
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 29 deletions.
8 changes: 0 additions & 8 deletions docs/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,3 @@ Use the following configuration:
# This requires `slotscheck` to be installed in that environment.
#
# language: system
Namespace packages
------------------

Namespace packages come in `different flavors <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/>`_.
When using the ``-m/--module`` flag in the CLI, all these flavors are supported.
When specifying file paths, *native* namespace packages are not supported.
2 changes: 1 addition & 1 deletion docs/discovery.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ However, there are some complications that you may need to be aware of.

You should generally be fine if you follow these rules:

- To check files in your current directory,
- To check files in your current directory, or subdirectories of it,
you should run slotscheck as ``python -m slotscheck``.
- To check files elsewhere, you may need to set the ``$PYTHONPATH``
environment variable.
Expand Down
5 changes: 3 additions & 2 deletions src/slotscheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from .discovery import (
AbsPath,
FailedImport,
FileNotInSysPathError,
ModuleLocated,
ModuleName,
ModuleTree,
Expand Down Expand Up @@ -162,9 +163,9 @@ def root(

try:
classes, modules = _collect(files, module, conf)
except ModuleNotFoundError as e:
except (ModuleNotFoundError, FileNotInSysPathError) as e:
print(
f"ERROR: Module '{e.name}' not found.\n\n"
f"ERROR: {e}.\n\n"
"See slotscheck.rtfd.io/en/latest/discovery.html\n"
"for help resolving common import problems."
)
Expand Down
40 changes: 32 additions & 8 deletions src/slotscheck/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import importlib
import pkgutil
import sys
from dataclasses import dataclass, field, replace
from functools import partial, reduce
from importlib.util import find_spec
from inspect import isclass
from itertools import chain, takewhile
from itertools import chain
from pathlib import Path
from textwrap import indent
from types import ModuleType
Expand Down Expand Up @@ -165,7 +166,7 @@ def module_tree(
except BaseException as e:
return FailedImport(module, e)
if spec is None:
raise ModuleNotFoundError(f"No module named '{module}'", name=module)
raise ModuleNotFoundError(f"No module named {module!r}", name=module)
*namespaces, name = module.split(".")
location = Path(spec.origin) if spec.has_location and spec.origin else None
tree: ModuleTree
Expand Down Expand Up @@ -289,6 +290,12 @@ class ModuleLocated(NamedTuple):
expected_location: Optional[AbsPath]


class FileNotInSysPathError(Exception):
def __init__(self, file: Path) -> None:
super().__init__(f"File {str(file)!r} is not in PYTHONPATH")
self.file = file


def _is_module(p: AbsPath) -> bool:
return (p.is_file() and p.suffixes == [".py"]) or _is_package(p)

Expand All @@ -297,15 +304,32 @@ def _is_package(p: AbsPath) -> bool:
return p.is_dir() and (p / _INIT_PY).is_file()


def find_modules(p: AbsPath) -> Iterable[ModuleLocated]:
"Recursively find modules at given path. Nonexistent Path is ignored"
def _module_parents(
p: AbsPath, sys_path: FrozenSet[AbsPath]
) -> Iterable[AbsPath]:
yield p
for pp in p.parents:
if pp in sys_path:
return
yield pp
raise FileNotInSysPathError(p)


def _find_modules(
p: AbsPath, sys_path: FrozenSet[AbsPath]
) -> Iterable[ModuleLocated]:
if p.name == _INIT_PY:
yield from find_modules(p.parent)
yield from _find_modules(p.parent, sys_path)
elif _is_module(p):
parents = [p] + list(takewhile(_is_package, p.parents))
parents = list(_module_parents(p, sys_path))
yield ModuleLocated(
".".join(p.stem for p in reversed(parents)),
(p / "__init__.py" if _is_package(p) else p),
(p / _INIT_PY if _is_package(p) else p),
)
elif p.is_dir():
yield from flatten(map(find_modules, p.iterdir()))
yield from flatten(_find_modules(cp, sys_path) for cp in p.iterdir())


def find_modules(p: AbsPath) -> Iterable[ModuleLocated]:
"Recursively find modules at given path. Nonexistent Path is ignored"
return _find_modules(p, frozenset(map(Path, sys.path)))
27 changes: 26 additions & 1 deletion tests/src/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re
from importlib.util import find_spec
from pathlib import Path

Expand Down Expand Up @@ -33,12 +34,26 @@ def test_module_doesnt_exist(runner: CliRunner):
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert result.output == (
"ERROR: Module 'foo' not found.\n\n"
"ERROR: No module named 'foo'.\n\n"
"See slotscheck.rtfd.io/en/latest/discovery.html\n"
"for help resolving common import problems.\n"
)


def test_python_file_not_in_sys_path(runner: CliRunner, tmp_path: Path):
file = tmp_path / "foo.py"
file.write_text('print("Hello, world!")', encoding="utf-8")
result = runner.invoke(cli, [str(file)])
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert re.fullmatch(
"ERROR: File '.*/foo.py' is not in PYTHONPATH.\n\n"
"See slotscheck.rtfd.io/en/latest/discovery.html\n"
"for help resolving common import problems.\n",
result.output,
)


def test_module_is_uninspectable(runner: CliRunner):
result = runner.invoke(cli, ["-m", "broken.submodule"])
assert result.exit_code == 1
Expand Down Expand Up @@ -165,6 +180,16 @@ def test_multiple_modules(runner: CliRunner):
assert result.output == "All OK!\nScanned 11 module(s), 70 class(es).\n"


def test_implicitly_namespaced_path(runner: CliRunner):
result = runner.invoke(
cli,
[str(EXAMPLES_DIR / "implicitly_namespaced")],
catch_exceptions=False,
)
assert result.exit_code == 0
assert result.output == "All OK!\nScanned 7 module(s), 1 class(es).\n"


def test_multiple_paths(runner: CliRunner):
result = runner.invoke(
cli,
Expand Down
32 changes: 23 additions & 9 deletions tests/src/test_discovery.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from pathlib import Path
from typing import FrozenSet, List, TypeVar
from unittest import mock

import pytest

from slotscheck.discovery import (
FailedImport,
FileNotInSysPathError,
Module,
ModuleLocated,
ModuleTree,
Expand Down Expand Up @@ -371,32 +373,34 @@ def test_given_nonpython_file(self):
def test_given_python_file(self):
location = EXAMPLES_DIR / "files/subdir/myfile.py"
result = list(find_modules(location))
assert result == [ModuleLocated("myfile", location)]
assert result == [ModuleLocated("files.subdir.myfile", location)]

def test_given_python_root_module(self):
location = EXAMPLES_DIR / "files/subdir/some_module/"
result = list(find_modules(location))
assert result == [
ModuleLocated("some_module", location / "__init__.py")
ModuleLocated("files.subdir.some_module", location / "__init__.py")
]

def test_given_dir_containing_python_files(self):
location = EXAMPLES_DIR / "files/my_scripts/"
result = list(find_modules(location))
assert len(result) == 4
assert set(result) == {
ModuleLocated("bla", location / "bla.py"),
ModuleLocated("foo", location / "foo.py"),
ModuleLocated("foo", location / "sub/foo.py"),
ModuleLocated("mymodule", location / "mymodule/__init__.py"),
ModuleLocated("files.my_scripts.bla", location / "bla.py"),
ModuleLocated("files.my_scripts.foo", location / "foo.py"),
ModuleLocated("files.my_scripts.sub.foo", location / "sub/foo.py"),
ModuleLocated(
"files.my_scripts.mymodule", location / "mymodule/__init__.py"
),
}

def test_given_file_within_module(self):
location = EXAMPLES_DIR / "files/subdir/some_module/sub/foo.py"
result = list(find_modules(location))
assert result == [
ModuleLocated(
"some_module.sub.foo",
"files.subdir.some_module.sub.foo",
EXAMPLES_DIR / "files/subdir/some_module/sub/foo.py",
)
]
Expand All @@ -405,13 +409,23 @@ def test_given_submodule(self):
location = EXAMPLES_DIR / "files/subdir/some_module/sub"
result = list(find_modules(location))
assert result == [
ModuleLocated("some_module.sub", location / "__init__.py")
ModuleLocated(
"files.subdir.some_module.sub", location / "__init__.py"
)
]

def test_given_init_py(self):
location = EXAMPLES_DIR / "files/subdir/some_module/sub/__init__.py"
result = list(find_modules(location))
assert result == [ModuleLocated("some_module.sub", location)]
assert result == [
ModuleLocated("files.subdir.some_module.sub", location)
]

def test_given_file_not_in_sys_path(self, tmp_path: Path):
location = tmp_path / "foo.py"
location.touch()
with pytest.raises(FileNotInSysPathError, match=r"foo\.py"):
list(find_modules(location))


class TestConsolidate:
Expand Down

0 comments on commit 17d5993

Please sign in to comment.