diff --git a/.gitignore b/.gitignore index 555bb49d..ce2db2b4 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,4 @@ dist doc -mkdoc2.py -mkdoc.py -melobot -melodoc \ No newline at end of file +mkdoc2.py \ No newline at end of file diff --git a/docs/dev/api/mkdoc.md b/docs/dev/api/mkdoc.md new file mode 100644 index 00000000..765d9a81 --- /dev/null +++ b/docs/dev/api/mkdoc.md @@ -0,0 +1,271 @@ +--- +title: liteyuki.mkdoc +order: 1 +icon: laptop-code +category: API +--- + +### ***def*** `get_relative_path(base_path: str, target_path: str) -> str` + +获取相对路径 + +Args: + + base_path: 基础路径 + + target_path: 目标路径 + +### ***def*** `write_to_files(file_data: dict[str, str]) -> None` + +输出文件 + +Args: + + file_data: 文件数据 相对路径 + +### ***def*** `get_file_list(module_folder: str) -> None` + + + +### ***def*** `get_module_info_normal(file_path: str, ignore_private: bool) -> ModuleInfo` + +获取函数和类 + +Args: + + file_path: Python 文件路径 + + ignore_private: 忽略私有函数和类 + +Returns: + + 模块信息 + +### ***def*** `generate_markdown(module_info: ModuleInfo, front_matter: Any) -> str` + +生成模块的Markdown + +你可在此自定义生成的Markdown格式 + +Args: + + module_info: 模块信息 + + front_matter: 自定义选项title, index, icon, category + +Returns: + + Markdown 字符串 + +### ***def*** `generate_docs(module_folder: str, output_dir: str, with_top: bool, ignored_paths: Any) -> None` + +生成文档 + +Args: + + module_folder: 模块文件夹 + + output_dir: 输出文件夹 + + with_top: 是否包含顶层文件夹 False时例如docs/api/module_a, docs/api/module_b, True时例如docs/api/module/module_a.md, docs/api/module/module_b.md + + ignored_paths: 忽略的路径 + +### ***class*** `DefType(Enum)` + + + +###   ***attr*** `FUNCTION: 'function'` + +###   ***attr*** `METHOD: 'method'` + +###   ***attr*** `STATIC_METHOD: 'staticmethod'` + +###   ***attr*** `CLASS_METHOD: 'classmethod'` + +###   ***attr*** `PROPERTY: 'property'` + +### ***class*** `FunctionInfo(BaseModel)` + + + +### ***class*** `AttributeInfo(BaseModel)` + + + +### ***class*** `ClassInfo(BaseModel)` + + + +### ***class*** `ModuleInfo(BaseModel)` + + + +### ***var*** `NO_TYPE_ANY = 'Any'` + + + +### ***var*** `NO_TYPE_HINT = 'NoTypeHint'` + + + +### ***var*** `FUNCTION = 'function'` + + + +### ***var*** `METHOD = 'method'` + + + +### ***var*** `STATIC_METHOD = 'staticmethod'` + + + +### ***var*** `CLASS_METHOD = 'classmethod'` + + + +### ***var*** `PROPERTY = 'property'` + + + +### ***var*** `file_list = []` + + + +### ***var*** `dot_sep_module_path = file_path.replace(os.sep, '.').replace('.py', '').replace('.pyi', '')` + + + +### ***var*** `module_docstring = ast.get_docstring(tree)` + + + +### ***var*** `module_info = ModuleInfo(module_path=dot_sep_module_path, functions=[], classes=[], attributes=[], docstring=module_docstring if module_docstring else '')` + + + +### ***var*** `content = ''` + + + +### ***var*** `front_matter = '---\n' + '\n'.join([f'{k}: {v}' for k, v in front_matter.items()]) + '\n---\n\n'` + + + +### ***var*** `file_list = get_file_list(module_folder)` + + + +### ***var*** `replace_data = {'__init__': 'README', '.py': '.md'}` + + + +### ***var*** `file_content = file.read()` + + + +### ***var*** `tree = ast.parse(file_content)` + + + +### ***var*** `args_with_type = [f'{arg[0]}: {arg[1]}' if arg[1] else arg[0] for arg in func.args]` + + + +### ***var*** `ignored_paths = []` + + + +### ***var*** `no_module_name_pyfile_path = get_relative_path(module_folder, pyfile_path)` + + + +### ***var*** `rel_md_path = pyfile_path if with_top else no_module_name_pyfile_path` + + + +### ***var*** `abs_md_path = os.path.join(output_dir, rel_md_path)` + + + +### ***var*** `module_info = get_module_info_normal(pyfile_path)` + + + +### ***var*** `md_content = generate_markdown(module_info, front_matter)` + + + +### ***var*** `inherit = f"({', '.join(cls.inherit)})" if cls.inherit else ''` + + + +### ***var*** `rel_md_path = rel_md_path.replace(rk, rv)` + + + +### ***var*** `front_matter = {'title': module_info.module_path.replace('.__init__', '').replace('_', '\\n'), 'index': 'true', 'icon': 'laptop-code', 'category': 'API'}` + + + +### ***var*** `front_matter = {'title': module_info.module_path.replace('_', '\\n'), 'order': '1', 'icon': 'laptop-code', 'category': 'API'}` + + + +### ***var*** `function_docstring = ast.get_docstring(node)` + + + +### ***var*** `func_info = FunctionInfo(name=node.name, args=[(arg.arg, ast.unparse(arg.annotation) if arg.annotation else NO_TYPE_ANY) for arg in node.args.args], return_type=ast.unparse(node.returns) if node.returns else 'None', docstring=function_docstring if function_docstring else '', type=DefType.FUNCTION, is_async=isinstance(node, ast.AsyncFunctionDef))` + + + +### ***var*** `class_docstring = ast.get_docstring(node)` + + + +### ***var*** `class_info = ClassInfo(name=node.name, docstring=class_docstring if class_docstring else '', methods=[], attributes=[], inherit=[ast.unparse(base) for base in node.bases])` + + + +### ***var*** `args_with_type = [f'{arg[0]}: {arg[1]}' if arg[1] else arg[0] for arg in method.args]` + + + +### ***var*** `args_with_type = [f'{arg[0]}: {arg[1]}' if arg[1] and arg[0] != 'self' else arg[0] for arg in method.args]` + + + +### ***var*** `first_arg = node.args.args[0]` + + + +### ***var*** `method_docstring = ast.get_docstring(class_node)` + + + +### ***var*** `def_type = DefType.METHOD` + + + +### ***var*** `def_type = DefType.STATIC_METHOD` + + + +### ***var*** `attr_type = NO_TYPE_HINT` + + + +### ***var*** `def_type = DefType.CLASS_METHOD` + + + +### ***var*** `attr_type = ast.unparse(node.value.annotation)` + + + +### ***var*** `def_type = DefType.PROPERTY` + + + diff --git a/docs/en/dev/api/liteyuki/mkdoc.md b/docs/en/dev/api/liteyuki/mkdoc.md new file mode 100644 index 00000000..765d9a81 --- /dev/null +++ b/docs/en/dev/api/liteyuki/mkdoc.md @@ -0,0 +1,271 @@ +--- +title: liteyuki.mkdoc +order: 1 +icon: laptop-code +category: API +--- + +### ***def*** `get_relative_path(base_path: str, target_path: str) -> str` + +获取相对路径 + +Args: + + base_path: 基础路径 + + target_path: 目标路径 + +### ***def*** `write_to_files(file_data: dict[str, str]) -> None` + +输出文件 + +Args: + + file_data: 文件数据 相对路径 + +### ***def*** `get_file_list(module_folder: str) -> None` + + + +### ***def*** `get_module_info_normal(file_path: str, ignore_private: bool) -> ModuleInfo` + +获取函数和类 + +Args: + + file_path: Python 文件路径 + + ignore_private: 忽略私有函数和类 + +Returns: + + 模块信息 + +### ***def*** `generate_markdown(module_info: ModuleInfo, front_matter: Any) -> str` + +生成模块的Markdown + +你可在此自定义生成的Markdown格式 + +Args: + + module_info: 模块信息 + + front_matter: 自定义选项title, index, icon, category + +Returns: + + Markdown 字符串 + +### ***def*** `generate_docs(module_folder: str, output_dir: str, with_top: bool, ignored_paths: Any) -> None` + +生成文档 + +Args: + + module_folder: 模块文件夹 + + output_dir: 输出文件夹 + + with_top: 是否包含顶层文件夹 False时例如docs/api/module_a, docs/api/module_b, True时例如docs/api/module/module_a.md, docs/api/module/module_b.md + + ignored_paths: 忽略的路径 + +### ***class*** `DefType(Enum)` + + + +###   ***attr*** `FUNCTION: 'function'` + +###   ***attr*** `METHOD: 'method'` + +###   ***attr*** `STATIC_METHOD: 'staticmethod'` + +###   ***attr*** `CLASS_METHOD: 'classmethod'` + +###   ***attr*** `PROPERTY: 'property'` + +### ***class*** `FunctionInfo(BaseModel)` + + + +### ***class*** `AttributeInfo(BaseModel)` + + + +### ***class*** `ClassInfo(BaseModel)` + + + +### ***class*** `ModuleInfo(BaseModel)` + + + +### ***var*** `NO_TYPE_ANY = 'Any'` + + + +### ***var*** `NO_TYPE_HINT = 'NoTypeHint'` + + + +### ***var*** `FUNCTION = 'function'` + + + +### ***var*** `METHOD = 'method'` + + + +### ***var*** `STATIC_METHOD = 'staticmethod'` + + + +### ***var*** `CLASS_METHOD = 'classmethod'` + + + +### ***var*** `PROPERTY = 'property'` + + + +### ***var*** `file_list = []` + + + +### ***var*** `dot_sep_module_path = file_path.replace(os.sep, '.').replace('.py', '').replace('.pyi', '')` + + + +### ***var*** `module_docstring = ast.get_docstring(tree)` + + + +### ***var*** `module_info = ModuleInfo(module_path=dot_sep_module_path, functions=[], classes=[], attributes=[], docstring=module_docstring if module_docstring else '')` + + + +### ***var*** `content = ''` + + + +### ***var*** `front_matter = '---\n' + '\n'.join([f'{k}: {v}' for k, v in front_matter.items()]) + '\n---\n\n'` + + + +### ***var*** `file_list = get_file_list(module_folder)` + + + +### ***var*** `replace_data = {'__init__': 'README', '.py': '.md'}` + + + +### ***var*** `file_content = file.read()` + + + +### ***var*** `tree = ast.parse(file_content)` + + + +### ***var*** `args_with_type = [f'{arg[0]}: {arg[1]}' if arg[1] else arg[0] for arg in func.args]` + + + +### ***var*** `ignored_paths = []` + + + +### ***var*** `no_module_name_pyfile_path = get_relative_path(module_folder, pyfile_path)` + + + +### ***var*** `rel_md_path = pyfile_path if with_top else no_module_name_pyfile_path` + + + +### ***var*** `abs_md_path = os.path.join(output_dir, rel_md_path)` + + + +### ***var*** `module_info = get_module_info_normal(pyfile_path)` + + + +### ***var*** `md_content = generate_markdown(module_info, front_matter)` + + + +### ***var*** `inherit = f"({', '.join(cls.inherit)})" if cls.inherit else ''` + + + +### ***var*** `rel_md_path = rel_md_path.replace(rk, rv)` + + + +### ***var*** `front_matter = {'title': module_info.module_path.replace('.__init__', '').replace('_', '\\n'), 'index': 'true', 'icon': 'laptop-code', 'category': 'API'}` + + + +### ***var*** `front_matter = {'title': module_info.module_path.replace('_', '\\n'), 'order': '1', 'icon': 'laptop-code', 'category': 'API'}` + + + +### ***var*** `function_docstring = ast.get_docstring(node)` + + + +### ***var*** `func_info = FunctionInfo(name=node.name, args=[(arg.arg, ast.unparse(arg.annotation) if arg.annotation else NO_TYPE_ANY) for arg in node.args.args], return_type=ast.unparse(node.returns) if node.returns else 'None', docstring=function_docstring if function_docstring else '', type=DefType.FUNCTION, is_async=isinstance(node, ast.AsyncFunctionDef))` + + + +### ***var*** `class_docstring = ast.get_docstring(node)` + + + +### ***var*** `class_info = ClassInfo(name=node.name, docstring=class_docstring if class_docstring else '', methods=[], attributes=[], inherit=[ast.unparse(base) for base in node.bases])` + + + +### ***var*** `args_with_type = [f'{arg[0]}: {arg[1]}' if arg[1] else arg[0] for arg in method.args]` + + + +### ***var*** `args_with_type = [f'{arg[0]}: {arg[1]}' if arg[1] and arg[0] != 'self' else arg[0] for arg in method.args]` + + + +### ***var*** `first_arg = node.args.args[0]` + + + +### ***var*** `method_docstring = ast.get_docstring(class_node)` + + + +### ***var*** `def_type = DefType.METHOD` + + + +### ***var*** `def_type = DefType.STATIC_METHOD` + + + +### ***var*** `attr_type = NO_TYPE_HINT` + + + +### ***var*** `def_type = DefType.CLASS_METHOD` + + + +### ***var*** `attr_type = ast.unparse(node.value.annotation)` + + + +### ***var*** `def_type = DefType.PROPERTY` + + + diff --git a/liteyuki/mkdoc.py b/liteyuki/mkdoc.py new file mode 100644 index 00000000..49060c56 --- /dev/null +++ b/liteyuki/mkdoc.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2020-2024 LiteyukiStudio. All Rights Reserved + +@Time : 2024/8/19 上午6:23 +@Author : snowykami +@Email : snowykami@outlook.com +@File : mkdoc.py +@Software: PyCharm +""" + +import ast +import os +import shutil +from typing import Any +from enum import Enum +from pydantic import BaseModel + +NO_TYPE_ANY = "Any" +NO_TYPE_HINT = "NoTypeHint" + + +class DefType(Enum): + FUNCTION = "function" + METHOD = "method" + STATIC_METHOD = "staticmethod" + CLASS_METHOD = "classmethod" + PROPERTY = "property" + + +class FunctionInfo(BaseModel): + name: str + args: list[tuple[str, str]] + return_type: str + docstring: str + + type: DefType + """若为类中def,则有""" + is_async: bool + + +class AttributeInfo(BaseModel): + name: str + type: str + value: Any = None + docstring: str = "" + + +class ClassInfo(BaseModel): + name: str + docstring: str + methods: list[FunctionInfo] + attributes: list[AttributeInfo] + inherit: list[str] + + +class ModuleInfo(BaseModel): + module_path: str + """点分割模块路径 例如 liteyuki.bot""" + + functions: list[FunctionInfo] + classes: list[ClassInfo] + attributes: list[AttributeInfo] + docstring: str + + +def get_relative_path(base_path: str, target_path: str) -> str: + """ + 获取相对路径 + Args: + base_path: 基础路径 + target_path: 目标路径 + """ + return os.path.relpath(target_path, base_path) + + +def write_to_files(file_data: dict[str, str]): + """ + 输出文件 + Args: + file_data: 文件数据 相对路径 + """ + + for rp, data in file_data.items(): + + if not os.path.exists(os.path.dirname(rp)): + os.makedirs(os.path.dirname(rp)) + with open(rp, 'w', encoding='utf-8') as f: + f.write(data) + + +def get_file_list(module_folder: str): + file_list = [] + for root, dirs, files in os.walk(module_folder): + for file in files: + if file.endswith((".py", ".pyi")): + file_list.append(os.path.join(root, file)) + return file_list + + +def get_module_info_normal(file_path: str, ignore_private: bool = True) -> ModuleInfo: + """ + 获取函数和类 + Args: + file_path: Python 文件路径 + ignore_private: 忽略私有函数和类 + Returns: + 模块信息 + """ + + with open(file_path, 'r', encoding='utf-8') as file: + file_content = file.read() + tree = ast.parse(file_content) + + dot_sep_module_path = file_path.replace(os.sep, '.').replace(".py", "").replace(".pyi", "") + module_docstring = ast.get_docstring(tree) + + module_info = ModuleInfo( + module_path=dot_sep_module_path, + functions=[], + classes=[], + attributes=[], + docstring=module_docstring if module_docstring else "" + ) + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + # 模块函数 且不在类中 若ignore_private=True则忽略私有函数 + if not any(isinstance(parent, ast.ClassDef) for parent in ast.iter_child_nodes(node)) and (not ignore_private or not node.name.startswith('_')): + + # 判断第一个参数是否为self或cls,后期用其他办法优化 + if node.args.args: + first_arg = node.args.args[0] + if first_arg.arg in ("self", "cls"): + continue + + function_docstring = ast.get_docstring(node) + + func_info = FunctionInfo( + name=node.name, + args=[(arg.arg, ast.unparse(arg.annotation) if arg.annotation else NO_TYPE_ANY) for arg in node.args.args], + return_type=ast.unparse(node.returns) if node.returns else "None", + docstring=function_docstring if function_docstring else "", + type=DefType.FUNCTION, + is_async=isinstance(node, ast.AsyncFunctionDef) + ) + module_info.functions.append(func_info) + + elif isinstance(node, ast.ClassDef): + # 模块类 + class_docstring = ast.get_docstring(node) + + class_info = ClassInfo( + name=node.name, + docstring=class_docstring if class_docstring else "", + methods=[], + attributes=[], + inherit=[ast.unparse(base) for base in node.bases] + ) + + for class_node in node.body: + # methods [instance, static, class, property],保留__init__方法 + if isinstance(class_node, ast.FunctionDef) and (not ignore_private or not class_node.name.startswith('_') or class_node.name == "__init__"): + method_docstring = ast.get_docstring(class_node) + def_type = DefType.METHOD + if class_node.decorator_list: + if any(isinstance(decorator, ast.Name) and decorator.id == "staticmethod" for decorator in class_node.decorator_list): + def_type = DefType.STATIC_METHOD + elif any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in class_node.decorator_list): + def_type = DefType.CLASS_METHOD + elif any(isinstance(decorator, ast.Name) and decorator.id == "property" for decorator in class_node.decorator_list): + def_type = DefType.PROPERTY + class_info.methods.append(FunctionInfo( + name=class_node.name, + args=[(arg.arg, ast.unparse(arg.annotation) if arg.annotation else NO_TYPE_ANY) for arg in class_node.args.args], + return_type=ast.unparse(class_node.returns) if class_node.returns else "None", + docstring=method_docstring if method_docstring else "", + type=def_type, + is_async=isinstance(class_node, ast.AsyncFunctionDef) + )) + # attributes + elif isinstance(class_node, ast.Assign): + for target in class_node.targets: + if isinstance(target, ast.Name): + class_info.attributes.append(AttributeInfo( + name=target.id, + type=ast.unparse(class_node.value) + )) + module_info.classes.append(class_info) + + elif isinstance(node, ast.Assign): + # 检查是否在类或函数中 + if not any(isinstance(parent, (ast.ClassDef, ast.FunctionDef)) for parent in ast.iter_child_nodes(node)): + # 模块属性变量 + for target in node.targets: + if isinstance(target, ast.Name) and (not ignore_private or not target.id.startswith('_')): + attr_type = NO_TYPE_HINT + if isinstance(node.value, ast.AnnAssign) and node.value.annotation: + attr_type = ast.unparse(node.value.annotation) + module_info.attributes.append(AttributeInfo( + name=target.id, + type=attr_type, + value=ast.unparse(node.value) if node.value else None + )) + + return module_info + + +def generate_markdown(module_info: ModuleInfo, front_matter=None) -> str: + """ + 生成模块的Markdown + 你可在此自定义生成的Markdown格式 + Args: + module_info: 模块信息 + front_matter: 自定义选项title, index, icon, category + Returns: + Markdown 字符串 + """ + + content = "" + + front_matter = "---\n" + "\n".join([f"{k}: {v}" for k, v in front_matter.items()]) + "\n---\n\n" + + content += front_matter + + + + # 模块函数 + for func in module_info.functions: + args_with_type = [f"{arg[0]}: {arg[1]}" if arg[1] else arg[0] for arg in func.args] + content += f"### ***{'async ' if func.is_async else ''}def*** `{func.name}({', '.join(args_with_type)}) -> {func.return_type}`\n\n" + + func.docstring = func.docstring.replace("\n", "\n\n") + content += f"{func.docstring}\n\n" + + # 类 + for cls in module_info.classes: + if cls.inherit: + inherit = f"({', '.join(cls.inherit)})" if cls.inherit else "" + content += f"### ***class*** `{cls.name}{inherit}`\n\n" + else: + content += f"### ***class*** `{cls.name}`\n\n" + + cls.docstring = cls.docstring.replace("\n", "\n\n") + content += f"{cls.docstring}\n\n" + for method in cls.methods: + # 类函数 + + if method.type != DefType.METHOD: + args_with_type = [f"{arg[0]}: {arg[1]}" if arg[1] else arg[0] for arg in method.args] + content += f"###   ***@{method.type.value}***\n" + else: + # self不加类型提示 + args_with_type = [f"{arg[0]}: {arg[1]}" if arg[1] and arg[0] != "self" else arg[0] for arg in method.args] + content += f"###   ***{'async ' if method.is_async else ''}def*** `{method.name}({', '.join(args_with_type)}) -> {method.return_type}`\n\n" + + method.docstring = method.docstring.replace("\n", "\n\n") + content += f" {method.docstring}\n\n" + for attr in cls.attributes: + content += f"###   ***attr*** `{attr.name}: {attr.type}`\n\n" + + # 模块属性 + for attr in module_info.attributes: + if attr.type == NO_TYPE_HINT: + content += f"### ***var*** `{attr.name} = {attr.value}`\n\n" + else: + content += f"### ***var*** `{attr.name}: {attr.type} = {attr.value}`\n\n" + + attr.docstring = attr.docstring.replace("\n", "\n\n") + content += f"{attr.docstring}\n\n" + + return content + + +def generate_docs(module_folder: str, output_dir: str, with_top: bool = False, ignored_paths=None): + """ + 生成文档 + Args: + module_folder: 模块文件夹 + output_dir: 输出文件夹 + with_top: 是否包含顶层文件夹 False时例如docs/api/module_a, docs/api/module_b, True时例如docs/api/module/module_a.md, docs/api/module/module_b.md + ignored_paths: 忽略的路径 + """ + if ignored_paths is None: + ignored_paths = [] + file_data: dict[str, str] = {} # 路径 -> 字串 + + file_list = get_file_list(module_folder) + + # 清理输出目录 + shutil.rmtree(output_dir, ignore_errors=True) + os.mkdir(output_dir) + + replace_data = { + "__init__": "README", + ".py" : ".md", + } + + for pyfile_path in file_list: + if any(ignored_path.replace("\\", "/") in pyfile_path.replace("\\", "/") for ignored_path in ignored_paths): + continue + + no_module_name_pyfile_path = get_relative_path(module_folder, pyfile_path) # 去头路径 + + # markdown相对路径 + rel_md_path = pyfile_path if with_top else no_module_name_pyfile_path + for rk, rv in replace_data.items(): + rel_md_path = rel_md_path.replace(rk, rv) + + abs_md_path = os.path.join(output_dir, rel_md_path) + + # 获取模块信息 + module_info = get_module_info_normal(pyfile_path) + + # 生成markdown + + if "README" in abs_md_path: + front_matter = { + "title" : module_info.module_path.replace(".__init__", "").replace("_", "\\n"), + "index" : "true", + "icon" : "laptop-code", + "category": "API" + } + else: + front_matter = { + "title" : module_info.module_path.replace("_", "\\n"), + "order" : "1", + "icon" : "laptop-code", + "category": "API" + } + + md_content = generate_markdown(module_info, front_matter) + print(f"Generate {pyfile_path} -> {abs_md_path}") + file_data[abs_md_path] = md_content + + write_to_files(file_data) + + +# 入口脚本 +if __name__ == '__main__': + # 这里填入你的模块路径 + generate_docs('liteyuki', 'docs/dev/api', with_top=False, ignored_paths=["liteyuki/plugins"]) + generate_docs('liteyuki', 'docs/en/dev/api', with_top=True, ignored_paths=["liteyuki/plugins"]) + # generate_docs('melobot', 'melodoc', with_top=False)