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

Add --python-executable and --no-infer-executable flags #4692

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/source/command_line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,21 @@ Here are some more useful flags:
updates the cache, but regular incremental mode ignores cache files
written by quick mode.

- ``--python-executable EXECUTABLE`` This flag will attempt to set
``--python-version`` if not already set based on the interpreter given.

- ``--python-version X.Y`` will make mypy typecheck your code as if it were
run under Python version X.Y. Without this option, mypy will default to using
whatever version of Python is running mypy. Note that the ``-2`` and
``--py2`` flags are aliases for ``--python-version 2.7``. See
:ref:`version_and_platform_checks` for more about this feature.
:ref:`version_and_platform_checks` for more about this feature. This flag
will attempt to find a Python executable of the corresponding version. If
you'd like to disable this, see ``--no-infer-executable`` below.

- ``--no-infer-executable`` will disable searching for a usable Python
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this flag makes any sense without PEP561, and that --no-site-packages was a better name in that scenario, as it indicated the purpose of the executable

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I think I might agree in principle with the first (but I split this out for ease of review mostly).

As for the flag name, I'm not so sure. If the executable is ever used for anything other than PEP 561, the --no-site-packages flag would be misleading. It is misleading now anyway, it doesn't actually disable searching, it sets options.python_executable to None, which happens to mean that searching for PEP 561 packages is not done.

Copy link
Contributor

@eric-wieser eric-wieser Mar 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it sets options.python_executable to None, which happens to mean that searching for PEP 561 packages is not done.

You've got this backwards. options.python_executable is None is an implementation-detail of how we represent "don't do pep561 searching", chosen over storing a redundant boolean alongside it. We could add a @property def do_pep561_searching to Options to make that more explicit, I suppose.

We shouldn't be exposing that detail in the command line arguments.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I suppose from a user perspective it would be better to name it --no-site-packages.

executable based on the Python version mypy is using to type check code.
Use this flag if mypy cannot find a Python executable for the version of
Python being checked, and don't need mypy to use an executable.

- ``--platform PLATFORM`` will make mypy typecheck your code as if it were
run under the the given operating system. Without this option, mypy will
Expand Down Expand Up @@ -447,6 +457,8 @@ For the remaining flags you can read the full ``mypy -h`` output.

Command line flags are liable to change between releases.

.. _PEP 561: https://www.python.org/dev/peps/pep-0561/

.. _integrating-mypy:

Integrating mypy into another Python application
Expand Down
86 changes: 84 additions & 2 deletions mypy/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Mypy type checker command line tool."""

import argparse
import ast
import configparser
import fnmatch
import os
import re
import subprocess
import sys
import time

Expand Down Expand Up @@ -205,6 +207,72 @@ def invert_flag_name(flag: str) -> str:
return '--no-{}'.format(flag[2:])


class PythonExecutableInferenceError(Exception):
"""Represents a failure to infer the version or executable while searching."""


if sys.platform == 'win32':
def python_executable_prefix(v: str) -> List[str]:
return ['py', '-{}'.format(v)]
else:
def python_executable_prefix(v: str) -> List[str]:
return ['python{}'.format(v)]


def _python_version_from_executable(python_executable: str) -> Tuple[int, int]:
try:
check = subprocess.check_output([python_executable, '-c',
'import sys; print(repr(sys.version_info[:2]))'],
stderr=subprocess.STDOUT).decode()
return ast.literal_eval(check)
except (subprocess.CalledProcessError, FileNotFoundError):
raise PythonExecutableInferenceError(
'Error: invalid Python executable {}'.format(python_executable))


def _python_executable_from_version(python_version: Tuple[int, int]) -> str:
if sys.version_info[:2] == python_version:
return sys.executable
str_ver = '.'.join(map(str, python_version))
print(str_ver)
try:
sys_exe = subprocess.check_output(python_executable_prefix(str_ver) +
['-c', 'import sys; print(sys.executable)'],
stderr=subprocess.STDOUT).decode().strip()
return sys_exe
except (subprocess.CalledProcessError, FileNotFoundError):
raise PythonExecutableInferenceError(
'Error: failed to find a Python executable matching version {},'
' perhaps try --python-executable, or --no-infer-executable?'.format(python_version))


def infer_python_version_and_executable(options: Options,
special_opts: argparse.Namespace
) -> Options:
# Infer Python version and/or executable if one is not given
if special_opts.python_executable is not None and special_opts.python_version is not None:
py_exe_ver = _python_version_from_executable(special_opts.python_executable)
if py_exe_ver != special_opts.python_version:
raise PythonExecutableInferenceError(
'Python version {} did not match executable {}, got version {}.'.format(
special_opts.python_version, special_opts.python_executable, py_exe_ver
))
else:
options.python_version = special_opts.python_version
options.python_executable = special_opts.python_executable
elif special_opts.python_executable is None and special_opts.python_version is not None:
options.python_version = special_opts.python_version
py_exe = None
if not special_opts.no_executable:
py_exe = _python_executable_from_version(special_opts.python_version)
options.python_executable = py_exe
elif special_opts.python_version is None and special_opts.python_executable is not None:
options.python_version = _python_version_from_executable(
special_opts.python_executable)
options.python_executable = special_opts.python_executable
return options


def process_options(args: List[str],
require_targets: bool = True,
server_options: bool = False,
Expand Down Expand Up @@ -255,10 +323,16 @@ def add_invertible_flag(flag: str,
parser.add_argument('-V', '--version', action='version',
version='%(prog)s ' + __version__)
parser.add_argument('--python-version', type=parse_version, metavar='x.y',
help='use Python x.y')
help='use Python x.y', dest='special-opts:python_version')
parser.add_argument('--python-executable', action='store', metavar='EXECUTABLE',
help="Python executable which will be used in typechecking.",
dest='special-opts:python_executable')
parser.add_argument('--no-infer-executable', action='store_true',
dest='special-opts:no_executable',
help="Do not infer a Python executable based on the version.")
parser.add_argument('--platform', action='store', metavar='PLATFORM',
help="typecheck special-cased code for the given OS platform "
"(defaults to sys.platform).")
"(defaults to sys.platform).")
parser.add_argument('-2', '--py2', dest='python_version', action='store_const',
const=defaults.PYTHON2_VERSION, help="use Python 2 mode")
parser.add_argument('--ignore-missing-imports', action='store_true',
Expand Down Expand Up @@ -482,6 +556,14 @@ 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:
options = infer_python_version_and_executable(options, special_opts)
except PythonExecutableInferenceError as e:
parser.error(str(e))

if special_opts.no_executable:
options.python_executable = None

# Check for invalid argument combinations.
if require_targets:
code_methods = sum(bool(c) for c in [special_opts.modules,
Expand Down
1 change: 1 addition & 0 deletions mypy/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def __init__(self) -> None:
# -- build options --
self.build_type = BuildType.STANDARD
self.python_version = sys.version_info[:2] # type: Tuple[int, int]
self.python_executable = sys.executable # type: Optional[str]
self.platform = sys.platform
self.custom_typing_module = None # type: Optional[str]
self.custom_typeshed_dir = None # type: Optional[str]
Expand Down
1 change: 1 addition & 0 deletions mypy/test/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ def parse_options(program_text: str, testcase: DataDrivenTestCase,
flag_list = None
if flags:
flag_list = flags.group(1).split()
flag_list.append('--no-infer-executable') # the tests shouldn't need an installed Python
targets, options = process_options(flag_list, require_targets=False)
if targets:
# TODO: support specifying targets via the flags pragma
Expand Down
51 changes: 50 additions & 1 deletion mypy/test/testargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
defaults, and that argparse doesn't assign any new members to the Options
object it creates.
"""
import argparse
import sys

