-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Implement PEP 561 searching #4403
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
Conversation
The tests install a typed package and verify that the modules can be found correctly. It then does the same for a stub package.
Hm, that self test failure is strange. It seems it is picking up the pytest package? I will have to investigate that. |
docs/source/installed_packages.rst
Outdated
Making PEP 561 compatible packages | ||
********************************** | ||
|
||
Packages that supply type information should put a ``py.typed``. |
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.
... in their package directory. Maybe we should add an example of how to do this with setup.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.
Yes an example in the docs seems like a good idea, I will add that.
mypy/build.py
Outdated
|
||
def call_python(python: str, command: str) -> str: | ||
return check_output(python + ' -c ' + command, | ||
stderr=STDOUT).decode('UTF-8') |
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.
Why do you need to redirect stderr? And is utf-8 safe? Maybe we should pass PYTHONIOENCODING (https://docs.python.org/3/using/cmdline.html#envvar-PYTHONIOENCODING).
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 originally wanted to handle error text, but I think it may not be needed after all.
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'm not the most familiar with best practices for encoding/decoding, but wouldn't the encoding of sys.stdout
be most proper?
mypy/build.py
Outdated
if not check.startswith('Python'): | ||
return package_dirs | ||
# If we have a working python executable, query information from it | ||
output = call_python(python, SITE_PACKAGE_COMMANDS[0]) |
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 clearer to have two named constants instead of indexing with 0 and 1.
mypy/build.py
Outdated
for pkg_dir in package_dirs: | ||
stub_name = components[0] + '_stubs' | ||
typed_file = os.path.join(pkg_dir, components[0], 'py.typed') | ||
stub_typed_file = os.path.join(pkg_dir, stub_name, 'py.typed') |
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 wasn't clear to me from reading PEP 561 that stub packages also need a py.typed file.
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.
Hm, I intended the section saying
Package maintainers who wish to support type checking of their code MUST add a marker file named py.typed to their package supporting typing.
to mean all packages should add it (in my mind including stub only packages). But I suppose having the _stubs
suffix is rather indicative that it supports typing. I don't entirely have a preference here, but it seems more consistent to have the file in stub packages.
mypy/main.py
Outdated
@@ -245,6 +245,7 @@ def add_invertible_flag(flag: str, | |||
version='%(prog)s ' + __version__) | |||
parser.add_argument('--python-version', type=parse_version, metavar='x.y', | |||
help='use Python x.y') | |||
parser.add_argument('--python', action='store', help="Point to a Python executable.") |
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.
This is rather laconic. Maybe "Python executable whose installed packages will be used in typechecking".
package_data={'typedpkg': ['py.typed']}, | ||
packages=['typedpkg'], | ||
include_package_data=True, | ||
) |
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.
add a newline
|
||
def ex(a: Iterable[str]) -> Tuple[str, ...]: | ||
"""Example typed package. This intentionally has an error.""" | ||
return a + ('Hello') |
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.
another missing newline (also in some files further down)
mypy/build.py
Outdated
if path: | ||
if any((path.startswith(d) for d in package_dirs_cache)): |
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.
Couldn't this break if the package_dirs_cache contains /a/b and the package is in a/bc?
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.
Good point, I will fix this.
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'm actually less concerned about this. Since the directory will always be of the form eg /path/to/site-packages
, any string concatenated to that will either be wrong (/path/to/site-packagesfoo
?), or unsafe (as in it's unsafe to add site-packages to your path).
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.
os.path.commonpath([d, path]) == d
would be correct, as would not os.path.relpath(path, d).startswith('..')
|
Yes I found that out earlier. It is due to my adding of site-packages to the candidate directories if pytest is not found otherwise. I have fixed that and will add some tests for scenarios where modules shouldn't be found. |
Also added newlines and refactored call_python in build and get_package_dirs.
Bah testing installed packages is maddening. It seems I need to refactor find_module a bit more seriously as it isn't the most malleable to fit to finding typed packages. |
mypy/build.py
Outdated
|
||
|
||
def call_python(python: str, command: str) -> str: | ||
return check_output(python + ' -c ' + command).decode(sys.stdout.encoding) |
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.
Use check_output([python, '-c', command])
to save a level of quoting
mypy/test/helpers.py
Outdated
return [ | ||
s.rstrip('\n\r') | ||
for stream in streams | ||
for s in str(stream, 'utf8').splitlines() |
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.
stream.decode('utf8')
is a much more common spelling
mypy/build.py
Outdated
if os.path.isfile(stub_typed_file): | ||
components[0] = stub_name | ||
rest = components[:-1] | ||
path = os.path.join(pkg_dir, *rest) |
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.
Should this update dir_chain
?
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 doesn't necessarily have to, as dir_chain
isn't used after this, unless I am overlooking something.
mypy/build.py
Outdated
path = os.path.join(pkg_dir, dir_chain) | ||
dirs.append(path) | ||
|
||
find_module_dir_cache[dir_chain] = dirs |
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's used right here, isn't it?
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.
Ah, good point, I will fix this.
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.
Actually looking again we don't want to change this as it looks up the unmutated dir_cache
.
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.
Would be good if you would somehow make that clearer, perhaps ideal_dir
vs real_dir
or something.
There's also #4623 working in the same area. |
@carljm: The problem is that a lot of the caching just about worked when there were no extra arguments, but is now broken because it's not also keyed on those new arguments. The refactor of find_module might have gone a little too far |
mypy/build.py
Outdated
path = os.path.join(pkg_dir, dir_chain) | ||
dirs.append(path) | ||
|
||
find_module_dir_cache[dir_chain, lib_path] = dirs |
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.
This causes the list to grown on every invocation - should the third party section be inside the cache-checking if?
find_module_isdir_cache[pathitem, dir_chain] = isdir | ||
if isdir: | ||
dirs.append(dir) | ||
find_module_dir_cache[dir_chain, lib_path] = dirs |
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.
@eric-wieser I realized I was essentially mutating the dir cache to add site packages then stripping that out. So I refactored things to not do that, which is much nicer.
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.
Much better!
Everything from here to the top of this function would be better as a separate helper function, because it would then be obvious that the caching is correct.
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.
Left a TODO.
mypy/build.py
Outdated
third_party_dirs.append(path) | ||
|
||
return tuple(third_party_dirs + | ||
find_module_dir_cache[dir_chain, lib_path]), components |
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.
Isn't this the same as the components
that was passed in? Why return it?
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.
Ah, I meant to remove this. Doing that now.
mypy/build.py
Outdated
path = os.path.join(pkg_dir, *rest) | ||
if os.path.isdir(path): | ||
third_party_dirs.append(path) | ||
components[0] = prefix |
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.
Why do you modify components
? This would be clearer as:
stub_components = [stub_name] + components[1:]
path = os.path.join(pkg_dir, *stub_components[:-1])
Rather than mutating the components
list
mypy/build.py
Outdated
|
||
|
||
def find_module_in_base_dirs(id: str, candidate_base_dirs: Iterable[str], | ||
last_component: str) -> Optional[str]: |
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.
This isn't really correct on python 2 - you need to walk up the tree looking for init.py files at each level, rather than assuming that only the last stage matters
Edit: I see that happens in verify_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.
Note: this is one of the correctness issues for mypy's import implementation (that should be addressed in a separate PR, not here). It's not even enough to verify __init__.py
at each level, you really have to go level-by-level from the top down like the real implementation does, otherwise if you are importing a.b.c
and you have two different a/__init__.py
on the path, you might "find" a/b/c.py
under the wrong a/
directory and think everything is fine, when really the code you found is not importable at runtime.
# many elements of lib_path don't even have a subdirectory 'foo/bar'. Discover | ||
# that only once and cache it for when we look for modules like 'foo.bar.blah' | ||
# that will require the same subdirectory. | ||
if (id, python_executable, lib_path) not in find_module_cache: |
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.
Could do key = (id, python_executable, tuple(lib_path))
to avoid writing this three times.
# many elements of lib_path don't even have a subdirectory 'foo/bar'. Discover | ||
# that only once and cache it for when we look for modules like 'foo.bar.blah' | ||
# that will require the same subdirectory. | ||
if (id, python_executable, lib_path) not in find_module_cache: | ||
components = id.split('.') | ||
dir_chain = os.sep.join(components[:-1]) # e.g., 'foo/bar' |
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.
nit: may as well do this line within find_base_dirs
, since you don't use the result anywhere else
FWIW after I merged master into this,
|
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've not dared to review the core of the code in build.py yet, but I trust that Eric has looked at that thoroughly, so here are a few quick comments about the rest.
In the sake of progress I would really like to see a few smaller PRs that are easier to review and merge (example: splitting run() and split_lines() out of testpythoneval.py), perhaps leaving the big fireworks for last. Also please try to have less repetition in the test files.
Also -- the meaning of mypy.defaults.PYTHON3_VERSION
is getting dubious. Maybe add a comment explaining what it's still used for?
@@ -482,6 +528,33 @@ def add_invertible_flag(flag: str, | |||
print("Warning: --no-fast-parser no longer has any effect. The fast parser " | |||
"is now mypy's default and only parser.") | |||
|
|||
try: |
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.
This is a pretty complex block of logic -- can you at least split it out into a helper function?
@@ -53,6 +53,8 @@ def build(self, source: str) -> Tuple[List[str], Optional[Dict[str, MypyFile]]]: | |||
options.use_builtins_fixtures = True | |||
options.show_traceback = True | |||
options.cache_dir = os.devnull | |||
options.python_version = (3, 6) |
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.
That seems a bit arbitrary. Why not mypy.defaults.PYTHON3_VERSION
? Or sys.version_info[:2]
? (Then again I realize this is very far from your code -- the feature that this tests isn't even announced yet. :-)
Ditto for testmerge.py, and half-ditto for testsemanal.py.
@@ -256,7 +261,7 @@ def add_stubs(driver: Driver) -> None: | |||
module = file_to_module(f[len(stubdir) + 1:]) | |||
modules.add(module) | |||
|
|||
driver.add_mypy_modules('stubs', sorted(modules)) | |||
driver.add_mypy_modules('stubs', sorted(modules), extra_args=['--python-version=3.5']) |
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.
Why 3.5 here?
@@ -184,7 +184,7 @@ async def f() -> None: | |||
[typing fixtures/typing-full.pyi] | |||
|
|||
[case testAsyncForComprehension] | |||
# flags: --fast-parser --python-version 3.6 | |||
# flags: --fast-parser --python-version 3.6 --no-site-packages |
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's unfortunate you had to add --no-site-packages
to so many test files. Maybe it's better to set some more conservative default options for all data-driven testcases in one place?
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 think I can add it somewhere in testcheck.py
, I'll take a look.
@@ -581,7 +581,7 @@ m.py:6: error: Explicit "Any" is not allowed | |||
m.py:9: error: Explicit "Any" is not allowed | |||
|
|||
[case testDisallowAnyExplicitVarDeclaration] | |||
# cmd: mypy m.py | |||
# cmd: mypy --python-version=3.6 m.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.
Similar for the Python version here. Maybe all these tests should be run with 3.6 unless they override it?
Ok, thanks for the review!
That makes a lot of sense, I will try to split things out into smaller PRs. With "less repetition in the test files" I presume you mean that the sprinkling of flags around test cases is less than ideal? I somewhat agree, however this is a reflection of the change of mypy defaulting to sys.executable. Quite a few tests depend on syntax or libraries that may not be available in the running Python. If we pin the few tests that have minimum version dependencies, my thinking was that we would get the best coverage of testing across supported Python versions (so that tests without hard dependencies will be checked on all Python versions). Since modifying the default off of 3.6 did find some real bugs in mypy and typeshed, I think it would be valuable to have this.
I will add a note about this. |
Changing the default python version would be another easy one to split out, and would remove many of the tests from this diff |
Split out of #4403, these are helpers, and will eventually be used by other tests.
This sets the default Python version used for type checking to `sys.version_info`. Fixes #4620. The design of this is such that we set tests to default to the running Python whenever possible, but modify tests that use new syntax and libraries to run on Python 3.5 or 3.6. Example output of failing tests on 3.4 before test changes https://gist.github.com/ethanhs/f782bec70eab0678d9e869465b40a571#file-output-log-L512. This was split out of #4403.
Okay, so with #4692, all but the core searching should be split out of this PR. |
I think I'd be inclined to leave the executable-finding in the same PR, as the feature doesn't make a huge amount of sense without PEP561 |
Closing in favor of #4693 because this has a lot of noise not needed, and I'd rather not rebase over 100 commits. |
Split out of python#4403, these are helpers, and will eventually be used by other tests.
This was discovered in python#4403. I thought I'd add it while I am splitting changes out of that PR. It was originally introduced in python#4526 but not added to the docs it seems. (the rest of the diff is due to my using an actual output of the help command from master)
This sets the default Python version used for type checking to `sys.version_info`. Fixes python#4620. The design of this is such that we set tests to default to the running Python whenever possible, but modify tests that use new syntax and libraries to run on Python 3.5 or 3.6. Example output of failing tests on 3.4 before test changes https://gist.github.com/ethanhs/f782bec70eab0678d9e869465b40a571#file-output-log-L512. This was split out of python#4403.
This is an implementation of PEP 561.
--python-executable
flag--python-executable
flag--python-version
flag sets--python-executable
if possible.This branch should work as intended and be feature complete, but it is possible that I've overlooked something/made a mistake.
(Picked up from #4278 for simplicity).
Fixes #2625, #1190, #965.