-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix discovery of modules in namespace packages #228
Fix discovery of modules in namespace packages #228
Conversation
Instead of relying on `__init__.py` files, stop at the first parent directory that is in `sys.path`. This gives the shortest module name under which the file can really be imported. (Unless there are name conflicts in `sys.path`, which is arguably a misconfiguration; this is caught by the location check in `module_tree`.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting approach, seems indeed better than the current one. Would it be doable to add an end-to-end test in test_cli.py
that demonstrates the need for this? (i.e. a test that fails without your fix)
src/slotscheck/discovery.py
Outdated
"Recursively find modules at given path. Nonexistent Path is ignored" | ||
if python_path is None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems this if
is only triggered on the first call. Can you make the argument non-optional to prevent this check every call? Minor inconvenience to callers to do frozenset(map(Path, sys.path))
themselves.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Factored out an internal helper function instead so I don't need to update every caller of find_modules
, and also in case we'll want to change the algorithm in the future.
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## main #228 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 6 6
Lines 495 509 +14
Branches 103 106 +3
=========================================
+ Hits 495 509 +14 ☔ View full report in Codecov by Sentry. |
e293d77
to
abda1a7
Compare
@ariebovenberg I also added a small change that allows packages to span multiple directories, which is an important use case for namespace packages. |
Excellent! Ill have a good look this weekend. Note: please update the docs on namespace packages (grep namespace in docs/) and if relevant the docs on module discovery. |
abda1a7
to
1789a08
Compare
@ariebovenberg Shall I just delete the section on namespace packages? Otherwise I have to say that everything works both with and without |
I think the docs on discovery can stay as is: the general idea and all provided examples still hold, and the specific implementation that I changed was not described. |
I've noticed that the end-to-end suite doesn't properly cover the 'no extra sys.path' case, which is actually quite common for users that just run in version 0.18 you get:
with the latest changes becomes really ugly:
The solution should be really easy though: raise To prevent this regression in the future, let's also have a test case like this in def test_python_file_not_in_sys_path(runner: CliRunner):
result = runner.invoke(cli, ["existing/path/to/python_file.py"])
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert result.output == (
"ERROR: Module 'foo' not found.\n\n"
"See slotscheck.rtfd.io/en/latest/discovery.html\n"
"for help resolving common import problems.\n"
) |
Taking a step back, I'm considering putting this new behavior behind a CLI flag, similar to mypy's battle-tested approach Rationale: Namespace packages are (unfortunately) a niche feature and assuming non- imagine a common setup:
Running Perhaps we can kill two birds with one stone by adding a What do you think? |
FWIW, mypy works with implicit namespace packages out of the box. (EDIT: To clarify, it works when using Also note that in a typical case slotscheck will be run from the venv created for the project, where the project itself is installed as an editable package. This means that I did not quite get the idea of your option. Do you mind explaining what it does exactly? Or just feel free to add commits to this PR to implement it. |
ea43705
to
9d6ae69
Compare
It seems I didn't understand mypy's policy fully. I understand it works with namespace packages out of the box, but from the docs I assumed it'd be more conservative:
However in practice it still runs: mkdir -p a/b/c
touch foo.py
mypy a/b/c/foo.py # this runs Your fix brings slotscheck closer to this behavior:
👍 So I guess we're go then for this feature. In general I'm fine with bringing the module discovery capabilities in line with mypy—so long as it remains maintainable. |
One niggle is that I'm getting a test failure locally which doesn't appear on CI:
Any idea what could be causing this? |
That's probably because I've split a part of the change into #230, but you still have |
I noticed the same thing. The default behavior does not match the documentation.
One important difference between mypy and slotscheck is that mypy does not rely on importing modules. As such, it has more flexibility and can use different configuration variables like |
src/slotscheck/discovery.py
Outdated
@@ -308,7 +308,7 @@ def _module_parents( | |||
if pp in sys_path: | |||
return | |||
yield pp | |||
raise ValueError(f"File {p} is outside of PYTHONPATH ({sys.path})") | |||
raise ModuleNotFoundError(f"No module named '{p.stem}'", name=p.stem) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand that you want to reuse the logic in cli.py, but this error can be very misleading. ModuleNotFoundError
means we could not resolve a "dotted.module.name". Here we could not find a module name for a file path. We were going in the opposite direction (file -> module instead of module -> file), which does not normally happen in Python. I suggest that we add a new exception type for this, keep the full file path, and handle it in cli.py similar to ModuleNotFoundError.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, let's do a custom exception 👍
tests/src/test_cli.py
Outdated
@@ -39,6 +39,19 @@ def test_module_doesnt_exist(runner: CliRunner): | |||
) | |||
|
|||
|
|||
def test_python_file_not_in_sys_path(runner: CliRunner, tmpdir): | |||
file = tmpdir / "foo.py" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like tmp_path
is now recommended over tmpdir
: https://docs.pytest.org/en/latest/how-to/tmp_path.html
It returns pathlib.Path
instead of the legacy py.path.local
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Didn't know about this! Excellent! love pathlib
😉 I'm aware of |
src/slotscheck/discovery.py
Outdated
m.name # type: ignore | ||
).submodule_search_locations | ||
return _package(m.name, Path(subdir)) | ||
spec = m.module_finder.find_spec(m.name) # type: ignore |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's qualify this type: ignore
with a category type: ignore[union-attr]
or whatever the error is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in #230
tests/src/test_cli.py
Outdated
@@ -39,6 +39,19 @@ def test_module_doesnt_exist(runner: CliRunner): | |||
) | |||
|
|||
|
|||
def test_python_file_not_in_sys_path(runner: CliRunner, tmpdir): | |||
file = tmpdir / "foo.py" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Didn't know about this! Excellent! love pathlib
src/slotscheck/discovery.py
Outdated
@@ -308,7 +308,7 @@ def _module_parents( | |||
if pp in sys_path: | |||
return | |||
yield pp | |||
raise ValueError(f"File {p} is outside of PYTHONPATH ({sys.path})") | |||
raise ModuleNotFoundError(f"No module named '{p.stem}'", name=p.stem) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, let's do a custom exception 👍
@ariebovenberg Please take another look. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good 👌 . I made a small addition to the docs, PR can be merged pending CI checks
@@ -164,7 +165,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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice.
Instead of relying on
__init__.py
files, stop at the first parent directory that is insys.path
. This gives the shortest module name under which the file can really be imported. (Unless there are name conflicts insys.path
, which is arguably a misconfiguration; this is caught by the location check inmodule_tree
.)