| #!/usr/bin/env python3 |
| # Copyright (C) 2022 The Android Open Source Project |
| # |
| # 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. |
| |
| # This tool checks that every SQL object created without prefix |
| # 'internal_' is documented with proper schema. |
| |
| from __future__ import absolute_import |
| from __future__ import division |
| from __future__ import print_function |
| |
| import os |
| import re |
| import sys |
| from typing import Union, List, Tuple, Dict |
| from dataclasses import dataclass |
| |
| from python.generators.stdlib_docs.utils import * |
| from python.generators.stdlib_docs.validate import * |
| from python.generators.stdlib_docs.parse import * |
| |
| CommentLines = List[str] |
| AnyDocs = Union['TableViewDocs', 'FunctionDocs', 'ViewFunctionDocs'] |
| |
| |
| # Stores documentation for CREATE {TABLE|VIEW} with comment split into |
| # segments. |
| @dataclass |
| class TableViewDocs: |
| name: str |
| obj_type: str |
| desc: CommentLines |
| columns: CommentLines |
| path: str |
| |
| # Contructs new TableViewDocs from the entire comment, by splitting it on |
| # typed lines. Returns None for improperly structured schemas. |
| @staticmethod |
| def create_from_comment(path: str, comment: CommentLines, module: str, |
| matches: Tuple) -> Tuple['TableViewDocs', Errors]: |
| obj_type, name = matches[:2] |
| |
| # Ignore internal tables and views. |
| if re.match(r"^internal_.*", name): |
| return None, [] |
| |
| errors = validate_name(name, module) |
| col_start = None |
| has_desc = False |
| |
| # Splits code into segments by finding beginning of column segment. |
| for i, line in enumerate(comment): |
| # Ignore only '--' line. |
| if line == "--": |
| continue |
| |
| m = re.match(Pattern['typed_line'], line) |
| |
| # Ignore untyped lines |
| if not m: |
| if not col_start: |
| has_desc = True |
| continue |
| |
| line_type = m.group(1) |
| if line_type == "column" and not col_start: |
| col_start = i |
| continue |
| |
| if not has_desc: |
| errors.append(f"No description for {obj_type}: '{name}' in {path}'\n") |
| return None, errors |
| |
| if not col_start: |
| errors.append(f"No columns for {obj_type}: '{name}' in {path}'\n") |
| return None, errors |
| |
| return ( |
| TableViewDocs(name, obj_type, comment[:col_start], comment[col_start:], |
| path), |
| errors, |
| ) |
| |
| def check_comment(self) -> Errors: |
| return validate_columns(self) |
| |
| def parse_comment(self) -> dict: |
| return { |
| 'name': self.name, |
| 'type': self.obj_type, |
| 'desc': parse_desc(self), |
| 'cols': parse_columns(self) |
| } |
| |
| |
| # Stores documentation for create_function with comment split into segments. |
| class FunctionDocs: |
| |
| def __init__( |
| self, |
| path: str, |
| data_from_sql: dict, |
| module: str, |
| name: str, |
| desc: str, |
| args: CommentLines, |
| ret: CommentLines, |
| ): |
| self.path = path |
| self.data_from_sql = data_from_sql |
| self.module = module |
| self.name = name |
| self.desc = desc |
| self.args = args |
| self.ret = ret |
| |
| # Contructs new FunctionDocs from whole comment, by splitting it on typed |
| # lines. Returns None for improperly structured schemas. |
| @staticmethod |
| def create_from_comment(path: str, comment: CommentLines, module: str, |
| matches: Tuple) -> Tuple['FunctionDocs', Errors]: |
| name, args, ret, sql = matches |
| |
| # Ignore internal functions. |
| if re.match(r"^INTERNAL_.*", name): |
| return None, [] |
| |
| errors = validate_name(name, module, upper=True) |
| has_desc, start_args, start_ret = False, None, None |
| |
| args_dict, parse_errors = parse_args_str(args) |
| errors += parse_errors |
| |
| # Splits code into segments by finding beginning of args and ret segments. |
| for i, line in enumerate(comment): |
| # Ignore only '--' line. |
| if line == "--": |
| continue |
| |
| m = re.match(Pattern['typed_line'], line) |
| |
| # Ignore untyped lines |
| if not m: |
| if not start_args: |
| has_desc = True |
| continue |
| |
| line_type = m.group(1) |
| if line_type == "arg" and not start_args: |
| start_args = i |
| continue |
| |
| if line_type == "ret" and not start_ret: |
| start_ret = i |
| continue |
| |
| if not has_desc: |
| errors.append(f"No description for '{name}' in {path}'\n") |
| return None, errors |
| |
| if not start_ret or (args_dict and not start_args): |
| errors.append(f"Function requires 'arg' and 'ret' comments.\n" |
| f"'{name}' in {path}\n") |
| return None, errors |
| |
| if not args_dict: |
| start_args = start_ret |
| |
| data_from_sql = {'name': name, 'args': args_dict, 'ret': ret, 'sql': sql} |
| return ( |
| FunctionDocs( |
| path, |
| data_from_sql, |
| module, |
| name, |
| comment[:start_args], |
| comment[start_args:start_ret] if args_dict else None, |
| comment[start_ret:], |
| ), |
| errors, |
| ) |
| |
| def check_comment(self) -> Errors: |
| errors = validate_args(self) |
| errors += validate_ret(self) |
| return errors |
| |
| def parse_comment(self) -> dict: |
| ret_type, ret_desc = parse_ret(self) |
| return { |
| 'name': self.name, |
| 'desc': parse_desc(self), |
| 'args': parse_args(self), |
| 'return_type': ret_type, |
| 'return_desc': ret_desc |
| } |
| |
| |
| # Stores documentation for create_view_function with comment split into |
| # segments. |
| class ViewFunctionDocs: |
| |
| def __init__( |
| self, |
| path: str, |
| data_from_sql: str, |
| module: str, |
| name: str, |
| desc: CommentLines, |
| args: CommentLines, |
| columns: CommentLines, |
| ): |
| self.path = path |
| self.data_from_sql = data_from_sql |
| self.module = module |
| self.name = name |
| self.desc = desc |
| self.args = args |
| self.columns = columns |
| |
| # Contructs new ViewFunctionDocs from whole comment, by splitting it on typed |
| # lines. Returns None for improperly structured schemas. |
| @staticmethod |
| def create_from_comment(path: str, comment: CommentLines, module: str, |
| matches: Tuple) -> Tuple['ViewFunctionDocs', Errors]: |
| name, args, columns, sql = matches |
| |
| # Ignore internal functions. |
| if re.match(r"^INTERNAL_.*", name): |
| return None, [] |
| |
| errors = validate_name(name, module, upper=True) |
| args_dict, parse_errors = parse_args_str(args) |
| errors += parse_errors |
| has_desc, start_args, start_cols = False, None, None |
| |
| # Splits code into segments by finding beginning of args and cols segments. |
| for i, line in enumerate(comment): |
| # Ignore only '--' line. |
| if line == "--": |
| continue |
| |
| m = re.match(Pattern['typed_line'], line) |
| |
| # Ignore untyped lines |
| if not m: |
| if not start_args: |
| has_desc = True |
| continue |
| |
| line_type = m.group(1) |
| if line_type == "arg" and not start_args: |
| start_args = i |
| continue |
| |
| if line_type == "column" and not start_cols: |
| start_cols = i |
| continue |
| |
| if not has_desc: |
| errors.append(f"No description for '{name}' in {path}'\n") |
| return None, errors |
| |
| if not start_cols or (args_dict and not start_args): |
| errors.append(f"Function requires 'arg' and 'column' comments.\n" |
| f"'{name}' in {path}\n") |
| return None, errors |
| |
| if not args_dict: |
| start_args = start_cols |
| |
| cols_dict, parse_errors = parse_args_str(columns) |
| errors += parse_errors |
| |
| data_from_sql = dict(name=name, args=args_dict, columns=cols_dict, sql=sql) |
| return ( |
| ViewFunctionDocs( |
| path, |
| data_from_sql, |
| module, |
| name, |
| comment[:start_args], |
| comment[start_args:start_cols] if args_dict else None, |
| comment[start_cols:], |
| ), |
| errors, |
| ) |
| |
| def check_comment(self) -> Errors: |
| errors = validate_args(self) |
| errors += validate_columns(self, use_data_from_sql=True) |
| return errors |
| |
| def parse_comment(self) -> dict: |
| return { |
| 'name': self.name, |
| 'desc': parse_desc(self), |
| 'args': parse_args(self), |
| 'cols': parse_columns(self) |
| } |
| |
| |
| # Reads the provided SQL and, if possible, generates a dictionary with data |
| # from documentation together with errors from validation of the schema. |
| def parse_file_to_dict(path: str, sql: str) -> Tuple[Dict[str, any], Errors]: |
| if sys.platform.startswith('win'): |
| path = path.replace("\\", "/") |
| |
| # Get module name |
| module_name = path.split("/stdlib/")[-1].split("/")[0] |
| |
| imports, import_errors = parse_typed_docs(path, module_name, sql, |
| Pattern['create_table_view'], |
| TableViewDocs) |
| functions, function_errors = parse_typed_docs(path, module_name, sql, |
| Pattern['create_function'], |
| FunctionDocs) |
| view_functions, view_function_errors = parse_typed_docs( |
| path, module_name, sql, Pattern['create_view_function'], ViewFunctionDocs) |
| |
| errors = import_errors + function_errors + view_function_errors |
| |
| if errors: |
| sys.stderr.write("\n\n".join(errors)) |
| |
| return ({ |
| 'imports': [imp.parse_comment() for imp in imports if imp], |
| 'functions': [fun.parse_comment() for fun in functions if fun], |
| 'view_functions': [ |
| view_fun.parse_comment() for view_fun in view_functions if view_fun |
| ] |
| }, errors) |