# 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.*\.py):(?P\d+):((?P\d+):)? (?Perror):(?P.*)$' ) REGEX_MYPY_ERROR_SUMMARY = re.compile( r'Found (?P\d+) errors? in (?P\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)