Files
paddlepaddle--paddle/tools/type_checking.py
T
2026-07-13 12:40:42 +08:00

465 lines
14 KiB
Python

# Copyright (c) 2024 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# We type-check the `Example` codes from docstring, like:
# 1. checking from input `apis`
# > python type_checking.py paddle.abs paddle.abs_ paddle.sin
# 2. checking from spec, with increment api
# > python type_checking.py
# 3. checking from spec, with full apis
# > python type_checking.py --full-test
# `--full-test` and `apis` should not be set at the same time.
from __future__ import annotations
import argparse
import doctest
import os
import pathlib
import pty
import re
import subprocess
import sys
import tempfile
from abc import abstractmethod
from dataclasses import dataclass, field
from typing import Any
from sampcd_processor_utils import (
extract_code_blocks_from_docstr,
get_docstring,
init_logger as init_samplecode_logger,
)
COLOR_CYAN = '\033[96m'
COLOR_RED = '\033[91m'
COLOR_BOLD = '\033[1m'
COLOR_CLEAR = '\033[0m'
class TypeCheckingLogger:
def __init__(self, debug: bool = False) -> None:
self._debug = debug
def set_debug(self, debug: bool) -> None:
self._debug = debug
def debug(self, msg: str) -> None:
if self._debug:
print(msg)
def info(self, msg: str) -> None:
print(msg)
def error(self, msg: str) -> None:
print(msg)
logger = TypeCheckingLogger()
class TypeChecker:
style: str = 'google'
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
@abstractmethod
def run_on_directory(
self,
dir: pathlib.Path,
filename_to_codeblock_identifier: dict[str, str],
) -> tuple[dict[str, str], str] | None:
pass
@abstractmethod
def print_summary(
self,
error_messages: dict[str, str],
raw_summary: str,
whl_error: list[str],
) -> None:
pass
@dataclass
class TestResult:
api_name: str
msg: str
fail: bool = False
extra_info: dict[str, Any] = field(default_factory=dict)
def pty_run(
command: list[str], env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
"""Run a command in a pseudo-terminal to capture colored output."""
master_fd, slave_fd = pty.openpty()
try:
# Start subprocess with its stdout/stderr attached to the pty slave.
# Do not use text=True here because we're passing raw fds; we'll decode
# the bytes we read from master_fd ourselves.
proc = subprocess.Popen(
command,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
env=env,
)
# Parent no longer needs the slave fd — close it so the child can
# receive EOF properly when it exits.
try:
os.close(slave_fd)
slave_fd = -1
except OSError:
pass
stdout_chunks: list[str] = []
while True:
try:
chunk = os.read(master_fd, 4096)
if not chunk:
break
stdout_chunks.append(chunk.decode(errors="ignore"))
except OSError:
break
proc.wait()
stdout = ''.join(stdout_chunks)
return subprocess.CompletedProcess(
args=command,
returncode=proc.returncode,
stdout=stdout,
stderr=None,
)
finally:
try:
os.close(master_fd)
except OSError:
pass
try:
os.close(slave_fd)
except OSError:
pass
class MypyChecker(TypeChecker):
REGEX_MYPY_ERROR_ITEM = re.compile(
r'^(?P<filepath>.*\.py):(?P<lineno>\d+):((?P<colno>\d+):)? (?P<level>error):(?P<msg>.*)$'
)
REGEX_MYPY_ERROR_SUMMARY = re.compile(
r'Found (?P<num_errors>\d+) errors? in (?P<num_files>\d+) files?'
)
REGEX_TRIM_COLOR = re.compile(r'\x1b\[[0-9;]*m')
def __init__(
self,
config_file: str,
cache_dir: str,
debug: bool = False,
*args: Any,
**kwargs: Any,
) -> None:
self.config_file = config_file
self.cache_dir = cache_dir
self.debug = debug
super().__init__(*args, **kwargs)
def _parse_output(
self, output: str, filename_to_codeblock_identifier: dict[str, str]
) -> tuple[dict[str, str], str]:
current_api = None
results: dict[str, str] = {}
summary = ''
for line in output.splitlines():
line_no_color = self.REGEX_TRIM_COLOR.sub('', line)
if self.REGEX_MYPY_ERROR_SUMMARY.match(line_no_color.strip()):
summary = line.strip()
continue
m = self.REGEX_MYPY_ERROR_ITEM.match(line_no_color)
if m:
filename = pathlib.Path(m.group('filepath')).stem
if filename not in filename_to_codeblock_identifier:
raise ValueError(
f'Unknown filename {filename} in mypy output'
)
current_api = filename_to_codeblock_identifier[filename]
results[current_api] = (
results.get(current_api, '') + line + '\n'
)
else:
assert current_api is not None, (
f'Cannot parse mypy output line: {line}'
' (no preceding filename line)'
)
results[current_api] += line + '\n'
assert summary, 'No summary found in mypy output'
return results, summary
def _mypy_env(self) -> dict[str, str]:
env = os.environ.copy()
if 'MYPY_NUM_WORKERS' not in env:
num_workers = os.cpu_count()
if num_workers is not None:
env['MYPY_NUM_WORKERS'] = str(num_workers)
return env
def run_on_directory(
self,
dir: pathlib.Path,
filename_to_codeblock_identifier: dict[str, str],
) -> tuple[dict[str, str], str] | None:
res = pty_run(
[
sys.executable,
'-m',
'mypy',
f'--config-file={self.config_file}',
f'--cache-dir={self.cache_dir}',
"--pretty",
str(dir),
],
env=self._mypy_env(),
)
if res.returncode == 0:
print(f'No type errors found in directory {dir}')
return None
logger.debug('>>> Mypy stdout:')
logger.debug(res.stdout)
logger.debug('>>> Mypy stderr:')
logger.debug(res.stderr)
return self._parse_output(res.stdout, filename_to_codeblock_identifier)
def print_summary(
self,
error_messages: dict[str, str],
raw_summary: str,
whl_error: list[str],
) -> None:
failed_apis = {
codeblock_identifier.split(':')[0]
for codeblock_identifier in error_messages.keys()
}
if whl_error is not None and whl_error:
logger.info(f"{whl_error} is not in whl.")
logger.info("")
logger.info("Please check the whl package and API_PR.spec!")
logger.info(
"You can follow these steps in order to generate API.spec:"
)
logger.info("1. cd ${paddle_path}, compile paddle;")
logger.info("2. pip install build/python/dist/(build whl package);")
logger.info(
"3. run 'python tools/print_signatures.py paddle > paddle/fluid/API.spec'."
)
if not failed_apis:
logger.info(">>> Type checking is successful!")
return
for codeblock_identifier, msg in error_messages.items():
logger.error(
f"{COLOR_RED}{COLOR_BOLD}TYPE CHECKING FAILED{COLOR_CLEAR} in {COLOR_CYAN}{COLOR_BOLD}{codeblock_identifier}{COLOR_CLEAR}"
)
logger.error(msg)
logger.error(">>> Mypy summary:")
logger.error(raw_summary)
logger.error(">>> Mistakes found in type checking!")
logger.error(
">>> Please recheck the type annotations. Run `tools/type_checking.py` to check the typing issues:"
)
logger.error(
" $ python tools/type_checking.py "
+ " ".join(sorted(failed_apis))
)
logger.error(
">>> For more information: https://www.paddlepaddle.org.cn/documentation/docs/zh/develop/dev_guides/style_guide_and_references/type_annotations_specification_cn.html"
)
def parse_args() -> argparse.Namespace:
"""
Parse input arguments
"""
parser = argparse.ArgumentParser(
description='run Sample Code Type Checking'
)
parser.add_argument('--debug', dest='debug', action="store_true")
parser.add_argument(
'--config-file',
dest='config_file',
type=str,
default=None,
help='config file for type checker',
)
parser.add_argument(
'--cache-dir',
dest='cache_dir',
type=str,
default=None,
help='cache dir for mypy',
)
group = parser.add_mutually_exclusive_group()
group.add_argument('apis', nargs='*', type=str, default=[])
group.add_argument('--full-test', dest='full_test', action="store_true")
args = parser.parse_args()
return args
def codeblock_identifier_to_filename(codeblock_identifier: str) -> str:
# convert codeblock_identifier to a valid filename
return codeblock_identifier.replace('.', '_').replace(':', '__')
def preprocess_codeblock(codeblock: str) -> str:
# skip checking when the codeblock startswith `>>> # type: ignore`
codeblock_for_checking = []
for line in codeblock.splitlines():
if line.strip().startswith('>>> # type: ignore'):
break
codeblock_for_checking.append(line)
codeblock_for_checking = '\n'.join(codeblock_for_checking)
# remove `doctest` in the codeblock, or the module `doctest` cannot `get_examples`` correctly
codeblock_for_checking = re.sub(
r'#\s*x?doctest\s*:.*', '', codeblock_for_checking
)
# `get_examples` codes with `>>>` and `...` stripped
_example_code = doctest.DocTestParser().get_examples(codeblock_for_checking)
example_code = '\n'.join(
[l for e in _example_code for l in e.source.splitlines()]
)
return example_code
def generate_code_snippets(
type_checker: TypeChecker,
dir: pathlib.Path,
docstrings_to_test: dict[str, str],
) -> dict[str, str]:
_test_style = (
type_checker.style
if type_checker.style in {'google', 'freeform'}
else 'google'
)
google_style = _test_style == 'google'
codeblocks: list[tuple[str, str]] = []
filename_to_codeblock_identifier: dict[str, str] = {}
for api_name, raw_docstring in docstrings_to_test.items():
# we may extract more than one codeblocks from docstring.
for codeblock in extract_code_blocks_from_docstr(
raw_docstring, google_style=google_style
):
codeblock_name = codeblock['name']
codeblock_id = codeblock['id']
codeblock_identifier = (
f'{api_name}:{codeblock_name or codeblock_id}'
)
codeblocks.append(
(
codeblock_identifier,
preprocess_codeblock(codeblock['codes']),
)
)
for codeblock_identifier, codeblock in codeblocks:
filename = codeblock_identifier_to_filename(codeblock_identifier)
filename_to_codeblock_identifier[filename] = codeblock_identifier
with (dir / f'{filename}.py').open('w', encoding='utf-8') as f:
f.write(codeblock)
return filename_to_codeblock_identifier
def get_test_results(
type_checker: TypeChecker,
docstrings_to_test: dict[str, str],
) -> tuple[dict[str, str], str] | None:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = pathlib.Path(tmp_dir)
logger.info(f">>> Store code snippets to {tmp_dir} ...")
filename_to_codeblock_identifier = generate_code_snippets(
type_checker, tmp_dir, docstrings_to_test
)
logger.info(">>> Preprocess code snippets and run type checker ...")
results = type_checker.run_on_directory(
tmp_dir, filename_to_codeblock_identifier
)
return results
def run_type_checker(
args: argparse.Namespace, type_checker: TypeChecker
) -> None:
# init logger for samplecode utils
init_samplecode_logger(debug=args.debug)
# init our logger
logger.set_debug(args.debug)
logger.info(
"----------------Codeblock Type Checking Start--------------------"
)
logger.info(">>> Get docstring from api ...")
filter_api = lambda api_name: 'libpaddle' in api_name
docstrings_to_test, whl_error = get_docstring(
full_test=args.full_test,
filter_api=filter_api,
apis=[(api, api) for api in args.apis],
)
results = get_test_results(type_checker, docstrings_to_test)
if results is None:
logger.info(">>> No type errors found.")
return
logger.info(">>> Print summary ...")
error_messages, raw_summary = results
type_checker.print_summary(
error_messages=error_messages,
raw_summary=raw_summary,
whl_error=whl_error,
)
raise SystemExit(1)
if __name__ == '__main__':
base_path = pathlib.Path(__file__).resolve().parent.parent
args = parse_args()
mypy_checker = MypyChecker(
config_file=(
args.config_file
if args.config_file
else str(base_path / 'pyproject.toml')
),
cache_dir=str(
args.cache_dir if args.cache_dir else (base_path / '.mypy_cache')
),
debug=args.debug,
)
run_type_checker(args, mypy_checker)