import pytest # type: ignore

from mypy.test.helpers import Suite, assert_equal
from mypy.options import Options
from mypy.main import process_options
from mypy.main import (process_options, PythonExecutableInferenceError,
infer_python_version_and_executable)


class ArgSuite(Suite):
Expand All @@ -17,3 +22,47 @@ def test_coherence(self) -> None:
# FIX: test this too. Requires changing working dir to avoid finding 'setup.cfg'
options.config_file = parsed_options.config_file
assert_equal(options, parsed_options)

def test_executable_inference(self) -> None:
"""Test the --python-executable flag with --python-version"""
sys_ver_str = '.'.join(map(str, sys.version_info[:2]))

base = ['file.py'] # dummy file

# test inference given one (infer the other)
matching_version = base + ['--python-version={}'.format(sys_ver_str)]
_, options = process_options(matching_version)
assert options.python_version == sys.version_info[:2]
assert options.python_executable == sys.executable

matching_version = base + ['--python-executable={}'.format(sys.executable)]
_, options = process_options(matching_version)
assert options.python_version == sys.version_info[:2]
assert options.python_executable == sys.executable

# test inference given both
matching_version = base + ['--python-version={}'.format(sys_ver_str),
'--python-executable={}'.format(sys.executable)]
_, options = process_options(matching_version)
assert options.python_version == sys.version_info[:2]
assert options.python_executable == sys.executable

# test that we error if the version mismatch
# argparse sys.exits on a parser.error, we need to check the raw inference function
options = Options()

special_opts = argparse.Namespace()
special_opts.python_executable = sys.executable
special_opts.python_version = (2, 10) # obviously wrong
special_opts.no_executable = None
with pytest.raises(PythonExecutableInferenceError) as e:
options = infer_python_version_and_executable(options, special_opts)
assert str(e.value) == 'Python version (2, 10) did not match executable {}, got' \
' version {}.'.format(sys.executable, str(sys.version_info[:2]))

# test that --no-infer-executable will disable executable inference
matching_version = base + ['--python-version={}'.format(sys_ver_str),
'--no-infer-executable']
_, options = process_options(matching_version)
assert options.python_version == sys.version_info[:2]
assert options.python_executable is None
1 change: 1 addition & 0 deletions mypy/test/testcmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def test_python_cmdline(testcase: DataDrivenTestCase) -> None:
file.write('{}\n'.format(s))
args = parse_args(testcase.input[0])
args.append('--show-traceback')
args.append('--no-infer-executable')
# Type check the program.
fixed = [python3_path,
os.path.join(testcase.old_cwd, 'scripts', 'mypy')]
Expand Down
2 changes: 1 addition & 1 deletion mypy/test/testpythoneval.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_python_evaluation(testcase: DataDrivenTestCase) -> None:
version.
"""
assert testcase.old_cwd is not None, "test was not properly set up"
mypy_cmdline = ['--show-traceback']
mypy_cmdline = ['--show-traceback', '--no-infer-executable']
py2 = testcase.name.lower().endswith('python2')
if py2:
mypy_cmdline.append('--py2')
Expand Down
1 change: 1 addition & 0 deletions runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def add_mypy_cmd(self, name: str, mypy_args: List[str], cwd: Optional[str] = Non
return
args = [sys.executable, self.mypy] + mypy_args
args.append('--show-traceback')
args.append('--no-infer-executable')
self.waiter.add(LazySubprocess(full_name, args, cwd=cwd, env=self.env))

def add_mypy(self, name: str, *args: str, cwd: Optional[str] = None) -> None:
Expand Down