testpep561.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. from __future__ import annotations
  2. import os
  3. import re
  4. import subprocess
  5. import sys
  6. import tempfile
  7. from contextlib import contextmanager
  8. from typing import Iterator
  9. import filelock
  10. import mypy.api
  11. from mypy.test.config import package_path, pip_lock, pip_timeout, test_temp_dir
  12. from mypy.test.data import DataDrivenTestCase, DataSuite
  13. from mypy.test.helpers import assert_string_arrays_equal, perform_file_operations
  14. # NOTE: options.use_builtins_fixtures should not be set in these
  15. # tests, otherwise mypy will ignore installed third-party packages.
  16. class PEP561Suite(DataSuite):
  17. files = ["pep561.test"]
  18. base_path = "."
  19. def run_case(self, test_case: DataDrivenTestCase) -> None:
  20. test_pep561(test_case)
  21. @contextmanager
  22. def virtualenv(python_executable: str = sys.executable) -> Iterator[tuple[str, str]]:
  23. """Context manager that creates a virtualenv in a temporary directory
  24. Returns the path to the created Python executable
  25. """
  26. with tempfile.TemporaryDirectory() as venv_dir:
  27. proc = subprocess.run(
  28. [python_executable, "-m", "venv", venv_dir], cwd=os.getcwd(), capture_output=True
  29. )
  30. if proc.returncode != 0:
  31. err = proc.stdout.decode("utf-8") + proc.stderr.decode("utf-8")
  32. raise Exception("Failed to create venv.\n" + err)
  33. if sys.platform == "win32":
  34. yield venv_dir, os.path.abspath(os.path.join(venv_dir, "Scripts", "python"))
  35. else:
  36. yield venv_dir, os.path.abspath(os.path.join(venv_dir, "bin", "python"))
  37. def upgrade_pip(python_executable: str) -> None:
  38. """Install pip>=21.3.1. Required for editable installs with PEP 660."""
  39. if (
  40. sys.version_info >= (3, 11)
  41. or (3, 10, 3) <= sys.version_info < (3, 11)
  42. or (3, 9, 11) <= sys.version_info < (3, 10)
  43. or (3, 8, 13) <= sys.version_info < (3, 9)
  44. ):
  45. # Skip for more recent Python releases which come with pip>=21.3.1
  46. # out of the box - for performance reasons.
  47. return
  48. install_cmd = [python_executable, "-m", "pip", "install", "pip>=21.3.1"]
  49. try:
  50. with filelock.FileLock(pip_lock, timeout=pip_timeout):
  51. proc = subprocess.run(install_cmd, capture_output=True, env=os.environ)
  52. except filelock.Timeout as err:
  53. raise Exception(f"Failed to acquire {pip_lock}") from err
  54. if proc.returncode != 0:
  55. raise Exception(proc.stdout.decode("utf-8") + proc.stderr.decode("utf-8"))
  56. def install_package(
  57. pkg: str, python_executable: str = sys.executable, editable: bool = False
  58. ) -> None:
  59. """Install a package from test-data/packages/pkg/"""
  60. working_dir = os.path.join(package_path, pkg)
  61. with tempfile.TemporaryDirectory() as dir:
  62. install_cmd = [python_executable, "-m", "pip", "install"]
  63. if editable:
  64. install_cmd.append("-e")
  65. install_cmd.append(".")
  66. # Note that newer versions of pip (21.3+) don't
  67. # follow this env variable, but this is for compatibility
  68. env = {"PIP_BUILD": dir}
  69. # Inherit environment for Windows
  70. env.update(os.environ)
  71. try:
  72. with filelock.FileLock(pip_lock, timeout=pip_timeout):
  73. proc = subprocess.run(install_cmd, cwd=working_dir, capture_output=True, env=env)
  74. except filelock.Timeout as err:
  75. raise Exception(f"Failed to acquire {pip_lock}") from err
  76. if proc.returncode != 0:
  77. raise Exception(proc.stdout.decode("utf-8") + proc.stderr.decode("utf-8"))
  78. def test_pep561(testcase: DataDrivenTestCase) -> None:
  79. """Test running mypy on files that depend on PEP 561 packages."""
  80. assert testcase.old_cwd is not None, "test was not properly set up"
  81. python = sys.executable
  82. assert python is not None, "Should be impossible"
  83. pkgs, pip_args = parse_pkgs(testcase.input[0])
  84. mypy_args = parse_mypy_args(testcase.input[1])
  85. editable = False
  86. for arg in pip_args:
  87. if arg == "editable":
  88. editable = True
  89. else:
  90. raise ValueError(f"Unknown pip argument: {arg}")
  91. assert pkgs, "No packages to install for PEP 561 test?"
  92. with virtualenv(python) as venv:
  93. venv_dir, python_executable = venv
  94. if editable:
  95. # Editable installs with PEP 660 require pip>=21.3
  96. upgrade_pip(python_executable)
  97. for pkg in pkgs:
  98. install_package(pkg, python_executable, editable)
  99. cmd_line = list(mypy_args)
  100. has_program = not ("-p" in cmd_line or "--package" in cmd_line)
  101. if has_program:
  102. program = testcase.name + ".py"
  103. with open(program, "w", encoding="utf-8") as f:
  104. for s in testcase.input:
  105. f.write(f"{s}\n")
  106. cmd_line.append(program)
  107. cmd_line.extend(["--no-error-summary", "--hide-error-codes"])
  108. if python_executable != sys.executable:
  109. cmd_line.append(f"--python-executable={python_executable}")
  110. steps = testcase.find_steps()
  111. if steps != [[]]:
  112. steps = [[]] + steps # type: ignore[assignment]
  113. for i, operations in enumerate(steps):
  114. perform_file_operations(operations)
  115. output = []
  116. # Type check the module
  117. out, err, returncode = mypy.api.run(cmd_line)
  118. # split lines, remove newlines, and remove directory of test case
  119. for line in (out + err).splitlines():
  120. if line.startswith(test_temp_dir + os.sep):
  121. output.append(line[len(test_temp_dir + os.sep) :].rstrip("\r\n"))
  122. else:
  123. # Normalize paths so that the output is the same on Windows and Linux/macOS.
  124. line = line.replace(test_temp_dir + os.sep, test_temp_dir + "/")
  125. output.append(line.rstrip("\r\n"))
  126. iter_count = "" if i == 0 else f" on iteration {i + 1}"
  127. expected = testcase.output if i == 0 else testcase.output2.get(i + 1, [])
  128. assert_string_arrays_equal(
  129. expected,
  130. output,
  131. f"Invalid output ({testcase.file}, line {testcase.line}){iter_count}",
  132. )
  133. if has_program:
  134. os.remove(program)
  135. def parse_pkgs(comment: str) -> tuple[list[str], list[str]]:
  136. if not comment.startswith("# pkgs:"):
  137. return ([], [])
  138. else:
  139. pkgs_str, *args = comment[7:].split(";")
  140. return ([pkg.strip() for pkg in pkgs_str.split(",")], [arg.strip() for arg in args])
  141. def parse_mypy_args(line: str) -> list[str]:
  142. m = re.match("# flags: (.*)$", line)
  143. if not m:
  144. return [] # No args; mypy will spit out an error.
  145. return m.group(1).split()
  146. def test_mypy_path_is_respected() -> None:
  147. assert False
  148. packages = "packages"
  149. pkg_name = "a"
  150. with tempfile.TemporaryDirectory() as temp_dir:
  151. old_dir = os.getcwd()
  152. os.chdir(temp_dir)
  153. try:
  154. # Create the pkg for files to go into
  155. full_pkg_name = os.path.join(temp_dir, packages, pkg_name)
  156. os.makedirs(full_pkg_name)
  157. # Create the empty __init__ file to declare a package
  158. pkg_init_name = os.path.join(temp_dir, packages, pkg_name, "__init__.py")
  159. open(pkg_init_name, "w", encoding="utf8").close()
  160. mypy_config_path = os.path.join(temp_dir, "mypy.ini")
  161. with open(mypy_config_path, "w") as mypy_file:
  162. mypy_file.write("[mypy]\n")
  163. mypy_file.write(f"mypy_path = ./{packages}\n")
  164. with virtualenv() as venv:
  165. venv_dir, python_executable = venv
  166. cmd_line_args = []
  167. if python_executable != sys.executable:
  168. cmd_line_args.append(f"--python-executable={python_executable}")
  169. cmd_line_args.extend(["--config-file", mypy_config_path, "--package", pkg_name])
  170. out, err, returncode = mypy.api.run(cmd_line_args)
  171. assert returncode == 0
  172. finally:
  173. os.chdir(old_dir)