mirror of
https://github.com/N4S4/synology-api.git
synced 2025-07-22 12:08:56 +00:00
381 lines
15 KiB
Python
381 lines
15 KiB
Python
import argparse
|
|
import re
|
|
import sys
|
|
import warnings
|
|
import yaml
|
|
from os import listdir
|
|
from os.path import isfile, join
|
|
from docstring_extractor import get_docstrings
|
|
|
|
####################
|
|
# Config Constants #
|
|
####################
|
|
DOCS_TRACKER = './docs_status.yaml'
|
|
PARSE_DIR = './synology_api'
|
|
API_LIST_FILE='./documentation/docs/apis/readme.md'
|
|
DOCS_DIR = './documentation/docs/apis/classes/'
|
|
EXCLUDED_FILES = {'__init__.py', 'auth.py', 'base_api.py', 'error_codes.py', 'exceptions.py', 'utils.py'}
|
|
|
|
####################
|
|
# String Constants #
|
|
####################
|
|
META_TAG = '---\n'
|
|
SEPARATOR = '\n\n\n---\n\n\n'
|
|
NEWLINE = ' \n'
|
|
AUTO_GEN_TAG = '\n<!-- ' + '-'*44 + ' -->\n'
|
|
AUTO_GEN_MESSAGE = '<!-- THIS FILE IS AUTO-GENERATED. DO NOT MODIFY. -->'
|
|
AUTO_GEN_DISCLAIMER= AUTO_GEN_TAG + AUTO_GEN_MESSAGE + AUTO_GEN_TAG + NEWLINE
|
|
|
|
##################
|
|
# RegEx Patterns #
|
|
##################
|
|
# Match admonitions, level in group 1 and message in group 2
|
|
ADMONITIONS = [
|
|
{'pattern': r'(Note:)(.*)', 'level': 'note'},
|
|
{'pattern': r'(Info:)(.*)', 'level': 'info'},
|
|
{'pattern': r'(Tip:)(.*)', 'level': 'tip'},
|
|
{'pattern': r'(Warning:)(.*)', 'level': 'warning'},
|
|
{'pattern': r'(Danger:)(.*)', 'level': 'danger'},
|
|
]
|
|
|
|
# Match example return block, header in group 1 and content in group 2
|
|
EXAMPLE_RETURN_PATTERN = r'(?s)(Example return\n-.*)(```.*```)'
|
|
|
|
# Match API name in string, API name in group 1
|
|
CLASS_API_NAME_PATTERN = r'api_name\s*=\s*f?[\'"](.*)[\'"]'
|
|
|
|
# Match first API name after provided method name, API name in group 1
|
|
def METHOD_API_NAME_PATTERN(method_name: str) -> str:
|
|
return rf"(?s)def {method_name}\(.*?api_name\s*=\s*f?['\"]([^'\"]+)"
|
|
|
|
# Match concatenated string in API name, prefix in group 1, concatenation in group 2, suffix in group 3
|
|
# Applies for 'prefix' + 'concatenation' + 'suffix'
|
|
API_NAME_CONCAT_PATTERN = r'(.*)([\'"]\s*\+.*\+\s*[\'"])(.*)'
|
|
|
|
# Match concatenated f-string in API name, prefix in group 1, concatenation in group 2, suffix in group 3
|
|
# Applies for f'prefix{concatenation}suffix'
|
|
API_NAME_CONCAT_PATTERN_FSTR = r'(.*)(\{.*\})(.*)'
|
|
|
|
####################
|
|
# Style Generators #
|
|
###################
|
|
def __stylize(text: str, styles: list[str]) -> str:
|
|
style_map = {'code': '`', 'bold': '**', 'italic': '_', 'underline': '___'}
|
|
content = ''
|
|
for style_str in styles:
|
|
if style_str not in style_map:
|
|
warnings.warn(f'Unknown style: {style_str}', UserWarning)
|
|
content += style_map.get(style_str, '')
|
|
content += text
|
|
for style_str in reversed(styles):
|
|
content += style_map.get(style_str, '')
|
|
return content
|
|
|
|
def header(level: str, text: str, styles: list[str] = []) -> str:
|
|
"""Generate header element with styles"""
|
|
header_levels = {'h1': '#', 'h2': '##', 'h3': '###', 'h4': '####'}
|
|
if level not in header_levels:
|
|
warnings.warn(f'Unknown header level: {level}', UserWarning)
|
|
return header_levels.get(level, '') + ' ' + __stylize(text, styles) + '\n'
|
|
|
|
def text(text: str, styles: list[str] = [], newline: bool = False) -> str:
|
|
"""Generate text element with styles"""
|
|
return __stylize(text, styles) + (NEWLINE if newline else ' ')
|
|
|
|
def link(text: str, url: str, fullstop: bool = False, newline: bool = False) -> str:
|
|
"""Generate link element"""
|
|
return f' [{text}]({url})'+ ('.' if fullstop else ' ') + (NEWLINE if newline else '')
|
|
|
|
def div(content: str, spacing: str = '', side: str = '', size: str = '') -> str:
|
|
"""Generate div element"""
|
|
return f'<div class="{spacing}-{side}--{size}">\n{content}\n</div>\n'
|
|
|
|
def details(summary: str, content: str) -> str:
|
|
"""Generate details element"""
|
|
details = f'<details>\n<summary>{summary}</summary>'
|
|
details += f'\n{content}\n</details>\n'
|
|
return details
|
|
|
|
def list_item(text: str, styles: list[str] = []) -> str:
|
|
"""Generate list element"""
|
|
return f'- {__stylize(text, styles)}{NEWLINE}'
|
|
|
|
def metadata(class_name: str) -> tuple[str, str]:
|
|
"""Generate front matter header"""
|
|
for i, api in enumerate(get_docs_status()):
|
|
key = (list(api.keys())[0])
|
|
if key == class_name:
|
|
display_order = 1 if class_name == 'BaseApi' else i + 2
|
|
docs_status = api[key]['status']
|
|
status_indicator = '✅' if docs_status == 'finished' else '🚧'
|
|
|
|
content = META_TAG
|
|
content += f'sidebar_position: {display_order}\n'
|
|
content += f'title: {status_indicator} {class_name}\n'
|
|
content += META_TAG
|
|
content += AUTO_GEN_DISCLAIMER
|
|
|
|
return (content, docs_status)
|
|
|
|
def admonition(level: str, text: str) -> str:
|
|
"""Generate admonition"""
|
|
return f':::{level}\n \n{text}\n \n:::\n'
|
|
|
|
def status_disclaimer(status: str) -> str:
|
|
"""Return admonition disclaimer based on API status"""
|
|
if status == 'partial':
|
|
return admonition('warning', 'This API is partially documented or under construction.')
|
|
elif status == 'not_started':
|
|
return admonition('warning', 'This API is not documented yet.')
|
|
return ''
|
|
|
|
def multi_class_disclaimer(classes: list[str]) -> str:
|
|
"""Return tip informing about all classes documented on the page"""
|
|
content = f'This page contains documentation for the `{classes[0]}` class and its subclasses: \n'
|
|
for i, class_name in enumerate(classes[1:], start=1):
|
|
content += list_item(link(class_name, f'#{class_name.lower()}'))
|
|
return admonition('tip', content)
|
|
|
|
def dedup_newlines(text: str) -> str:
|
|
return re.sub(r'\n{2}', ' \n', text)
|
|
|
|
|
|
|
|
|
|
def init_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description='This script parses docstrings from the wrapper source files and generates markdown files for docusaurus.'
|
|
)
|
|
|
|
parser.add_argument('-a', '--all',
|
|
action='store_true',
|
|
help='Parse all non-excluded files')
|
|
parser.add_argument('-f', '--file',
|
|
type=str,
|
|
action='extend',
|
|
nargs="+",
|
|
help='Parse specified files. This overrides the excluded files.')
|
|
parser.add_argument('-l', '--api-list',
|
|
action='store_true',
|
|
help='Parses APIs used by the class and generates MD for Supported APIs page.')
|
|
parser.add_argument('-e', '--excluded',
|
|
action='store_true',
|
|
help='Show a list of the excluded files to parse.')
|
|
|
|
return parser
|
|
|
|
def get_files_to_parse() -> list[str]:
|
|
files = listdir(PARSE_DIR)
|
|
return [file for file in files if isfile(join(PARSE_DIR, file)) and file not in EXCLUDED_FILES]
|
|
|
|
def validate_args(parser: argparse.ArgumentParser) -> tuple[list[str], bool]:
|
|
if len(sys.argv)==1:
|
|
parser.print_help(sys.stderr)
|
|
sys.exit(1)
|
|
|
|
args = parser.parse_args()
|
|
if args.excluded:
|
|
print('Excluded files:')
|
|
for file in EXCLUDED_FILES:
|
|
print(file)
|
|
sys.exit(1)
|
|
|
|
if args.all or args.api_list:
|
|
files = get_files_to_parse()
|
|
elif args.file:
|
|
files = args.file
|
|
|
|
return (files, args.api_list, args.all or args.file)
|
|
|
|
def validate_str(context: str, strs: list[str]):
|
|
for current in strs:
|
|
if not isinstance(current, str):
|
|
warnings.warn(f'[{context}] Invalid string: {current}', UserWarning)
|
|
|
|
def get_docs_status():
|
|
with open (DOCS_TRACKER, 'r') as stream:
|
|
return yaml.safe_load(stream)
|
|
|
|
def is_private(signature: str) -> bool:
|
|
return signature.startswith('_')
|
|
|
|
def insert_admonitions(content: str) -> str:
|
|
for adm in ADMONITIONS:
|
|
content = re.sub(adm['pattern'], lambda match: admonition(adm['level'], match.group(2)), content)
|
|
return content
|
|
|
|
def gen_supported_apis() -> str:
|
|
content = META_TAG
|
|
content += f'sidebar_position: 1\n'
|
|
content += f'title: Supported APIs\n'
|
|
content += META_TAG
|
|
content += AUTO_GEN_DISCLAIMER
|
|
content += header('h1', 'Supported APIs')
|
|
content += text('At the moment there are quite a few APIs implemented. They could be totally or partically implemented, for specific documentation about an API in particular, please see')
|
|
content += link('APIs', './category/api-classes', fullstop=True, newline=True)
|
|
|
|
return content
|
|
|
|
def check_concatenation(api_name: str) -> str:
|
|
match_p1 = re.search(API_NAME_CONCAT_PATTERN, api_name)
|
|
match_p2 = re.search(API_NAME_CONCAT_PATTERN_FSTR, api_name)
|
|
match_concat = match_p1 or match_p2
|
|
|
|
if match_concat:
|
|
concatenation = match_concat.group(2).replace('self.', '')
|
|
concatenation = concatenation.replace('\'', '')
|
|
concatenation = concatenation.replace('"', '')
|
|
concatenation = concatenation.replace('+', '')
|
|
concatenation = concatenation.strip()
|
|
concatenation = concatenation.upper()
|
|
api_name = match_concat.group(1) + '{' + concatenation + '}' + match_concat.group(3)
|
|
|
|
return api_name
|
|
|
|
def parse_class_apis(class_name: str, file_content: str, file_path: str) -> str:
|
|
matches = re.findall(CLASS_API_NAME_PATTERN, file_content)
|
|
section = header('h3', link(class_name, f'./apis/classes/{file_path.replace(".py", "")}'))
|
|
for api_name in matches:
|
|
api_name = check_concatenation(api_name)
|
|
|
|
if section.find(api_name) == -1: # Don't add duplicates
|
|
section += list_item(api_name, ['code'])
|
|
return section + NEWLINE
|
|
|
|
def parse_method_api(method_name: str, file_content: str) -> str:
|
|
match = re.search(METHOD_API_NAME_PATTERN(method_name), file_content)
|
|
section = ''
|
|
if match:
|
|
api_name = check_concatenation(match.group(1))
|
|
section = header('h4', 'Internal API')
|
|
section += div(text(api_name, ['code']), 'padding', 'left', 'md')
|
|
else:
|
|
warnings.warn(f'Method {method_name} seems to not be directly calling any internal API, this is expected for utility methods that use other calls in the class.', UserWarning)
|
|
return section + NEWLINE
|
|
|
|
def gen_header(class_name: str, docstring: str, classes: list[str]) -> str:
|
|
content = ''
|
|
docs_status = ''
|
|
|
|
if class_name == classes[0]:
|
|
content, docs_status = metadata(class_name)
|
|
if len(classes) > 1:
|
|
content += multi_class_disclaimer(classes)
|
|
|
|
content += header('h1', class_name) if class_name == classes[0] else header('h2', class_name)
|
|
content += status_disclaimer(docs_status)
|
|
content += header('h2', 'Overview')
|
|
|
|
docstring = docstring.replace('Supported methods:', header('h3', 'Supported methods'))
|
|
docstring = docstring.replace('Getters', text('Getters', ['bold']))
|
|
docstring = docstring.replace('Setters', text('Setters', ['bold']))
|
|
docstring = docstring.replace('Actions', text('Actions', ['bold']))
|
|
|
|
content += docstring + '\n'
|
|
content += header('h2', 'Methods')
|
|
|
|
return content
|
|
|
|
def gen_method(method: dict, file_content: str) -> str:
|
|
content = header('h3', method['name'], ['code'])
|
|
docstring = method['docstring']
|
|
if docstring is None:
|
|
return content + SEPARATOR
|
|
|
|
description = text(docstring.short_description or '', newline=True)
|
|
# In some cases, the whole docstring text will be parsed in the long_description.
|
|
# Avoid appending it in that case.
|
|
if isinstance(docstring.long_description, str) and docstring.long_description.find('Parameters') != -1:
|
|
print(docstring.params)
|
|
print('========>', docstring.long_description)
|
|
warnings.warn(f'[{method["name"]}] failed to parse docstrings. Make sure the format is correct. Check guidelines if needed.', UserWarning)
|
|
else:
|
|
description += text(docstring.long_description or '', newline=True)
|
|
|
|
description = dedup_newlines(description)
|
|
|
|
internal_api = parse_method_api(method['name'], file_content)
|
|
|
|
parameters = ''
|
|
if docstring.params:
|
|
parameters = header('h4', 'Parameters')
|
|
parameters_body = ''
|
|
for param in docstring.params:
|
|
validate_str(method['name'] + ' - params', [param.arg_name, param.type_name, param.description])
|
|
parameters_body += text(param.arg_name or '', ['bold', 'italic'])
|
|
parameters_body += text(param.type_name or '', ['code'], newline=True)
|
|
parameters_body += text(dedup_newlines(param.description or ''), newline=True)
|
|
parameters_body += NEWLINE
|
|
parameters += div(content=parameters_body, spacing='padding', side='left', size='md')
|
|
|
|
returns = ''
|
|
if docstring.returns:
|
|
validate_str(method['name'] + ' - returns', [docstring.returns.type_name, docstring.returns.description])
|
|
returns = header('h4', 'Returns')
|
|
returns_body = text(docstring.returns.type_name or '', ['code'], newline=True)
|
|
returns_body += text(dedup_newlines(docstring.returns.description or ''), newline=True)
|
|
returns += div(content=returns_body, spacing='padding', side='left', size='md')
|
|
|
|
example_return = ''
|
|
example_match = re.search(EXAMPLE_RETURN_PATTERN, method.get('docstring_text', ''))
|
|
if example_match:
|
|
example_return = header('h4', 'Example return')
|
|
example_return += details(summary='Click to expand', content=example_match.group(2))
|
|
|
|
content += description
|
|
content += internal_api
|
|
content += parameters
|
|
content += returns
|
|
content += example_return
|
|
content += SEPARATOR
|
|
content = insert_admonitions(content)
|
|
|
|
return content
|
|
|
|
def write(path: str, content: str):
|
|
with open(path, 'w', encoding="utf-8") as f:
|
|
print('Writing into:', path)
|
|
f.write(content)
|
|
|
|
def main():
|
|
parser = init_parser()
|
|
files, parse_api_list, parse_docs = validate_args(parser)
|
|
|
|
### Generation for Getting Started/Supported APIs with all the APIs user per class.
|
|
supported_apis = gen_supported_apis()
|
|
|
|
for file_name in files:
|
|
doc_content = ''
|
|
file_path = join(PARSE_DIR, file_name)
|
|
print('Processing: ' + file_name)
|
|
with open(file_path, 'r', encoding="utf-8") as f:
|
|
file_content = f.read()
|
|
docstrings = get_docstrings(file_content)
|
|
if docstrings is None:
|
|
warnings.warn(f'Failed to parse {file_name}', UserWarning)
|
|
continue
|
|
|
|
classes = [c for c in docstrings["content"] if not is_private(c['name'])]
|
|
classes_names = [c['name'] for c in classes]
|
|
for i, class_item in enumerate(classes):
|
|
supported_apis += parse_class_apis(class_item['name'], file_content, file_name)
|
|
doc_content += gen_header(
|
|
class_item['name'],
|
|
class_item['docstring_text'],
|
|
classes=classes_names
|
|
)
|
|
|
|
methods = [m for m in class_item['content'] if not is_private(m['name'])]
|
|
for method in methods:
|
|
doc_content += gen_method(method, file_content)
|
|
|
|
# Write to md files if the args were set
|
|
if parse_docs:
|
|
write(DOCS_DIR + file_name.replace('.py', '.md'), doc_content)
|
|
print('='*20)
|
|
# Write to md files if the args were set
|
|
if parse_api_list:
|
|
write(API_LIST_FILE, supported_apis)
|
|
|
|
if __name__ == "__main__":
|
|
main() |