|
| 1 | +import argparse |
| 2 | +import os |
| 3 | +import tempfile |
| 4 | +import typing |
| 5 | +import sys |
| 6 | +import subprocess |
| 7 | +import json |
| 8 | +import pathlib |
| 9 | + |
| 10 | +from typing import Optional, List |
| 11 | +from subprocess import CalledProcessError |
| 12 | + |
| 13 | +from .Check import Check |
| 14 | +from ci_tools.functions import install_into_venv |
| 15 | +from ci_tools.variables import discover_repo_root, in_ci, set_envvar_defaults, set_envvar_defaults |
| 16 | +from ci_tools.environment_exclusions import is_check_enabled, is_typing_ignored |
| 17 | +from ci_tools.functions import get_pip_command |
| 18 | +from ci_tools.logging import logger |
| 19 | + |
| 20 | +PYRIGHT_VERSION = "1.1.287" |
| 21 | +REPO_ROOT = discover_repo_root() |
| 22 | + |
| 23 | +def install_from_main(setup_path: str) -> int: |
| 24 | + path = pathlib.Path(setup_path) |
| 25 | + subdirectory = path.relative_to(REPO_ROOT) |
| 26 | + cwd = os.getcwd() |
| 27 | + with tempfile.TemporaryDirectory() as temp_dir_name: |
| 28 | + os.chdir(temp_dir_name) |
| 29 | + try: |
| 30 | + subprocess.check_call(['git', 'init'], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) |
| 31 | + subprocess.check_call( |
| 32 | + ['git', 'clone', '--no-checkout', 'https://github.com/Azure/azure-sdk-for-python.git', '--depth', '1'], |
| 33 | + stdout=subprocess.DEVNULL, |
| 34 | + stderr=subprocess.STDOUT |
| 35 | + ) |
| 36 | + os.chdir("azure-sdk-for-python") |
| 37 | + subprocess.check_call(['git', 'sparse-checkout', 'init', '--cone'], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) |
| 38 | + subprocess.check_call(['git', 'sparse-checkout', 'set', subdirectory], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) |
| 39 | + subprocess.check_call(['git', 'checkout', 'main'], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) |
| 40 | + |
| 41 | + if not os.path.exists(os.path.join(os.getcwd(), subdirectory)): |
| 42 | + # code is not checked into main yet, nothing to compare |
| 43 | + logger.info(f"{subdirectory} is not checked into main, nothing to compare.") |
| 44 | + return 1 |
| 45 | + |
| 46 | + os.chdir(subdirectory) |
| 47 | + |
| 48 | + command = get_pip_command() + [ |
| 49 | + "install", |
| 50 | + ".", |
| 51 | + "--force-reinstall" |
| 52 | + ] |
| 53 | + |
| 54 | + subprocess.check_call(command, stdout=subprocess.DEVNULL) |
| 55 | + finally: |
| 56 | + os.chdir(cwd) # allow temp dir to be deleted |
| 57 | + return 0 |
| 58 | + |
| 59 | +def get_type_complete_score(commands: typing.List[str], check_pytyped: bool = False) -> float: |
| 60 | + try: |
| 61 | + response = subprocess.run( |
| 62 | + commands, |
| 63 | + check=True, |
| 64 | + capture_output=True, |
| 65 | + ) |
| 66 | + except subprocess.CalledProcessError as e: |
| 67 | + if e.returncode != 1: |
| 68 | + logger.error( |
| 69 | + f"Running verifytypes failed: {e.stderr}. See https://aka.ms/python/typing-guide for information." |
| 70 | + ) |
| 71 | + return -1.0 |
| 72 | + |
| 73 | + report = json.loads(e.output) |
| 74 | + if check_pytyped: |
| 75 | + pytyped_present = report["typeCompleteness"].get("pyTypedPath", None) |
| 76 | + if not pytyped_present: |
| 77 | + logger.error( |
| 78 | + f"No py.typed file was found. See https://aka.ms/python/typing-guide for information." |
| 79 | + ) |
| 80 | + return -1.0 |
| 81 | + return report["typeCompleteness"]["completenessScore"] |
| 82 | + |
| 83 | + # library scores 100% |
| 84 | + report = json.loads(response.stdout) |
| 85 | + return report["typeCompleteness"]["completenessScore"] |
| 86 | + |
| 87 | +class verifytypes(Check): |
| 88 | + def __init__(self) -> None: |
| 89 | + super().__init__() |
| 90 | + |
| 91 | + def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None) -> None: |
| 92 | + """Register the verifytypes check. The verifytypes check installs verifytypes and runs verifytypes against the target package. |
| 93 | + """ |
| 94 | + parents = parent_parsers or [] |
| 95 | + p = subparsers.add_parser("verifytypes", parents=parents, help="Run the verifytypes check to verify type completeness of a package.") |
| 96 | + p.set_defaults(func=self.run) |
| 97 | + |
| 98 | + def run(self, args: argparse.Namespace) -> int: |
| 99 | + """Run the verifytypes check command.""" |
| 100 | + logger.info("Running verifytypes check...") |
| 101 | + |
| 102 | + set_envvar_defaults() |
| 103 | + targeted = self.get_targeted_directories(args) |
| 104 | + |
| 105 | + results: List[int] = [] |
| 106 | + |
| 107 | + for parsed in targeted: |
| 108 | + package_dir = parsed.folder |
| 109 | + package_name = parsed.name |
| 110 | + module = parsed.namespace |
| 111 | + executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir) |
| 112 | + logger.info(f"Processing {package_name} for verifytypes check") |
| 113 | + |
| 114 | + self.install_dev_reqs(executable, args, package_dir) |
| 115 | + |
| 116 | + # install pyright |
| 117 | + try: |
| 118 | + install_into_venv(executable, [f"pyright=={PYRIGHT_VERSION}"], package_dir) |
| 119 | + except CalledProcessError as e: |
| 120 | + logger.error(f"Failed to install pyright: {e}") |
| 121 | + return e.returncode |
| 122 | + |
| 123 | + if in_ci(): |
| 124 | + if not is_check_enabled(package_dir, "verifytypes") or is_typing_ignored(package_name): |
| 125 | + logger.info( |
| 126 | + f"{package_name} opts-out of verifytypes check. See https://aka.ms/python/typing-guide for information." |
| 127 | + ) |
| 128 | + continue |
| 129 | + |
| 130 | + commands = [ |
| 131 | + executable, |
| 132 | + "-m", |
| 133 | + "pyright", |
| 134 | + "--verifytypes", |
| 135 | + module, |
| 136 | + "--ignoreexternal", |
| 137 | + "--outputjson", |
| 138 | + ] |
| 139 | + |
| 140 | + # get type completeness score from current code |
| 141 | + score_from_current = get_type_complete_score(commands, check_pytyped=True) |
| 142 | + if (score_from_current == -1.0): |
| 143 | + results.append(1) |
| 144 | + continue |
| 145 | + |
| 146 | + try: |
| 147 | + subprocess.check_call(commands[:-1]) |
| 148 | + except subprocess.CalledProcessError: |
| 149 | + logger.warning("verifytypes reported issues.") # we don't fail on verifytypes, only if type completeness score worsens from main |
| 150 | + |
| 151 | + if in_ci(): |
| 152 | + # get type completeness score from main |
| 153 | + logger.info( |
| 154 | + "Getting the type completeness score from the code in main..." |
| 155 | + ) |
| 156 | + if (install_from_main(os.path.abspath(package_dir)) > 0): |
| 157 | + continue |
| 158 | + |
| 159 | + score_from_main = get_type_complete_score(commands) |
| 160 | + if (score_from_main == -1.0): |
| 161 | + results.append(1) |
| 162 | + continue |
| 163 | + |
| 164 | + score_from_main_rounded = round(score_from_main * 100, 1) |
| 165 | + score_from_current_rounded = round(score_from_current * 100, 1) |
| 166 | + logger.info("\n-----Type completeness score comparison-----\n") |
| 167 | + logger.info(f"Score in main: {score_from_main_rounded}%") |
| 168 | + # Give a 5% buffer for type completeness score to decrease |
| 169 | + if score_from_current_rounded < score_from_main_rounded - 5: |
| 170 | + logger.error( |
| 171 | + f"\nERROR: The type completeness score of {package_name} has significantly decreased compared to the score in main. " |
| 172 | + f"See the above output for areas to improve. See https://aka.ms/python/typing-guide for information." |
| 173 | + ) |
| 174 | + results.append(1) |
| 175 | + return max(results) if results else 0 |
0 commit comments