Skip to content
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

Implement PEP 561 searching #4403

Closed
wants to merge 105 commits into from
Closed

Implement PEP 561 searching #4403

wants to merge 105 commits into from

Conversation

ethanhs
Copy link
Collaborator

@ethanhs ethanhs commented Dec 22, 2017

This is an implementation of PEP 561.

  • Test functionality
  • Check PEP 561 conformance to resolution order
  • ignore errors in these files
  • support running with alternate Python executable
  • document PEP 561 feature
  • document --python-executable flag
  • test --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.

@ethanhs
Copy link
Collaborator Author

ethanhs commented Dec 22, 2017

Hm, that self test failure is strange. It seems it is picking up the pytest package? I will have to investigate that.

Making PEP 561 compatible packages
**********************************

Packages that supply type information should put a ``py.typed``.
Copy link
Member

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?

Copy link
Collaborator Author

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')
Copy link
Member

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).

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

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])
Copy link
Member

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')
Copy link
Member

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.

Copy link
Collaborator Author

@ethanhs ethanhs Dec 23, 2017

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.")
Copy link
Member

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,
)
Copy link
Member

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')
Copy link
Member

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)):
Copy link
Member

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?

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

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).

Copy link
Contributor

@eric-wieser eric-wieser Jan 16, 2018

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('..')

@JelleZijlstra
Copy link
Member

pytest is picked up because your implementation seems to always accept .py files in site-packages; pytest is implemented as a single file pytest.py. I added some print statements and got found pytest at .../lib/python3.6/site-packages/pytest.py. I'm not sure where in your code it's going wrong.

@ethanhs
Copy link
Collaborator Author

ethanhs commented Dec 23, 2017

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.

@ethanhs
Copy link
Collaborator Author

ethanhs commented Jan 3, 2018

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)
Copy link
Contributor

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

return [
s.rstrip('\n\r')
for stream in streams
for s in str(stream, 'utf8').splitlines()
Copy link
Contributor

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)
Copy link
Contributor

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?

Copy link
Collaborator Author

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
Copy link
Contributor

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?

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

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.

Copy link
Contributor

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.

@carljm
Copy link
Member

carljm commented Mar 5, 2018

There's also #4623 working in the same area.

@eric-wieser
Copy link
Contributor

@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
Copy link
Contributor

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
Copy link
Collaborator Author

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.

Copy link
Contributor

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.

Copy link
Collaborator Author

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
Copy link
Contributor

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?

Copy link
Collaborator Author

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
Copy link
Contributor

@eric-wieser eric-wieser Mar 5, 2018

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]:
Copy link
Contributor

@eric-wieser eric-wieser Mar 5, 2018

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

Copy link
Member

@carljm carljm Mar 5, 2018

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:
Copy link
Contributor

@eric-wieser eric-wieser Mar 5, 2018

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'
Copy link
Contributor

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

@gvanrossum
Copy link
Member

FWIW after I merged master into this, TestPEP561.test_typed_pkg started to fail, like this:

>       assert out == expected_out, err
E       AssertionError: 
E       assert "simple.py:4:...ltins.str]'\n" == "simple.py:4: ...ltins.str]'\n"
E         Skipping 37 identical leading characters in diff, use -v to show
E         - 'builtins.list[builtins.str]'
E         ?            ^^^
E         + 'builtins.tuple[builtins.str]'
E         ?           +++ ^

Copy link
Member

@gvanrossum gvanrossum left a 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:
Copy link
Member

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)
Copy link
Member

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'])
Copy link
Member

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
Copy link
Member

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?

Copy link
Collaborator Author

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
Copy link
Member

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?

@ethanhs
Copy link
Collaborator Author

ethanhs commented Mar 6, 2018

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.

Ok, thanks for the review!

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.

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.

Also -- the meaning of mypy.defaults.PYTHON3_VERSION is getting dubious. Maybe add a comment explaining what it's still used for?

I will add a note about this.

@eric-wieser
Copy link
Contributor

I would really like to see a few smaller PR

Changing the default python version would be another easy one to split out, and would remove many of the tests from this diff

gvanrossum pushed a commit that referenced this pull request Mar 6, 2018
Split out of #4403, these are helpers, and will eventually be used by other tests.
gvanrossum pushed a commit that referenced this pull request Mar 6, 2018
This was discovered in #4403. I thought I'd add it while I am splitting changes out of that PR. It was originally introduced in #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)
gvanrossum pushed a commit that referenced this pull request Mar 6, 2018
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.
@ethanhs
Copy link
Collaborator Author

ethanhs commented Mar 7, 2018

Okay, so with #4692, all but the core searching should be split out of this PR.

@eric-wieser
Copy link
Contributor

eric-wieser commented Mar 7, 2018

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

@ethanhs ethanhs mentioned this pull request Mar 7, 2018
9 tasks
@ethanhs
Copy link
Collaborator Author

ethanhs commented Mar 7, 2018

Closing in favor of #4693 because this has a lot of noise not needed, and I'd rather not rebase over 100 commits.

@ethanhs ethanhs closed this Mar 7, 2018
yedpodtrzitko pushed a commit to kiwicom/mypy that referenced this pull request Mar 15, 2018
Split out of python#4403, these are helpers, and will eventually be used by other tests.
yedpodtrzitko pushed a commit to kiwicom/mypy that referenced this pull request Mar 15, 2018
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)
yedpodtrzitko pushed a commit to kiwicom/mypy that referenced this pull request Mar 15, 2018
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants