From 9ed39b51f7e6b7f54b74c25ff7f33cc4ebf6fa2f Mon Sep 17 00:00:00 2001 From: guanjihuan Date: Mon, 2 Nov 2026 17:00:28 +0800 Subject: [PATCH] 0.2.0 --- .gitignore | 4 + INSTALLATION.md | 78 + PyPI/setup.cfg | 2 +- PyPI/src/guan.egg-info/PKG-INFO | 17 - PyPI/src/guan.egg-info/SOURCES.txt | 24 - PyPI/src/guan.egg-info/dependency_links.txt | 1 - PyPI/src/guan.egg-info/top_level.txt | 1 - PyPI/src/guan/AI_chat.py | 59 - PyPI/src/guan/__init__.py | 1 + PyPI/src/guan/deprecated.py | 106 ++ PyPI/src/guan/others.py | 48 +- README.md | 25 +- tutorials/00_test.py | 3 + tutorials/README.md | 93 + tutorials/daily_scenarios/01_ai_chat.py | 19 + tutorials/daily_scenarios/02_timing_log.py | 7 + .../daily_scenarios/03_function_timer.py | 25 + tutorials/daily_scenarios/04_try_except.py | 23 + .../daily_scenarios/05_dump_and_load_data.py | 7 + tutorials/daily_scenarios/06_print_array.py | 4 + .../daily_scenarios/07_matrix_in_file.py | 6 + .../08_parallel_calculation.py | 15 + .../daily_scenarios/09_loop_calculation.py | 33 + tutorials/daily_scenarios/10_is_hermitian.py | 16 + .../daily_scenarios/11_split_parameters.py | 9 + .../physics_scenarios/01_pauli_matrices.py | 4 + .../02_real_space_hamiltonian.py | 5 + .../physics_scenarios/03_band_structure.py | 14 + .../04_chern_number_and_wilson_loop.py | 9 + ...5_density_of_states_with_green_function.py | 34 + .../06_conductance_and_scattering_matrix.py | 12 + .../07_wavefunction_gauge.py | 22 + website/audit_site.py | 279 +++ website/build_site.py | 1277 +++++++++++++ website/extract.py | 1572 +++++++++++++++++ website/i18n.py | 294 +++ website/md_render.py | 535 ++++++ website/selftest.py | 911 ++++++++++ website/static/icon.jpg | Bin 0 -> 22142 bytes website/static/site.js | 485 +++++ website/static/style.css | 891 ++++++++++ website/templates/base.html | 72 + website/tutorials.py | 272 +++ 43 files changed, 7163 insertions(+), 151 deletions(-) create mode 100644 .gitignore create mode 100644 INSTALLATION.md delete mode 100644 PyPI/src/guan.egg-info/PKG-INFO delete mode 100644 PyPI/src/guan.egg-info/SOURCES.txt delete mode 100644 PyPI/src/guan.egg-info/dependency_links.txt delete mode 100644 PyPI/src/guan.egg-info/top_level.txt create mode 100644 PyPI/src/guan/deprecated.py create mode 100644 tutorials/00_test.py create mode 100644 tutorials/README.md create mode 100644 tutorials/daily_scenarios/01_ai_chat.py create mode 100644 tutorials/daily_scenarios/02_timing_log.py create mode 100644 tutorials/daily_scenarios/03_function_timer.py create mode 100644 tutorials/daily_scenarios/04_try_except.py create mode 100644 tutorials/daily_scenarios/05_dump_and_load_data.py create mode 100644 tutorials/daily_scenarios/06_print_array.py create mode 100644 tutorials/daily_scenarios/07_matrix_in_file.py create mode 100644 tutorials/daily_scenarios/08_parallel_calculation.py create mode 100644 tutorials/daily_scenarios/09_loop_calculation.py create mode 100644 tutorials/daily_scenarios/10_is_hermitian.py create mode 100644 tutorials/daily_scenarios/11_split_parameters.py create mode 100644 tutorials/physics_scenarios/01_pauli_matrices.py create mode 100644 tutorials/physics_scenarios/02_real_space_hamiltonian.py create mode 100644 tutorials/physics_scenarios/03_band_structure.py create mode 100644 tutorials/physics_scenarios/04_chern_number_and_wilson_loop.py create mode 100644 tutorials/physics_scenarios/05_density_of_states_with_green_function.py create mode 100644 tutorials/physics_scenarios/06_conductance_and_scattering_matrix.py create mode 100644 tutorials/physics_scenarios/07_wavefunction_gauge.py create mode 100644 website/audit_site.py create mode 100644 website/build_site.py create mode 100644 website/extract.py create mode 100644 website/i18n.py create mode 100644 website/md_render.py create mode 100644 website/selftest.py create mode 100644 website/static/icon.jpg create mode 100644 website/static/site.js create mode 100644 website/static/style.css create mode 100644 website/templates/base.html create mode 100644 website/tutorials.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e37e385 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +dist/ +__pycache__/ +*.py[cod] +*.egg-info/ diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..495b8c5 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,78 @@ +## Installation + +Press the shortcut key "Win+R", type words "cmd", press "Enter", then open a terminal in Windows. + +Execute the following command in a terminal to install (upgrade) Guan package to the latest version (https://pypi.org/project/guan/): + +```bash +pip install --upgrade guan -i https://pypi.python.org/simple +``` + +or + +```bash +pip install --upgrade guan +``` + +See the installed version of Guan package on your computer: + +```bash +pip show guan +``` + +If you do not use this package anymore, you can uninstall it: + +```bash +pip uninstall guan +``` + +Usage: + +```python +import guan +``` + +Prerequisites: + +```text +Python3 + +[Packages partially used, which have been included in Anaconda] +numpy +math +cmath +os +copy +matplotlib +re +datetime +bs4 +requests +functools +sympy +collections +pickle +time +urllib +hashlib +shutil +logging +ssl +random +json +... + +[Packages partially used, which need to be additionally installed when they are needed] +jieba (pip install jieba) +imageio (pip install imageio) +pdfplumber (pip install pdfplumber) +PyPDF2 (pip install PyPDF2) +pdfminer3k (pip install pdfminer3k) +pygame (pip install pygame) +akshare (pip install akshare) +pyttsx3 (pip install pyttsx3) +pydub (pip install pydub) +qrcode (pip install qrcode) +FFmpeg (http://www.ffmpeg.org/download.html) +... +``` diff --git a/PyPI/setup.cfg b/PyPI/setup.cfg index bd80faa..2546167 100644 --- a/PyPI/setup.cfg +++ b/PyPI/setup.cfg @@ -1,7 +1,7 @@ [metadata] # replace with your username: name = guan -version = 0.1.202 +version = 0.2.0 author = guanjihuan author_email = guanjihuan@163.com description = An open source python package diff --git a/PyPI/src/guan.egg-info/PKG-INFO b/PyPI/src/guan.egg-info/PKG-INFO deleted file mode 100644 index 642414a..0000000 --- a/PyPI/src/guan.egg-info/PKG-INFO +++ /dev/null @@ -1,17 +0,0 @@ -Metadata-Version: 2.4 -Name: guan -Version: 0.1.202 -Summary: An open source python package -Home-page: https://py.guanjihuan.com -Author: guanjihuan -Author-email: guanjihuan@163.com -Project-URL: Bug Tracker, https://py.guanjihuan.com -Classifier: Programming Language :: Python :: 3 -Classifier: License :: OSI Approved :: MIT License -Classifier: Operating System :: OS Independent -Requires-Python: >=3.6 -Description-Content-Type: text/markdown -License-File: LICENSE -Dynamic: license-file - -Guan is an open-source python package developed and maintained by https://www.guanjihuan.com/about (Ji-Huan Guan, 关济寰). The primary location of this package is on website https://py.guanjihuan.com. The GitHub location of this package is on https://github.com/guanjihuan/py.guanjihuan.com. diff --git a/PyPI/src/guan.egg-info/SOURCES.txt b/PyPI/src/guan.egg-info/SOURCES.txt deleted file mode 100644 index b6c9f7b..0000000 --- a/PyPI/src/guan.egg-info/SOURCES.txt +++ /dev/null @@ -1,24 +0,0 @@ -LICENSE -README.md -pyproject.toml -setup.cfg -src/guan/AI_chat.py -src/guan/Fourier_transform.py -src/guan/Green_functions.py -src/guan/Hamiltonian_of_examples.py -src/guan/__init__.py -src/guan/band_structures_and_wave_functions.py -src/guan/basic_functions.py -src/guan/data_processing.py -src/guan/decorators.py -src/guan/density_of_states.py -src/guan/figure_plotting.py -src/guan/file_reading_and_writing.py -src/guan/machine_learning.py -src/guan/others.py -src/guan/quantum_transport.py -src/guan/topological_invariant.py -src/guan.egg-info/PKG-INFO -src/guan.egg-info/SOURCES.txt -src/guan.egg-info/dependency_links.txt -src/guan.egg-info/top_level.txt \ No newline at end of file diff --git a/PyPI/src/guan.egg-info/dependency_links.txt b/PyPI/src/guan.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/PyPI/src/guan.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/PyPI/src/guan.egg-info/top_level.txt b/PyPI/src/guan.egg-info/top_level.txt deleted file mode 100644 index ff25243..0000000 --- a/PyPI/src/guan.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -guan diff --git a/PyPI/src/guan/AI_chat.py b/PyPI/src/guan/AI_chat.py index efc1bde..cd5e5ba 100644 --- a/PyPI/src/guan/AI_chat.py +++ b/PyPI/src/guan/AI_chat.py @@ -1,64 +1,5 @@ # Module: AI_chat -# AI 对话(当前默认模型为 'hunyuan-lite',无记忆) -def chat(prompt='你好', model=1, stream=1, stream_label=0): - import requests - url = "http://api.guanjihuan.com/chat" - data = { - "prompt": prompt, - "model": model, - } - if stream == 1: - if stream_label == 1: - print('\n--- Start Chat Stream Message ---\n') - requests_response = requests.post(url, json=data, stream=True) - response = '' - if requests_response.status_code == 200: - for line in requests_response.iter_lines(): - if line: - if stream == 1: - print(line.decode('utf-8'), end='', flush=True) - response += line.decode('utf-8') - print() - else: - pass - if stream == 1: - if stream_label == 1: - print('\n--- End Chat Stream Message ---\n') - return response - -# 加上函数代码的 AI 对话(当前默认模型为 'hunyuan-lite',无记忆) -def chat_with_function_code(function_name, prompt='', model=1, stream=1): - import guan - function_source = guan.get_source(function_name) - if prompt == '': - response = guan.chat(prompt=function_source, model=model, stream=stream) - else: - response = guan.chat(prompt=function_source+'\n\n'+prompt, model=model, stream=stream) - return response - -# 机器人自动对话(当前默认模型为 'hunyuan-lite',无记忆) -def auto_chat(prompt='你好', round=2, model=1, stream=1): - import guan - response0 = prompt - for i0 in range(round): - print(f'\n【对话第 {i0+1} 轮】\n') - print('机器人 1: ') - response1 = guan.chat(prompt=response0, model=model, stream=stream) - print('机器人 2: ') - response0 = guan.chat(prompt=response1, model=model, stream=stream) - -# 机器人自动对话(引导对话)(当前默认模型为 'hunyuan-lite',无记忆) -def auto_chat_with_guide(prompt='你好', guide_message='(回答字数少于30个字,最后反问我一个问题)', round=5, model=1, stream=1): - import guan - response0 = prompt - for i0 in range(round): - print(f'\n【对话第 {i0+1} 轮】\n') - print('机器人 1: ') - response1 = guan.chat(prompt=response0+guide_message, model=model, stream=stream) - print('机器人 2: ') - response0 = guan.chat(prompt=response1+guide_message, model=model, stream=stream) - # 使用 OpenAI 框架对话(需要 API Key) def openai_chat(prompt="你好", model="qwen-plus", temperature=0.7, system_message=None, history=[], print_show=1, load_env=1): import os diff --git a/PyPI/src/guan/__init__.py b/PyPI/src/guan/__init__.py index f6c5d52..02b0e5d 100644 --- a/PyPI/src/guan/__init__.py +++ b/PyPI/src/guan/__init__.py @@ -15,4 +15,5 @@ from .data_processing import * from .AI_chat import * from .decorators import * from .others import * +from .deprecated import * statistics_of_guan_package() \ No newline at end of file diff --git a/PyPI/src/guan/deprecated.py b/PyPI/src/guan/deprecated.py new file mode 100644 index 0000000..3025508 --- /dev/null +++ b/PyPI/src/guan/deprecated.py @@ -0,0 +1,106 @@ +# Module: deprecated + +# --- 废弃函数/版本兼容(不推荐使用,并可能在未来的版本中被移除) --- + +# AI 对话(当前默认模型为 'hunyuan-lite',无记忆) +def chat(prompt='你好', model=1, stream=1, stream_label=0): + import requests + url = "http://api.guanjihuan.com/chat" + data = { + "prompt": prompt, + "model": model, + } + if stream == 1: + if stream_label == 1: + print('\n--- Start Chat Stream Message ---\n') + requests_response = requests.post(url, json=data, stream=True) + response = '' + if requests_response.status_code == 200: + for line in requests_response.iter_lines(): + if line: + if stream == 1: + print(line.decode('utf-8'), end='', flush=True) + response += line.decode('utf-8') + print() + else: + pass + if stream == 1: + if stream_label == 1: + print('\n--- End Chat Stream Message ---\n') + return response + +# 加上函数代码的 AI 对话(当前默认模型为 'hunyuan-lite',无记忆) +def chat_with_function_code(function_name, prompt='', model=1, stream=1): + import guan + function_source = guan.get_source(function_name) + if prompt == '': + response = guan.chat(prompt=function_source, model=model, stream=stream) + else: + response = guan.chat(prompt=function_source+'\n\n'+prompt, model=model, stream=stream) + return response + +# 机器人自动对话(当前默认模型为 'hunyuan-lite',无记忆) +def auto_chat(prompt='你好', round=2, model=1, stream=1): + import guan + response0 = prompt + for i0 in range(round): + print(f'\n【对话第 {i0+1} 轮】\n') + print('机器人 1: ') + response1 = guan.chat(prompt=response0, model=model, stream=stream) + print('机器人 2: ') + response0 = guan.chat(prompt=response1, model=model, stream=stream) + +# 机器人自动对话(引导对话)(当前默认模型为 'hunyuan-lite',无记忆) +def auto_chat_with_guide(prompt='你好', guide_message='(回答字数少于30个字,最后反问我一个问题)', round=5, model=1, stream=1): + import guan + response0 = prompt + for i0 in range(round): + print(f'\n【对话第 {i0+1} 轮】\n') + print('机器人 1: ') + response1 = guan.chat(prompt=response0+guide_message, model=model, stream=stream) + print('机器人 2: ') + response0 = guan.chat(prompt=response1+guide_message, model=model, stream=stream) + +def make_sh_file(sh_filename='a', command_line='python a.py', cpu_num=1, task_name='task', cd_dir=0): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.make_sh_file_for_qsub().') + guan.make_sh_file_for_qsub(sh_filename=sh_filename, command_line=command_line, cpu_num=cpu_num, task_name=task_name, cd_dir=cd_dir) + +def plot_without_starting_fig(plt, fig, ax, x_array, y_array, xlabel='x', ylabel='y', title='', fontsize=20, style='', y_min=None, y_max=None, linewidth=None, markersize=None, color=None, fontfamily='Times New Roman'): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.plot_without_starting_fig_ax().') + guan.plot_without_starting_fig_ax(plt, fig, ax, x_array, y_array, xlabel=xlabel, ylabel=ylabel, title=title, fontsize=fontsize, style=style, y_min=y_min, y_max=y_max, linewidth=linewidth, markersize=markersize, color=color, fontfamily=fontfamily) + +def draw_dots_and_lines_without_starting_fig(plt, fig, ax, coordinate_array, draw_dots=1, draw_lines=1, max_distance=1, line_style='-k', linewidth=1, dot_style='ro', markersize=3): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.draw_dots_and_lines_without_starting_fig_ax().') + guan.draw_dots_and_lines_without_starting_fig_ax(plt, fig, ax, coordinate_array, draw_dots=draw_dots, draw_lines=draw_lines, max_distance=max_distance, line_style=line_style, linewidth=linewidth, dot_style=dot_style, markersize=markersize) + +def get_days_of_the_current_month(str_or_datetime='str'): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_current_month().') + date_array = guan.get_date_array_of_the_current_month(str_or_datetime=str_or_datetime) + return date_array + +def get_days_of_the_last_month(str_or_datetime='str'): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_last_month().') + date_array = guan.get_date_array_of_the_last_month(str_or_datetime=str_or_datetime) + return date_array + +def get_days_of_the_month_before_last(str_or_datetime='str'): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_month_before_last().') + date_array = guan.get_date_array_of_the_month_before_last(str_or_datetime=str_or_datetime) + return date_array + +def pdf_to_text(pdf_path): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.pdf_to_text_with_pdfminer3k().') + content = guan.pdf_to_text_with_pdfminer3k(pdf_path) + return content + +def statistics_with_day_and_time(content='', filename='time_logging', file_format='.txt'): + import guan + print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.logging_with_day_and_time().') + guan.logging_with_day_and_time(content=content, filename=filename, file_format=file_format) \ No newline at end of file diff --git a/PyPI/src/guan/others.py b/PyPI/src/guan/others.py index f652d4f..97e2321 100644 --- a/PyPI/src/guan/others.py +++ b/PyPI/src/guan/others.py @@ -1282,50 +1282,4 @@ def initialize_hamiltonian_from_atom_object_list(atom_object_list): hamiltonian = np.zeros((dim*num, dim*num)) for i0 in range(num): hamiltonian[i0*dim+0:i0*dim+dim, i0*dim+0:i0*dim+dim] = atom_object_list[i0].energy - return hamiltonian - -# --- 废弃函数/版本兼容(不推荐使用,并可能在未来的版本中被移除)deprecated --- - -def make_sh_file(sh_filename='a', command_line='python a.py', cpu_num=1, task_name='task', cd_dir=0): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.make_sh_file_for_qsub().') - guan.make_sh_file_for_qsub(sh_filename=sh_filename, command_line=command_line, cpu_num=cpu_num, task_name=task_name, cd_dir=cd_dir) - -def plot_without_starting_fig(plt, fig, ax, x_array, y_array, xlabel='x', ylabel='y', title='', fontsize=20, style='', y_min=None, y_max=None, linewidth=None, markersize=None, color=None, fontfamily='Times New Roman'): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.plot_without_starting_fig_ax().') - guan.plot_without_starting_fig_ax(plt, fig, ax, x_array, y_array, xlabel=xlabel, ylabel=ylabel, title=title, fontsize=fontsize, style=style, y_min=y_min, y_max=y_max, linewidth=linewidth, markersize=markersize, color=color, fontfamily=fontfamily) - -def draw_dots_and_lines_without_starting_fig(plt, fig, ax, coordinate_array, draw_dots=1, draw_lines=1, max_distance=1, line_style='-k', linewidth=1, dot_style='ro', markersize=3): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.draw_dots_and_lines_without_starting_fig_ax().') - guan.draw_dots_and_lines_without_starting_fig_ax(plt, fig, ax, coordinate_array, draw_dots=draw_dots, draw_lines=draw_lines, max_distance=max_distance, line_style=line_style, linewidth=linewidth, dot_style=dot_style, markersize=markersize) - -def get_days_of_the_current_month(str_or_datetime='str'): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_current_month().') - date_array = guan.get_date_array_of_the_current_month(str_or_datetime=str_or_datetime) - return date_array - -def get_days_of_the_last_month(str_or_datetime='str'): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_last_month().') - date_array = guan.get_date_array_of_the_last_month(str_or_datetime=str_or_datetime) - return date_array - -def get_days_of_the_month_before_last(str_or_datetime='str'): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.get_date_array_of_the_month_before_last().') - date_array = guan.get_date_array_of_the_month_before_last(str_or_datetime=str_or_datetime) - return date_array - -def pdf_to_text(pdf_path): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.pdf_to_text_with_pdfminer3k().') - content = guan.pdf_to_text_with_pdfminer3k(pdf_path) - return content - -def statistics_with_day_and_time(content='', filename='time_logging', file_format='.txt'): - import guan - print('Warning: The current function name has been deprecated, which will be deleted in the future version. Please change it into guan.logging_with_day_and_time().') - guan.logging_with_day_and_time(content=content, filename=filename, file_format=file_format) \ No newline at end of file + return hamiltonian \ No newline at end of file diff --git a/README.md b/README.md index 24d0047..73eaceb 100755 --- a/README.md +++ b/README.md @@ -6,11 +6,15 @@ The primary location of this package is on https://py.guanjihuan.com. ## Installation +```bash pip install --upgrade guan +``` ## Usage +```python import guan +``` ## Summary of API Reference @@ -42,4 +46,23 @@ There is no official paper related to this package temporarily, but you can cite + Ji-Huan Guan, Wen-Kai Lou, and Kai Chang, Topological hidden phase transition in honeycomb bilayers with a high Chern number, Phys. Rev. B 110, 165303 (2024). + Ji-Huan Guan, Yan-Yang Zhang, Si-Si Wang, Yan Yu, Yang Xia and Shu-Shen Li, Barrier Tunneling and Loop Polarization in Hopf Semimetals. Phys. Rev. B 102, 064203 (2020). -+ Ji-Huan Guan, Yan-Yang Zhang, Wei-Er Lu, Yang Xia and Shu-Shen Li, Barrier tunneling of the loop-nodal semimetal in the hyperhoneycomb lattice. J. Phys.: Condens. Matter 30, 185402 (2018). \ No newline at end of file ++ Ji-Huan Guan, Yan-Yang Zhang, Wei-Er Lu, Yang Xia and Shu-Shen Li, Barrier tunneling of the loop-nodal semimetal in the hyperhoneycomb lattice. J. Phys.: Condens. Matter 30, 185402 (2018). + +## Website + +The website (https://py.guanjihuan.com) is generated from the source code in `PyPI/src/guan` by static parsing, so it always matches the source code. It hosts the home page, the module list, the data tables, the full API reference, the about page, the installation page and `api.json`. + + +Build the distribution: + +```bash +python -m build --outdir dist --no-isolation PyPI +``` + +Build the website: + +```bash +python website/build_site.py -o dist/site +``` + +The build also checks the generated pages for broken links and reports source files that fail to parse. Pass `--strict` to make it exit with a non-zero status when something is wrong, which is handy in CI. \ No newline at end of file diff --git a/tutorials/00_test.py b/tutorials/00_test.py new file mode 100644 index 0000000..071bc5a --- /dev/null +++ b/tutorials/00_test.py @@ -0,0 +1,3 @@ +# 测试代码 +import guan +guan.test() diff --git a/tutorials/README.md b/tutorials/README.md new file mode 100644 index 0000000..88d232d --- /dev/null +++ b/tutorials/README.md @@ -0,0 +1,93 @@ +# Tutorials + +Learning examples for the [Guan package](https://py.guanjihuan.com), extracted from the blog post +[Guan软件包学习示例](https://www.guanjihuan.com/archives/38367). + +One example per file. Each file is a self-contained script that you can run directly, or copy the +functions you need into your own project. + +## Installation + +```bash +pip install --upgrade guan +``` + +```python +import guan +``` + +## Run the examples + +```bash +python tutorials/daily_scenarios/01_ai_chat.py +python tutorials/physics_scenarios/03_band_structure.py +``` + +## Directory + +``` +tutorials/ +├── 00_test.py # package self test +├── daily_scenarios/ # 一、日常场景 +│ ├── 01_ai_chat.py +│ ├── 02_timing_log.py +│ ├── 03_function_timer.py +│ ├── 04_try_except.py +│ ├── 05_dump_and_load_data.py +│ ├── 06_print_array.py +│ ├── 07_matrix_in_file.py +│ ├── 08_parallel_calculation.py +│ ├── 09_loop_calculation.py +│ ├── 10_is_hermitian.py +│ └── 11_split_parameters.py +└── physics_scenarios/ # 二、物理场景 + ├── 01_pauli_matrices.py + ├── 02_real_space_hamiltonian.py + ├── 03_band_structure.py + ├── 04_chern_number_and_wilson_loop.py + ├── 05_density_of_states_with_green_function.py + ├── 06_conductance_and_scattering_matrix.py + └── 07_wavefunction_gauge.py +``` + +## 一、日常场景 / Daily scenarios + +| File | 说明 | Requirements | +| --- | --- | --- | +| `00_test.py` | 软件包自检 `guan.test()` | - | +| `01_ai_chat.py` | AI 对话:内置模型、OpenAI 兼容接口、LangChain、Ollama、llama.cpp、ModelScope | OpenAI 接口需要 API Key;Ollama / llama.cpp / ModelScope 需要本地模型 | +| `02_timing_log.py` | 带日期和时间的运行日志 `guan.logging_with_day_and_time` | - | +| `03_function_timer.py` | 函数计时器:`@guan.timer_decorator` 与 `guan.timer` | - | +| `04_try_except.py` | try except 简写:`@guan.try_decorator` 与 `guan.try_except` | - | +| `05_dump_and_load_data.py` | 变量写入文件并读回 | numpy | +| `06_print_array.py` | 打印数组 | numpy | +| `07_matrix_in_file.py` | 矩阵写入 Markdown / LaTeX 格式文件后查看 | numpy | +| `08_parallel_calculation.py` | 多进程并行计算 | - | +| `09_loop_calculation.py` | 循环参数计算(一、二、三个参数)与作图 | numpy, matplotlib | +| `10_is_hermitian.py` | 检查是否为厄米矩阵(相对误差 1e-5) | numpy | +| `11_split_parameters.py` | 数组分割,为并行计算准备参数 | numpy | + +## 二、物理场景 / Physics scenarios + +| File | 说明 | Requirements | +| --- | --- | --- | +| `01_pauli_matrices.py` | 泡利矩阵 | numpy | +| `02_real_space_hamiltonian.py` | 一维 / 二维方格 / 三维立方格的实空间哈密顿量 | numpy | +| `03_band_structure.py` | 能带图计算:一维链、方格带、石墨烯锯齿形带 | numpy, matplotlib | +| `04_chern_number_and_wilson_loop.py` | 陈数与 Wilson loop 计算 | numpy | +| `05_density_of_states_with_green_function.py` | 用格林函数计算态密度(方格与立方格,局域态密度) | numpy, matplotlib | +| `06_conductance_and_scattering_matrix.py` | 电导与散射矩阵的计算 | numpy, matplotlib | +| `07_wavefunction_gauge.py` | 波函数规范(fixed gauge)的选取 | numpy | + +## 说明 / Notes + ++ Some examples need an API Key, a local Ollama/llama.cpp/ModelScope model, or a specific model path. + Edit the paths in the scripts before running them. ++ `04_try_except.py` contains a deliberate `bug_code` call to demonstrate the exception handling. ++ Figures are saved to the current working directory when the plotting functions are used. + +## License / 许可 + +The source post is published under [CC BY](https://www.guanjihuan.com), by +[Ji-Huan Guan](https://www.guanjihuan.com/about). Please cite the source when quoting: +https://www.guanjihuan.com/archives/38367 diff --git a/tutorials/daily_scenarios/01_ai_chat.py b/tutorials/daily_scenarios/01_ai_chat.py new file mode 100644 index 0000000..9028cc0 --- /dev/null +++ b/tutorials/daily_scenarios/01_ai_chat.py @@ -0,0 +1,19 @@ +# AI 对话 +import guan + +response, history = guan.openai_chat("你好", model="qwen-plus") # 需要 API Key +print() + +response = guan.langchain_chat_without_memory(prompt="你好", model="qwen-plus") # 需要 API Key +response == guan.langchain_chat_with_memory(prompt="你好", model="qwen-plus") # 需要 API Key +print() + +response = guan.ollama_chat(prompt='你好', model="qwen2.5:0.5b") # 需要运行 Ollama 和下载对应的模型 +print() + +llm = guan.load_llama_model(model_path="D:/models/Qwen/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf") # 需要有模型文件 +response, history = guan.llama_chat(llm, '你好 /no_think') +print() + +model, tokenizer = guan.load_modelscope_model(model_name="D:/models/Qwen/Qwen3-0.6B") # 需要有模型文件 +response, history = guan.modelscope_chat(model, tokenizer, '你好 /no_think') diff --git a/tutorials/daily_scenarios/02_timing_log.py b/tutorials/daily_scenarios/02_timing_log.py new file mode 100644 index 0000000..5d772d2 --- /dev/null +++ b/tutorials/daily_scenarios/02_timing_log.py @@ -0,0 +1,7 @@ +# 运行时间日志 +import guan +import time +guan.logging_with_day_and_time(content='start') +for i in range(3): + time.sleep(5) + guan.logging_with_day_and_time(f'end_of_{i}') diff --git a/tutorials/daily_scenarios/03_function_timer.py b/tutorials/daily_scenarios/03_function_timer.py new file mode 100644 index 0000000..6ecc92b --- /dev/null +++ b/tutorials/daily_scenarios/03_function_timer.py @@ -0,0 +1,25 @@ +# 函数计时器 +import guan + +@guan.timer_decorator +def test1(a, b): + import time + print(a) + time.sleep(1) + print(b) + print('Run finished.') + +for _ in range(2): + test1(10, b=20) + +print() + +def test2(a, b): + import time + print(a) + time.sleep(1) + print(b) + print('Run finished.') + +for _ in range(2): + guan.timer(test2, 100, b=200) diff --git a/tutorials/daily_scenarios/04_try_except.py b/tutorials/daily_scenarios/04_try_except.py new file mode 100644 index 0000000..aaaf06c --- /dev/null +++ b/tutorials/daily_scenarios/04_try_except.py @@ -0,0 +1,23 @@ +# try except 简写 +import guan + +@guan.try_decorator +def test1(a, b): + print(a) + bug_code + print(b) + return 'return_message1' + +result1 = test1(10, b=20) +print(result1) + +print() + +def test2(a, b): + print(a) + bug_code + print(b) + return 'return_message2' + +result2 = guan.try_except(test2, 100, b=200) +print(result2) diff --git a/tutorials/daily_scenarios/05_dump_and_load_data.py b/tutorials/daily_scenarios/05_dump_and_load_data.py new file mode 100644 index 0000000..f62a898 --- /dev/null +++ b/tutorials/daily_scenarios/05_dump_and_load_data.py @@ -0,0 +1,7 @@ +# 变量写入文件 +import guan +import numpy as np +data = np.array([1, 2, 3]) +guan.dump_data(data, filename='a') +loaded_data = guan.load_data(filename='a') +print(loaded_data) diff --git a/tutorials/daily_scenarios/06_print_array.py b/tutorials/daily_scenarios/06_print_array.py new file mode 100644 index 0000000..cf5eb92 --- /dev/null +++ b/tutorials/daily_scenarios/06_print_array.py @@ -0,0 +1,4 @@ +# 打印数组 +import guan +a = [1, 2, 3, 'a', 'b', 'c'] +guan.print_array(a) diff --git a/tutorials/daily_scenarios/07_matrix_in_file.py b/tutorials/daily_scenarios/07_matrix_in_file.py new file mode 100644 index 0000000..4e12b1c --- /dev/null +++ b/tutorials/daily_scenarios/07_matrix_in_file.py @@ -0,0 +1,6 @@ +# 矩阵写入文件后查看 +import guan +import numpy as np +matrix = np.random.rand(5, 5) +guan.write_matrix_in_markdown_format(matrix=matrix, filename='markdown_matrix') +guan.write_matrix_in_latex_format(matrix=matrix, filename='latex_matrix') diff --git a/tutorials/daily_scenarios/08_parallel_calculation.py b/tutorials/daily_scenarios/08_parallel_calculation.py new file mode 100644 index 0000000..3547bbc --- /dev/null +++ b/tutorials/daily_scenarios/08_parallel_calculation.py @@ -0,0 +1,15 @@ +# 并行计算 +import guan +import time +import os + +def run_proc(name): + start_time = time.time() + time.sleep(5) + end_time = time.time() + print ('Process id running on name %s = %s' % (name, os.getpid()), '; running time = %s' % (end_time-start_time)) + return f'name_{name}' + +if __name__ == '__main__': + result_array = guan.parallel_calculation_with_multiprocessing_Pool(func=run_proc, args_list=range(32), show_time=1) + print(result_array) diff --git a/tutorials/daily_scenarios/09_loop_calculation.py b/tutorials/daily_scenarios/09_loop_calculation.py new file mode 100644 index 0000000..3f07579 --- /dev/null +++ b/tutorials/daily_scenarios/09_loop_calculation.py @@ -0,0 +1,33 @@ +# 循环参数计算 + +import guan +import numpy as np + +def test_1(x): + return 2*x + +x_array = np.arange(0, 5, 1) +result_array = guan.loop_calculation_with_one_parameter(test_1, x_array) +print(result_array) +guan.plot(x_array, result_array) +print() + +def test_2(x, y): + return x+y + +x_array = np.arange(0, 5, 1) +y_array = np.arange(0, 3, 1) +result_array = guan.loop_calculation_with_two_parameters(test_2, x_array, y_array) +print(result_array) +guan.plot_contour(x_array, y_array, result_array) +print() + +def test_3(x, y, z): + return x+y+z + +x_array = np.arange(0, 5, 1) +y_array = np.arange(0, 3, 1) +z_array = np.arange(0, 2, 1) +result_array = guan.loop_calculation_with_three_parameters(test_3, x_array, y_array, z_array) +print(result_array) +guan.plot_contour(y_array, z_array, result_array[:, :, 4]) diff --git a/tutorials/daily_scenarios/10_is_hermitian.py b/tutorials/daily_scenarios/10_is_hermitian.py new file mode 100644 index 0000000..9f0a6f1 --- /dev/null +++ b/tutorials/daily_scenarios/10_is_hermitian.py @@ -0,0 +1,16 @@ +# 检查是否为厄米矩阵 +import guan + +matrix1 = [ + [2, 1.00001-1j], + [1+1j, 1] +] + +print(guan.is_hermitian(matrix1)) + +matrix2 = [ + [2, 1.00002-1j], + [1+1j, 1] +] + +print(guan.is_hermitian(matrix2)) diff --git a/tutorials/daily_scenarios/11_split_parameters.py b/tutorials/daily_scenarios/11_split_parameters.py new file mode 100644 index 0000000..00673c1 --- /dev/null +++ b/tutorials/daily_scenarios/11_split_parameters.py @@ -0,0 +1,9 @@ +# 数组分割 +import numpy as np +import guan +task_num = 4 +parameter_array_all = np.arange(0, 17, 1) +for task_index in range(task_num): + parameter_array = guan.preprocess_for_parallel_calculations(parameter_array_all, task_num, task_index) + print(parameter_array) + print() diff --git a/tutorials/physics_scenarios/01_pauli_matrices.py b/tutorials/physics_scenarios/01_pauli_matrices.py new file mode 100644 index 0000000..910b87a --- /dev/null +++ b/tutorials/physics_scenarios/01_pauli_matrices.py @@ -0,0 +1,4 @@ +# 泡利矩阵 +import guan +sigma_x = guan.sigma_x() +print(sigma_x) diff --git a/tutorials/physics_scenarios/02_real_space_hamiltonian.py b/tutorials/physics_scenarios/02_real_space_hamiltonian.py new file mode 100644 index 0000000..b12b409 --- /dev/null +++ b/tutorials/physics_scenarios/02_real_space_hamiltonian.py @@ -0,0 +1,5 @@ +# 实空间哈密顿量 +import guan +print('\n', guan.hamiltonian_of_finite_size_system_along_one_direction(3), '\n') +print(guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(2, 2), '\n') +print(guan.hamiltonian_of_finite_size_system_along_three_directions_for_cubic_lattice(2, 2, 2), '\n') diff --git a/tutorials/physics_scenarios/03_band_structure.py b/tutorials/physics_scenarios/03_band_structure.py new file mode 100644 index 0000000..0b964d4 --- /dev/null +++ b/tutorials/physics_scenarios/03_band_structure.py @@ -0,0 +1,14 @@ +# 能带图计算 +import guan +import numpy as np +k_array = np.linspace(-np.pi, np.pi, 100) + # one dimensional chain +hamiltonian_function = guan.one_dimensional_fourier_transform_with_k(unit_cell=0, hopping=1) +eigenvalue_array = guan.calculate_eigenvalue_with_one_parameter(k_array, hamiltonian_function) +guan.plot(k_array, eigenvalue_array, xlabel='k', ylabel='E', style='-k', fontfamily=None) +# square lattice ribbon +eigenvalue_array = guan.calculate_eigenvalue_with_one_parameter(k_array, guan.hamiltonian_of_square_lattice_in_quasi_one_dimension) +guan.plot(k_array, eigenvalue_array, xlabel='k', ylabel='E', style='-k', fontfamily=None) +# graphene ribbon +eigenvalue_array = guan.calculate_eigenvalue_with_one_parameter(k_array, guan.hamiltonian_of_graphene_with_zigzag_in_quasi_one_dimension) +guan.plot(k_array, eigenvalue_array, xlabel='k', ylabel='E', style='-k', fontfamily=None) diff --git a/tutorials/physics_scenarios/04_chern_number_and_wilson_loop.py b/tutorials/physics_scenarios/04_chern_number_and_wilson_loop.py new file mode 100644 index 0000000..7fb7320 --- /dev/null +++ b/tutorials/physics_scenarios/04_chern_number_and_wilson_loop.py @@ -0,0 +1,9 @@ +# 陈数和Wilson loop计算 +import guan +import numpy as np +chern_number = guan.calculate_chern_number_for_square_lattice_with_efficient_method(guan.hamiltonian_of_one_QAH_model, precision=100) +print('\nChern number=', chern_number, '\n') +wilson_loop_array = guan.calculate_wilson_loop(guan.hamiltonian_of_ssh_model) +print('Wilson loop =', wilson_loop_array) +p = np.log(wilson_loop_array)/2/np.pi/1j +print('\np =', p, '\n') diff --git a/tutorials/physics_scenarios/05_density_of_states_with_green_function.py b/tutorials/physics_scenarios/05_density_of_states_with_green_function.py new file mode 100644 index 0000000..74225d5 --- /dev/null +++ b/tutorials/physics_scenarios/05_density_of_states_with_green_function.py @@ -0,0 +1,34 @@ +# 使用格林函数计算态密度 +import guan +import numpy as np + +hamiltonian = guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(2,2) +fermi_energy_array = np.linspace(-4, 4, 400) +total_dos_array = guan.total_density_of_states_with_fermi_energy_array(fermi_energy_array, hamiltonian, broadening=0.1) +guan.plot(fermi_energy_array, total_dos_array, xlabel='E', ylabel='Total DOS', style='-', fontfamily=None) + +fermi_energy = 0 +N1 = 3 +N2 = 4 +hamiltonian = guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(N1,N2) +LDOS = guan.local_density_of_states_for_square_lattice(fermi_energy, hamiltonian, N1=N1, N2=N2) +print('square lattice:\n', LDOS, '\n') +h00 = guan.hamiltonian_of_finite_size_system_along_one_direction(N2) +h01 = np.identity(N2) +LDOS = guan.local_density_of_states_for_square_lattice_using_dyson_equation(fermi_energy, h00=h00, h01=h01, N2=N2, N1=N1) +print(LDOS, '\n\n') +LDOS2 = guan.local_density_of_states_for_square_lattice_using_dyson_equation_with_second_method(fermi_energy, h00, h01, N2, N1, internal_degree=1, broadening=0.01) +print(LDOS2, '\n\n') +guan.plot_contour(range(N1), range(N2), LDOS, fontfamily=None) +guan.plot_contour(range(N1), range(N2), LDOS2, fontfamily=None) + +N1 = 3 +N2 = 4 +N3 = 5 +hamiltonian = guan.hamiltonian_of_finite_size_system_along_three_directions_for_cubic_lattice(N1, N2, N3) +LDOS = guan.local_density_of_states_for_cubic_lattice(fermi_energy, hamiltonian, N1=N1, N2=N2, N3=N3) +print('cubic lattice:\n', LDOS, '\n') +h00 = guan.hamiltonian_of_finite_size_system_along_two_directions_for_square_lattice(N2, N3) +h01 = np.identity(N2*N3) +LDOS = guan.local_density_of_states_for_cubic_lattice_using_dyson_equation(fermi_energy, h00, h01, N3=N3, N2=N2, N1=N1) +print(LDOS) diff --git a/tutorials/physics_scenarios/06_conductance_and_scattering_matrix.py b/tutorials/physics_scenarios/06_conductance_and_scattering_matrix.py new file mode 100644 index 0000000..e545481 --- /dev/null +++ b/tutorials/physics_scenarios/06_conductance_and_scattering_matrix.py @@ -0,0 +1,12 @@ +# 电导和散射矩阵的计算 +import guan +import numpy as np + +fermi_energy_array = np.linspace(-4, 4, 400) +h00 = guan.hamiltonian_of_finite_size_system_along_one_direction(4) +h01 = np.identity(4) +conductance_array = guan.calculate_conductance_with_fermi_energy_array(fermi_energy_array, h00, h01) +guan.plot(fermi_energy_array, conductance_array, xlabel='E', ylabel='Conductance', style='-', fontfamily=None) + +fermi_energy = 0 +guan.print_or_write_scattering_matrix(fermi_energy, h00, h01) diff --git a/tutorials/physics_scenarios/07_wavefunction_gauge.py b/tutorials/physics_scenarios/07_wavefunction_gauge.py new file mode 100644 index 0000000..6720489 --- /dev/null +++ b/tutorials/physics_scenarios/07_wavefunction_gauge.py @@ -0,0 +1,22 @@ +# 波函数规范的选取 +import numpy as np +import cmath +import guan + +# Fixed gauge example 1 +vector = np.array([np.sqrt(0.5), np.sqrt(0.5)])*cmath.exp(np.random.uniform(0, 1)*1j) +print('\nExample 1\n', vector) +print(np.dot(vector.transpose().conj(), vector), '\n') + +vector = guan.find_vector_with_fixed_gauge_by_making_one_component_real(vector) +print(vector) +print(np.dot(vector.transpose().conj(), vector), '\n') + +# Fixed gauge example 2 +vector = np.array([1, 0])*cmath.exp(np.random.uniform(0, 1)*1j) +print('\nExample 2\n', vector) +print(np.dot(vector.transpose().conj(), vector), '\n') + +vector = guan.find_vector_with_fixed_gauge_by_making_one_component_real(vector) +print(vector) +print(np.dot(vector.transpose().conj(), vector), '\n') diff --git a/website/audit_site.py b/website/audit_site.py new file mode 100644 index 0000000..8100e06 --- /dev/null +++ b/website/audit_site.py @@ -0,0 +1,279 @@ +"""审计生成的站点:链接、HTML 结构、SEO、可访问性。 + +用法: + python website/audit_site.py [站点目录] +默认审计 website/dist/site。 +""" + +from __future__ import annotations + +import html +import json +import re +import sys +from collections import Counter +from pathlib import Path +from urllib.parse import unquote, urlparse + +ROOT = Path(__file__).resolve().parent.parent +SITE = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "website" / "dist" / "site" + +problems: list[str] = [] +notes: list[str] = [] + + +def report(kind: str, page: str, message: str) -> None: + problems.append(f"[{kind}] {page}: {message}") + + +HTML_PAGES = sorted(SITE.rglob("*.html")) +all_files = {p.relative_to(SITE).as_posix() for p in SITE.rglob("*") if p.is_file()} + +# --------------------------------------------------------------------------- +# 1. 链接检查 +# --------------------------------------------------------------------------- +ATTR_RE = re.compile(r"""\b(href|src)\s*=\s*(["'])(.*?)\2""", re.S | re.I) +ID_RE = re.compile(r"""\bid\s*=\s*(["'])(.*?)\1""", re.S | re.I) + +page_ids: dict[str, set[str]] = {} +for page in HTML_PAGES: + text = page.read_text(encoding="utf-8") + page_ids[page.relative_to(SITE).as_posix()] = {m.group(2) for m in ID_RE.finditer(text)} + +broken_links = 0 +external_links: Counter[str] = Counter() + +for page in HTML_PAGES: + rel = page.relative_to(SITE).as_posix() + text = page.read_text(encoding="utf-8") + for match in ATTR_RE.finditer(text): + attr, raw = match.group(1).lower(), match.group(3).strip() + if not raw or raw.startswith(("javascript:", "data:", "mailto:", "tel:", "#")): + if raw.startswith("#"): + anchor = unquote(raw[1:]) + if anchor and anchor not in page_ids[rel]: + report("锚点", rel, f"指向本页不存在的 id: {raw}") + continue + parsed = urlparse(raw) + if parsed.scheme in ("http", "https"): + external_links[parsed.netloc] += 1 + continue + if parsed.netloc: + report("链接", rel, f"未知协议/域名: {raw}") + continue + target = unquote(parsed.path) + if not target: + continue + base = page.parent + dest = (base / target).resolve() + try: + dest_rel = dest.relative_to(SITE.resolve()) + except ValueError: + report("链接", rel, f"指向站点外部: {raw}") + continue + if target.endswith("/"): + dest = dest / "index.html" + if not dest.exists(): + report("链接", rel, f"目标不存在: {raw}") + broken_links += 1 + elif parsed.fragment: + frag = unquote(parsed.fragment) + dest_rel = dest_rel.as_posix() + if dest_rel in page_ids and frag not in page_ids[dest_rel]: + report("锚点", rel, f"{raw} -> 目标页无 id: {frag}") + +# --------------------------------------------------------------------------- +# 2. HTML 结构检查 +# --------------------------------------------------------------------------- +VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr"} +BLOCK = {"html", "head", "body", "div", "main", "header", "footer", "nav", + "section", "article", "aside", "ul", "ol", "li", "table", "tbody", + "thead", "tr", "td", "th", "form", "p", "h1", "h2", "h3", "h4", "h5", + "h6", "pre", "blockquote", "details", "summary", "figure", "picture", + "video", "audio", "dl", "dt", "dd", "hr", "fieldset", "option", + "select", "textarea", "button", "nav", "template", "svg", "canvas", + "iframe", "span", "label", "code", "a", "strong", "em", "b", "i", + "small", "sub", "sup", "br", "img", "table", "caption", "colgroup", + "style", "script", "noscript"} + +for page in HTML_PAGES: + rel = page.relative_to(SITE).as_posix() + text = page.read_text(encoding="utf-8") + + # doctype + if not text.lstrip().lower().startswith("") + + # lang 属性 + m = re.search(r"]*)>", text, re.I) + if not m: + report("结构", rel, "缺少 标签") + elif not re.search(r"\blang\s*=", m.group(1), re.I): + report("SEO", rel, " 缺少 lang 属性") + + # title + if not re.search(r".+?", text, re.S | re.I): + report("SEO", rel, "缺少非空 ") + + # meta description + md = re.search(r'<meta\s+name=["\']description["\']\s+content=["\'](.*?)["\']', text, re.S | re.I) + if not md: + report("SEO", rel, "缺少 meta description") + elif not md.group(1).strip(): + report("SEO", rel, "meta description 为空") + + # viewport + if not re.search(r'<meta\s+name=["\']viewport["\']', text, re.I): + report("SEO", rel, "缺少 viewport meta") + + # 标题层级:h1 应唯一 + h1s = re.findall(r"<h1\b", text, re.I) + if len(h1s) != 1: + report("结构", rel, f"h1 数量为 {len(h1s)}(应为 1)") + + # 标题层级不跳级 + levels = [int(m) for m in re.findall(r"<h([1-6])\b", text, re.I)] + for prev, cur in zip(levels, levels[1:]): + if cur > prev + 1: + report("结构", rel, f"标题层级跳跃 h{prev} -> h{cur}") + + # 标签配对(简单栈式检查,忽略 void 与 script/style 内容) + stripped = re.sub(r"<(script|style)\b.*?</\1>", "", text, flags=re.S | re.I) + stack: list[str] = [] + for m in re.finditer(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*?(/?)>", stripped): + closing, tag, selfclose = m.group(1), m.group(2).lower(), m.group(3) + if tag in VOID or selfclose: + continue + if closing: + if not stack: + report("结构", rel, f"多余的 </{tag}>") + elif stack[-1] != tag: + if tag in stack: + while stack and stack[-1] != tag: + report("结构", rel, f"标签未闭合: <{stack.pop()}>") + stack.pop() + else: + report("结构", rel, f"标签错配: </{tag}> 对应 <{stack[-1]}>") + stack.pop() + else: + stack.pop() + else: + stack.append(tag) + for leftover in reversed(stack): + report("结构", rel, f"标签未闭合: <{leftover}>") + + # 重复 id + ids = [m.group(2) for m in ID_RE.finditer(text)] + dupes = [i for i, c in Counter(ids).items() if c > 1] + if dupes: + report("结构", rel, f"重复 id: {dupes[:5]}") + + # 图片 alt + for m in re.finditer(r"<img\b[^>]*>", text, re.I | re.S): + if not re.search(r"\balt\s*=", m.group(0), re.I): + report("可访问性", rel, f"img 缺少 alt: {m.group(0)[:70]}") + + # 链接文本 + for m in re.finditer(r"<a\b([^>]*)>(.*?)</a>", text, re.S | re.I): + attrs, inner = m.group(1), m.group(2) + if 'aria-label="' in attrs or "aria-label='" in attrs: + continue + text_only = re.sub(r"<[^>]+>", "", inner).strip() + if not text_only and not re.search(r"\bdownload\b", attrs, re.I): + report("可访问性", rel, f"空链接文本: {attrs[:70]}") + + # 按钮缺 type + for m in re.finditer(r"<button\b[^>]*>", text, re.I): + if not re.search(r"\btype\s*=", m.group(0), re.I): + report("结构", rel, "button 缺少 type 属性") + + # 硬编码语言 + if 'lang="en"' in text.lower() and "en/" in rel: + pass + +# --------------------------------------------------------------------------- +# 3. 跨页面语言检查 +# --------------------------------------------------------------------------- +for page in HTML_PAGES: + rel = page.relative_to(SITE).as_posix() + text = page.read_text(encoding="utf-8") + m = re.search(r"<html\b([^>]*)>", text, re.I) + lang = re.search(r'lang=["\']([^"\']+)["\']', m.group(1) if m else "") + expected = "en" if rel.startswith("en/") else "zh-CN" + if lang and lang.group(1) != expected: + report("i18n", rel, f"lang={lang.group(1)} 期望 {expected}") + +# 中英文页面数量配平 +zh_pages = {p.relative_to(SITE).as_posix() for p in HTML_PAGES if not p.relative_to(SITE).as_posix().startswith("en/")} +en_pages = {p.relative_to(SITE).as_posix()[3:] for p in HTML_PAGES if p.relative_to(SITE).as_posix().startswith("en/")} +if zh_pages != en_pages: + only_zh = sorted(zh_pages - en_pages) + only_en = sorted(en_pages - zh_pages) + if only_zh: + report("i18n", "-", f"只有中文的页面: {only_zh}") + if only_en: + report("i18n", "-", f"只有英文的页面: {only_en}") + +# --------------------------------------------------------------------------- +# 4. 静态资源 +# --------------------------------------------------------------------------- +for page in HTML_PAGES: + rel = page.relative_to(SITE).as_posix() + text = page.read_text(encoding="utf-8") + for m in re.finditer(r'<script\b[^>]*\bsrc=["\']([^"\']+)["\']', text, re.I): + src = urlparse(m.group(1)).path + p = (page.parent / unquote(src)).resolve() + if not p.exists(): + report("资源", rel, f"脚本不存在: {m.group(1)}") + +# 孤立的静态资源 +referenced: set[str] = set() +for page in HTML_PAGES: + text = page.read_text(encoding="utf-8") + for m in ATTR_RE.finditer(text): + raw = m.group(3).strip() + if raw.startswith(("http", "mailto:", "javascript:", "data:", "#")): + continue + target = unquote(urlparse(raw).path) + if target: + referenced.add(((page.parent / target).resolve()).as_posix()) +for f in SITE.rglob("*"): + if f.is_file() and f.suffix.lower() in (".css", ".js", ".jpg", ".png", ".svg", ".ico"): + if f.resolve().as_posix() not in referenced: + notes.append(f"未被引用的静态文件: {f.relative_to(SITE).as_posix()}") + +# --------------------------------------------------------------------------- +# 5. api.json +# --------------------------------------------------------------------------- +api_json = SITE / "api.json" +if api_json.exists(): + try: + data = json.loads(api_json.read_text(encoding="utf-8")) + notes.append(f"api.json 解析正常,顶层键: {list(data)[:8] if isinstance(data, dict) else type(data)}") + except Exception as exc: + report("api.json", "-", f"解析失败: {exc}") + +# --------------------------------------------------------------------------- +# 6. 控制台输出 +# --------------------------------------------------------------------------- +print("=" * 60) +print(f"审计目录: {SITE}") +print(f"页面数: {len(HTML_PAGES)} 文件数: {len(all_files)}") +print("=" * 60) +if problems: + print(f"\n发现 {len(problems)} 个问题:\n") + for p in problems: + print(" " + p) +else: + print("\n未发现问题。") +if notes: + print(f"\n提示 ({len(notes)}):") + for n in notes: + print(" - " + n) +if external_links: + print("\n外部域名引用:") + for domain, count in external_links.most_common(): + print(f" {domain}: {count}") +print() +sys.exit(1 if problems else 0) diff --git a/website/build_site.py b/website/build_site.py new file mode 100644 index 0000000..17d67f3 --- /dev/null +++ b/website/build_site.py @@ -0,0 +1,1277 @@ +"""生成软件包的网站。 + +用法: + python website/build_site.py # 输出到 dist/site + python website/build_site.py -o build/site # 指定输出目录 + python website/build_site.py --source PyPI/src/guan + +网站的内容有四个来源,都不是手工维护的: + * 包源码(静态解析,见 extract.py)—— API 参考 + * 仓库根目录的 README.md(渲染,见 md_render.py)—— 关于、用法、引用 + * 仓库根目录的 INSTALLATION.md(渲染,见 md_render.py)—— 安装 + * 仓库根目录的 tutorials/(读取,见 tutorials.py)—— 教程与示例 +因此不会与实际发布的包脱节。 + +构建过程不假设任何固定的包名、目录名或函数名: + * 包目录自动发现(PyPI/src、src 等常见布局都能找到) + * 页面与导航按实际有内容与否动态生成 + * 首页示例代码从源码里真实的函数挑出来,而不是写死 + +站点有中英文两套界面(见 i18n.py):中文在根目录,英文在 en/ 子目录。 +只有网站框架(导航、按钮、标题这些)会翻译,文件内容一律保持原样。 +""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import shutil +import sys +import urllib.parse +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +# 允许 `python -m website.build_site` 与 `python website/build_site.py` 两种调用方式 +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import extract # noqa: E402 +import i18n # noqa: E402 +import md_render # noqa: E402 +import tutorials # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +PYPI_DIR = ROOT / "PyPI" +TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" +STATIC_DIR = Path(__file__).resolve().parent / "static" + +# 源码可能放在这些地方,按顺序找 +SOURCE_CANDIDATES = ( + PYPI_DIR / "src", + ROOT / "src", + ROOT, +) + +# 教程目录(示例脚本)可能放在这些地方,按顺序找 +TUTORIALS_CANDIDATES = ( + ROOT / "tutorials", + PYPI_DIR / "tutorials", +) + + +def e(text: object) -> str: + """HTML 转义。""" + return html.escape(str(text), quote=True) + + +def display_name(package: dict) -> str: + """站点上展示的名字(品牌名)。 + + PyPI 上的包名与导入名必须是小写的(pip install guan、import guan), + 但网站标题、页头品牌这类地方用大写更醒目——所以展示名与包名分开。 + 展示名仍由包名推导,不写死具体的名字。 + """ + name = package.get("display_name") or package["name"] + if name.islower() and name.isalnum(): + return name.upper() + return name + + +def load_template(name: str) -> str: + return (TEMPLATE_DIR / name).read_text(encoding="utf-8") + + +def render(template: str, **context: object) -> str: + """极简模板替换:{{ key }} 形式。 + + 只扫描模板本身一次,替换进去的内容不会被二次替换—— + 否则源码或 README 里出现 {{version}} 之类会被误改。 + """ + return re.sub( + r"\{\{\s*(\w+)\s*\}\}", + lambda match: str(context.get(match.group(1), match.group(0))), + template, + ) + + +# -------------------------------------------------------------------------- +# 页面定义 +# -------------------------------------------------------------------------- + + +@dataclass +class PageSpec: + """一个页面的定义。 + + 页面是否出现在导航里,由 available 决定——这样源码里没有函数时, + 「API 参考」页会自动消失,而不是留下一个空页面。 + + 标题与导航文字都用 i18n.py 里的文案键,两种语言各写一份; + title_fn 用于标题里要带包名这类动态内容的情况。 + """ + + key: str + filename: str + label_key: str + title_key: str + builder: Callable[[dict], str] + description_fn: Callable[[dict], str] + title_fn: Callable[[dict], str] | None = None + available: Callable[[dict], bool] | None = None + + def page_title(self, data: dict) -> str: + if self.title_fn is not None: + return self.title_fn(data) + return data["t"](self.title_key) + + def description(self, data: dict) -> str: + return self.description_fn(data) + + +def _has_functions(data: dict) -> bool: + """包里有没有可展示的函数(类的方法也算)。""" + if data["stats"]["functions"]: + return True + return any(module["classes"] for module in data["modules"]) + + +def _has_installation(data: dict) -> bool: + """有没有安装说明文档可渲染。""" + return Path(data["meta"]["installation_file"]).exists() + + +def _has_tutorials(data: dict) -> bool: + """tutorials/ 目录里有没有可展示的示例脚本。""" + tutorials_data = data.get("tutorials") or {} + return bool(tutorials_data.get("files")) + + +PAGE_SPECS: list[PageSpec] = [ + PageSpec( + key="index", + filename="index.html", + label_key="nav_home", + title_key="page_home", + title_fn=lambda data: ( + f"{display_name(data['package'])} — " + f"{data['package']['description'] or data['package'].get('summary') or ''}" + ).strip(" —"), + builder=lambda data: build_home(data), + description_fn=lambda data: data["t"]( + "page_home", name=display_name(data["package"]) + ), + ), + PageSpec( + key="installation", + filename="installation.html", + label_key="nav_installation", + title_key="page_installation", + builder=lambda data: build_installation(data), + description_fn=lambda data: data["t"]( + "desc_installation", name=display_name(data["package"]) + ), + available=_has_installation, + ), + PageSpec( + key="api", + filename="api.html", + label_key="nav_api", + title_key="page_api", + builder=lambda data: build_api(data), + description_fn=lambda data: data["t"]( + "desc_api", name=display_name(data["package"]) + ), + available=_has_functions, + ), + PageSpec( + key="tutorials", + filename="tutorials.html", + label_key="nav_tutorials", + title_key="page_tutorials", + builder=lambda data: build_tutorials(data), + description_fn=lambda data: data["t"]( + "desc_tutorials", name=display_name(data["package"]) + ), + available=_has_tutorials, + ), + PageSpec( + key="about", + filename="about.html", + label_key="nav_about", + title_key="page_about", + builder=lambda data: build_readme(data), + description_fn=lambda data: data["t"]( + "desc_about", name=display_name(data["package"]) + ), + ), +] + + +def active_pages(data: dict) -> list[PageSpec]: + """返回实际要生成的页面列表。""" + pages = [] + for spec in PAGE_SPECS: + if spec.available is not None and not spec.available(data): + continue + pages.append(spec) + return pages + + +def lang_url(lang: str, filename: str, from_lang: str) -> str: + """另一种语言里同一个页面的相对路径(相对于当前页面)。 + + 中文页面在根目录,英文页面在 en/ 子目录,所以两个方向要分别处理。 + """ + target_dir = i18n.LANG_DIRS[lang] + current_dir = i18n.LANG_DIRS[from_lang] + if target_dir == current_dir: + return filename + if not target_dir: + return "../" + filename + if not current_dir: + return target_dir + "/" + filename + return "../" + target_dir + "/" + filename + + +def lang_dir(output: Path, lang: str) -> Path: + """某种语言的页面所在目录。""" + directory = i18n.LANG_DIRS[lang] + return output / directory if directory else output + + +def absolute_url(base: str, path: str) -> str: + """把站点内的相对路径拼成绝对地址。 + + base 是站点根地址(如 https://py.guanjihuan.com),path 是页面里的相对路径 + (如 "api.html" 或 "../api.html")。搜索引擎要求 hreflang 与 canonical + 用绝对地址,所以这里把 ".." 之类的片段解析掉再拼。 + 不知道根地址时原样返回,页面至少还能用相对地址互相跳转。 + """ + if not base: + return path + parts: list[str] = [] + for segment in path.split("/"): + if segment in ("", "."): + continue + if segment == "..": + if parts: + parts.pop() + continue + parts.append(segment) + return base + "/" + "/".join(parts) + + +def site_base_url(package: dict, override: str | None = None) -> str: + """站点根地址。 + + 优先用命令行给的 --base-url;没给就从包元数据里的主页地址推。 + 末尾的斜杠去掉,免得拼出 "https://x.com//api.html" 这种双斜杠。 + """ + candidate = override or package.get("url") or "" + return candidate.strip().rstrip("/") + + + +def _lang_context(data: dict, lang: str, pages: list[PageSpec]) -> dict: + """按语言准备一份数据:文案翻译器,加上该语言的导航标签。""" + t = i18n.translator(lang) + meta = dict(data["meta"]) + meta["page_links"] = [ + {"key": spec.key, "filename": spec.filename, "label": t(spec.label_key)} + for spec in pages + if spec.key != "index" + ] + return {**data, "meta": meta, "t": t, "lang": lang} + + +def nav_html(pages: list[PageSpec], active_key: str, t) -> str: + """生成导航条(顶栏与页脚共用)。""" + items = [] + for spec in pages: + class_attr = "is-active" if spec.key == active_key else "" + items.append( + f'<a href="{spec.filename}" class="{class_attr}">{e(t(spec.label_key))}</a>' + ) + return "\n ".join(items) + + +def page( + template: str, + *, + title: str, + description: str, + content: str, + info: dict, + stats: dict, + pages: list[PageSpec], + active: str, + lang: str, + filename: str, + site_url: str = "", + extra_js: str = "", +) -> str: + """套上 base.html 外壳。""" + package = info + t = i18n.translator(lang) + other = next(item for item in i18n.LANGS if item != lang) + return render( + template, + # 标题与描述会进到 <title> 和 meta 里,必须转义 + # 首页标题已经以展示名开头,后面不再重复追加「· 展示名」 + title=e( + title + if title.startswith(display_name(package)) + else f"{title} · {display_name(package)}" + ), + description=e(description), + content=content, + version=package["version"], + package_name=display_name(package), + import_name=package.get("import_name") or package["name"], + nav_items=nav_html(pages, active, t), + foot_nav_items=nav_html(pages, active, t), + module_count=stats["modules"], + function_count=stats["functions"], + class_count=stats["classes"], + line_count=stats["lines"], + lang=i18n.HTML_LANG[lang], + lang_switch=lang_url(other, filename, lang), + lang_switch_label=i18n.LANG_NAMES_FULL[other], + lang_switch_title=t("switch_language"), + # 无障碍名称里必须包含按钮上看得见的文字,否则读屏软件会读出 + # 一个跟界面上的标签对不上的名字 + lang_switch_aria=t("switch_to_lang", lang=i18n.LANG_NAMES_FULL[other]), + lang_switch_hreflang=i18n.HTML_LANG[other], + # hreflang 与 canonical 用绝对地址——搜索引擎只认绝对的 + alt_zh=absolute_url(site_url, lang_url("zh", filename, lang)), + alt_en=absolute_url(site_url, lang_url("en", filename, lang)), + # x-default 指向默认语言(中文)的那一份 + alt_default=absolute_url(site_url, lang_url(i18n.DEFAULT_LANG, filename, lang)), + canonical=absolute_url(site_url, lang_url(lang, filename, lang)), + skip_link=t("skip"), + theme_label=t("theme_toggle"), + menu_label=t("menu_open"), + to_top_label=t("to_top"), + foot_meta=t( + "foot_stats", + version=package["version"], + modules=stats["modules"], + functions=stats["functions"], + lines=stats["lines"], + ), + site_i18n=json.dumps(t.js_all(), ensure_ascii=False), + extra_js=extra_js, + ) + + +# -------------------------------------------------------------------------- +# 首页 +# -------------------------------------------------------------------------- + + +def page_filename(data: dict, key: str) -> str: + """取某个页面的文件名;页面不存在时返回空串。""" + for link in data["meta"].get("page_links", []): + if link.get("key") == key: + return link.get("filename", "") + return "" + + +def build_home(data: dict) -> str: + package = data["package"] + stats = data["stats"] + modules = data["modules"] + import_name = package.get("import_name") or package["name"] + t = data["t"] + + api_page = page_filename(data, "api") + cards = [] + for module in modules: + count = e(t("functions_count", count=len(module["functions"]))) + # 没有 API 页时(包里还没有函数),卡片不做成链接 + if api_page: + cards.append( + f"""<a class="module-card" href="{e(api_page)}#{e(module['name'])}"> + <span class="module-name">{e(module['name'])}</span> + <span class="module-count">{count}</span> +</a>""" + ) + else: + cards.append( + f"""<div class="module-card"> + <span class="module-name">{e(module['name'])}</span> + <span class="module-count">{count}</span> +</div>""" + ) + + # 首页按钮跟着实际生成的页面走,避免链到不存在的页面 + buttons = [] + for index, link in enumerate(data["meta"].get("page_links", [])): + style = "btn" if index == 0 else "btn btn-ghost" + buttons.append(f'<a class="{style}" href="{e(link["filename"])}">{e(link["label"])}</a>') + hero_links = "\n ".join(buttons) + + return f""" +<section class="hero"> + <div class="hero-text"> + <p class="eyebrow">{e(t("hero_eyebrow"))}</p> + <h1>{e(display_name(package))}</h1> + <p class="lead">{e(package['description'] or package.get('summary') or '')}</p> + <div class="install"> + <code>pip install --upgrade {e(package['name'])}</code> + <button class="copy" type="button" data-copy="pip install --upgrade {e(package['name'])}">{e(t("copy"))}</button> + </div> + <p class="hero-meta">{e(t("hero_version"))} <strong>v{e(package['version'])}</strong> + · {e(t("hero_python", version=package['requires_python'] or '3.6+'))}</p> + <p class="hero-links"> + {hero_links} + </p> + </div> +</section> + +<section class="stats"> + <div class="stat"><span class="stat-value">{stats['modules']}</span><span class="stat-label">{e(t("stat_modules"))}</span></div> + <div class="stat"><span class="stat-value">{stats['functions']}</span><span class="stat-label">{e(t("stat_functions"))}</span></div> + <div class="stat"><span class="stat-value">{stats['lines']:,}</span><span class="stat-label">{e(t("stat_lines"))}</span></div> +</section> + +<section class="section"> + <div class="section-head"> + <h2>{e(t("section_modules"))}</h2> + {f'<a class="more" href="{e(api_page)}">{e(t("all_api"))}</a>' if api_page else ""} + </div> + <div class="module-grid"> +{chr(10).join(cards)} + </div> +</section> + +<section class="section"> + <div class="split"> + <div class="panel"> + <h2>{e(t("section_package"))}</h2> + <table class="params"> + <tbody> + <tr><td>{e(t("field_display_name"))}</td><td><code>{e(display_name(package))}</code></td></tr> + <tr><td>{e(t("field_pypi_name"))}</td><td><code>{e(package['name'])}</code></td></tr> + <tr><td>{e(t("field_import_name"))}</td><td><code>{e(import_name)}</code></td></tr> + <tr><td>{e(t("field_version"))}</td><td><code>{e(package['version'])}</code></td></tr> + <tr><td>{e(t("field_python"))}</td><td><code>{e(package['requires_python'] or '>=3.6')}</code></td></tr> + <tr><td>{e(t("field_homepage"))}</td><td><a href="{e(package.get('url') or '#')}">{e(package.get('url') or '—')}</a></td></tr> + </tbody> + </table> + </div> + </div> +</section> +""" + + +# -------------------------------------------------------------------------- +# API 参考 +# -------------------------------------------------------------------------- + + +def _function_block( + function: dict, + module_name: str, + home_of: dict[str, str], + import_name: str, + t, + anchor: str | None = None, +) -> str: + params = function["params"] + param_rows = [] + for param in params: + default = param["default"] + default_html = ( + f'<code class="param-default">{e(default)}</code>' + if default + else f'<span class="muted">{e(t("param_required"))}</span>' + ) + annotation = ( + f'<span class="param-type">{e(param["annotation"])}</span>' + if param["annotation"] + else "" + ) + param_rows.append( + f'<tr><td><code>{e(param["name"])}</code></td><td>{annotation}</td><td>{default_html}</td></tr>' + ) + params_html = ( + f"""<table class="params"> +<thead><tr><th>{e(t("param_name"))}</th><th>{e(t("param_type"))}</th><th>{e(t("param_default"))}</th></tr></thead> +<tbody> +{chr(10).join(param_rows)} +</tbody> +</table>""" + if param_rows + else "" + ) + + badges = [] + if function.get("deprecated"): + badges.append(f'<span class="badge badge-warn">{e(t("badge_deprecated"))}</span>') + if not function.get("exported", True): + badges.append(f'<span class="badge badge-muted">{e(t("badge_not_exported"))}</span>') + if function.get("is_async"): + badges.append('<span class="badge">async</span>') + if function.get("decorators"): + badges.append('<span class="badge">@' + e(function["decorators"][0]) + "</span>") + badges_html = " ".join(badges) + + # 源码里的分节注释(形如 "# --- 某某某 ---")作为小标签显示 + section_html = ( + f'<p class="func-section">{e(function["section"])}</p>' + if function.get("section") + else "" + ) + + calls_html = "" + if function.get("calls"): + links = [] + for call in function["calls"]: + target = home_of.get(call) + if target: + links.append( + f'<a href="#{e(target)}"><code>{e(import_name)}.{e(call)}</code></a>' + ) + else: + links.append(f"<code>{e(import_name)}.{e(call)}</code>") + calls_html = f'<p class="calls">{e(t("calls"))}{", ".join(links)}</p>' + + doc_html = _docstring_html(function) + anchor_id = anchor or f"{module_name}.{function['name']}" + owner = function.get("owner") or "" + shown_name = f"{owner}.{function['name']}" if owner else function["name"] + return f""" +<article class="func" id="{e(anchor_id)}"> + {section_html} + <h3><code class="func-name">{e(shown_name)}</code>{badges_html}</h3> + <p class="sig"><code>{e(function['signature'])}</code></p> + {f'<p class="desc">{e(function["description"])}</p>' if function["description"] else ""} + {doc_html} + {params_html} + {calls_html} + <details class="source"> + <summary>{e(t("view_source"))}</summary> + <pre class="code"><code>{e(function['source'])}</code></pre> + </details> +</article> +""" + + +def _docstring_html(function: dict) -> str: + """docstring 除了首段(已作为简短说明)之外的其余段落。""" + docstring = (function.get("docstring") or "").strip() + if not docstring: + return "" + paragraphs = [p.strip() for p in docstring.split("\n\n") if p.strip()] + rest = paragraphs[1:] + if not rest: + return "" + body = "".join(f"<p>{e(p)}</p>" for p in rest) + return f'<div class="docstring">{body}</div>' + + +def build_api(data: dict) -> str: + package = data["package"] + modules = data["modules"] + import_name = package.get("import_name") or package["name"] + t = data["t"] + + # 函数名 → 该函数在页面上的锚点,用于生成交叉参考链接 + # 类的锚点是「模块.类」,类的方法是「模块.类.方法」, + # 与模块级函数的「模块.函数」不一样 + home_of: dict[str, str] = {} + for module in modules: + for function in module["functions"]: + home_of.setdefault( + function["name"], f"{module['name']}.{function['name']}" + ) + for klass in module["classes"]: + home_of.setdefault( + klass["name"], f"{module['name']}.{klass['name']}" + ) + for method in klass["methods"]: + home_of.setdefault( + method["name"], f"{module['name']}.{klass['name']}.{method['name']}" + ) + + nav_items = [] + body = [] + for module in modules: + nav_items.append( + f'<li><a href="#{e(module["name"])}"><span class="nav-name">{e(module["name"])}</span>' + f'<span class="nav-count">{len(module["functions"])}</span></a></li>' + ) + functions = "\n".join( + _function_block(function, module["name"], home_of, import_name, t) + for function in module["functions"] + ) + classes_html = "" + if module["classes"]: + class_blocks = [] + for klass in module["classes"]: + methods = "\n".join( + _function_block( + method, + module["name"], + home_of, + import_name, + t, + anchor=f"{module['name']}.{klass['name']}.{method['name']}", + ) + for method in klass["methods"] + ) + bases = ( + f'<p class="muted">{e(t("base_classes"))}{", ".join(f"<code>{e(b)}</code>" for b in klass["bases"])}</p>' + if klass.get("bases") + else "" + ) + class_blocks.append( + f"""<div class="class-block" id="{e(module['name'])}.{e(klass['name'])}"> + <h3 class="class-name">class {e(klass['name'])}</h3> + {f'<p class="desc">{e(klass["description"])}</p>' if klass["description"] else ""} + {bases} + {methods} +</div>""" + ) + classes_html = ( + f'<h3 class="group-title">{e(t("api_classes_title"))}</h3>\n' + + "\n".join(class_blocks) + ) + + error_html = ( + f'<p class="badge badge-warn">{e(t("parse_error"))}:{e(module["error"])}</p>' + if module.get("error") + else "" + ) + + # 只有真的有函数时才写「函数」这个分组标题, + # 否则像包根目录那种空模块会留下一对空标题和空网格 + functions_html = ( + f'<h3 class="group-title">{e(t("api_functions_title"))}</h3>\n' + f' <div class="func-grid">\n{functions}\n </div>' + if module["functions"] + else "" + ) + + body.append( + f"""<section class="module" id="{e(module['name'])}"> + <h2 class="module-title">{e(module['name'])}</h2> + <p class="module-meta">{e(t("module_meta", lines=module['line_count'], count=len(module['functions'])))}</p> + {error_html} + {classes_html} + {functions_html} +</section>""" + ) + + return f""" +<header class="page-head"> + <h1>{e(t("page_api"))}</h1> +</header> +<div class="api-layout"> + <aside class="api-nav"> + <p class="api-nav-title">{e(t("api_modules_title"))}</p> + <ul> +{chr(10).join(nav_items)} + </ul> + </aside> + <div class="api-body"> +{chr(10).join(body)} + </div> +</div> +""" + + +# -------------------------------------------------------------------------- +# 关于页 +# -------------------------------------------------------------------------- + + +def _markdown_doc_page( + data: dict, + *, + title: str, + lead: str, + source_path: str, + file_key: str, + show_toc: bool = True, +) -> str: + """把一份 Markdown 文档渲染成页面内容。""" + t = data["t"] + doc_path = Path(data["meta"][file_key]) + if not doc_path.exists(): + return ( + f'<header class="page-head"><h1>{e(title)}</h1></header>' + f'<p class="muted">{e(t("file_not_found", path=source_path))}</p>' + ) + try: + markdown = doc_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + try: + markdown = doc_path.read_text(encoding="gbk") + except (OSError, UnicodeDecodeError): + markdown = "" + rendered, toc = md_render.render_document(markdown) + + toc_html = "" + if toc and show_toc: + items = "\n".join( + f'<li class="toc-l{entry["level"]}">' + f'<a href="#{e(entry["id"])}">{e(entry["text"])}</a></li>' + for entry in toc + ) + toc_html = f""" +<aside class="toc"> + <p class="toc-title">{e(t("toc_title"))}</p> + <ul> +{items} + </ul> +</aside> +""" + + lead_html = f' <p class="lead">{lead}</p>\n' if lead else "" + layout_class = "doc-layout" if toc_html else "doc-layout doc-layout-flat" + + return f""" +<header class="page-head"> + <h1>{e(title)}</h1> +{lead_html}</header> +<div class="{layout_class}"> +{toc_html} + <article class="markdown"> +{rendered} + </article> +</div> +""" + + +def build_readme(data: dict) -> str: + """把 README.md 渲染成页面内容。""" + return _markdown_doc_page( + data, + title=data["t"]("page_about"), + lead="", + source_path=data["meta"]["readme_path"], + file_key="readme_file", + ) + + +def build_installation(data: dict) -> str: + """把 INSTALLATION.md 渲染成「安装」页。""" + return _markdown_doc_page( + data, + title=data["t"]("page_installation"), + lead="", + source_path=data["meta"]["installation_path"], + file_key="installation_file", + show_toc=False, + ) + + +# -------------------------------------------------------------------------- +# 教程页 +# -------------------------------------------------------------------------- + + +def _tutorial_card(item: dict, t) -> str: + """一个示例脚本的卡片:标题、说明、源码。""" + description = item.get("description") or "" + description_html = ( + f'<p class="tut-desc">{md_render.render_inline(description)}</p>' + if description + else "" + ) + + source_link = ( + f' · <a href="{e(item["source"])}" rel="noopener">{e(t("view_original"))}</a>' + if item.get("source") + else "" + ) + + return f""" +<article class="tut-card" id="{e(item['anchor'])}"> + <div class="tut-card-head"> + <h3 class="tut-title">{e(item['title'])}</h3> + <span class="tut-lines">{e(t("lines", count=item.get('lines', 0)))}</span> + </div> + {description_html} + <details class="source"> + <summary>{e(t("view_source"))}</summary> + <pre class="code"><code>{e(item['code'])}</code></pre> + </details> + <p class="tut-links"> + <a href="{e(item['path'])}" download="{e(Path(item['path']).name)}" + data-download="{e(Path(item['path']).name)}">{e(t("download_script"))}</a>{source_link} + </p> +</article> +""" + + +def build_tutorials(data: dict) -> str: + """把 tutorials/ 目录里的示例脚本渲染成「教程」页。""" + tutorials_data = data.get("tutorials") or {} + groups = tutorials_data.get("groups", []) + t = data["t"] + + nav_items = [] + body = [] + for group in groups: + # 根目录下的脚本归为「开始使用」这一组,分组名按语言给 + title = group["title"] + subtitle_text = group.get("subtitle") or "" + if not group.get("key"): + title = t("tut_root_title") + subtitle_text = t("tut_root_subtitle") + nav_items.append( + f'<li><a href="#{e(group["anchor"])}">' + f'<span class="nav-name">{e(title)}</span>' + f'<span class="nav-count">{len(group["files"])}</span></a></li>' + ) + cards = "\n".join(_tutorial_card(item, t) for item in group["files"]) + subtitle = ( + f'<p class="tut-group-sub">{e(subtitle_text)}</p>' + if subtitle_text + else "" + ) + body.append( + f"""<section class="tut-group" id="{e(group['anchor'])}"> + <h2 class="tut-group-title">{e(title)}</h2> + {subtitle} + <div class="tut-cards"> +{cards} + </div> +</section>""" + ) + + return f""" +<header class="page-head"> + <h1>{e(t("page_tutorials"))}</h1> +</header> +<div class="tut-layout"> + <aside class="toc tut-nav"> + <p class="toc-title">{e(t("tut_groups_title"))}</p> + <ul> +{chr(10).join(nav_items)} + </ul> + </aside> + <div class="tut-body"> +{chr(10).join(body)} + </div> +</div> +""" + + +# -------------------------------------------------------------------------- +# 构建 +# -------------------------------------------------------------------------- + + +def _relative(path: Path) -> str: + """尽量用相对仓库根目录的路径展示。""" + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.resolve().as_posix() + + +def _metadata_dir(source_dir: Path) -> Path: + """在源码目录附近找放 pyproject.toml / setup.cfg 的目录。""" + candidates = [ + source_dir, + source_dir.parent, + source_dir.parent.parent, + PYPI_DIR, + ROOT, + ] + for candidate in candidates: + if (candidate / "pyproject.toml").exists() or (candidate / "setup.cfg").exists(): + return candidate + return source_dir + + +def _readme_file() -> Path: + """找 README.md(先仓库根目录,再包目录)。""" + for candidate in (ROOT / "README.md", PYPI_DIR / "README.md"): + if candidate.exists(): + return candidate + return ROOT / "README.md" + + +def _installation_file() -> Path: + """找 INSTALLATION.md(先仓库根目录,再包目录)。""" + for candidate in (ROOT / "INSTALLATION.md", PYPI_DIR / "INSTALLATION.md"): + if candidate.exists(): + return candidate + return ROOT / "INSTALLATION.md" + + +def copy_local_assets( + output: Path, base_dirs: list[Path] +) -> tuple[list[str], list[str]]: + """把页面里引用的本地文件(图片、文档等)复制到输出目录。 + + README 里可能写 `![logo](docs/logo.png)` 或 `[说明](docs/notes.md)`, + 这些文件不在网站目录里,需要按相对路径复制过去,否则图片会裂开、 + 链接会断掉。相对路径依次按 base_dirs 里的目录去找。 + + 站点有多个语言目录,每套页面里的引用都是相对于自己所在目录的, + 所以每个语言目录都要备一份这些文件。 + 返回 (已复制, 找不到的图片)。 + """ + copied: list[str] = [] + missing: list[str] = [] + for page_file in sorted(output.rglob("*.html")): + page_dir = page_file.parent + text = page_file.read_text(encoding="utf-8") + for kind, pattern in (("src", r'src="([^"]+)"'), ("href", r'href="([^"]+)"')): + for raw in re.findall(pattern, text): + src = html.unescape(raw).split("#")[0].split("?")[0] + if not src or re.match( + r"^(?:https?:|data:|//|mailto:|javascript:)", src + ): + continue + if src.startswith("/"): + continue # 站点根路径,交给服务器处理 + if (page_dir / src).exists(): + continue # 已经是网站自带的资源(如 static/style.css) + source = _find_local_file(src, base_dirs) + if source is None: + if kind == "src": + missing.append(src) + continue + target = (page_dir / src).resolve() + # 不允许复制到输出目录之外 + try: + target.relative_to(output.resolve()) + except ValueError: + continue + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + copied.append(src) + return copied, missing + + +def _find_local_file(name: str, base_dirs: list[Path]) -> Path | None: + """在若干个基准目录里找本地文件。""" + for base in base_dirs: + candidate = (Path(base) / name).resolve() + if candidate.is_file(): + return candidate + return None + + +def clean_output(output: Path) -> None: + """清空输出目录,只留下本次要生成的东西。 + + 先删文件、再按层级从深到浅删目录:某个文件被占用时, + 不影响其余文件的重建;实在删不掉的目录最后再整体试一次。 + """ + if not output.exists(): + return + paths = sorted(output.rglob("*"), key=lambda p: len(p.parts), reverse=True) + for path in paths: + try: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + path.rmdir() + except OSError: + pass + for path in paths: + if path.is_dir(): + try: + shutil.rmtree(path) + except OSError: + pass + + +def verify_output(output: Path) -> list[str]: + """检查生成的页面:内部链接指向的文件与锚点都必须存在。 + + 源码一旦大改,很容易出现「函数改名后旧锚点失效」这类问题, + 构建时顺手查一遍,可以第一时间发现。 + + 链接都是相对于页面自己所在目录的,所以每个语言目录都要单独查。 + """ + problems: list[str] = [] + pages = sorted(output.rglob("*.html")) + anchors: dict[str, set[str]] = {} + for page_file in pages: + text = page_file.read_text(encoding="utf-8") + key = page_file.relative_to(output).as_posix() + anchors[key] = set(re.findall(r'id="([^"]+)"', text)) + + for page_file in pages: + text = page_file.read_text(encoding="utf-8") + key = page_file.relative_to(output).as_posix() + for href in re.findall(r'href="([^"]+)"', text): + href = html.unescape(href) + if href.startswith(("http://", "https://", "mailto:", "data:", "javascript:")): + continue + if href.startswith("#"): + anchor = urllib.parse.unquote(href[1:]) + if anchor and anchor not in anchors[key]: + problems.append(f"{key}:锚点 {href} 不存在") + continue + target, _, fragment = href.partition("#") + target = urllib.parse.unquote(target) + if not target: + continue + target_path = (page_file.parent / target).resolve() + if not target_path.exists(): + problems.append(f"{key}:链接 {href} 指向的文件不存在") + continue + if fragment and target.endswith(".html"): + try: + target_key = target_path.relative_to(output.resolve()).as_posix() + except ValueError: + continue + if urllib.parse.unquote(fragment) not in anchors.get(target_key, set()): + problems.append(f"{key}:链接 {href} 的锚点不存在") + # 页面里引用的本地文件(图片等)也要在 + for src in re.findall(r'src="([^"]+)"', text): + src = html.unescape(src).split("#")[0].split("?")[0] + if not src or src.startswith(("http:", "https:", "data:", "//", "/")): + continue + if not (page_file.parent / urllib.parse.unquote(src)).exists(): + problems.append(f"{key}:引用的文件 {src} 不存在") + return problems + + +def generate( + source_dir: Path | None = None, + output: Path = ROOT / "dist" / "site", + readme_path: Path | None = None, + installation_path: Path | None = None, + tutorials_dir: Path | None = None, + base_url: str | None = None, + quiet: bool = False, +) -> dict: + """生成网站,返回提取到的数据字典。 + + source_dir 包源码目录;为 None 时自动发现 + output 网站输出目录 + readme_path README.md 路径,用于生成「关于」页面 + installation_path INSTALLATION.md 路径,用于生成「安装」页面 + tutorials_dir 示例脚本目录;为 None 时自动发现,没有就不生成「教程」页 + base_url 站点根地址;为 None 时从包元数据里的主页地址推 + """ + if source_dir is None: + source_dir = extract.discover_package_dir(list(SOURCE_CANDIDATES)) + if source_dir is None: + raise SystemExit( + "找不到包源码目录。请用 --source 指定,例如 --source PyPI/src/<包名>" + ) + source_dir = Path(source_dir).resolve() + if not source_dir.is_dir(): + raise SystemExit(f"包源码目录不存在或不是目录:{source_dir}") + + output = Path(output).resolve() + clean_output(output) + output.mkdir(parents=True, exist_ok=True) + + # 界面文案必须两种语言都写全,缺了就直接报错, + # 免得构建出「一半中文一半英文」的页面 + incomplete = i18n.missing_keys() + if incomplete: + raise SystemExit( + "界面文案不完整:\n " + "\n ".join(incomplete) + ) + + # 解析、导出关系、交叉参考分三步做,每一步只用做一次 + modules = extract.parse_modules(source_dir) + metadata_dir = _metadata_dir(source_dir) + info = extract.parse_package_info( + metadata_dir / "pyproject.toml", + metadata_dir / "setup.cfg", + source_dir / "__init__.py", + package_dir=source_dir, + import_name=source_dir.name, + ) + exports, _ = extract.compute_exports(source_dir, modules) + extract.apply_exports(modules, exports) + extract.resolve_calls(modules, source_dir) + data = extract.to_dict(modules, info, exports) + + readme = Path(readme_path) if readme_path else _readme_file() + installation = Path(installation_path) if installation_path else _installation_file() + data["meta"] = { + "source_path": _relative(source_dir), + "readme_path": _relative(readme), + "readme_file": str(readme), + "installation_path": _relative(installation), + "installation_file": str(installation), + "metadata_dir": _relative(metadata_dir), + } + # 教程:tutorials/ 目录里的示例脚本,没有这个目录就不生成「教程」页 + if tutorials_dir is None: + tutorials_dir = next( + (path for path in TUTORIALS_CANDIDATES if path.is_dir()), None + ) + if tutorials_dir is not None: + tutorials_dir = Path(tutorials_dir).resolve() + data["tutorials"] = tutorials.collect(tutorials_dir) + data["meta"]["tutorials_path"] = _relative(tutorials_dir) + + # 站点根地址:hreflang、canonical、sitemap 都要用绝对地址 + site_url = site_base_url(data["package"], base_url) + data["meta"]["site_url"] = site_url + + stats = data["stats"] + shell = load_template("base.html") + pages = active_pages(data) + + # 每种语言生成一套页面:中文在根目录,英文在 en/ 子目录。 + # 页面里的链接都相对于自己所在目录,所以每个目录都是自洽的。 + for lang in i18n.LANGS: + context = _lang_context(data, lang, pages) + directory = lang_dir(output, lang) + directory.mkdir(parents=True, exist_ok=True) + for spec in pages: + content = spec.builder(context) + html_text = page( + shell, + title=spec.page_title(context), + description=spec.description(context), + content=content, + info=data["package"], + stats=stats, + pages=pages, + active=spec.key, + lang=lang, + filename=spec.filename, + site_url=site_url, + ) + (directory / spec.filename).write_text(html_text, encoding="utf-8") + + # 机器可读的完整 API 数据 + # 对外发布的产物里只放相对路径,不泄露本机的绝对路径 + api_data = dict(data) + api_data["meta"] = { + **data["meta"], + "readme_file": data["meta"]["readme_path"], + "installation_file": data["meta"]["installation_path"], + } + if "tutorials" in api_data: + # 源码正文太大,索引里只留标题、说明这些摘要信息 + api_data["tutorials"] = tutorials.without_code(api_data["tutorials"]) + (output / "api.json").write_text( + json.dumps(api_data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + # 静态资源:每个语言目录里都放一份 + for lang in i18n.LANGS: + static_output = lang_dir(output, lang) / "static" + static_output.mkdir(parents=True, exist_ok=True) + for asset in STATIC_DIR.glob("*"): + if asset.is_file(): + shutil.copy2(asset, static_output / asset.name) + + # README / INSTALLATION / 教程里引用的本地图片、文档与脚本 + base_dirs = [readme.parent, installation.parent, ROOT] + if tutorials_dir is not None: + base_dirs.append(Path(tutorials_dir).parent) + copied, missing = copy_local_assets(output, base_dirs) + + errors = [m for m in modules if m.error] + problems = verify_output(output) + if not quiet: + print(f"网站已生成:{output}") + print( + f" {stats['modules']} 个模块 · {stats['functions']} 个函数 " + f"· {stats['classes']} 个类 · {stats['lines']} 行源码" + ) + print( + " 语言:" + + " · ".join( + f"{i18n.LANG_NAMES[lang]}({lang_dir(output, lang).relative_to(output) or '.'})" + for lang in i18n.LANGS + ) + ) + print(f" 页面:{', '.join(spec.filename for spec in pages)}") + if data.get("tutorials"): + groups = data["tutorials"]["groups"] + names = "、".join(f"{group['title']}({len(group['files'])})" for group in groups) + print(f" 教程:{data['tutorials']['count']} 个示例脚本 — {names}") + for module in errors: + print(f" ! {module.path}:{module.error}") + for name in missing: + print(f" ! 找不到图片资源:{name}") + for problem in problems: + print(f" ! {problem}") + if problems: + print(f" (共 {len(problems)} 处链接问题)") + if copied: + print(f" 已复制 {len(copied)} 个本地资源:{', '.join(sorted(set(copied)))}") + + # 供调用方判断构建是否干净 + data["problems"] = problems + return data + + +def main() -> None: + parser = argparse.ArgumentParser(description="生成软件包的网站") + parser.add_argument( + "-o", + "--output", + default=str(ROOT / "dist" / "site"), + help="网站输出目录(默认 dist/site)", + ) + parser.add_argument( + "--source", + default=None, + help="包源码目录(默认自动发现,例如 PyPI/src/<包名>)", + ) + parser.add_argument( + "--readme", + default=None, + help="README.md 路径(默认仓库根目录的 README.md)", + ) + parser.add_argument( + "--installation", + default=None, + help="INSTALLATION.md 路径(默认仓库根目录的 INSTALLATION.md)", + ) + parser.add_argument( + "--tutorials", + default=None, + help="示例脚本目录(默认仓库根目录的 tutorials/,没有则不生成教程页)", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="只生成网站,不打印检查结果", + ) + parser.add_argument( + "--strict", + action="store_true", + help="发现断链或解析错误时以非零状态退出(用于 CI)", + ) + args = parser.parse_args() + + data = generate( + source_dir=Path(args.source) if args.source else None, + output=Path(args.output), + readme_path=Path(args.readme) if args.readme else None, + installation_path=Path(args.installation) if args.installation else None, + tutorials_dir=Path(args.tutorials) if args.tutorials else None, + quiet=args.quiet, + ) + + if args.strict: + problems = list(data.get("problems") or []) + problems += [ + f"源码解析失败:{module.path}" + for module in data.get("modules", []) + if module.get("error") + ] + if problems: + print(f"严格模式下发现 {len(problems)} 个问题,构建失败。") + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/website/extract.py b/website/extract.py new file mode 100644 index 0000000..d1bb56f --- /dev/null +++ b/website/extract.py @@ -0,0 +1,1572 @@ +"""从包源码树中静态提取 API 数据。 + +本模块只做「静态解析」:读源码文本 + ast 解析,不 import 被解析的包。 +因此不需要安装 numpy / torch / transformers 等重依赖, +也不会触发包内 statistics_of_guan_package() 的联网行为。 + +提取的内容全部来自源码本身,**不假设任何固定的包名、目录名、模块名或函数名**, +这样即使源码结构或内容大幅变动,网站仍然能自动识别并生成: + + * 包目录自动发现(支持 src/ 布局、任意包名、嵌套子包) + * 模块名 / 模块头注释(形如 "# Module: basic_functions"),缺失时回退到模块名 + * 每个函数的签名、参数与默认值、装饰器、返回类型 + * 函数说明:优先取定义正上方紧邻的注释块,其次取 docstring + * 分节注释(形如 "# --- 某某某 ---") + * 类及其方法、基类 + * 函数体内对包内其它函数的调用(用于生成交叉参考) + * 弃用标记(函数体中出现 'deprecated' 提示,或 @deprecated 装饰器) + * 字面量形式的数据表(函数返回的,或模块级定义的 dict / list) + * 包的导出关系:__init__.py 中的 import * / 显式导入 / __all__ + * 解析失败的文件不会中断构建,只会被记录为错误 +""" + +from __future__ import annotations + +import ast +import configparser +import json +import re +import tomllib +import warnings +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# "# --- 标题 ---" 形式的分节注释 +SECTION_RE = re.compile(r"^#\s*-{3,}\s*(.*?)\s*-{3,}\s*$") + +# 模块头注释 "# Module: xxx" +MODULE_HEADER_RE = re.compile(r"^#\s*Module:\s*(.+?)\s*$") + +# 弃用相关的关键字(装饰器名、警告文字等) +DEPRECATED_RE = re.compile(r"deprecat", re.IGNORECASE) + + +def _parse(text: str, filename: str) -> ast.Module: + """解析源码,屏蔽被解析文件自身的 SyntaxWarning 等噪音。""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return ast.parse(text, filename=filename) + + +# -------------------------------------------------------------------------- +# 数据结构 +# -------------------------------------------------------------------------- + + +@dataclass +class Param: + """函数的一个参数。""" + + name: str + default: str | None = None # 默认值的源码文本,None 表示无默认值 + annotation: str | None = None + kind: str = "positional" # positional / vararg / keyword_only / kwarg + + @property + def required(self) -> bool: + """是否必须传参(*args / **kwargs 不算)。""" + return ( + self.default is None + and self.kind in ("positional", "keyword_only") + ) + + +@dataclass +class Function: + """一个函数(或方法)。""" + + name: str + module: str + signature: str + params: list[Param] = field(default_factory=list) + description: str = "" + docstring: str = "" + decorators: list[str] = field(default_factory=list) + lineno: int = 0 + end_lineno: int = 0 + source: str = "" + section: str = "" + deprecated: bool = False + deprecated_note: str = "" + calls: list[str] = field(default_factory=list) # 该函数调用到的包内函数 + returns: str | None = None + is_async: bool = False + exported: bool = True # 是否出现在包命名空间中 + owner: str = "" # 所属类(方法才有) + + @property + def anchor(self) -> str: + return f"{self.module}.{self.name}" + + @property + def required_params(self) -> list[Param]: + return [p for p in self.params if p.required] + + +@dataclass +class Class: + """一个类。""" + + name: str + module: str + description: str = "" + docstring: str = "" + bases: list[str] = field(default_factory=list) + methods: list[Function] = field(default_factory=list) + attributes: list[str] = field(default_factory=list) + lineno: int = 0 + exported: bool = True + + +@dataclass +class DataTable: + """源码中以字面量形式定义的数据表。""" + + name: str + module: str + kind: str # dict / list + description: str = "" + entries: list[tuple[str, str]] = field(default_factory=list) # (键, 值的源码文本) + lineno: int = 0 + origin: str = "function" # function(函数返回)/ module(模块级常量) + + +@dataclass +class Module: + """一个 Python 模块。""" + + name: str + file: str + path: str = "" # 相对包根目录的路径 + source_path: str = "" # 磁盘上的绝对路径 + title: str = "" + description: str = "" + functions: list[Function] = field(default_factory=list) + classes: list[Class] = field(default_factory=list) + data: list[DataTable] = field(default_factory=list) + line_count: int = 0 + is_package: bool = False # 该文件所在目录是一个包(有 __init__.py) + is_init: bool = False # 该文件本身是不是 __init__.py + is_entry: bool = False # 是否是包入口 __init__.py + error: str = "" # 解析失败时的错误信息 + + @property + def anchor(self) -> str: + return self.name + + +@dataclass +class PackageInfo: + """包的基本信息,来自 pyproject.toml / setup.cfg / __init__.py。""" + + name: str = "" + import_name: str = "" # 导入时使用的名字(包目录名) + version: str = "" + description: str = "" + summary: str = "" + requires_python: str = "" + url: str = "" + urls: dict[str, str] = field(default_factory=dict) + author: str = "" + author_email: str = "" + license: str = "" + classifiers: list[str] = field(default_factory=list) + + +# -------------------------------------------------------------------------- +# 文本小工具 +# -------------------------------------------------------------------------- + + +def _read_text(path: Path) -> tuple[str, str]: + """读源码文本,返回 (文本, 编码)。对编码问题尽量宽容。""" + raw = path.read_bytes() + for encoding in ("utf-8-sig", "utf-8", "gbk", "latin-1"): + try: + return raw.decode(encoding), encoding + except UnicodeDecodeError: + continue + return raw.decode("utf-8", errors="replace"), "utf-8" + + +def _first_paragraph(text: str) -> str: + """取文本的第一段,压成一行。""" + text = (text or "").strip() + if not text: + return "" + parts = re.split(r"\n\s*\n", text, maxsplit=1) + return " ".join(line.strip() for line in parts[0].splitlines() if line.strip()) + + +def _is_dunder(name: str) -> bool: + """是否是 __xxx__ 形式的内部名字。""" + return len(name) > 4 and name.startswith("__") and name.endswith("__") + + +def _definition_start(node: ast.AST) -> int: + """定义真正开始的行号:装饰器之上的那一行。 + + 带装饰器时 ast 的 lineno 指向 def 那一行, + 上方的注释块会被装饰器隔开,取说明文字时要以装饰器为起点。 + """ + start = getattr(node, "lineno", 0) + for decorator in getattr(node, "decorator_list", []) or []: + start = min(start, decorator.lineno) + return start + + +def _comment_block_above(lines: list[str], lineno: int) -> str: + """取函数/类定义正上方紧邻的注释块(自下而上收集连续的 # 注释行)。""" + collected: list[str] = [] + index = lineno - 2 # lineno 为 1-based,转换成上一行的 0-based 下标 + while index >= 0: + stripped = lines[index].strip() + if stripped.startswith("#"): + collected.append(stripped.lstrip("#").strip()) + index -= 1 + else: + break + collected.reverse() + return " ".join(part for part in collected if part) + + +def _docstring_of(node: ast.AST) -> str: + """取节点 docstring(没有则为空串)。""" + try: + return ast.get_docstring(node) or "" + except TypeError: + return "" + + +# -------------------------------------------------------------------------- +# 签名与参数 +# -------------------------------------------------------------------------- + + +def _format_params(args: ast.arguments) -> list[Param]: + """把 ast.arguments 转成参数列表。""" + params: list[Param] = [] + positional = list(args.posonlyargs) + list(args.args) + defaults: list[ast.expr | None] = [None] * ( + len(positional) - len(args.defaults) + ) + list(args.defaults) + for arg, default in zip(positional, defaults): + params.append( + Param( + name=arg.arg, + default=ast.unparse(default) if default is not None else None, + annotation=ast.unparse(arg.annotation) if arg.annotation else None, + kind="positional", + ) + ) + if args.vararg: + params.append( + Param( + name="*" + args.vararg.arg, + annotation=ast.unparse(args.vararg.annotation) + if args.vararg.annotation + else None, + kind="vararg", + ) + ) + for arg, default in zip(args.kwonlyargs, args.kw_defaults): + params.append( + Param( + name=arg.arg, + default=ast.unparse(default) if default is not None else None, + annotation=ast.unparse(arg.annotation) if arg.annotation else None, + kind="keyword_only", + ) + ) + if args.kwarg: + params.append( + Param( + name="**" + args.kwarg.arg, + annotation=ast.unparse(args.kwarg.annotation) + if args.kwarg.annotation + else None, + kind="kwarg", + ) + ) + return params + + +def _format_signature( + name: str, args: ast.arguments, returns: ast.expr | None = None +) -> str: + """还原出可读的函数签名文本。 + + 优先用 ast.unparse 直接还原参数表(能正确处理仅位置参数分隔符 "/"、 + 关键字限定、注解与默认值),失败时退回手工拼接。 + """ + try: + text = ast.unparse(args) + except Exception: # pragma: no cover - ast.unparse 极少失败 + text = _format_params_text(args) + signature = f"{name}({text})" + if returns is not None: + try: + signature += " -> " + ast.unparse(returns) + except Exception: # pragma: no cover + pass + return signature + + +def _format_params_text(args: ast.arguments) -> str: + """手工拼接参数列表(ast.unparse 不可用时的退路)。""" + parts: list[str] = [] + positional = list(args.posonlyargs) + list(args.args) + defaults: list[ast.expr | None] = [None] * ( + len(positional) - len(args.defaults) + ) + list(args.defaults) + for arg, default in zip(positional, defaults): + text = arg.arg + if arg.annotation is not None: + text += ": " + ast.unparse(arg.annotation) + if default is not None: + text += "=" + ast.unparse(default) + parts.append(text) + if args.vararg: + text = "*" + args.vararg.arg + if args.vararg.annotation is not None: + text += ": " + ast.unparse(args.vararg.annotation) + parts.append(text) + for arg, default in zip(args.kwonlyargs, args.kw_defaults): + text = arg.arg + if arg.annotation is not None: + text += ": " + ast.unparse(arg.annotation) + if default is not None: + text += "=" + ast.unparse(default) + parts.append(text) + if args.kwarg: + text = "**" + args.kwarg.arg + if args.kwarg.annotation is not None: + text += ": " + ast.unparse(args.kwarg.annotation) + parts.append(text) + return ", ".join(parts) + + +# -------------------------------------------------------------------------- +# 弃用、调用、数据表 +# -------------------------------------------------------------------------- + + +def _deprecation(node: ast.AST) -> tuple[bool, str]: + """判断是否已弃用,返回 (是否弃用, 说明文字)。 + + 识别几种写法: + * 装饰器名里含 deprecated,例如 @deprecated + * 函数体里的字符串含 deprecated,例如提示信息 + * 出现 DeprecationWarning / PendingDeprecationWarning, + 无论是直接写名字还是写成 warnings.DeprecationWarning + """ + note = "" + for decorator in getattr(node, "decorator_list", []) or []: + text = ast.unparse(decorator) + if DEPRECATED_RE.search(text): + return True, text + for child in ast.walk(node): + if isinstance(child, ast.Constant) and isinstance(child.value, str): + if DEPRECATED_RE.search(child.value): + note = child.value.strip() + break + elif isinstance(child, ast.Name) and DEPRECATED_RE.search(child.id): + if "Warning" in child.id: + return True, note or child.id + elif isinstance(child, ast.Attribute) and DEPRECATED_RE.search(child.attr): + if "Warning" in child.attr: + return True, note or child.attr + return False, note + + +def _package_aliases(tree: ast.Module, names: set[str]) -> set[str]: + """找出源码里指向本包的别名,例如 import guan / import guan as g。""" + aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + root = alias.name.split(".")[0] + if root in names: + aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.ImportFrom): + root = (node.module or "").split(".")[0] + if root in names: + for alias in node.names: + aliases.add(alias.asname or alias.name) + return aliases + + +def _iter_calls(node: ast.AST): + """遍历节点里的调用,但不下钻到嵌套的函数 / 类 / lambda 内部。 + + 嵌套函数体内的调用属于嵌套函数,不该算到外层函数头上。 + """ + for child in ast.iter_child_nodes(node): + if isinstance( + child, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda), + ): + continue + if isinstance(child, ast.Call): + yield child + yield from _iter_calls(child) + + +def _package_calls( + node: ast.AST, + aliases: set[str], + direct_names: set[str] | None = None, + shadowed: set[str] | None = None, +) -> list[str]: + """收集函数体内出现的包内函数调用(去重且保持顺序)。 + + 两种形式都算: + * 别名调用:``guan.foo()`` / ``core.foo()``,别名须在 aliases 里 + * 直接调用:``from .core import foo`` 之后的 ``foo()``, + 名字须在 direct_names 里,且没有被本模块的同名定义遮蔽 + """ + found: list[str] = [] + direct_names = direct_names or set() + shadowed = shadowed or set() + for child in _iter_calls(node): + func = child.func + name = "" + if ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id in aliases + ): + name = func.attr + elif ( + isinstance(func, ast.Name) + and func.id in direct_names + and func.id not in shadowed + ): + name = func.id + if name and name not in found: + found.append(name) + return found + + +def _literal_value(node: ast.AST) -> Any: + """尝试把 AST 节点求值成 Python 字面量,失败则返回 None。""" + try: + return ast.literal_eval(node) + except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError): + return None + + +def _format_value(value: Any) -> str: + """把字面量转成适合网页展示的文本。""" + if isinstance(value, bool): + return "True" if value else "False" + if value is None: + return "None" + if isinstance(value, float): + return repr(value) + if isinstance(value, str): + return value + if isinstance(value, (dict, list, tuple)): + try: + return json.dumps(value, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return repr(value) + return str(value) + + +def _table_entries(value: Any) -> list[tuple[str, str]]: + """把 dict / list 字面量转成 (键, 值) 列表。""" + if isinstance(value, dict): + return [(str(k), _format_value(v)) for k, v in value.items()] + if isinstance(value, (list, tuple)): + return [(str(i), _format_value(v)) for i, v in enumerate(value)] + return [] + + +def _collect_data_tables(node: ast.FunctionDef, module_name: str) -> list[DataTable]: + """从一个函数里提取以字面量形式写死的数据表。 + + 覆盖常见的几种写法: + * 直接 return 一个 dict / list 字面量 + * 先建一个容器变量,再逐项 constants['x'] = ... 赋值,最后 return 该变量 + * 用 container.update({...}) 追加 + * return 一个元组,其中的元素是 dict / list + """ + tables: list[DataTable] = [] + variables: dict[str, Any] = {} + + for statement in node.body: + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = _literal_value(statement.value) + if isinstance(target, ast.Name) and value is not None: + variables[target.id] = value + elif isinstance(target, ast.Subscript) and isinstance(target.value, ast.Name): + container = variables.get(target.value.id) + key = _literal_value(target.slice) + item = _literal_value(statement.value) + if isinstance(container, dict) and key is not None and item is not None: + container[key] = item + elif isinstance(statement, ast.AugAssign) and isinstance(statement.target, ast.Name): + container = variables.get(statement.target.id) + item = _literal_value(statement.value) + if isinstance(container, list) and isinstance(item, list): + container.extend(item) + variables[statement.target.id] = container + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if ( + isinstance(call.func, ast.Attribute) + and isinstance(call.func.value, ast.Name) + and call.args + ): + container = variables.get(call.func.value.id) + patch = _literal_value(call.args[0]) + if call.func.attr == "update" and isinstance(container, dict): + if isinstance(patch, dict): + container.update(patch) + elif call.func.attr == "append" and isinstance(container, list): + if patch is not None: + container.append(patch) + elif call.func.attr == "extend" and isinstance(container, list): + if isinstance(patch, (list, tuple)): + container.extend(patch) + elif isinstance(statement, ast.Return) and statement.value is not None: + value = _literal_value(statement.value) + if value is None and isinstance(statement.value, ast.Name): + value = variables.get(statement.value.id) + candidates: list[Any] = [] + if isinstance(value, (list, tuple)): + candidates = [v for v in value if isinstance(v, (dict, list, tuple))] + elif value is not None: + candidates = [value] + for candidate in candidates: + if not isinstance(candidate, (dict, list, tuple)) or not candidate: + continue + kind = "dict" if isinstance(candidate, dict) else "list" + entries = _table_entries(candidate) + if entries: + tables.append( + DataTable( + name=node.name, + module=module_name, + kind=kind, + entries=entries, + lineno=node.lineno, + origin="function", + ) + ) + + return tables + + +def _collect_module_tables(tree: ast.Module, module_name: str) -> list[DataTable]: + """提取模块级以字面量定义的数据表(常量表)。""" + tables: list[DataTable] = [] + for statement in tree.body: + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value_node = statement.value + elif isinstance(statement, ast.AnnAssign) and statement.value is not None: + target = statement.target + value_node = statement.value + else: + continue + if not isinstance(target, ast.Name): + continue + if _is_dunder(target.id): + continue # __all__ / __version__ 之类的内部名字不算数据表 + value = _literal_value(value_node) + if not isinstance(value, (dict, list)) or not value: + continue + kind = "dict" if isinstance(value, dict) else "list" + tables.append( + DataTable( + name=target.id, + module=module_name, + kind=kind, + entries=_table_entries(value), + lineno=statement.lineno, + origin="module", + ) + ) + return tables + + +# -------------------------------------------------------------------------- +# 模块解析 +# -------------------------------------------------------------------------- + + +def _find_sections(lines: list[str]) -> list[tuple[int, str]]: + """找出所有分节注释,返回 (行号, 标题)。""" + sections: list[tuple[int, str]] = [] + for number, line in enumerate(lines, start=1): + match = SECTION_RE.match(line.strip()) + if match: + sections.append((number, match.group(1))) + return sections + + +def _module_all(tree: ast.Module) -> set[str] | None: + """取模块级的 __all__(没有则返回 None)。""" + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + value = _literal_value(node.value) + if isinstance(value, (list, tuple, set)): + return {str(item) for item in value} + elif isinstance(node, ast.AnnAssign): + if isinstance(node.target, ast.Name) and node.target.id == "__all__": + value = _literal_value(node.value) if node.value else None + if isinstance(value, (list, tuple, set)): + return {str(item) for item in value} + return None + + +def parse_module(path: Path, module_name: str, relative_path: str = "") -> Module: + """解析单个模块文件。 + + 解析失败不会抛出异常,而是在返回的 Module 上记录 error, + 以免一个文件的语法问题导致整个网站构建失败。 + """ + module = Module( + name=module_name, + file=path.name, + path=relative_path or path.name, + source_path=str(path), + ) + try: + text, _ = _read_text(path) + except OSError as exc: + module.error = f"无法读取文件:{exc}" + return module + + module.line_count = len(text.splitlines()) + try: + tree = _parse(text, str(path)) + except SyntaxError as exc: + module.error = f"语法错误:第 {exc.lineno} 行 {exc.msg}" + return module + + lines = text.splitlines() + + for line in lines: + header = MODULE_HEADER_RE.match(line.strip()) + if header: + module.title = header.group(1) + break + + module.description = _first_paragraph(_docstring_of(tree)) + if not module.title: + module.title = module_name + + sections = _find_sections(lines) + aliases = _package_aliases(tree, {module_name.split(".")[0]}) | {module_name} + + def section_for(lineno: int) -> str: + current = "" + for number, title in sections: + if number < lineno: + current = title + else: + break + return current + + def describe(node: ast.AST) -> str: + """函数说明:注释块优先,其次 docstring。 + + 注释块从装饰器往上找,这样「@装饰器」不会把说明文字挡掉。 + """ + comment = _comment_block_above(lines, _definition_start(node)) + if comment: + return comment + return _first_paragraph(_docstring_of(node)) + + def build_function( + node: ast.FunctionDef | ast.AsyncFunctionDef, owner: str = "" + ) -> Function: + end = node.end_lineno or node.lineno + # 装饰器在 def 之上,源码片段要从装饰器开始 + start = _definition_start(node) + source = "\n".join(lines[start - 1 : end]) + deprecated, note = _deprecation(node) + docstring = _docstring_of(node) + return Function( + name=node.name, + module=module_name, + signature=_format_signature(node.name, node.args, node.returns), + params=_format_params(node.args), + description=describe(node), + docstring=docstring, + decorators=[ast.unparse(d) for d in node.decorator_list], + lineno=node.lineno, + end_lineno=end, + source=source, + section=section_for(node.lineno), + deprecated=deprecated, + deprecated_note=note, + calls=_package_calls(node, aliases), + returns=ast.unparse(node.returns) if node.returns else None, + is_async=isinstance(node, ast.AsyncFunctionDef), + owner=owner, + ) + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + module.functions.append(build_function(node)) + module.data.extend(_collect_data_tables(node, module_name)) + elif isinstance(node, ast.ClassDef): + klass = Class( + name=node.name, + module=module_name, + description=describe(node), + docstring=_docstring_of(node), + bases=[ast.unparse(base) for base in node.bases], + lineno=node.lineno, + ) + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + klass.methods.append(build_function(item, owner=node.name)) + elif isinstance(item, ast.Assign) and len(item.targets) == 1: + if isinstance(item.targets[0], ast.Name): + klass.attributes.append(item.targets[0].id) + module.classes.append(klass) + + module.data.extend(_collect_module_tables(tree, module_name)) + + # 让数据表带上说明文字 + for table in module.data: + if table.origin == "function": + for function in module.functions: + if function.name == table.name: + table.description = function.description + break + + return module + + +# -------------------------------------------------------------------------- +# 包解析 +# -------------------------------------------------------------------------- + + +def module_name_for(path: Path, package_root: Path) -> str: + """由文件路径推出模块名(相对包根目录,支持子包)。""" + relative = path.relative_to(package_root) + parts = list(relative.parts) + if parts[-1] == "__init__.py": + parts = parts[:-1] + else: + parts[-1] = parts[-1].removesuffix(".py") + return ".".join(parts) + + +def _is_build_dir(path: Path) -> bool: + """判断目录是不是网站构建脚本所在目录(那不是被发布的包)。""" + return (path / "build_site.py").is_file() or (path / "extract.py").is_file() + + +def discover_package_dir(search_roots: list[Path]) -> Path | None: + """在候选目录里找出包目录(含 __init__.py 的那一层)。 + + 依次检查每个候选根目录下的子目录;若根目录本身就是包,也算命中。 + 这样仓库把源码放在 PyPI/src、src 或根目录都能被认出来。 + """ + for root in search_roots: + root = Path(root) + if not root.is_dir() or _is_build_dir(root): + continue + if (root / "__init__.py").is_file(): + return root + candidates = [ + child + for child in sorted(root.iterdir()) + if child.is_dir() + and not child.name.startswith((".", "_")) + and not _is_build_dir(child) + and (child / "__init__.py").is_file() + ] + if len(candidates) == 1: + return candidates[0] + if candidates: + # 多个候选时,优先挑模块文件最多的那个 + return max(candidates, key=lambda d: len(list(d.rglob("*.py")))) + # 退一步:支持没有 __init__.py 的命名空间包(PEP 420) + for root in search_roots: + root = Path(root) + if not root.is_dir() or _is_build_dir(root): + continue + loose = [ + child + for child in sorted(root.iterdir()) + if child.is_dir() + and not child.name.startswith((".", "_")) + and not _is_build_dir(child) + and any(child.glob("*.py")) + ] + if loose: + return max(loose, key=lambda d: len(list(d.rglob("*.py")))) + return None + + +def _import_order(entry_module: Module) -> list[str]: + """从包入口 __init__.py 解析导入顺序(from .xxx import * 等)。""" + order: list[str] = [] + path = Path(entry_module.source_path or entry_module.path) + try: + text, _ = _read_text(path) + tree = _parse(text, str(path)) + except (OSError, SyntaxError): + return order + for node in tree.body: + if isinstance(node, ast.ImportFrom) and node.level and node.module: + order.append(node.module) + elif isinstance(node, ast.ImportFrom) and node.level and node.names: + for alias in node.names: + order.append(alias.name) + return order + + +_TREE_CACHE: dict[str, ast.Module | None] = {} + + +def _tree_of(module: Module) -> ast.Module | None: + """取模块的 AST(带缓存,解析失败返回 None)。""" + path = Path(module.source_path or module.path) + key = str(path) + if key in _TREE_CACHE: + return _TREE_CACHE[key] + tree: ast.Module | None = None + try: + text, _ = _read_text(path) + tree = _parse(text, str(path)) + except (OSError, SyntaxError): + tree = None + _TREE_CACHE[key] = tree + return tree + + +def _package_context(module: Module) -> list[str]: + """模块在包内的相对位置(用于解析 from .xxx import)。""" + if module.is_entry: + return [] + if module.is_init: + return module.name.split(".") + parts = module.name.split(".") + return parts[:-1] + + +def _resolve_relative(module: Module, level: int, name: str) -> str: + """把 `from .xxx import y` 里的 xxx 解析成包内的完整模块名。""" + context = _package_context(module) + if level > 1: + context = context[: max(0, len(context) - (level - 1))] + if name: + context = context + name.split(".") + return ".".join(context) + + +def _public_names( + module_name: str, + by_name: dict[str, Module], + stack: tuple[str, ...] = (), +) -> set[str]: + """一个模块通过 `from .x import *` 会带出哪些名字。 + + 除了本模块定义的函数与类,还包括它自己再导入的名字 + (比如子包的 __init__.py 里 `from .extra import *`), + 这样包入口 `from .sub import *` 也能把子包里的函数带出来。 + """ + module = by_name.get(module_name) + if module is None or module_name in stack: + return set() + tree = _tree_of(module) + if tree is None: + return set() + + explicit = _module_all(tree) + if explicit is not None: + return set(explicit) + + names: set[str] = set() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if not node.name.startswith("_"): + names.add(node.name) + elif isinstance(node, ast.ImportFrom): + if node.level: + if node.module: + target = _resolve_relative(module, node.level, node.module) + names |= _public_names(target, by_name, stack + (module_name,)) + else: + # from . import mod:带出子模块本身 + for alias in node.names: + if alias.name != "*": + names.add(alias.asname or alias.name) + elif node.module: + for alias in node.names: + if alias.name != "*": + names.add(alias.asname or alias.name) + elif isinstance(node, ast.Import): + for alias in node.names: + names.add(alias.asname or alias.name.split(".")[0]) + return names + + +def _module_constants(module: Module) -> set[str]: + """模块顶层定义的常量名(大写或普通赋值)。""" + tree = _tree_of(module) + if tree is None: + return set() + names: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + elif isinstance(node, ast.AnnAssign): + if isinstance(node.target, ast.Name): + names.add(node.target.id) + return names + + +def _definition_index(modules: list[Module]) -> dict[str, str]: + """名字 → 真正定义它的模块。 + + 同名时以先出现的模块为准;跨子包转发时也能指对真正定义的地方。 + """ + index: dict[str, str] = {} + for module in modules: + for function in module.functions: + index.setdefault(function.name, module.name) + for klass in module.classes: + index.setdefault(klass.name, module.name) + return index + + +def compute_exports( + package_root: Path, modules: list[Module] +) -> tuple[dict[str, str], set[str]]: + """计算包命名空间里可见的名字。 + + 返回 (名字 → 定义所在模块, 包级显式 __all__)。 + 解析 __init__.py 里的几种导入写法: + * from .mod import * → 整个模块的公开名字 + * from .mod import name → 单个名字 + * from .mod import name as x→ 别名 + * from . import mod → 子模块本身 + + 只有包内真正定义出来的名字才算导出,第三方库(numpy、os 之类) + 即使在 __init__.py 里被 import,也不会出现在导出表里。 + """ + exports: dict[str, str] = {} + explicit_all: set[str] = set() + + by_name = {module.name: module for module in modules} + entry = next((m for m in modules if m.is_entry), None) + if entry is None: + return exports, explicit_all + + tree = _tree_of(entry) + if tree is None: + return exports, explicit_all + + # 包里真正定义出来的名字(排除 import 进来的第三方名字,如 numpy) + owners = _definition_index(modules) + defined_names: set[str] = set(owners) + for module in modules: + defined_names.update(_module_constants(module)) + + def owner_of(name: str) -> str: + return owners.get(name, entry.name) + + def is_public(name: str) -> bool: + """下划线开头的名字不算公开 API。""" + return bool(name) and not name.startswith("_") + + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + value = _literal_value(node.value) + if isinstance(value, (list, tuple, set)): + explicit_all = {str(item) for item in value} + elif isinstance(node, ast.ImportFrom): + source = node.module or "" + if node.level and not source: + # from . import mod:把子模块本身挂到包命名空间上 + for alias in node.names: + if alias.name == "*": + continue + target = _resolve_relative(entry, node.level, alias.name) + if target in by_name: + exports.setdefault(alias.asname or alias.name, target) + elif node.level and source: + source = _resolve_relative(entry, node.level, source) + if source not in by_name: + continue + for alias in node.names: + if alias.name == "*": + for name in _public_names(source, by_name): + if name in defined_names and is_public(name): + exports.setdefault(name, owner_of(name)) + elif is_public(alias.asname or alias.name): + exports.setdefault( + alias.asname or alias.name, owner_of(alias.name) + ) + elif source: + # from 包名 import xxx:只认包内真正定义过的名字 + for alias in node.names: + if alias.name == "*": + continue + if alias.name in defined_names and is_public(alias.asname or alias.name): + exports.setdefault( + alias.asname or alias.name, owner_of(alias.name) + ) + elif isinstance(node, ast.Import): + # 直接 import 进来的多半是第三方库,只有包内定义的名字才算导出 + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + if alias.name.count(".") == 0 and name in defined_names and is_public(name): + exports.setdefault(name, owner_of(name)) + + # 入口自身定义的函数、类与常量总是可见 + for function in entry.functions: + if is_public(function.name): + exports.setdefault(function.name, entry.name) + for klass in entry.classes: + if is_public(klass.name): + exports.setdefault(klass.name, entry.name) + for name in _module_constants(entry): + if is_public(name): + exports.setdefault(name, entry.name) + + # 注意:__all__ 只影响 `from 包 import *`, + # 不影响 `包.名字` 的访问,所以这里不据此裁剪导出表。 + return exports, explicit_all + + +def parse_modules(source_dir: Path) -> list[Module]: + """解析包目录下的所有模块(只做语法解析,不涉及导出关系)。 + + 递归处理子包;包入口 __init__.py 也作为一个模块返回(is_entry=True)。 + 顺序按 __init__.py 中的导入顺序排列,其余模块按名字追加。 + """ + package_root = Path(source_dir) + modules: list[Module] = [] + + files = [ + p + for p in sorted(package_root.rglob("*.py")) + if "__pycache__" not in p.parts and p.name != "setup.py" + ] + for path in files: + relative = path.relative_to(package_root) + name = module_name_for(path, package_root) + if not name: + # 包入口 __init__.py:模块名就是包名 + name = package_root.name + module = parse_module(path, name, relative.as_posix()) + module.is_init = path.name == "__init__.py" + module.is_entry = module.is_init and path.parent == package_root + module.is_package = (path.parent / "__init__.py").is_file() + if module.is_entry: + module.title = module.title or package_root.name + modules.append(module) + + # 排序:按 __init__.py 的导入顺序 + entry = next((m for m in modules if m.is_entry), None) + order: list[str] = [] + if entry is not None: + order = _import_order(entry) + rank = {name: index for index, name in enumerate(order)} + + def sort_key(module: Module) -> tuple: + if module.is_entry: + return (1, 0, module.name) + return (0, rank.get(module.name, len(rank)), module.name) + + modules.sort(key=sort_key) + return modules + + +def apply_exports(modules: list[Module], exports: dict[str, str]) -> None: + """按导出表标记每个函数 / 类是否出现在包命名空间里。""" + for module in modules: + for function in module.functions: + function.exported = function.name in exports + for klass in module.classes: + klass.exported = klass.name in exports + + +def _direct_package_names(module: Module, by_name: dict[str, Module]) -> set[str]: + """本模块用 `from .xxx import y` 直接引进来的包内名字。 + + 这些名字在函数体里是以裸名字调用的(``y()``), + 只靠「包名.xxx」的形式是找不到它们的。 + """ + tree = _tree_of(module) + if tree is None: + return set() + names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or not node.level: + continue + for alias in node.names: + if alias.name == "*": + continue + name = alias.asname or alias.name + if name in by_name: + continue # 这是子模块本身,不是函数 + names.add(name) + return names + + +def resolve_calls( + modules: list[Module], + package_root: Path, + package_aliases: set[str] | None = None, +) -> None: + """算出每个函数调用到的包内函数(用于生成交叉参考)。""" + package_root = Path(package_root) + by_name = {module.name: module for module in modules} + + # 包内的调用(交叉参考)应使用整个包的别名集合: + # 包名本身、模块名、以及源码里显式写下的别名。 + all_aliases: set[str] = {package_root.name} + all_aliases |= {m.name for m in modules} + all_aliases |= {m.name.split(".")[0] for m in modules} + if package_aliases: + all_aliases |= package_aliases + for module in modules: + all_aliases |= _explicit_aliases(module) + + for module in modules: + tree = _tree_of(module) + if tree is None: + continue + direct = _direct_package_names(module, by_name) + # 本模块自己定义的名字会遮住直接引入的同名函数 + shadowed = {f.name for f in module.functions} + shadowed |= {c.name for c in module.classes} + calls_by_function: dict[tuple[str, int], list[str]] = {} + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + calls_by_function[(node.name, node.lineno)] = _package_calls( + node, all_aliases, direct, shadowed + ) + for function in module.functions: + function.calls = calls_by_function.get( + (function.name, function.lineno), function.calls + ) + + +def parse_package( + source_dir: Path, package_aliases: set[str] | None = None +) -> list[Module]: + """解析整个包目录,返回模块列表。 + + 解析、导出关系、交叉参考三步都做完了。 + """ + package_root = Path(source_dir) + modules = parse_modules(package_root) + exports, _ = compute_exports(package_root, modules) + apply_exports(modules, exports) + resolve_calls(modules, package_root, package_aliases) + return modules + + +def _explicit_aliases(module: Module) -> set[str]: + """源码里指向包内模块的别名。 + + 只认相对导入(``from .core import foo as f``、``from . import core as c``), + 第三方库的别名(``import numpy as np``)不算, + 否则 ``np.array(...)`` 会被误认成对包内 ``array`` 的调用。 + """ + tree = _tree_of(module) + if tree is None: + return set() + aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.level: + for alias in node.names: + if alias.asname: + aliases.add(alias.asname) + return aliases + + +# -------------------------------------------------------------------------- +# 包元信息 +# -------------------------------------------------------------------------- + + +def _read_pyproject(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + text, _ = _read_text(path) + return tomllib.loads(text) + except (OSError, ValueError, tomllib.TOMLDecodeError): + return {} + + +def _read_setup_cfg(path: Path) -> dict[str, dict[str, str]]: + if not path.exists(): + return {} + parser = configparser.ConfigParser() + try: + text, _ = _read_text(path) + parser.read_string(text) + except (OSError, ValueError, configparser.Error): + return {} + return {section: dict(parser[section]) for section in parser.sections()} + + +def _version_from_init(path: Path) -> str: + """从 __init__.py 里找 __version__ = "..."。""" + if not path.exists(): + return "" + try: + text, _ = _read_text(path) + tree = _parse(text, str(path)) + except (OSError, SyntaxError): + return "" + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__version__": + value = _literal_value(node.value) + if isinstance(value, str): + return value + return "" + + +def _module_attr_value(path: Path, attr: str) -> str: + """在模块里找某个赋值(``attr = "..."``)的字面量值。""" + try: + text, _ = _read_text(path) + tree = _parse(text, str(path)) + except (OSError, SyntaxError): + return "" + for node in tree.body: + value = None + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == attr: + value = _literal_value(node.value) + elif isinstance(node, ast.AnnAssign): + if ( + isinstance(node.target, ast.Name) + and node.target.id == attr + and node.value is not None + ): + value = _literal_value(node.value) + if isinstance(value, str): + return value + return "" + + +def _attr_value(package_dir: Path | None, spec: str) -> str: + """解析 ``attr: 包名.__version__``,静态读出那个值。 + + 不 import 被解析的包,只按路径找文件读字面量。 + """ + if not package_dir: + return "" + spec = spec.strip() + if "." not in spec: + return "" + module_part, _, attr = spec.rpartition(".") + parts = module_part.split(".") + candidates = [] + if module_part == package_dir.name: + # 指的就是包自己 + candidates.append(package_dir / "__init__.py") + candidates.append(package_dir.joinpath(*parts).with_suffix(".py")) + candidates.append(package_dir.joinpath(*parts, "__init__.py")) + for path in candidates: + if path.is_file(): + value = _module_attr_value(path, attr) + if value: + return value + return "" + + +def _resolve_version_directive( + value: str, package_dir: Path | None, base_dir: Path | None +) -> str: + """把 ``attr:`` / ``file:`` 形式的版本号写法解析成真正的值。 + + setup.cfg 里常写 ``version = attr: pkg.__version__``, + 直接显示出来会很难看,这里静态地把它读出来。 + """ + value = value.strip() + if value.startswith("attr:"): + return _attr_value(package_dir, value[5:]) or value + if value.startswith("file:"): + name = value[5:].strip() + if base_dir: + path = Path(base_dir) / name + if path.is_file(): + try: + text, _ = _read_text(path) + except OSError: + return value + first = text.strip().splitlines()[0].strip() if text.strip() else "" + if first: + return first + return value + + +def parse_package_info( + pyproject: Path, + setup_cfg: Path | None = None, + init_file: Path | None = None, + package_dir: Path | None = None, + import_name: str = "", +) -> PackageInfo: + """读取包信息。 + + 同时兼容三种来源:PEP 621 的 [project] 表、setuptools 的 [metadata] 表、 + 以及 setup.cfg。本仓库的元数据写在 setup.cfg 的 [metadata] 段里。 + """ + sources: list[dict[str, Any]] = [] + + data = _read_pyproject(Path(pyproject)) + project = data.get("project") + if isinstance(project, dict): + sources.append(project) + metadata = data.get("metadata") + if isinstance(metadata, dict): + sources.append(metadata) + + cfg = _read_setup_cfg(Path(setup_cfg)) if setup_cfg else {} + for section in ("metadata", "options"): + if section in cfg: + sources.append(cfg[section]) + + def pick(*keys: str, default: str = "") -> str: + for source in sources: + for key in keys: + value = source.get(key) + if isinstance(value, str) and value: + return value + return default + + urls: dict[str, str] = {} + for source in sources: + raw = source.get("project_urls") or source.get("urls") + if not raw: + continue + if isinstance(raw, dict): # pyproject 的 [project.urls] 表 + for key, value in raw.items(): + if isinstance(value, str): + urls.setdefault(str(key), value) + continue + raw = str(raw) + if raw.strip().startswith("{"): + try: + urls = {str(k): str(v) for k, v in json.loads(raw).items()} + break + except (ValueError, TypeError): + continue + for line in raw.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + urls.setdefault(key.strip(), value.strip()) + + classifiers: list[str] = [] + for source in sources: + value = source.get("classifiers") + if isinstance(value, list): + classifiers = [str(item) for item in value] + break + if isinstance(value, str) and value.strip(): + classifiers = [line.strip() for line in value.splitlines() if line.strip()] + break + + author = pick("author", "maintainer") + author_email = pick("author_email", "maintainer_email") + if not author or not author_email: + # PEP 621 的 authors / maintainers 是 [{name, email}, ...] 列表 + for source in sources: + for key in ("authors", "maintainers"): + people = source.get(key) + if not isinstance(people, list): + continue + for person in people: + if not isinstance(person, dict): + continue + if not author and person.get("name"): + author = str(person["name"]) + if not author_email and person.get("email"): + author_email = str(person["email"]) + + license_text = pick("license") + if not license_text: + for source in sources: + value = source.get("license") + if isinstance(value, dict): + license_text = str(value.get("text") or value.get("file") or "") + if license_text: + break + + version = pick("version") + if version: + base_dir = Path(setup_cfg).parent if setup_cfg else None + version = _resolve_version_directive(version, package_dir, base_dir) + if not version and init_file is not None: + version = _version_from_init(Path(init_file)) + + name = pick("name") + if not name and package_dir is not None: + name = Path(package_dir).name + if not import_name and package_dir is not None: + import_name = Path(package_dir).name + + description = pick("description", "summary") + if description.startswith("file:"): + # 描述指向一个文件时(比如 `description = file: README.md`), + # 取那个文件的第一段文字,而不是把 "file: ..." 摆在网站上 + base_dir = Path(setup_cfg).parent if setup_cfg else None + if base_dir: + path = base_dir / description[5:].strip() + if path.is_file(): + try: + text, _ = _read_text(path) + except OSError: + text = "" + description = _first_paragraph(text) or description + + return PackageInfo( + name=name, + import_name=import_name or name, + version=version, + description=description, + summary=pick("summary"), + requires_python=pick("requires-python", "requires_python", "python_requires"), + url=pick("url", "home-page", "homepage"), + urls=urls, + author=author, + author_email=author_email, + license=license_text, + classifiers=classifiers, + ) + + +# -------------------------------------------------------------------------- +# 序列化 +# -------------------------------------------------------------------------- + + +def _function_to_dict(function: Function) -> dict[str, Any]: + """把函数(或方法)序列化成统一的字典结构。""" + return { + "name": function.name, + "signature": function.signature, + "description": function.description, + "docstring": function.docstring, + "decorators": function.decorators, + "lineno": function.lineno, + "end_lineno": function.end_lineno, + "section": function.section, + "deprecated": function.deprecated, + "deprecated_note": function.deprecated_note, + "calls": function.calls, + "returns": function.returns, + "is_async": function.is_async, + "exported": function.exported, + "owner": function.owner, + "params": [ + { + "name": p.name, + "default": p.default, + "annotation": p.annotation, + "kind": p.kind, + "required": p.required, + } + for p in function.params + ], + "source": function.source, + } + + +def to_dict( + modules: list[Module], + info: PackageInfo, + exports: dict[str, str] | None = None, +) -> dict[str, Any]: + """整理成可直接序列化为 JSON 的结构。""" + exported_names = set(exports or {}) + return { + "package": { + "name": info.name, + "import_name": info.import_name or info.name, + "version": info.version, + "description": info.description, + "summary": info.summary, + "requires_python": info.requires_python, + "url": info.url, + "urls": info.urls, + "author": info.author, + "author_email": info.author_email, + "license": info.license, + "classifiers": info.classifiers, + }, + "stats": { + "modules": len(modules), + "functions": sum(len(m.functions) for m in modules), + "classes": sum(len(m.classes) for m in modules), + "lines": sum(m.line_count for m in modules), + "data_tables": sum(len(m.data) for m in modules), + "exported_functions": sum( + 1 + for m in modules + for f in m.functions + if f.exported or f.name in exported_names + ), + }, + "exports": dict(sorted((exports or {}).items())), + "modules": [ + { + "name": m.name, + "file": m.file, + "path": m.path, + "title": m.title, + "description": m.description, + "line_count": m.line_count, + "is_package": m.is_package, + "is_entry": m.is_entry, + "error": m.error, + "data": [ + { + "name": d.name, + "kind": d.kind, + "description": d.description, + "lineno": d.lineno, + "origin": d.origin, + "entries": [{"key": k, "value": v} for k, v in d.entries], + } + for d in m.data + ], + "classes": [ + { + "name": c.name, + "description": c.description, + "docstring": c.docstring, + "bases": c.bases, + "lineno": c.lineno, + "exported": c.exported, + "attributes": list(c.attributes), + "methods": [_function_to_dict(f) for f in c.methods], + } + for c in m.classes + ], + "functions": [_function_to_dict(f) for f in m.functions], + } + for m in modules + ], + } diff --git a/website/i18n.py b/website/i18n.py new file mode 100644 index 0000000..09a840e --- /dev/null +++ b/website/i18n.py @@ -0,0 +1,294 @@ +"""站点界面的中英文文案。 + +只有「网站框架」的部分需要翻译:导航、按钮、页面标题、页脚统计等等。 +文档正文(README.md、INSTALLATION.md)、源码里的说明与注释、教程脚本 +都保持原样,不在这里翻译——它们是文件内容,不是界面。 + +站点是静态的,所以不做运行时切换,而是构建时生成两套页面: + * 中文在站点根目录(index.html) + * 英文在 en/ 子目录(en/index.html) +两套页面里的文件引用都相对于各自所在的目录,所以每个语言目录都是自洽的。 + +新增文案时,zh 与 en 两边都要写;缺哪一边,构建时会直接报错, +免得网站上线后有一半界面还是另一种语言。 +""" + +from __future__ import annotations + +# 站点支持的语言,按顺序构建 +LANGS: tuple[str, ...] = ("zh", "en") + +# 默认语言(首页用的语言,也是没有指定语言时的回退) +DEFAULT_LANG = "zh" + +# 每种语言的页面放在站点根目录下的哪个子目录 +LANG_DIRS: dict[str, str] = { + "zh": "", + "en": "en", +} + +# <html lang="..."> 与 hreflang 用的取值 +HTML_LANG: dict[str, str] = { + "zh": "zh-CN", + "en": "en", +} + +# 语言在界面上的名字(切换按钮上显示的) +LANG_NAMES: dict[str, str] = { + "zh": "中", + "en": "En", +} + +# 语言切换按钮上显示的完整语言名。 +# 用完整名字而不是「中 / En」,是因为无障碍规则要求: +# 按钮上看得见的文字必须包含在它给读屏软件的无障碍名称里。 +# 「En」配「切换语言」对不上,写成「English」配「切换到 English 界面」就没问题。 +LANG_NAMES_FULL: dict[str, str] = { + "zh": "中文", + "en": "English", +} + +# -------------------------------------------------------------------------- +# 界面文案 +# -------------------------------------------------------------------------- + +STRINGS: dict[str, dict[str, str]] = { + "zh": { + # 顶栏与页脚 + "skip": "跳到主要内容", + "theme_toggle": "切换深浅色主题", + "menu_open": "打开菜单", + "menu_close": "关闭菜单", + "to_top": "回到顶部", + "foot_stats": "{version} · {modules} 个模块 · {functions} 个函数 · {lines} 行源码", + "switch_language": "切换语言", + "switch_to_lang": "切换到 {lang} 界面", + # 导航 + "nav_home": "首页", + "nav_installation": "安装", + "nav_api": "API 参考", + "nav_tutorials": "教程", + "nav_about": "关于", + # 页面标题与描述 + "page_home": "{name} 官方网站", + "page_installation": "安装", + "page_api": "API 参考", + "page_tutorials": "教程", + "page_about": "关于", + "desc_installation": "{name} 的安装与使用说明", + "desc_api": "{name} 的完整 API 参考", + "desc_tutorials": "{name} 的教程与示例代码", + "desc_about": "关于 {name}", + # 首页 + "hero_eyebrow": "Python package", + "hero_version": "当前版本", + "hero_python": "支持 Python {version}", + "copy": "复制", + "stat_modules": "模块", + "stat_functions": "函数", + "stat_lines": "行源码", + "section_modules": "模块一览", + "all_api": "全部 API →", + "section_package": "包信息", + "field_display_name": "展示名", + "field_pypi_name": "PyPI 名称", + "field_import_name": "导入名", + "field_version": "版本", + "field_python": "Python", + "field_homepage": "主页", + # API 参考 + "api_modules_title": "模块", + "api_functions_title": "函数", + "api_classes_title": "类", + "param_name": "参数", + "param_type": "类型", + "param_default": "默认值", + "param_required": "必填", + "badge_deprecated": "已弃用", + "badge_not_exported": "未导出", + "parse_error": "解析失败", + "view_source": "查看源码", + "calls": "调用了:", + "base_classes": "基类:", + "module_meta": "{lines} 行 · {count} 个函数", + # 关于 / 安装 / 教程 + "toc_title": "目录", + "tut_groups_title": "分组", + "tut_root_title": "开始使用", + "tut_root_subtitle": "Getting started", + "file_not_found": "未找到 {path}。", + "download_script": "下载脚本", + "view_original": "查看原文", + "lines": "{count} 行", + "functions_count": "{count} 个函数", + }, + "en": { + # 顶栏与页脚 + "skip": "Skip to main content", + "theme_toggle": "Toggle light and dark theme", + "menu_open": "Open menu", + "menu_close": "Close menu", + "to_top": "Back to top", + "foot_stats": ( + "{version} · {modules} modules · {functions} functions · {lines} lines of code" + ), + "switch_language": "Switch language", + "switch_to_lang": "Switch to {lang} interface", + # 导航 + "nav_home": "Home", + "nav_installation": "Install", + "nav_api": "API Reference", + "nav_tutorials": "Tutorials", + "nav_about": "About", + # 页面标题与描述 + "page_home": "{name} official site", + "page_installation": "Installation", + "page_api": "API Reference", + "page_tutorials": "Tutorials", + "page_about": "About", + "desc_installation": "How to install and use {name}", + "desc_api": "The complete API reference of {name}", + "desc_tutorials": "Tutorials and example code for {name}", + "desc_about": "About {name}", + # 首页 + "hero_eyebrow": "Python package", + "hero_version": "Current version", + "hero_python": "Supports Python {version}", + "copy": "Copy", + "stat_modules": "Modules", + "stat_functions": "Functions", + "stat_lines": "Lines of code", + "section_modules": "Modules", + "all_api": "All API →", + "section_package": "Package info", + "field_display_name": "Display name", + "field_pypi_name": "PyPI name", + "field_import_name": "Import name", + "field_version": "Version", + "field_python": "Python", + "field_homepage": "Homepage", + # API 参考 + "api_modules_title": "Modules", + "api_functions_title": "Functions", + "api_classes_title": "Classes", + "param_name": "Parameter", + "param_type": "Type", + "param_default": "Default", + "param_required": "Required", + "badge_deprecated": "Deprecated", + "badge_not_exported": "Not exported", + "parse_error": "Parse error", + "view_source": "View source", + "calls": "Calls: ", + "base_classes": "Base classes: ", + "module_meta": "{lines} lines · {count} functions", + # 关于 / 安装 / 教程 + "toc_title": "Contents", + "tut_groups_title": "Groups", + "tut_root_title": "Getting started", + "tut_root_subtitle": "Start here", + "file_not_found": "{path} not found.", + "download_script": "Download script", + "view_original": "View original", + "lines": "{count} lines", + "functions_count": "{count} functions", + }, +} + +# -------------------------------------------------------------------------- +# 脚本里用到的文案 +# -------------------------------------------------------------------------- +# site.js 在运行时才生成一些按钮(复制按钮、筛选框……), +# 这些文字没法在构建时写进 HTML,只能由页面把文案传给脚本。 + +JS_STRINGS: dict[str, dict[str, str]] = { + "zh": { + "copied": "已复制", + "copy": "复制", + "copy_code": "复制代码", + "copy_anchor": "复制本函数的链接", + "theme_to_light": "切换到浅色主题", + "theme_to_dark": "切换到深色主题", + "menu_open": "打开菜单", + "menu_close": "关闭菜单", + "filter_placeholder": "在本页筛选函数…", + "filter_label": "筛选函数", + "filter_clear": "清除", + "filter_count": "显示 {shown} / {total} 个函数", + }, + "en": { + "copied": "Copied", + "copy": "Copy", + "copy_code": "Copy code", + "copy_anchor": "Copy link to this function", + "theme_to_light": "Switch to light theme", + "theme_to_dark": "Switch to dark theme", + "menu_open": "Open menu", + "menu_close": "Close menu", + "filter_placeholder": "Filter functions on this page…", + "filter_label": "Filter functions", + "filter_clear": "Clear", + "filter_count": "Showing {shown} / {total} functions", + }, +} + + +class Translator: + """按语言取文案的小工具。 + + t("nav_api") 取文案;t("lines", count=3) 取带占位符的文案。 + 找不到的键原样返回,方便一眼看出漏了哪一条。 + """ + + def __init__(self, lang: str) -> None: + if lang not in STRINGS: + raise ValueError(f"没有这种语言:{lang}") + self.lang = lang + self._strings = STRINGS[lang] + self._js = JS_STRINGS[lang] + + def __call__(self, key: str, **kwargs: object) -> str: + text = self._strings.get(key) + if text is None: + # 漏写时退回默认语言,再退回键名本身 + text = STRINGS[DEFAULT_LANG].get(key, key) + if not kwargs: + return text + return text.format(**kwargs) + + def js(self, key: str, **kwargs: object) -> str: + text = self._js.get(key) + if text is None: + text = JS_STRINGS[DEFAULT_LANG].get(key, key) + if not kwargs: + return text + return text.format(**kwargs) + + def js_all(self) -> dict[str, str]: + """整份脚本文案,供页面传给 site.js。""" + return dict(self._js) + + +def translator(lang: str) -> Translator: + return Translator(lang) + + +def missing_keys() -> list[str]: + """检查各语言的文案是否齐全,返回缺失的键。 + + 构建前跑一次,缺哪边就补哪边——比上线后才发现强。 + """ + problems: list[str] = [] + reference = set(STRINGS[DEFAULT_LANG]) + for lang, strings in STRINGS.items(): + for key in sorted(reference - set(strings)): + problems.append(f"{lang} 缺少文案 {key}") + for key in sorted(set(strings) - reference): + problems.append(f"{lang} 多出文案 {key}") + js_reference = set(JS_STRINGS[DEFAULT_LANG]) + for lang, strings in JS_STRINGS.items(): + for key in sorted(js_reference - set(strings)): + problems.append(f"{lang} 缺少脚本文案 {key}") + for key in sorted(set(strings) - js_reference): + problems.append(f"{lang} 多出脚本文案 {key}") + return problems diff --git a/website/md_render.py b/website/md_render.py new file mode 100644 index 0000000..b87b4aa --- /dev/null +++ b/website/md_render.py @@ -0,0 +1,535 @@ +"""把 Markdown 转成 HTML。 + +构建后端只依赖标准库,所以这里自带一个精简的 Markdown 渲染器, +覆盖 README 里常见与可能出现的写法: + + * 标题(# ~ ######)与 setext 标题(=== / ---) + * 段落 + * 无序列表(+ - *)与有序列表(1. 1)),支持嵌套与任务列表 + * 围栏代码块(```)与缩进代码块(4 空格) + * 行内代码、**粗体**、*斜体*、~~删除线~~ + * [文字](链接)、![图片](链接)、引用式链接 [文字][ref] 与 [ref]: 地址 + * 裸露的 URL、邮箱、<url> 自动链接 + * 引用块(>) + * 分隔线(---) + * 表格(GitHub 风格) +""" + +from __future__ import annotations + +import html +import re + +# -------------------------------------------------------------------------- +# 行内 +# -------------------------------------------------------------------------- + +CODE_SPAN_RE = re.compile(r"`([^`]+)`") +# 链接地址里允许出现一层成对的括号,例如维基百科风格的链接 +_URL = r"[^()\s]*(?:\([^()]*\)[^()\s]*)*" +IMAGE_RE = re.compile(r"!\[([^\]]*)\]\((" + _URL + r")(?:\s+\"[^\"]*\")?\)") +LINK_RE = re.compile(r"\[([^\]]*)\]\((" + _URL + r")(?:\s+\"[^\"]*\")?\)") +REF_LINK_RE = re.compile(r"\[([^\]]*)\]\[([^\]]*)\]") +AUTOLINK_RE = re.compile(r"https?://[^\s<>\"'),,。]+") +ANGLE_AUTOLINK_RE = re.compile(r"<((?:https?|mailto):[^>\s]+)>") +EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b") +BOLD_RE = re.compile(r"\*\*(.+?)\*\*") +ITALIC_RE = re.compile(r"(?<![\w*])\*([^*\n]+)\*(?![\w*])") +STRIKE_RE = re.compile(r"~~(.+?)~~") +HR_RE = re.compile(r"^(\s*)([-*_])(\s*\2){2,}\s*$") + +# 行内链接引用定义: [ref]: https://... +REF_DEF_RE = re.compile(r"^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+\"[^\"]*\")?\s*$") + +# 列表项:允许只有一个符号(空列表项),但符号与文字之间必须有空格 +LIST_ITEM_RE = re.compile( + r"^(?P<indent>[ \t]*)(?P<bullet>[-*+]|\d+[.)])(?:[ \t]+(?P<text>.*))?$" +) + +_PLACEHOLDER = "\x00{}\x00" + + +def _stash(store: list[str], html_text: str) -> str: + """把已渲染好的片段存起来,返回占位符。""" + store.append(html_text) + return _PLACEHOLDER.format(len(store) - 1) + + +def _restore(text: str, store: list[str]) -> str: + for index, value in enumerate(store): + text = text.replace(_PLACEHOLDER.format(index), value) + return text + + +def render_inline(text: str, references: dict[str, str] | None = None) -> str: + """渲染行内标记。""" + store: list[str] = [] + references = references or {} + + # 行内代码:内容原样转义,不参与后续的强调处理 + def code_sub(match: re.Match) -> str: + return _stash(store, f"<code>{html.escape(match.group(1))}</code>") + + text = CODE_SPAN_RE.sub(code_sub, text) + + # 图片:![alt](url) + def image_sub(match: re.Match) -> str: + alt = html.escape(match.group(1)) + url = html.escape(match.group(2), quote=True) + return _stash(store, f'<img src="{url}" alt="{alt}">') + + text = IMAGE_RE.sub(image_sub, text) + + # 引用式链接 [文字][ref](ref 为空时用文字本身作 ref) + def ref_sub(match: re.Match) -> str: + label = match.group(1) + ref = (match.group(2) or label).strip().lower() + url = references.get(ref) + if not url: + return match.group(0) + return _stash( + store, + f'<a href="{html.escape(url, quote=True)}">{html.escape(label)}</a>', + ) + + text = REF_LINK_RE.sub(ref_sub, text) + + # 链接:[文字](地址) + def link_sub(match: re.Match) -> str: + label = html.escape(match.group(1)) + url = html.escape(match.group(2), quote=True) + return _stash(store, f'<a href="{url}">{label}</a>') + + text = LINK_RE.sub(link_sub, text) + + # <url> 自动链接 + def angle_sub(match: re.Match) -> str: + url = match.group(1) + return _stash( + store, + f'<a href="{html.escape(url, quote=True)}">{html.escape(url)}</a>', + ) + + text = ANGLE_AUTOLINK_RE.sub(angle_sub, text) + + # 裸露的 URL 与邮箱 + def url_sub(match: re.Match) -> str: + url = match.group(0) + trailing = "" + # 句末标点不属于链接;成对括号之外的右括号也不属于 + while url and url[-1] in ".,;:!?": + trailing = url[-1] + trailing + url = url[:-1] + while url.endswith(")") and url.count(")") > url.count("("): + trailing = ")" + trailing + url = url[:-1] + return _stash( + store, + f'<a href="{html.escape(url, quote=True)}">{html.escape(url)}</a>{trailing}', + ) + + text = AUTOLINK_RE.sub(url_sub, text) + + def mail_sub(match: re.Match) -> str: + address = match.group(0) + return _stash( + store, + f'<a href="mailto:{html.escape(address, quote=True)}">' + f"{html.escape(address)}</a>", + ) + + text = EMAIL_RE.sub(mail_sub, text) + + # 剩下的普通文本转义 + text = html.escape(text) + + # 强调 + text = STRIKE_RE.sub(r"<del>\1</del>", text) + text = BOLD_RE.sub(r"<strong>\1</strong>", text) + text = ITALIC_RE.sub(r"<em>\1</em>", text) + + return _restore(text, store) + + +# -------------------------------------------------------------------------- +# 块级 +# -------------------------------------------------------------------------- + + +def _is_table_separator(line: str) -> bool: + stripped = line.strip() + if "|" not in stripped or "-" not in stripped: + return False + return all( + part.strip().replace(":", "").replace("-", "") == "" + for part in stripped.strip("|").split("|") + ) + + +def _split_table_row(line: str) -> list[str]: + stripped = line.strip() + if stripped.startswith("|"): + stripped = stripped[1:] + if stripped.endswith("|"): + stripped = stripped[:-1] + return [cell.strip() for cell in stripped.split("|")] + + +def _indent_of(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def _starts_block(lines: list[str], index: int) -> bool: + """判断该行是否是一个新块的开头(用于终止段落)。""" + line = lines[index] + stripped = line.strip() + if not stripped: + return True + if stripped.startswith("```") or stripped.startswith("~~~"): + return True + if re.match(r"^#{1,6}\s", stripped): + return True + if stripped.startswith(">"): + return True + if re.match(r"^[-*+]\s", stripped): + return True + if re.match(r"^\d+[.)]\s", stripped): + return True + if line.startswith(" ") and line.strip(): + return True + if HR_RE.match(line): + return True + if _is_setext_underline(stripped): + return True + if index + 1 < len(lines) and "|" in stripped and _is_table_separator(lines[index + 1]): + return True + return False + + +def _is_setext_underline(stripped: str) -> bool: + """是否是 setext 标题的下划线(=== 或 ---)。""" + if not stripped: + return False + if set(stripped) == {"="}: + return True + # 单个 "-" 可能是空列表项,因此要求至少两个 + return set(stripped) == {"-"} and len(stripped) >= 2 + + +def _render_table( + lines: list[str], index: int, references: dict[str, str] +) -> tuple[str, int]: + """渲染 GitHub 风格表格,返回 (html, 下一行下标)。""" + header = _split_table_row(lines[index]) + index += 2 # 跳过表头与分隔行 + rows: list[list[str]] = [] + while index < len(lines) and "|" in lines[index] and lines[index].strip(): + rows.append(_split_table_row(lines[index])) + index += 1 + + out = ['<div class="table-wrap"><table class="md-table">'] + out.append("<thead><tr>") + for cell in header: + out.append(f"<th>{render_inline(cell, references)}</th>") + out.append("</tr></thead><tbody>") + width = len(header) + for row in rows: + out.append("<tr>") + # 补齐缺失的单元格,避免表格错位 + cells = list(row[:width]) + [""] * max(0, width - len(row)) + for cell in cells: + out.append(f"<td>{render_inline(cell, references)}</td>") + out.append("</tr>") + out.append("</tbody></table></div>") + return "\n".join(out), index + + +def _dedent(lines: list[str]) -> list[str]: + """去掉一组行的公共缩进。""" + indents = [_indent_of(line) for line in lines if line.strip()] + if not indents: + return list(lines) + common = min(indents) + return [line[common:] if line.strip() else "" for line in lines] + + +def _render_list( + lines: list[str], + index: int, + toc: list[dict], + used: set[str], + references: dict[str, str], +) -> tuple[str, int]: + """渲染有序 / 无序列表(支持嵌套列表与任务列表),返回 (html, 下一行下标)。""" + match = LIST_ITEM_RE.match(lines[index]) + if not match: + return "", index + ordered = bool(re.match(r"^\d+[.)]$", match.group("bullet"))) + base_indent = _indent_of(lines[index]) + + items: list[list[str]] = [] # 每个列表项的内容行 + current: list[str] = [] + i = index + + def is_same_kind(line: str) -> bool: + item = LIST_ITEM_RE.match(line) + if not item: + return False + if _indent_of(line) > base_indent: + return False + return bool(re.match(r"^\d+[.)]$", item.group("bullet"))) == ordered + + while i < len(lines): + line = lines[i] + if not line.strip(): + # 空行:其后若仍是本列表则继续,否则列表结束 + look = i + 1 + while look < len(lines) and not lines[look].strip(): + look += 1 + if look < len(lines): + item = LIST_ITEM_RE.match(lines[look]) + if item and _indent_of(lines[look]) <= base_indent and is_same_kind(lines[look]): + current.append("") + i = look + continue + if item and _indent_of(lines[look]) > base_indent: + current.append("") + i = look + continue + break + + item = LIST_ITEM_RE.match(line) + + if is_same_kind(line): + if current: + items.append(current) + current = [(item.group("text") or "") if item else ""] + i += 1 + continue + + if HR_RE.match(line) and _indent_of(line) <= base_indent: + # 分隔线结束当前列表 + break + + if item and _indent_of(line) > base_indent: + # 嵌套列表:整块收进当前项 + if current: + current.append(line) + i += 1 + continue + + if item and _indent_of(line) <= base_indent: + # 同级的另一种列表(例如无序列表之后紧跟有序列表):另起一个列表 + break + + if current: + current.append(line.strip()) # 懒续行 + i += 1 + continue + + break + + if current: + items.append(current) + + tag = "ol" if ordered else "ul" + out = [f"<{tag}>"] + for item_lines in items: + content = _dedent(item_lines) + text = content[0] if content else "" + # 嵌套内容按它自己的缩进单独去掉一层, + # 否则更深一层的列表会被当成缩进代码块 + inner = _dedent(content[1:]) if len(content) > 1 else [] + checkbox = "" + task = re.match(r"^\[([ xX])\]\s+(.*)$", text) + if task: + checked = " checked" if task.group(1).lower() == "x" else "" + checkbox = f'<input type="checkbox" disabled{checked}> ' + text = task.group(2) + body = checkbox + render_inline(text, references) + if any(line.strip() for line in inner): + nested = _render("\n".join(inner), toc, used, references) + body += nested + out.append(f"<li>{body}</li>") + out.append(f"</{tag}>") + return "\n".join(out), i + + +def render(markdown: str) -> str: + """把 Markdown 全文转成 HTML。""" + html_text, _ = render_document(markdown) + return html_text + + +def _slug(text: str, used: set[str]) -> str: + """由标题文字生成唯一锚点。""" + base = re.sub(r"[^\w一-鿿]+", "-", text.strip().lower()).strip("-") + if not base: + base = "section" + slug = base + counter = 2 + while slug in used: + slug = f"{base}-{counter}" + counter += 1 + used.add(slug) + return slug + + +def render_document(markdown: str) -> tuple[str, list[dict]]: + """渲染 Markdown,同时返回目录(标题级别、标题文字、锚点)。 + + 目录用于在宽屏上给正文旁边配一个可跳转的侧栏。 + """ + toc: list[dict] = [] + used: set[str] = set() + references = _collect_references(markdown) + html_text = _render(markdown, toc, used, references) + return html_text, toc + + +def _collect_references(markdown: str) -> dict[str, str]: + """收集全文里的链接引用定义 [ref]: url。""" + references: dict[str, str] = {} + in_fence = False + for line in markdown.replace("\r\n", "\n").split("\n"): + stripped = line.strip() + if stripped.startswith("```") or stripped.startswith("~~~"): + in_fence = not in_fence + continue + if in_fence: + continue + match = REF_DEF_RE.match(line) + if match: + references[match.group(1).strip().lower()] = match.group(2) + return references + + +def _render( + markdown: str, + toc: list[dict], + used: set[str], + references: dict[str, str] | None = None, +) -> str: + lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n") + if references is None: + references = _collect_references(markdown) + out: list[str] = [] + index = 0 + total = len(lines) + + while index < total: + line = lines[index] + stripped = line.strip() + + if not stripped: + index += 1 + continue + + # 围栏代码块 + fence = re.match(r"^(`{3,}|~{3,})\s*(\S*)\s*$", stripped) + if fence: + marker = fence.group(1)[0] + language = fence.group(2) + index += 1 + code: list[str] = [] + while index < total: + closing = lines[index].strip() + if closing.startswith(marker * 3) and set(closing) <= {marker, " "}: + index += 1 + break + code.append(lines[index]) + index += 1 + class_attr = f' class="language-{html.escape(language)}"' if language else "" + body = html.escape("\n".join(code)) + out.append(f"<pre class=\"code\"><code{class_attr}>{body}</code></pre>") + continue + + # 缩进代码块 + if line.startswith(" ") and line.strip(): + code = [] + while index < total and ( + lines[index].startswith(" ") or not lines[index].strip() + ): + if not lines[index].strip() and ( + index + 1 >= total or not lines[index + 1].startswith(" ") + ): + break + code.append(lines[index][4:]) + index += 1 + out.append( + '<pre class="code"><code>' + html.escape("\n".join(code)) + "</code></pre>" + ) + continue + + # 链接引用定义:单独成行,不渲染 + if REF_DEF_RE.match(line): + index += 1 + continue + + # ATX 标题 + heading = re.match(r"^(#{1,6})\s+(.*)$", stripped) + if heading: + level = len(heading.group(1)) + text = heading.group(2).rstrip("#").strip() + anchor = _slug(re.sub(r"`|\*\*|\*", "", text), used) + if level in (2, 3): + toc.append({"level": level, "text": re.sub(r"`|\*\*|\*", "", text), "id": anchor}) + out.append( + f'<h{level} id="{html.escape(anchor, quote=True)}">' + f"{render_inline(text, references)}</h{level}>" + ) + index += 1 + continue + + # 分隔线 + if HR_RE.match(line): + out.append("<hr>") + index += 1 + continue + + # 表格 + if "|" in stripped and index + 1 < total and _is_table_separator(lines[index + 1]): + table_html, index = _render_table(lines, index, references) + out.append(table_html) + continue + + # 引用块 + if stripped.startswith(">"): + quoted = [] + while index < total and lines[index].strip().startswith(">"): + quoted.append(lines[index].strip().lstrip(">").strip()) + index += 1 + out.append( + "<blockquote>" + + _render("\n".join(quoted), toc, used, references) + + "</blockquote>" + ) + continue + + # 列表 + if LIST_ITEM_RE.match(stripped): + list_html, index = _render_list(lines, index, toc, used, references) + out.append(list_html) + continue + + # 段落(若整段之后紧跟 === 或 ---,其实是 setext 标题) + parts: list[str] = [] + while index < total and not _starts_block(lines, index): + parts.append(lines[index].strip()) + index += 1 + text = " ".join(parts) + underline = lines[index].strip() if index < total else "" + if parts and _is_setext_underline(underline): + level = 1 if set(underline) == {"="} else 2 + anchor = _slug(re.sub(r"`|\*\*|\*", "", text), used) + if level in (2, 3): + toc.append({"level": level, "text": re.sub(r"`|\*\*|\*", "", text), "id": anchor}) + out.append( + f'<h{level} id="{html.escape(anchor, quote=True)}">' + f"{render_inline(text, references)}</h{level}>" + ) + index += 1 + continue + out.append("<p>" + render_inline(text, references) + "</p>") + + return "\n".join(out) diff --git a/website/selftest.py b/website/selftest.py new file mode 100644 index 0000000..ae150f4 --- /dev/null +++ b/website/selftest.py @@ -0,0 +1,911 @@ +"""网站构建脚本的自检:用人造包检验通用性。 + +人造包刻意与本仓库的 guan 包结构不同,覆盖各种「源码大改」后可能出现的形态: + +包一(mypkg) + * 包名不同(mypkg)、目录布局不同(<root>/src/mypkg) + * 元数据写在 pyproject.toml 的 [project] 表里(不是 setup.cfg) + * 函数说明用 docstring,而不是定义上方的注释 + * 带类型注解、async 函数、@deprecated 装饰器 + * 有嵌套子包(mypkg.sub.extra) + * 有私有函数(下划线开头)与显式 __all__ + * 有一个语法错误的文件(构建不能中断) + +包二(edge)—— 各种边角情形 + * 空文件、带 BOM 的文件、CRLF 换行的文件、GBK 编码的文件 + * 文件名不是合法标识符(my-module.py) + * 函数内部再定义函数(不应出现在 API 顶层) + * 类里的 property 与 async 方法 + * `from . import helper` 形式的相对导入 + * README 里带本地图片、表格、任务列表 + * README 与源码里出现 {{version}} 这类模板占位符(不能被替换掉) + +包三(empty)—— 什么都没有 + * 没有任何函数时,「API 参考」页应当自动消失 + +用法: + python website/selftest.py +""" + +from __future__ import annotations + +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import build_site # noqa: E402 +import extract # noqa: E402 +import i18n # noqa: E402 +import md_render # noqa: E402 + +FAILURES: list[str] = [] + + +def check(condition: bool, message: str) -> None: + if condition: + print(f" ok {message}") + else: + print(f" FAIL {message}") + FAILURES.append(message) + + +# -------------------------------------------------------------------------- +# 包一:结构与人造包完全不同 +# -------------------------------------------------------------------------- + +PYPROJECT = """\ +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[project] +name = "mypkg" +version = "9.9.9" +description = "A synthetic package for testing" +requires-python = ">=3.10" +authors = [{name = "Tester", email = "tester@example.com"}] + +[project.urls] +Homepage = "https://example.com/mypkg" +Repository = "https://github.com/example/mypkg" + +[tool.setuptools] +packages = ["mypkg", "mypkg.sub"] +""" + +INIT = '''\ +"""A synthetic package.""" + +from .core import * +from .sub import * +from .extra_only import special + +__all__ = ["greet", "VERSION", "special"] + +VERSION = "9.9.9" +''' + +CORE = '''\ +"""Core helpers of the synthetic package.""" + + +def greet(name: str = "world") -> str: + """Return a greeting for *name*. + + This description comes from a docstring, not from a comment above. + """ + return f"hello {name}" + + +def _hidden_helper(x: int) -> int: + """Not exported because of the leading underscore.""" + return x + + +async def fetch(url: str) -> str: + """An async function.""" + return url + + +@deprecated +def old_api(): + """Deprecated on purpose.""" + return 1 + + +def table(): + """Return a literal table.""" + return {"alpha": 1, "beta": 2} + + +def use_greet(): + """Call another function through the package namespace.""" + import mypkg + + return mypkg.greet("there") +''' + +EXTRA_ONLY = '''\ +def special(): + """Only exported through an explicit import.""" + return 42 +''' + +SUB_INIT = '''\ +"""A subpackage.""" + +from .extra import * +''' + +SUB_EXTRA = '''\ +def sub_function(x): + """A function living in a nested subpackage.""" + return x + + +SUB_CONSTANTS = { + "one": 1, + "two": 2, +} +''' + +BROKEN = '''\ +def broken( +''' + + +def make_package(root: Path) -> Path: + """在 root 下造出人造包,返回包源码目录。""" + package = root / "src" / "mypkg" + (package / "sub").mkdir(parents=True) + (root / "pyproject.toml").write_text(PYPROJECT, encoding="utf-8") + (package / "__init__.py").write_text(INIT, encoding="utf-8") + (package / "core.py").write_text(CORE, encoding="utf-8") + (package / "extra_only.py").write_text(EXTRA_ONLY, encoding="utf-8") + (package / "broken.py").write_text(BROKEN, encoding="utf-8") + (package / "sub" / "__init__.py").write_text(SUB_INIT, encoding="utf-8") + (package / "sub" / "extra.py").write_text(SUB_EXTRA, encoding="utf-8") + (root / "README.md").write_text( + "# mypkg\n\nA synthetic package.\n\n+ one\n+ two\n", encoding="utf-8" + ) + (root / "INSTALLATION.md").write_text( + "## Installation\n\n```bash\npip install --upgrade mypkg\n```\n", + encoding="utf-8", + ) + return package + + +# -------------------------------------------------------------------------- +# 包二:边角情形 +# -------------------------------------------------------------------------- + +EDGE_PYPROJECT = """\ +[project] +name = "edge" +version = "0.1" +description = "Edge cases" +""" + +EDGE_INIT = '''\ +"""Edge case package.""" + +import os +import json as js + +from . import helper +from .plain import plain_function +''' + +EDGE_PLAIN = '''\ +"""Plain module.""" + + +def plain_function(): + """A plain function.""" + return 1 + + +def outer(): + """A function with a nested function inside.""" + + def inner(): + return 2 + + return inner() + + +def tricky(): + """模板占位符 {{version}} 不能被替换;<script>alert(1)</script> 必须被转义。""" + return 1 + + +def plain(): + """Module-level function named plain.""" + return 0 + + +def use_widget(): + """Call a class of the same package, to exercise cross references.""" + from .cls import Widget + + return Widget() + + +class Container: + """A class whose method has the same name as a module-level function.""" + + def plain(self): + """Method named plain.""" + return 0 +''' + +EDGE_CLS = '''\ +"""A class with fancy members.""" + + +class Widget: + """A widget.""" + + kind = "widget" + + @property + def size(self) -> int: + """The size.""" + return 1 + + async def refresh(self) -> None: + """Refresh the widget.""" + + def plain(self): + """A plain method.""" + return 3 +''' + + +def make_edge_package(root: Path) -> Path: + """造出各种边角情形的包。""" + package = root / "src" / "edge" + package.mkdir(parents=True) + (root / "pyproject.toml").write_text(EDGE_PYPROJECT, encoding="utf-8") + + (package / "__init__.py").write_text(EDGE_INIT, encoding="utf-8") + (package / "plain.py").write_text(EDGE_PLAIN, encoding="utf-8") + (package / "cls.py").write_text(EDGE_CLS, encoding="utf-8") + (package / "helper.py").write_text("", encoding="utf-8") # 空文件 + + # 带 BOM 的文件 + (package / "bom.py").write_text( + '"""With BOM."""\n\n\ndef bom_func():\n """BOM function."""\n return 1\n', + encoding="utf-8-sig", + ) + + # CRLF 换行 + (package / "crlf.py").write_bytes( + b'"""CRLF module."""\r\n\r\n\r\ndef crlf_func():\r\n """CRLF function."""\r\n return 1\r\n' + ) + + # GBK 编码 + (package / "gbk.py").write_bytes( + '"""GBK 模块。"""\n\n\ndef gbk_func():\n """中文说明。"""\n return 1\n'.encode( + "gbk" + ) + ) + + # 文件名不是合法标识符 + (package / "my-module.py").write_text( + '"""Odd name."""\n\n\ndef odd_func():\n """Odd function."""\n return 1\n', + encoding="utf-8", + ) + + # README:本地图片 + 表格 + 任务列表 + 模板占位符 + (root / "docs").mkdir() + (root / "docs" / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n") + (root / "README.md").write_text( + "# Edge\n\n" + "![logo](docs/logo.png)\n\n" + "版本号是 {{version}},不要被替换掉。\n\n" + "| 列 | 值 |\n|---|---|\n| a | 1 |\n| b |\n\n" + "- [ ] 待办\n- [x] 完成\n", + encoding="utf-8", + ) + return package + + +# -------------------------------------------------------------------------- +# 包四:元数据的各种退化情形 +# -------------------------------------------------------------------------- + +META_INIT = '''\ +"""Meta package.""" + +__version__ = "7.7.7" + + +def only(): + """Only one function.""" + return 1 +''' + + +def make_meta_package(root: Path) -> Path: + """没有可读元数据时,应当退回到目录名与 __init__.py 里的 __version__。""" + package = root / "src" / "meta" + package.mkdir(parents=True) + (package / "__init__.py").write_text(META_INIT, encoding="utf-8") + # 故意写坏的 pyproject.toml,不应让构建崩溃 + (root / "pyproject.toml").write_text( + "[project\nname = broken\n", encoding="utf-8" + ) + return package + + +# -------------------------------------------------------------------------- +# 包五:setup.cfg 里用 attr: 写版本号 +# -------------------------------------------------------------------------- + +ATTR_INIT = '''\ +"""Attr package.""" + +__version__ = "5.6.7" + + +def only(): + """Only one function.""" + return 1 +''' + + +def make_attr_package(root: Path) -> Path: + package = root / "src" / "attrpkg" + package.mkdir(parents=True) + (package / "__init__.py").write_text(ATTR_INIT, encoding="utf-8") + (root / "setup.cfg").write_text( + "[metadata]\n" + "name = attrpkg\n" + "version = attr: attrpkg.__version__\n" + "description = Version read from the package itself\n", + encoding="utf-8", + ) + (root / "README.md").write_text("# attrpkg\n", encoding="utf-8") + return package + + +# -------------------------------------------------------------------------- +# 包六:只有类,没有模块级函数 +# -------------------------------------------------------------------------- + +CLASS_ONLY_INIT = '''\ +"""Only classes.""" + +from .thing import Thing +''' + + +def make_class_only_package(root: Path) -> Path: + package = root / "src" / "classonly" + package.mkdir(parents=True) + (package / "__init__.py").write_text(CLASS_ONLY_INIT, encoding="utf-8") + (package / "thing.py").write_text( + '"""A thing."""\n' + "\n" + "\n" + "class Thing:\n" + ' """A thing."""\n' + "\n" + " def go(self):\n" + ' """Go."""\n' + " return 1\n", + encoding="utf-8", + ) + (root / "README.md").write_text("# classonly\n", encoding="utf-8") + return package + + +# -------------------------------------------------------------------------- +# 包三:空包 +# -------------------------------------------------------------------------- + + +def make_empty_package(root: Path) -> Path: + package = root / "src" / "empty" + package.mkdir(parents=True) + (root / "pyproject.toml").write_text( + '[project]\nname = "empty"\nversion = "0"\ndescription = "Nothing here"\n', + encoding="utf-8", + ) + (package / "__init__.py").write_text('"""Nothing here."""\n', encoding="utf-8") + (root / "README.md").write_text("# Empty\n", encoding="utf-8") + return package + + +def check_meta(data: dict, output: Path) -> None: + api = json.loads((output / "api.json").read_text(encoding="utf-8")) + print("\n元数据退化") + check(api["package"]["name"] == "meta", "元数据坏掉时退回目录名") + check(api["package"]["version"] == "7.7.7", "版本退回 __init__.py 里的 __version__") + check((output / "index.html").exists(), "元数据坏掉时网站照常生成") + check(not build_site.verify_output(output), "生成的页面没有断链") + + +# -------------------------------------------------------------------------- +# 检查项 +# -------------------------------------------------------------------------- + + +def build(package: Path, tmp: Path, name: str) -> tuple[dict, Path]: + output = tmp / name + data = build_site.generate( + source_dir=package, + output=output, + readme_path=package.parent.parent / "README.md", + installation_path=package.parent.parent / "INSTALLATION.md", + # 人造包不带上仓库里真实的 tutorials/,免得混入无关内容 + tutorials_dir=package.parent.parent / "没有这个目录", + quiet=True, + ) + return data, output + + +def check_basic(data: dict, output: Path) -> None: + api = json.loads((output / "api.json").read_text(encoding="utf-8")) + + print("\n包信息") + check(api["package"]["name"] == "mypkg", "包名来自元数据") + check(api["package"]["version"] == "9.9.9", "版本来自元数据") + check(api["package"]["import_name"] == "mypkg", "导入名来自包目录") + check(api["package"]["requires_python"] == ">=3.10", "requires-python 正确解析") + check( + api["package"]["urls"].get("Homepage") == "https://example.com/mypkg", + "[project.urls] 表正确解析", + ) + check(api["package"]["author"] == "Tester", "PEP 621 的 authors 正确解析") + check(api["package"]["author_email"] == "tester@example.com", "作者邮箱正确解析") + + print("\n模块发现") + names = [m["name"] for m in api["modules"]] + check("core" in names, "识别到 core 模块") + check("sub.extra" in names, "识别到嵌套子包模块 sub.extra") + check("mypkg" in names, "包入口被识别为模块") + check("broken" in names, "语法错误的文件仍被列出") + + print("\n函数提取") + functions = { + f["name"]: f + for module in api["modules"] + for f in module["functions"] + } + check("greet" in functions, "识别到函数 greet") + check( + "Return a greeting" in functions["greet"]["description"], + "docstring 被用作函数说明(源码没有注释时)", + ) + check( + "This description comes from a docstring" in functions["greet"]["docstring"], + "完整 docstring 保留在数据里", + ) + check(functions["greet"]["params"][0]["annotation"] == "str", "参数类型注解解析") + check( + functions["greet"]["params"][0]["default"] in ('"world"', "'world'"), + "默认值解析", + ) + check(functions["greet"]["returns"] == "str", "返回类型解析") + check(functions["fetch"]["is_async"], "async 函数被标记") + check(functions["old_api"]["deprecated"], "@deprecated 装饰器被识别") + check("_hidden_helper" in functions, "私有函数仍被提取(只是不导出)") + check( + functions["_hidden_helper"]["exported"] is False, + "下划线开头的函数不被标记为导出", + ) + check(functions["greet"]["exported"] is True, "导出的函数被标记") + + print("\n导出关系") + exports = api["exports"] + check("greet" in exports, "from .core import * 的名字被导出") + check("sub_function" in exports, "子包的名字被导出") + check("special" in exports, "显式 from .extra_only import special 被导出") + check("_hidden_helper" not in exports, "私有名字不在导出列表里") + + print("\n数据表") + tables = [table for module in api["modules"] for table in module["data"]] + table_names = {t["name"] for t in tables} + check("table" in table_names, "函数返回的字面量表被识别") + check("SUB_CONSTANTS" in table_names, "模块级字面量表被识别") + check(any(t["origin"] == "module" for t in tables), "模块级表被标注来源") + + print("\n容错") + broken = next(m for m in api["modules"] if m["name"] == "broken") + check(bool(broken["error"]), "语法错误被记录而不是抛异常") + + print("\n页面生成") + for filename in ("index.html", "api.html", "about.html", "installation.html"): + check((output / filename).exists(), f"生成了 {filename}") + check(not (output / "modules.html").exists(), "不再生成模块页") + check(not (output / "data.html").exists(), "不再生成数据页") + installation = (output / "installation.html").read_text(encoding="utf-8") + check( + "pip install --upgrade mypkg" in installation, + "安装页用真实的包名渲染 INSTALLATION.md", + ) + index = (output / "index.html").read_text(encoding="utf-8") + check("mypkg" in index, "首页出现包名") + check("guan." not in index, "首页没有残留旧包名") + check("import guan" not in index, "首页没有残留旧包的导入写法") + check("pip install --upgrade mypkg" in index, "安装命令用真实的包名") + check('href="installation.html"' in index, "首页有指向安装页的链接") + api_html = (output / "api.html").read_text(encoding="utf-8") + check("mypkg.greet" in api_html, "交叉引用使用真实的导入名") + check("guan." not in api_html, "API 页没有残留旧包名") + check(not build_site.verify_output(output), "生成的页面没有断链") + + +def check_edge(data: dict, output: Path) -> None: + api = json.loads((output / "api.json").read_text(encoding="utf-8")) + names = [m["name"] for m in api["modules"]] + + print("\n各种编码与文件形态") + check("helper" in names, "空文件也被识别为模块") + check("bom" in names, "带 BOM 的文件被识别") + check("crlf" in names, "CRLF 换行的文件被识别") + check("gbk" in names, "GBK 编码的文件被识别") + check("my-module" in names, "文件名不是标识符时也能识别") + + functions = { + f["name"]: f + for module in api["modules"] + for f in module["functions"] + } + check("bom_func" in functions, "BOM 文件里的函数被解析") + check("crlf_func" in functions, "CRLF 文件里的函数被解析") + check("gbk_func" in functions, "GBK 文件里的函数被解析") + check( + functions.get("gbk_func", {}).get("description") == "中文说明。", + "GBK 文件里的中文说明没有乱码", + ) + check("odd_func" in functions, "特殊文件名的模块里的函数被解析") + check("inner" not in functions, "函数内部的嵌套函数不出现在 API 顶层") + + print("\n类") + classes = { + c["name"]: c + for module in api["modules"] + for c in module["classes"] + } + check("Widget" in classes, "识别到类") + widget = classes.get("Widget", {}) + method_names = {m["name"] for m in widget.get("methods", [])} + check("size" in method_names, "property 被当作方法记录") + check("plain" in method_names, "普通方法被记录") + check( + any(m["name"] == "refresh" and m["is_async"] for m in widget.get("methods", [])), + "类里的 async 方法被标记", + ) + check("kind" in widget.get("attributes", []), "类属性被记录") + + print("\n相对导入") + check("helper" in api["exports"], "from . import helper 被识别为导出") + check("os" not in api["exports"], "第三方库(import os)不会被当成导出") + check("js" not in api["exports"], "第三方库的别名(import json as js)不会被当成导出") + + print("\nREADME 与资源") + about = (output / "about.html").read_text(encoding="utf-8") + check("{{version}}" in about, "README 里的 {{version}} 没有被模板替换掉") + check((output / "docs" / "logo.png").exists(), "README 引用的本地图片被复制过来") + check("docs/logo.png" in about, "图片的相对路径被保留") + check("<td></td>" in about, "表格缺失的单元格被补齐") + check("checkbox" in about, "任务列表被正确渲染") + check(not build_site.verify_output(output), "生成的页面没有断链") + + print("\n源码里的模板占位符") + api_html = (output / "api.html").read_text(encoding="utf-8") + check("{{version}}" in api_html, "源码里的 {{version}} 没有被替换掉") + check( + "<script>alert(1)</script>" not in api_html, + "docstring 里的 HTML 被转义,不会被当成标签执行", + ) + check("<script>" in api_html, "转义后的 HTML 实体出现在页面上") + + print("\n同名函数") + duplicated = [ + module + for module in api["modules"] + if any(f["name"] == "plain_function" for f in module["functions"]) + ] + check(len(duplicated) >= 1, "同名函数被分别记录") + ids = re.findall(r'id="([^"]+)"', api_html) + check(len(ids) == len(set(ids)), "页面里的 id 没有重复") + check("plain.Container.plain" in ids, "同名方法与函数各有各的锚点") + check("plain.plain" in ids, "模块级函数的锚点没有被方法覆盖") + check("cls.Widget" in ids, "类本身也有锚点") + + print("\n交叉参考") + use_widget = next( + f + for module in api["modules"] + for f in module["functions"] + if f["name"] == "use_widget" + ) + check("Widget" in use_widget["calls"], "直接 import 进来的包内函数也被认出来") + check('<a href="#cls.Widget">' in api_html, "交叉参考指向类自己的锚点") + check(not build_site.verify_output(output), "生成的页面没有断链") + + +def check_i18n(data: dict, output: Path) -> None: + print("\n中英文界面") + check(not i18n.missing_keys(), "两种语言的文案都写全了") + + for filename in ("index.html", "api.html", "about.html", "installation.html"): + if not (output / filename).exists(): + continue # 中文页面里就没有的(比如没有 INSTALLATION.md),英文也不该有 + check( + (output / "en" / filename).exists(), + f"生成了 en/{filename}", + ) + + zh_index = (output / "index.html").read_text(encoding="utf-8") + en_index = (output / "en" / "index.html").read_text(encoding="utf-8") + + check('<html lang="zh-CN">' in zh_index, "中文页面声明了语言") + check('<html lang="en">' in en_index, "英文页面声明了语言") + check(">首页<" in zh_index, "中文页面用中文导航") + check(">Home<" in en_index, "英文页面用英文导航") + check("模块一览" in zh_index, "中文页面的界面文字是中文") + check("模块一览" not in en_index, "英文页面的界面文字是英文") + check(">Modules<" in en_index, "英文页面有对应的英文标题") + + print("\n语言切换") + check('href="en/index.html"' in zh_index, "中文页面链到英文页面") + check('href="../index.html"' in en_index, "英文页面链回中文页面") + zh_api = (output / "api.html").read_text(encoding="utf-8") + en_api = (output / "en" / "api.html").read_text(encoding="utf-8") + check('href="en/api.html"' in zh_api, "API 页也能切到另一种语言") + check('href="../api.html"' in en_api, "英文 API 页能切回中文") + + print("\n文件内容保持原样") + # README、源码里的说明不翻译:两种语言的页面里都应当原样出现 + zh_about = (output / "about.html").read_text(encoding="utf-8") + en_about = (output / "en" / "about.html").read_text(encoding="utf-8") + check("版本号是 {{version}}" in zh_about, "中文页面里是 README 原文") + check("版本号是 {{version}}" in en_about, "英文页面里也是 README 原文") + check( + "A plain function." in en_api, + "英文页面里保留源码里的英文说明", + ) + check("The size." in en_api, "英文页面里保留 docstring 原文") + + print("\n两种语言的链接都是通的") + check(not build_site.verify_output(output), "生成的页面没有断链") + check((output / "en" / "static" / "style.css").exists(), "英文目录里有自己的样式") + check((output / "en" / "docs" / "logo.png").exists(), "英文目录里也有 README 的图片") + + +def check_cli(tmp: Path) -> None: + print("\n命令行与异常处理") + try: + build_site.generate(source_dir=tmp / "does-not-exist", output=tmp / "nope") + check(False, "不存在的源码目录应当报错") + except SystemExit: + check(True, "不存在的源码目录会给出明确报错而不是崩溃") + except Exception as error: # noqa: BLE001 + check(False, f"不存在的源码目录应当报 SystemExit,实际是 {error!r}") + + try: + build_site.generate(source_dir=tmp / "not-a-dir.txt", output=tmp / "nope2") + check(False, "指向文件的源码路径应当报错") + except SystemExit: + check(True, "源码路径不是目录时会给出明确报错") + except Exception as error: # noqa: BLE001 + check(False, f"源码路径不是目录时应当报 SystemExit,实际是 {error!r}") + + print("\n断链检查本身有效") + broken = tmp / "broken-site" + broken.mkdir(parents=True, exist_ok=True) + (broken / "index.html").write_text( + '<a href="missing.html">x</a><a href="#nope">y</a>', encoding="utf-8" + ) + problems = build_site.verify_output(broken) + check(len(problems) == 2, "断链与失效锚点都能被查出来") + + +def check_empty(data: dict, output: Path) -> None: + print("\n没有内容时的页面取舍") + check(not (output / "api.html").exists(), "没有函数时不生成 API 参考页") + check(not (output / "modules.html").exists(), "不再生成模块页") + check(not (output / "data.html").exists(), "不再生成数据页") + check((output / "index.html").exists(), "首页仍然生成") + check( + not (output / "installation.html").exists(), + "没有 INSTALLATION.md 时不生成安装页", + ) + index = (output / "index.html").read_text(encoding="utf-8") + check("api.html" not in index, "首页没有链到不存在的 API 页") + check( + "installation.html" not in index, + "首页没有链到不存在的安装页", + ) + check("modules.html" not in index, "首页没有链到已经不存在的模块页") + check("data.html" not in index, "首页没有链到已经不存在的数据页") + check(not build_site.verify_output(output), "生成的页面没有断链") + + +def check_attr(data: dict, output: Path) -> None: + api = json.loads((output / "api.json").read_text(encoding="utf-8")) + print("\n版本号的 attr: 写法") + check(api["package"]["version"] == "5.6.7", "attr: 形式的版本号被解析出来") + check( + api["package"]["description"] == "Version read from the package itself", + "setup.cfg 里的描述被正确读取", + ) + check(not build_site.verify_output(output), "生成的页面没有断链") + + +def check_class_only(data: dict, output: Path) -> None: + print("\n只有类的时候") + check((output / "api.html").exists(), "只有类时也会生成 API 参考页") + api_html = (output / "api.html").read_text(encoding="utf-8") + check("thing.Thing.go" in api_html, "类的方法有正确的锚点") + check(not build_site.verify_output(output), "生成的页面没有断链") + + +TUTORIALS_README = """\ +# Tutorials + +Some examples. + +## 一、日常场景 / Daily scenarios + +| File | 说明 | Requirements | +| --- | --- | --- | +| `01_hello.py` | 打个招呼 | numpy | +""" + +TUTORIALS_SCRIPT = """\ +# 1. 打个招呼 +# +# 来源 / Source: https://example.com/post +# +import numpy as np +print(np.zeros(3)) +""" + + +def make_tutorials(root: Path) -> Path: + """造一个 tutorials/ 目录。""" + directory = root / "tutorials" + (directory / "daily_scenarios").mkdir(parents=True, exist_ok=True) + (directory / "README.md").write_text(TUTORIALS_README, encoding="utf-8") + (directory / "daily_scenarios" / "01_hello.py").write_text( + TUTORIALS_SCRIPT, encoding="utf-8" + ) + return directory + + +def check_tutorials(tmp: Path) -> None: + print("\n教程页") + tutorials_dir = make_tutorials(tmp / "t") + package = make_package(tmp / "t") + output = tmp / "t" / "site-t" + data = build_site.generate( + source_dir=package, + output=output, + readme_path=tmp / "t" / "README.md", + installation_path=tmp / "t" / "INSTALLATION.md", + tutorials_dir=tutorials_dir, + quiet=True, + ) + + check((output / "tutorials.html").exists(), "有 tutorials/ 时生成教程页") + html = (output / "tutorials.html").read_text(encoding="utf-8") + check("一、日常场景" in html, "分组标题来自 README 的章节") + check("打个招呼" in html, "脚本标题与说明被收进来") + check("np.zeros(3)" in html, "脚本源码被收进来") + check( + "href=\"tutorials/daily_scenarios/01_hello.py\"" in html, + "页面给出脚本的下载地址", + ) + check( + (output / "tutorials" / "daily_scenarios" / "01_hello.py").exists(), + "脚本被复制到站点里", + ) + + api = json.loads((output / "api.json").read_text(encoding="utf-8")) + check(bool(api.get("tutorials", {}).get("files")), "api.json 里带上教程索引") + check( + "code" not in api["tutorials"]["groups"][0]["files"][0], + "api.json 里不重复存整段源码", + ) + check(data["tutorials"]["count"] == 1, "教程数量统计正确") + check(not build_site.verify_output(output), "生成的页面没有断链") + + +def check_markdown() -> None: + print("\nMarkdown 渲染") + html = md_render.render("- a\n1. b\n") + check("<ul>" in html and "<ol>" in html, "不同类型的列表分开渲染") + html = md_render.render("- a\n - b\n - c\n") + check(html.count("<ul>") == 3, "多层嵌套列表不会被当成代码块") + html = md_render.render("[x](https://example.com/a_(b))") + check('href="https://example.com/a_(b)"' in html, "链接地址里的括号不会被截断") + html = md_render.render("see https://example.com/a_(b).") + check('href="https://example.com/a_(b)"' in html, "裸露网址里的括号与句号处理正确") + html = md_render.render("| a | b |\n|---|---|\n| 1 |\n") + check("<td></td>" in html, "表格缺失的单元格被补齐") + check("<blockquote>" in md_render.render("> 引用\n"), "引用块被渲染") + check( + "<em>x</em>" in md_render.render("*x*\n"), + "斜体被渲染", + ) + + +# -------------------------------------------------------------------------- + + +def run() -> int: + tmp = Path(tempfile.mkdtemp(prefix="site-selftest-")) + try: + print("== 人造包一:结构完全不同 ==") + data, output = build(make_package(tmp / "a"), tmp / "a", "site-a") + check_basic(data, output) + + print("\n== 人造包二:边角情形 ==") + data, output = build(make_edge_package(tmp / "b"), tmp / "b", "site-b") + check_edge(data, output) + + print("\n== 人造包三:什么都没有 ==") + data, output = build(make_empty_package(tmp / "c"), tmp / "c", "site-c") + check_empty(data, output) + + print("\n== 人造包四:元数据退化 ==") + data, output = build(make_meta_package(tmp / "d"), tmp / "d", "site-d") + check_meta(data, output) + + print("\n== 人造包五:attr: 形式的版本号 ==") + data, output = build(make_attr_package(tmp / "e"), tmp / "e", "site-e") + check_attr(data, output) + + print("\n== 人造包六:只有类 ==") + data, output = build(make_class_only_package(tmp / "f"), tmp / "f", "site-f") + check_class_only(data, output) + + print("\n== 自动发现 ==") + discovered = extract.discover_package_dir([tmp / "a" / "src", tmp / "a"]) + check( + discovered is not None and discovered.name == "mypkg", + "自动发现包目录", + ) + check( + extract.discover_package_dir([Path(__file__).resolve().parent]) is None, + "网站脚本目录不会被误认成包", + ) + + print("\n== 教程页 ==") + check_tutorials(tmp) + + print("\n== 中英文界面 ==") + data, output = build(make_edge_package(tmp / "g"), tmp / "g", "site-g") + check_i18n(data, output) + + print("\n== Markdown ==") + check_markdown() + print("\n== 命令行与异常处理 ==") + check_cli(tmp) + + finally: + shutil.rmtree(tmp, ignore_errors=True) + + print() + if FAILURES: + print(f"{len(FAILURES)} 项检查未通过:") + for item in FAILURES: + print(f" - {item}") + return 1 + print("全部检查通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/website/static/icon.jpg b/website/static/icon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c79b458ec06cce4796473e807223149ee7aa4ecf GIT binary patch literal 22142 zcmeFY2Ut_xwk{k51W`bgB2uCv0#ZegD)}sch=BA?RJus-EfJBfL_k16h*AZF&^v^V zfPnN)=)EMA5J=%Jzg_-)zP-;r_w4)M^FPo3>_rB1Wv-Gn<{D$nca(WNdOQO<`%pzq z1w=(f1$qMffsSXXlazhzZ9pIm4bXKE2t*H}(FTENfIT*Vl(eZp)IduG5J>yPulCnh zP^>of?|r}b|LOoXL4cS6lKCej{pVw{Kzj=y@juhAYs3Kh)&A=L{T|&L#JZ`C;}<Ws za8(yyfJ8+_M^l13)(G{}n<0f}cvRfuH=;Xsc7h(K<s>AYr@3vfmfj7O+3m}Yd-Ui_ zqsQDj3Z<vlVQPBx2DV^jHS*+1d}E={{rjHrDOcSX9a8U8++B#T{W>N-Kot}mAdsTI z6zKr={fZCTGBR%80*2=^YWv?RM=91C8I=rAA-|*d8&Y=1zJ)z_Ko0PXtor=r@#CD{ z9)5QwgZFv|S<%W;Qn<OIy^eHQxKKey&|$*sxJo-{(zCqb9-r*T_d+fcQX@otxn35( z``lA!9xvOlxM`AX`o1;4Y<gNgSi~bID5CC@`dek2G>6Br>afx%aetnKrb3rozq*AD zmmEJaU#_R0tUA{Tj#-{LgFk>l`uFz#?-4kj0Nn$fprZQq`SV6iOZ{s*Nkc<TOLvlv z?ibOYI(>?so`If@j)9SZ;q)0`qdUdK%y@?B*Z!}Y{JQ+tRlw&AJsth8GyZ$qaRZ2j zo@$S3kDBTV=mZNDH4D{o3kVF@1zNz8{4yqg-l$Gc)6kv-9*N;Ja6;Kx;PI)cPXG@~ zO9Pk`szBg4h=zss+~pg0PM+6(N_WMTRWc~%3q9Z6qDD5IKJ3+-&)i;}VqoXEz<H7X znt-6tbt&mvGO}{=_wFmJs6J48sH>-MU}$7)VrgY<V{2#s+}*>|%iG7-FZgvxXjphe zWNh5~_=Lm{ACuD3GcvQXzvko?mz0*3S5#J2H#N7kwzYrn=<FXD92y=O9UGsWn_pO5 zT3%UQ!|m+u?c)y!heyBo0&L!&*!nAHf8dJ+;OhkN4ru6p@kMpQ8z5>H8rsV@PM*7? zP50FG{1wR{de*x!Uy2$}@!izHvORO_V_?53HOr6t#oF(j{qHgM>R;mQuZ;aae2s(7 zP*VXfkD3Jp0g>OOMG9{+_cTQcem?PU`u7<8TL%6u1OH#kK$TWBl5LYhOX9}Kyg`ZD z)R$p<Qf{S-);?leHX1A+5_qY3qubMS27=YgSN1>?1P)pbd5q&rGsCEdmLupEj4sZ0 z-#FJilV=!;dy}-URwCc;nk^(2k!nr625)?}r!{^sUmi0f%G@yChWz@xLov$JAI=fO z(0aO4-BD+9V_bq+M8OJCNa@Ij_ogNugM#|uxFnM4G3XV`Si9#jh?chBD#ly7{_R&a zPRhEksrxY~poF;%?JA6|W#+Zjx8K#w#T;5vR7;u&@;;MX`Qbym(i7@@o%M17*3|@- z*&1_o?S98?zM3lb4xYMoCpw2)nkz5cyAW-)H56VR>toQ}x)BPU_%VpZ6pYm#Gx9m# zT(os)LLNov@Xa40d+L=72)G275gdHS=oqvku#XC*Fk#yqV|3dsSE}^l5^8r%uJ!jx zRfDzR&CTD?D~8p_poYa`(462gC@jnv{u2f85!7`IYBB68)jbB8xx){M$Dr8uV^ECJ zF69!K_89b23XQeaqC7Z?`olA+k`(2;C?WnR5)+hirQSRZvEMFp3__w0Mc{{Lk3r#d zjqp82aS|tR<5;D%KmMZjF^HL|_!#uXV*MCI4UBI%D$y>T?Am<{THdCpgDLkRb5vL; znOglAbW-maw1D`Eo}C?r?g>x9XWWiKIMzS#B)m8du=TerFcV$|a2$hv>puoH#zPMI z*2D=uo{40J11NzTO;QG9vFqYTY+Goo9Gnn&=oJYW;l@y4z)uwcbnoBn|NHI#TloL6 z?wf}-LdHud9AQxlecwDT+q&|Um~B_q9U2J>@-4pkL>eOr&yC}eqt|Sr=1C8;PpF?7 zs%!6re_&OT?Cz(C|E}JL(_&cF*M-Kn1UN#z2=<(H(;C-Ui^n^r1NNZU0lK@Kc?_EI zK$2|g;p7@%_cQx3s8BjF)Jai!Rz3eYnDzD#m-1v7y3+3c{jhUakRR0BZXRg5w!uE5 zy0;0kYdtaq2)2JV@u1H^Mmx_>__xfkIeqx``Bv8+PtCYb&)3j3;2j9UeWN^@i%O@C zho8$7Diz2<WTs$DAe&V)Q@SPtr3%~36RY0Gps|fa_l$3wi|Jd3DxgCg!4PeOmH-|q zC~;Mw**QGnv&=`PY<nTu!v}U5)PTu>WWwJ1c(*`pC!Ad{H7n8Zuu&rXI@$g8?)6bp zR<!upZ8-t&@|?1PpNEP(#(L?TU0xn~+DXDI(Vx>I1wsFeP$}@4Z<rkfI0Cu9=zI*y zd}r9Fzq?IhQ`L0TrJOpz$xB=7RKIYyI^u89RGqJ2Tfm)g7H$DXQ)&4alu^R1h0R<! z2J!9MQt0K5K|-K`W6%a2{226E6gFcxiy@tj+){uO1jS-wCG*VeMsRJ+xQYZWYOD^N zf!u;xQp_WWkB|^qGw?$W2oBN#28@2ADtbW;iy_0+jzMYb3w}pL3WJOzdKMG5G(|ya zTeZn=MIb0$M<d^*H||*yw;MYIn)Y-k_Q#;rM}ldQ>HBB@bB<dHA-MA@1pQgzI(KM& znC5^;qDO4KrrJs_ok$Co^@N7rvxP&;yqozZDHomcjRo7TZ&p~vPL<;yjHlI_;x)-R zPUz>N{;ebL?9(-{TL}KT`cK6HBj-zevsB*&mvW2v=�gxj2kFJW}EHcrNcL1I?{! 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 literal 0 HcmV?d00001 diff --git a/website/static/site.js b/website/static/site.js new file mode 100644 index 0000000..70cb9a8 --- /dev/null +++ b/website/static/site.js @@ -0,0 +1,485 @@ +/* ========================================================================== + GUAN 官网 —— 交互脚本 + 站点是纯静态的,所以所有交互都放在这里,按功能分成若干独立的小模块。 + 每个模块都先判断页面上有没有对应的元素,没有就直接退出, + 这样同一份脚本可以用在所有页面上。 + ========================================================================== */ + +(function () { + "use strict"; + + var root = document.documentElement; + + /* 页面把自己的语言文案传进来(见 i18n.py), + 这里做一次兜底,脚本单独拿出来调试时也不会报错。 */ + var I18N = window.SITE_I18N || {}; + function text(key, fallback) { + return I18N[key] || fallback || key; + } + + /* ---------- 通用小工具 ---------- */ + + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function (resolve, reject) { + var area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + try { + document.execCommand("copy"); + resolve(); + } catch (err) { + reject(err); + } + document.body.removeChild(area); + }); + } + + /* 给按钮加一个「已复制 → 恢复」的反馈 */ + function flash(button, text, done) { + var original = button.getAttribute("data-label") || button.textContent; + button.setAttribute("data-label", original); + button.textContent = text; + clearTimeout(button._flashTimer); + button._flashTimer = setTimeout(function () { + button.textContent = original; + if (done) done(); + }, 1500); + } + + /* ========================================================================== + 主题切换:默认跟随系统,用户点一次就固定下来 + ========================================================================== */ + + (function theme() { + var button = document.getElementById("theme-toggle"); + if (!button) return; + + function current() { + var explicit = root.getAttribute("data-theme"); + if (explicit === "light" || explicit === "dark") return explicit; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + function apply(mode) { + if (mode === "light" || mode === "dark") { + root.setAttribute("data-theme", mode); + } else { + root.removeAttribute("data-theme"); + } + } + + button.addEventListener("click", function () { + var next = current() === "dark" ? "light" : "dark"; + apply(next); + try { + localStorage.setItem("guan-theme", next); + } catch (e) {} + button.setAttribute( + "title", + next === "dark" + ? text("theme_to_light", "切换到浅色主题") + : text("theme_to_dark", "切换到深色主题") + ); + button.setAttribute("aria-label", button.getAttribute("title")); + }); + })(); + + /* ========================================================================== + 窄屏菜单:展开 / 收起导航 + ========================================================================== */ + + (function navToggle() { + var toggle = document.getElementById("nav-toggle"); + var nav = document.getElementById("site-nav"); + if (!toggle || !nav) return; + + function close() { + nav.classList.remove("is-open"); + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-label", text("menu_open", "打开菜单")); + } + + toggle.addEventListener("click", function () { + var open = nav.classList.toggle("is-open"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + toggle.setAttribute( + "aria-label", + open ? text("menu_close", "关闭菜单") : text("menu_open", "打开菜单") + ); + }); + + nav.addEventListener("click", function (e) { + if (e.target.tagName === "A") close(); + }); + + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") close(); + }); + + window.addEventListener("resize", function () { + if (window.innerWidth > 900) close(); + }); + })(); + + /* ========================================================================== + 复制:安装命令、代码块、函数链接 + ========================================================================== */ + + (function copyButtons() { + document.querySelectorAll(".copy[data-copy]").forEach(function (button) { + button.addEventListener("click", function () { + var value = button.getAttribute("data-copy"); + copyText(value).then(function () { + flash(button, text("copied", "已复制")); + }, function () {}); + }); + }); + + // 给每个代码块加一个右上角的复制按钮 + document.querySelectorAll("pre.code").forEach(function (pre) { + var code = pre.querySelector("code"); + if (!code) return; + var button = document.createElement("button"); + button.type = "button"; + button.className = "code-copy"; + button.textContent = text("copy", "复制"); + button.title = text("copy_code", "复制代码"); + button.setAttribute("aria-label", text("copy_code", "复制代码")); + button.addEventListener("click", function () { + copyText(code.innerText).then(function () { + flash(button, text("copied", "已复制")); + }, function () {}); + }); + pre.appendChild(button); + }); + })(); + + /* ========================================================================== + 下载:教程页的「下载脚本」 + 直接用 <a download> 的话,本地以 file:// 方式预览时浏览器会忽略 + download 属性、把源码显示出来。这里改成拿页面里的源码生成文件再下载, + 这样无论站点怎么打开,点击都会启动下载程序。 + ========================================================================== */ + + (function downloadLinks() { + document.querySelectorAll("a[data-download]").forEach(function (link) { + link.addEventListener("click", function (event) { + var card = link.closest(".tut-card"); + var code = card && card.querySelector("pre.code code"); + if (!code) return; // 页面里没有源码,就用浏览器自己的下载行为 + + event.preventDefault(); + var blob = new Blob([code.textContent], { + type: "text/x-python;charset=utf-8" + }); + var url = URL.createObjectURL(blob); + var anchor = document.createElement("a"); + anchor.href = url; + anchor.download = link.getAttribute("data-download"); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + }); + }); + })(); + + /* ========================================================================== + 回到顶部 + ========================================================================== */ + + (function toTop() { + var button = document.getElementById("to-top"); + if (!button) return; + + var pending = false; + + function update() { + pending = false; + button.classList.toggle("is-visible", window.scrollY > 420); + } + + function onScroll() { + if (pending) return; + pending = true; + window.requestAnimationFrame(update); + } + + button.addEventListener("click", function () { + window.scrollTo({ top: 0, behavior: "smooth" }); + }); + + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + update(); + })(); + + /* ========================================================================== + 滚动跟随高亮:API 页的模块列表、关于页 / 教程页的目录 + -------------------------------------------------------------------------- + 侧栏里最后一个条目往往太短,页面滚不到它的标题上方那条基线, + 只按滚动位置判断的话,高亮会留在上一项上,点它也没反应。 + 所以点一下的时候先把高亮锁在被点的条目上,等用户自己滚动时 + 再交还给滚动位置决定;页面滚到底部时,最后一个条目也会被点亮。 + ========================================================================== */ + + function setupScrollSpy(options) { + var links = options.links; + var targets = options.targets; + var offset = options.offset || 120; + var container = options.container || null; + + if (!links.length || !targets.length) return; + + var locked = null; // 点击后锁定的目标 + var settledY = null; // 跳转停下来时的滚动位置 + var settleTimer = null; + var pending = false; + + function hrefId(link) { + var href = link.getAttribute("href") || ""; + if (href.charAt(0) !== "#") return ""; + return decodeURIComponent(href.slice(1)); + } + + function isVisible(el) { + return el.getClientRects().length > 0; + } + + /* 当前所在的目标:最后一个越过基线的;页面到底了就是最后一个 */ + function pick() { + var doc = document.documentElement; + var shown = targets.filter(isVisible); + if (!shown.length) return null; + if (window.innerHeight + window.pageYOffset >= doc.scrollHeight - 4) { + return shown[shown.length - 1]; + } + var current = null; + shown.forEach(function (target) { + if (target.getBoundingClientRect().top <= offset) current = target; + }); + return current || shown[0]; + } + + /* 让被点亮的条目留在侧栏自身的可视范围内 */ + function keepVisible(link) { + if (!container) return; + var box = container.getBoundingClientRect(); + var item = link.getBoundingClientRect(); + if (item.top < box.top) container.scrollTop -= box.top - item.top + 6; + else if (item.bottom > box.bottom) container.scrollTop += item.bottom - box.bottom + 6; + } + + function apply(target) { + links.forEach(function (link) { + var active = !!target && hrefId(link) === target.id; + link.classList.toggle("is-active", active); + if (active) keepVisible(link); + }); + } + + /* 滚动停下来之后记下位置,用来分辨「程序滚动」和「用户滚动」 */ + function markSettled() { + clearTimeout(settleTimer); + settleTimer = setTimeout(function () { + settledY = window.scrollY; + apply(locked || pick()); + }, 200); + } + + function update() { + pending = false; + // 用户自己滚动了 → 解除点击时的锁定 + if (locked && settledY !== null && Math.abs(window.scrollY - settledY) > 4) { + locked = null; + settledY = null; + } + apply(locked || pick()); + markSettled(); + } + + function onScroll() { + if (pending) return; + pending = true; + window.requestAnimationFrame(update); + } + + links.forEach(function (link) { + link.addEventListener("click", function () { + var id = hrefId(link); + if (!id) return; + var target = document.getElementById(id); + if (!target) return; + locked = target; + settledY = null; + apply(target); + markSettled(); + }); + }); + + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + apply(pick()); + markSettled(); + } + + /* ========================================================================== + API 页:模块速查、模块高亮、函数筛选 + ========================================================================== */ + + (function apiPage() { + var layout = document.querySelector(".api-layout"); + if (!layout) return; + + var modules = Array.prototype.slice.call(document.querySelectorAll(".module")); + + /* 每个模块下方加一排函数快捷入口 */ + modules.forEach(function (module) { + var funcs = Array.prototype.slice.call(module.querySelectorAll(".func")); + if (funcs.length < 2) return; + var list = document.createElement("ul"); + list.className = "module-index"; + funcs.forEach(function (func) { + var id = func.id; + if (!id) return; + var name = id.split(".").pop(); + var li = document.createElement("li"); + var link = document.createElement("a"); + link.href = "#" + id; + link.textContent = name; + link.setAttribute("data-target", id); + li.appendChild(link); + list.appendChild(li); + }); + var meta = module.querySelector(".module-meta"); + if (meta) meta.insertAdjacentElement("afterend", list); + else module.insertBefore(list, module.firstChild); + }); + + /* 函数卡片右上角的「复制链接」按钮 */ + document.querySelectorAll(".func").forEach(function (func) { + if (!func.id) return; + var button = document.createElement("button"); + button.type = "button"; + button.className = "anchor-btn"; + button.textContent = "#"; + button.title = text("copy_anchor", "复制本函数的链接"); + button.setAttribute("aria-label", text("copy_anchor", "复制本函数的链接")); + button.addEventListener("click", function () { + var url = location.href.split("#")[0] + "#" + func.id; + copyText(url).then(function () { + flash(button, text("copied", "已复制")); + }, function () {}); + }); + var heading = func.querySelector("h3"); + if (heading) heading.appendChild(button); + }); + + /* 顶部筛选框:按函数名 / 说明过滤 */ + var total = document.querySelectorAll(".func").length; + var filter = document.createElement("div"); + filter.className = "api-filter"; + filter.innerHTML = + '<input type="search" placeholder="' + + text("filter_placeholder", "在本页筛选函数…") + + '" aria-label="' + text("filter_label", "筛选函数") + + '" autocomplete="off">' + + '<span class="filter-count"></span>' + + '<button class="filter-clear" type="button" hidden>' + + text("filter_clear", "清除") + "</button>"; + // 放在正文列的上方,跟左侧模块列表对齐 + var body = document.querySelector(".api-body"); + if (body) body.insertBefore(filter, body.firstChild); + else layout.parentNode.insertBefore(filter, layout); + + var field = filter.querySelector("input"); + var count = filter.querySelector(".filter-count"); + var clear = filter.querySelector(".filter-clear"); + + function applyFilter() { + var q = field.value.trim().toLowerCase(); + clear.hidden = !q; + var shown = 0; + + document.querySelectorAll(".func").forEach(function (func) { + var content = (func.textContent || "").toLowerCase(); + var hit = !q || content.indexOf(q) !== -1; + func.classList.toggle("is-hidden", !hit); + if (hit) shown += 1; + }); + + // 一个函数都不剩的模块整块隐藏 + modules.forEach(function (module) { + var visible = module.querySelectorAll(".func:not(.is-hidden)").length; + module.style.display = visible ? "" : "none"; + var index = module.querySelector(".module-index"); + if (index) { + index.querySelectorAll("a").forEach(function (link) { + var target = document.getElementById(link.getAttribute("data-target")); + link.style.display = target && !target.classList.contains("is-hidden") ? "" : "none"; + }); + } + }); + + count.textContent = q + ? text("filter_count", "显示 {shown} / {total} 个函数") + .replace("{shown}", shown) + .replace("{total}", total) + : ""; + } + + field.addEventListener("input", applyFilter); + clear.addEventListener("click", function () { + field.value = ""; + applyFilter(); + field.focus(); + }); + + /* 左侧模块列表:滚动时高亮当前所在的模块 */ + var nav = document.querySelector(".api-nav"); + var navLinks = Array.prototype.slice.call( + document.querySelectorAll(".api-nav li a") + ); + if (!navLinks.length) return; + + setupScrollSpy({ + links: navLinks, + targets: modules, + offset: 120, + container: nav, + }); + })(); + + /* ========================================================================== + 关于页 / 教程页的目录:跟随滚动高亮当前章节 + ========================================================================== */ + + (function tocScrollSpy() { + var toc = document.querySelector(".toc"); + if (!toc) return; + + var links = Array.prototype.slice.call(toc.querySelectorAll("a[href^='#']")); + if (!links.length) return; + + var targets = links + .map(function (link) { + return document.getElementById(decodeURIComponent(link.getAttribute("href").slice(1))); + }) + .filter(Boolean); + if (!targets.length) return; + + setupScrollSpy({ + links: links, + targets: targets, + offset: 140, + container: toc, + }); + })(); +})(); diff --git a/website/static/style.css b/website/static/style.css new file mode 100644 index 0000000..7a8d396 --- /dev/null +++ b/website/static/style.css @@ -0,0 +1,891 @@ +/* ========================================================================== + GUAN 软件包官网 —— 样式表 + 设计要点: + * 浅色 / 深色两套配色,跟随系统,也可手动切换(html[data-theme]) + * 版心宽屏铺满,两侧留白随视口伸缩 + * 所有可交互元素都有明显的悬停与键盘焦点态 + ========================================================================== */ + +:root { + color-scheme: light; + --bg: #f5f7f8; + --bg-grad: radial-gradient(1200px 600px at 15% -10%, #eaf2ee 0%, transparent 60%), + radial-gradient(900px 500px at 95% 0%, #eef1f7 0%, transparent 55%); + --panel: #ffffff; + --panel-2: #fafbfc; + --border: #e3e7ea; + --border-strong: #cfd6db; + --text: #16191d; + --text-soft: #3c434b; + --muted: #69727c; + --accent: #2f6f4f; + --on-accent: #ffffff; + --accent-hover: #265c41; + --accent-soft: #e7f1eb; + --accent-line: #bcd8c9; + --warn: #9a5518; + --warn-soft: #fdf0e2; + --code-bg: #f1f4f6; + --code-head: #e9edf0; + /* ---- 字号阶梯:全站统一,字号只从这里取值 ---- */ + --fs-2xs: .75rem; /* 12 徽标 / 键盘提示 */ + --fs-xs: .8125rem; /* 13 小标签 / 次要说明 */ + --fs-sm: .875rem; /* 14 辅助说明 / 代码 */ + --fs-md: .9375rem; /* 15 控件 / 次级正文 */ + --fs-body: 1rem; /* 16 正文 */ + --fs-lg: 1.125rem; /* 18 小标题 */ + --fs-xl: 1.5rem; /* 24 区块标题 */ + --fs-display: clamp(2.25rem, 4.8vw, 3.5rem); /* 主视觉大标题 */ + --fs-title: clamp(1.75rem, 3vw, 2.25rem); /* 页面标题 */ + --fs-lead: clamp(1.0625rem, 1.2vw, 1.1875rem); /* 导语 */ + --fs-stat: clamp(1.375rem, 2.2vw, 1.75rem); /* 统计数字 */ + --fs-module: clamp(1.25rem, 2.2vw, 1.5625rem); /* 模块标题 */ + --radius: 10px; + --radius-lg: 16px; + /* ---- 字体栈 ---- + 只用系统自带字体:不依赖网络字体,国内访问更快、首屏也不闪。 + 拉丁字与中文字分段挑选:拉丁优先用 Segoe UI / PingFang 这类屏显细腻的, + 中文再依次回落到思源 / 雅黑,保证任何系统都不缺字。 */ + --sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, + BlinkMacSystemFont, Roboto, "Helvetica Neue", Arial, "PingFang SC", + "HarmonyOS Sans SC", "Noto Sans SC", "Noto Sans CJK SC", + "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; + /* 标题专用:Win11 自带的 Segoe UI Variable Display 是大字号光学尺寸, + 笔画比正文字体更实、更适合主视觉;其他系统回落到常规黑体 */ + --display: "Segoe UI Variable Display", "Segoe UI Variable Text", + "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, Roboto, + "Helvetica Neue", Arial, "PingFang SC", "HarmonyOS Sans SC", + "Noto Sans SC", "Noto Sans CJK SC", "Source Han Sans SC", + "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; + /* 等宽字体:优先 Cascadia(Windows 自带真粗体,不会有伪粗体的糊边), + 中文注释用思源等宽 / 更纱黑体等宽,代码块里中英混排才对得齐 */ + --mono: "Cascadia Mono", "Cascadia Code", ui-monospace, "SFMono-Regular", + "SF Mono", Menlo, Consolas, "JetBrains Mono", "Sarasa Mono SC", + "Noto Sans Mono CJK SC", "Source Code Pro", "Liberation Mono", monospace; + --wrap-max: 1680px; + --shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 8px 24px rgba(16, 24, 40, .06); + --shadow-lg: 0 2px 6px rgba(16, 24, 40, .07), 0 18px 44px rgba(16, 24, 40, .10); + --focus: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* 深色配色:手动切换时优先,其次跟随系统 */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --bg: #101315; + --bg-grad: radial-gradient(1200px 600px at 15% -10%, #16241d 0%, transparent 60%), + radial-gradient(900px 500px at 95% 0%, #17202b 0%, transparent 55%); + --panel: #191d21; + --panel-2: #1e2327; + --border: #2a3035; + --border-strong: #394147; + --text: #e7eaec; + --text-soft: #c3cad0; + --muted: #98a1ab; + --accent: #7fc9a3; + --on-accent: #0e1a14; + --accent-hover: #97d8b6; + --accent-soft: #1c2a23; + --accent-line: #35543f; + --warn: #e0a86a; + --warn-soft: #2d2318; + --code-bg: #1d2226; + --code-head: #23282d; + --shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 8px 24px rgba(0, 0, 0, .30); + --shadow-lg: 0 2px 8px rgba(0, 0, 0, .40), 0 18px 44px rgba(0, 0, 0, .45); + --focus: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #101315; + --bg-grad: radial-gradient(1200px 600px at 15% -10%, #16241d 0%, transparent 60%), + radial-gradient(900px 500px at 95% 0%, #17202b 0%, transparent 55%); + --panel: #191d21; + --panel-2: #1e2327; + --border: #2a3035; + --border-strong: #394147; + --text: #e7eaec; + --text-soft: #c3cad0; + --muted: #98a1ab; + --accent: #7fc9a3; + --on-accent: #0e1a14; + --accent-hover: #97d8b6; + --accent-soft: #1c2a23; + --accent-line: #35543f; + --warn: #e0a86a; + --warn-soft: #2d2318; + --code-bg: #1d2226; + --code-head: #23282d; + --shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 8px 24px rgba(0, 0, 0, .30); + --shadow-lg: 0 2px 8px rgba(0, 0, 0, .40), 0 18px 44px rgba(0, 0, 0, .45); + --focus: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); +} + +* { box-sizing: border-box; } + +/* 细一点的滚动条,页面更干净 */ +* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border-strong); border-radius: 6px; + border: 3px solid transparent; background-clip: content-box; +} +::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: content-box; } + +html { + scroll-behavior: smooth; + scroll-padding-top: 5.5rem; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--bg-grad), var(--bg); + background-attachment: fixed; + color: var(--text); + font-family: var(--sans); + line-height: 1.7; + font-size: var(--fs-body); + font-feature-settings: "kern" 1, "liga" 1, "calt" 1; + font-optical-sizing: auto; + overflow-wrap: break-word; +} + +/* 标题:统一用标题字体;字距保持默认,避免中文标题的字被挤在一起 */ +h1, h2, h3, h4 { + line-height: 1.25; + font-family: var(--display); + font-optical-sizing: auto; + font-weight: 700; + text-wrap: balance; +} + +/* 正文段落避免出现孤字(一行只剩一两个字) */ +p { text-wrap: pretty; } + +/* 键盘焦点统一可见,鼠标点击则不显示焦点框 */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} +:focus:not(:focus-visible) { outline: none; } + +/* ---------- 版心 ---------- */ + +.wrap { + width: 100%; + max-width: var(--wrap-max); + margin: 0 auto; + padding: 0 clamp(1rem, 3vw, 3rem); +} + +.skip { + position: absolute; left: -9999px; top: 0; + background: var(--panel); color: var(--text); + padding: .6rem 1rem; z-index: 99; + border-radius: var(--radius); box-shadow: var(--shadow); +} +.skip:focus { left: 1rem; top: 1rem; } + +/* ---------- 顶栏 ---------- */ + +.site-head { + position: sticky; top: 0; z-index: 30; + background: color-mix(in srgb, var(--panel) 82%, transparent); + backdrop-filter: saturate(180%) blur(14px); + -webkit-backdrop-filter: saturate(180%) blur(14px); + border-bottom: 1px solid var(--border); +} + +.head-inner { + display: flex; align-items: center; gap: clamp(.75rem, 1.6vw, 1.75rem); + min-height: 3.75rem; + flex-wrap: wrap; +} + +.brand { + display: flex; align-items: center; gap: .5rem; + text-decoration: none; color: var(--text); + border-radius: 8px; +} +.brand-mark { + width: 28px; height: 28px; + border-radius: 6px; object-fit: cover; + display: block; flex: none; +} +.brand-text { + font-family: var(--display); font-weight: 700; + font-size: var(--fs-lg); letter-spacing: -.015em; +} +.brand-version { + color: var(--muted); font-size: var(--fs-xs); font-family: var(--mono); + font-variant-numeric: tabular-nums; + background: var(--panel-2); border: 1px solid var(--border); + border-radius: 999px; padding: .05rem .45rem; +} + +.site-nav { display: flex; gap: clamp(.7rem, 1.4vw, 1.4rem); } +.site-nav a { + color: var(--muted); text-decoration: none; font-size: var(--fs-md); + padding: .35rem 0; border-bottom: 2px solid transparent; + white-space: nowrap; transition: color .15s, border-color .15s; +} +.site-nav a:hover { color: var(--text); } +.site-nav a.is-active { + color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; +} + +/* 顶栏右侧的操作区 */ +.head-actions { + margin-left: auto; + display: flex; align-items: center; gap: .5rem; + min-width: 0; +} + +.icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 2.25rem; height: 2.25rem; flex: 0 0 auto; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--panel); color: var(--muted); + cursor: pointer; font-size: var(--fs-md); line-height: 1; + transition: color .15s, border-color .15s, background .15s; +} +.icon-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } + +/* 语言切换:链到另一种语言的同一个页面 */ +.lang-switch { + display: inline-flex; align-items: center; justify-content: center; + height: 2.25rem; padding: 0 .7rem; flex: 0 0 auto; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--panel); color: var(--muted); + text-decoration: none; font-size: var(--fs-sm); line-height: 1; + white-space: nowrap; + transition: color .15s, border-color .15s, background .15s; +} +.lang-switch:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } + +/* 主题切换:三个状态用 data-theme 属性控制,图标由 JS 切换 */ +.theme-toggle .icon-sun { display: none; } +:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; } +:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .theme-toggle .icon-sun { display: block; } + :root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; } +} + +/* 窄屏的菜单按钮(宽屏隐藏) */ +.nav-toggle { + display: none; margin-left: auto; + width: 2.5rem; height: 2.25rem; padding: .55rem .5rem; + background: transparent; border: 1px solid var(--border); + border-radius: var(--radius); cursor: pointer; + flex-direction: column; justify-content: space-between; +} +.nav-toggle span { + display: block; height: 2px; background: var(--text); + border-radius: 2px; transition: transform .2s, opacity .2s; +} +.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); } +.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; } +.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); } + +/* ---------- 首页 ---------- */ + +.hero { + display: grid; gap: clamp(1.5rem, 3vw, 3rem); + grid-template-columns: minmax(0, 1fr); + align-items: start; + padding: clamp(2.25rem, 5vw, 4.25rem) 0 clamp(1.5rem, 3vw, 2.5rem); +} +.hero-text { min-width: 0; } + +.eyebrow { + font-family: var(--mono); text-transform: uppercase; letter-spacing: .16em; + font-size: var(--fs-xs); color: var(--accent); margin: 0 0 .7rem; font-weight: 600; +} +/* 主标题:换用标题字体(真粗体,不会有等宽字体的伪粗体糊边), + 字距收紧、行高压紧,让大字号更利落 */ +.hero h1 { + font-family: var(--display); + font-size: var(--fs-display); + font-weight: 800; + letter-spacing: -.035em; + line-height: 1.04; + margin: 0 0 .55rem; + text-wrap: balance; +} +.lead { + font-size: var(--fs-lead); color: var(--text-soft); + max-width: 46rem; margin: 0 0 1.4rem; +} + +/* 安装命令 */ +.install { + display: inline-flex; align-items: stretch; max-width: 100%; + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; + box-shadow: var(--shadow); +} +.install code { + display: block; padding: .7rem 1.1rem; font-family: var(--mono); font-size: var(--fs-md); + overflow-x: auto; white-space: nowrap; +} +.install .copy { + border: 0; border-left: 1px solid var(--border); background: var(--code-bg); + color: var(--muted); font-family: inherit; font-size: var(--fs-xs); + padding: 0 .85rem; cursor: pointer; white-space: nowrap; + transition: color .15s, background .15s; +} +.install .copy:hover { color: var(--accent); background: var(--accent-soft); } + +.hero-meta { color: var(--muted); font-size: var(--fs-md); margin: .9rem 0 0; } +.hero-meta strong { color: var(--text); font-variant-numeric: tabular-nums; } + +.hero-links { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.4rem 0 0; } +.btn { + display: inline-block; text-decoration: none; font-size: var(--fs-md); font-weight: 600; + padding: .5rem 1.05rem; border-radius: var(--radius); + background: var(--accent); color: var(--on-accent); border: 1px solid transparent; + transition: background .15s, transform .1s, box-shadow .15s; +} +.btn:hover { background: var(--accent-hover); box-shadow: var(--shadow); } +.btn:active { transform: translateY(1px); } +.btn-ghost { background: transparent; color: var(--text); border-color: var(--border); } +.btn-ghost:hover { + border-color: var(--accent); color: var(--accent); + background: var(--accent-soft); box-shadow: none; +} + +.stats { + display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); + gap: 1rem; margin: 0 0 clamp(2rem, 4vw, 3rem); +} +.stat { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.1rem; text-align: center; + transition: border-color .15s, transform .15s, box-shadow .15s; +} +.stat:hover { border-color: var(--accent-line); transform: translateY(-2px); box-shadow: var(--shadow); } +.stat-value { + display: block; font-size: var(--fs-stat); + font-weight: 700; font-family: var(--display); color: var(--accent); + font-variant-numeric: tabular-nums; /* 数字等宽,位数变化时不跳动 */ +} +.stat-label { display: block; color: var(--muted); font-size: var(--fs-xs); margin-top: .15rem; } + +.section { margin: clamp(2rem, 4vw, 3rem) 0; } +.section h2 { font-size: var(--fs-xl); margin-bottom: 1rem; } +.section-head { + display: flex; align-items: baseline; justify-content: space-between; + gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; +} +.section-head h2 { margin: 0; } +.more { color: var(--accent); text-decoration: none; font-size: var(--fs-md); white-space: nowrap; } +.more:hover { text-decoration: underline; } + +.module-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr)); gap: .7rem; +} +.module-card { + display: flex; flex-direction: column; gap: .2rem; + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: .85rem 1rem; + text-decoration: none; color: var(--text); + transition: border-color .15s, transform .15s, box-shadow .15s; +} +.module-card:hover { + border-color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow); +} +.module-name { font-family: var(--mono); font-size: var(--fs-md); overflow-wrap: anywhere; } +.module-count { color: var(--muted); font-size: var(--fs-xs); } +.module-desc { color: var(--muted); font-size: var(--fs-xs); } + +/* 双栏面板 */ +.split { + display: grid; gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr)); +} +.panel { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.2rem 1.4rem; +} +.panel h2 { font-size: var(--fs-lg); margin: 0 0 .7rem; } +.panel p { margin: .5rem 0; font-size: var(--fs-sm); } +.panel .params { margin: 0; } +.panel .params td:first-child { color: var(--muted); width: 34%; } + +/* ---------- 通用元素 ---------- */ + +.page-head { padding: clamp(2rem, 4vw, 3rem) 0 1rem; } +.page-head h1 { font-size: var(--fs-title); margin: 0 0 .5rem; } +.page-head .lead { margin-bottom: 0; } + +.muted { color: var(--muted); } + +/* 代码:等宽字体统一在这里设定; + 关掉连字,避免 -> 之类的符号被渲染成箭头,读代码时产生歧义 */ +code, pre, kbd, samp { + font-family: var(--mono); + font-variant-ligatures: none; + font-feature-settings: "kern" 1, "liga" 0, "calt" 0; +} +code { font-size: .9em; } + +/* 代码块:右上角带复制按钮 */ +pre.code { + position: relative; + background: var(--code-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1rem 1.1rem; + overflow-x: auto; font-size: var(--fs-sm); line-height: 1.65; + tab-size: 4; +} +pre.code code { font-size: inherit; } + +.code-copy { + position: absolute; top: .4rem; right: .4rem; + border: 1px solid var(--border); border-radius: 6px; + background: var(--panel); color: var(--muted); + font-family: inherit; font-size: var(--fs-xs); line-height: 1; + padding: .3rem .5rem; cursor: pointer; opacity: 0; + transition: opacity .15s, color .15s, border-color .15s; +} +pre.code:hover .code-copy, .code-copy:focus-visible { opacity: 1; } +.code-copy:hover { color: var(--accent); border-color: var(--accent); } + +a { color: var(--accent); } + +/* ---------- API 页 ---------- */ + +.api-layout { + display: grid; gap: clamp(1.5rem, 2.5vw, 2.5rem); + grid-template-columns: 20rem minmax(0, 1fr); + align-items: start; + padding-bottom: 3rem; +} + +.api-nav { + position: sticky; top: 5rem; align-self: start; + max-height: calc(100vh - 7rem); overflow-y: auto; overflow-x: hidden; + min-width: 0; +} +.api-nav-title { + font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .1em; + color: var(--muted); margin: 0 0 .6rem; +} +.api-nav ul { list-style: none; margin: 0; padding: 0; } +.api-nav li a { + display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; + padding: .3rem .5rem; border-radius: 6px; + color: var(--text); text-decoration: none; font-size: var(--fs-xs); font-family: var(--mono); + letter-spacing: -.01em; /* 模块名较长,收一点字距避免中途折行 */ + border-left: 2px solid transparent; +} +.api-nav li a .nav-name { + min-width: 0; flex: 1 1 auto; + overflow-wrap: anywhere; word-break: break-word; +} +.api-nav li a:hover { background: var(--accent-soft); } +.api-nav li a.is-active { + background: var(--accent-soft); color: var(--accent); + border-left-color: var(--accent); font-weight: 600; +} +.nav-count { color: var(--muted); font-size: var(--fs-2xs); flex-shrink: 0; } +/* 选中态的底色是 --accent-soft,跟 --muted 的对比度不够 4.5:1, + 这里改用 --accent(同一个强调色,深浅两套配色都够)。 */ +.api-nav li a.is-active .nav-count, +.tut-nav li a.is-active .nav-count { color: var(--accent); } + +.api-body { min-width: 0; container-type: inline-size; } + +/* API 页顶部的筛选框 */ +.api-filter { + display: flex; align-items: center; gap: .5rem; + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 0 .6rem; margin-bottom: 1.2rem; +} +.api-filter:focus-within { border-color: var(--accent); box-shadow: var(--focus); } +.api-filter input { + flex: 1 1 auto; min-width: 0; padding: .5rem 0; + border: 0; background: transparent; color: var(--text); + font-size: var(--fs-md); font-family: inherit; +} +.api-filter input:focus { outline: none; } +.api-filter .filter-count { color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; } +.api-filter .filter-clear { + border: 0; background: transparent; color: var(--muted); + cursor: pointer; font-size: var(--fs-xs); padding: .2rem .35rem; border-radius: 4px; +} +.api-filter .filter-clear:hover { color: var(--accent); background: var(--accent-soft); } + +.module { padding-top: 1rem; margin-bottom: clamp(2.5rem, 5vw, 4rem); } +.module-title { + font-family: var(--mono); font-size: var(--fs-module); margin: 0 0 .3rem; + padding-bottom: .5rem; border-bottom: 2px solid var(--accent); + overflow-wrap: anywhere; +} +.module-meta { color: var(--muted); font-size: var(--fs-sm); margin: .5rem 0 1.5rem; overflow-wrap: anywhere; } +.module-meta code { overflow-wrap: anywhere; } +.group-title { + font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .1em; + color: var(--muted); margin: 2rem 0 .8rem; font-weight: 700; +} + +/* 宽屏:函数卡片排成多列 */ +.func-grid { + display: grid; gap: .8rem; + grid-template-columns: repeat(auto-fill, minmax(min(30rem, 100%), 1fr)); + align-items: start; +} + +/* 内容区足够宽时,一行只放一个卡片,卡片更宽 */ +@container (min-width: 58rem) { + .func-grid { grid-template-columns: minmax(0, 1fr); } +} + +.func { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.1rem 1.25rem; + min-width: 0; + scroll-margin-top: 6rem; + transition: border-color .15s, box-shadow .15s; +} +.func:target { border-color: var(--accent); box-shadow: var(--shadow); } +.func.is-hidden { display: none; } +.func-section { + margin: 0 0 .4rem; color: var(--muted); font-size: var(--fs-xs); + overflow-wrap: anywhere; +} +.func h3 { + margin: 0 0 .4rem; display: flex; align-items: center; gap: .6rem; + flex-wrap: wrap; +} +.func-name { font-size: var(--fs-lg); font-weight: 600; overflow-wrap: anywhere; } + +/* 卡片右上角的「复制链接」按钮 */ +.anchor-btn { + margin-left: auto; flex: 0 0 auto; + border: 1px solid transparent; border-radius: 6px; + background: transparent; color: var(--muted); + cursor: pointer; font-size: var(--fs-2xs); line-height: 1; + padding: .25rem .45rem; opacity: .55; + transition: opacity .15s, color .15s, background .15s, border-color .15s; +} +.func:hover .anchor-btn, .anchor-btn:focus-visible { opacity: 1; } +.anchor-btn:hover { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); } + +.sig { margin: 0 0 .6rem; } +.sig code { font-size: var(--fs-sm); color: var(--text); word-break: break-word; } +.desc { margin: 0 0 .8rem; color: var(--text); font-size: var(--fs-md); } +.calls { color: var(--muted); font-size: var(--fs-xs); margin: .5rem 0 0; overflow-wrap: anywhere; } +.docstring p { font-size: var(--fs-sm); color: var(--text-soft); margin: .4rem 0; } + +.badge { + font-size: var(--fs-2xs); padding: .1rem .45rem; border-radius: 999px; + background: var(--accent-soft); color: var(--accent); font-family: var(--mono); + border: 1px solid transparent; +} +.badge-warn { background: var(--warn-soft); color: var(--warn); } +.badge-muted { background: var(--code-bg); color: var(--muted); } + +.params { width: 100%; border-collapse: collapse; margin: .8rem 0; font-size: var(--fs-sm); table-layout: fixed; } +.params th, .params td { + text-align: left; padding: .35rem .6rem; + border-bottom: 1px solid var(--border); vertical-align: top; + overflow-wrap: anywhere; +} +.params th { color: var(--muted); font-weight: 600; font-size: var(--fs-xs); } +.params th:nth-child(1) { width: 26%; } +.params th:nth-child(2) { width: 30%; } +.params th:nth-child(3) { width: 44%; } +.params td code { overflow-wrap: anywhere; } +.param-default { color: var(--warn); } +.param-type { color: var(--muted); font-size: var(--fs-xs); } + +.source { margin-top: .7rem; } +.source summary { + cursor: pointer; color: var(--muted); font-size: var(--fs-xs); + display: inline-block; padding: .1rem 0; +} +.source summary:hover { color: var(--accent); } +.source pre { margin-top: .6rem; } + +.class-block { + border-left: 3px solid var(--accent); padding-left: 1rem; margin-bottom: 1.5rem; +} +.class-name { font-family: var(--mono); } + +/* 模块内的函数速查 */ +.module-index { + list-style: none; margin: 0 0 1.4rem; padding: .7rem .9rem; + background: var(--panel-2); border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; flex-wrap: wrap; gap: .35rem .5rem; +} +.module-index a { + font-family: var(--mono); font-size: var(--fs-xs); text-decoration: none; + color: var(--text-soft); background: var(--panel); + border: 1px solid var(--border); border-radius: 999px; + padding: .1rem .55rem; transition: color .15s, border-color .15s, background .15s; +} +.module-index a:hover { + color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft); +} + +/* ---------- 关于页(README 渲染结果) ---------- */ + +/* 「安装」「关于」两页与首页用同一个版心宽度, + 区别只在于正文铺满版心,而不是缩在中间一小块 */ + +.doc-layout { + display: grid; gap: clamp(1.5rem, 3vw, 3rem); + grid-template-columns: 18rem minmax(0, 1fr); + align-items: start; + padding-bottom: 3rem; +} +.doc-layout.doc-layout-flat { grid-template-columns: minmax(0, 1fr); } +.doc-layout.doc-layout-flat .markdown { grid-column: 1; } + +.toc { + grid-column: 1; grid-row: 1; + position: sticky; top: 5rem; align-self: start; + max-height: calc(100vh - 7rem); overflow-y: auto; + border-right: 1px solid var(--border); padding-right: 1rem; + min-width: 0; +} +.toc-title { + font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .1em; + color: var(--muted); margin: 0 0 .6rem; +} +.toc ul { list-style: none; margin: 0; padding: 0; } +.toc a { + display: block; padding: .22rem .4rem; border-radius: 6px; + color: var(--muted); text-decoration: none; font-size: var(--fs-sm); + overflow-wrap: anywhere; +} +.toc a:hover { color: var(--text); background: var(--accent-soft); } +.toc a.is-active { color: var(--accent); font-weight: 600; background: var(--accent-soft); } +.toc-l3 a { padding-left: 1.3rem; font-size: var(--fs-xs); } + +/* 正文铺满版心:代码块、表格、列表和段落都跟着版心走, + 不再缩在中间一小块;段落不再限制行宽,文字一直排到版心右缘 */ +.markdown { grid-column: 2; grid-row: 1; max-width: none; min-width: 0; } +.markdown p, .markdown blockquote { max-width: none; } +.markdown p > img { max-width: 100%; } +.markdown h2 { + font-size: var(--fs-xl); margin: 2.2rem 0 .8rem; + padding-bottom: .4rem; border-bottom: 1px solid var(--border); + scroll-margin-top: 6rem; +} +.markdown h3 { font-size: var(--fs-lg); margin: 1.6rem 0 .6rem; scroll-margin-top: 6rem; } +.markdown h4, .markdown h5, .markdown h6 { font-size: var(--fs-body); margin: 1.2rem 0 .5rem; } +.markdown > h2:first-child { margin-top: 0; } +.markdown p { margin: .8rem 0; } +.markdown ul, .markdown ol { margin: .8rem 0; padding-left: 1.4rem; } +.markdown li { margin: .3rem 0; } +.markdown li p { margin: .2rem 0; } +.markdown hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; } +.markdown blockquote { + margin: 1rem 0; padding: .3rem 1rem; + border-left: 3px solid var(--accent); background: var(--panel); + border-radius: 0 var(--radius) var(--radius) 0; +} +.markdown blockquote p { margin: .4rem 0; } +.markdown code { + background: var(--code-bg); border: 1px solid var(--border); + border-radius: 4px; padding: .05rem .3rem; +} +.markdown pre.code code { border: 0; padding: 0; background: none; } +.markdown pre.code { margin: 1rem 0; } +.markdown .table-wrap { overflow-x: auto; margin: 1rem 0; } +.markdown .md-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); } +.markdown .md-table th, .markdown .md-table td { + border: 1px solid var(--border); padding: .45rem .7rem; text-align: left; +} +.markdown .md-table th { background: var(--code-bg); font-weight: 600; } +.markdown img { max-width: 100%; height: auto; border-radius: var(--radius); } + +/* ---------- 教程页 ---------- */ + +/* 与 API 页一样:左侧分组目录,右侧正文 */ +.tut-layout { + display: grid; gap: clamp(1.5rem, 3vw, 3rem); + grid-template-columns: 18rem minmax(0, 1fr); + align-items: start; + padding-bottom: 3rem; +} +.tut-body { min-width: 0; } + +/* 分组目录:名称与数量分开排 */ +.tut-nav li a { display: flex; align-items: baseline; gap: .6rem; } +.tut-nav .nav-count { margin-left: auto; } + +.tut-group { margin-bottom: clamp(2.5rem, 5vw, 4rem); scroll-margin-top: 6rem; } +.tut-group-title { + font-size: clamp(1.3rem, 2.4vw, 1.6rem); margin: 0 0 .3rem; + padding-bottom: .5rem; border-bottom: 2px solid var(--accent); +} +.tut-group-sub { color: var(--muted); font-size: .85rem; margin: .4rem 0 1.2rem; } + +.tut-cards { + display: grid; gap: .9rem; + grid-template-columns: repeat(auto-fill, minmax(min(28rem, 100%), 1fr)); + align-items: start; +} + +.tut-card { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.1rem 1.25rem; + min-width: 0; scroll-margin-top: 6rem; + transition: border-color .15s, box-shadow .15s; +} +.tut-card:target { border-color: var(--accent); box-shadow: var(--shadow); } +.tut-card-head { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; } +.tut-title { margin: 0 0 .5rem; font-size: 1.02rem; font-weight: 600; overflow-wrap: anywhere; } +.tut-lines { color: var(--muted); font-size: .78rem; margin-left: auto; } +.tut-desc { margin: 0 0 .7rem; font-size: .93rem; overflow-wrap: anywhere; } +.tut-links { margin: .6rem 0 0; font-size: .85rem; } +.tut-links a { color: var(--accent); text-decoration: none; } +.tut-links a:hover { text-decoration: underline; } + +/* ---------- 页脚 ---------- */ + +.site-foot { + border-top: 1px solid var(--border); margin-top: 4rem; + padding: 2rem 0 2.5rem; color: var(--muted); font-size: var(--fs-sm); + background: var(--panel); +} +.foot-inner { + display: flex; gap: 2rem; justify-content: space-between; + align-items: flex-start; flex-wrap: wrap; +} +.site-foot p { margin: .25rem 0; max-width: 60rem; } +.foot-meta { font-size: var(--fs-xs); } +.foot-nav { display: flex; gap: 1rem; flex-wrap: wrap; } +.foot-nav a { color: var(--muted); text-decoration: none; } +.foot-nav a:hover { color: var(--accent); } + +/* ---------- 回到顶部 ---------- */ + +.to-top { + position: fixed; right: clamp(1rem, 3vw, 2rem); bottom: clamp(1rem, 3vw, 2rem); + width: 2.75rem; height: 2.75rem; z-index: 25; + display: inline-flex; align-items: center; justify-content: center; + background: var(--panel); color: var(--muted); + border: 1px solid var(--border); border-radius: 50%; + box-shadow: var(--shadow); cursor: pointer; + opacity: 0; visibility: hidden; transform: translateY(8px); + transition: opacity .2s, transform .2s, visibility .2s, color .15s, border-color .15s; +} +.to-top.is-visible { opacity: 1; visibility: visible; transform: none; } +.to-top:hover { color: var(--accent); border-color: var(--accent); } + +/* ---------- 响应式 ---------- */ + +@media (max-width: 1400px) { + .api-layout { grid-template-columns: 17rem minmax(0, 1fr); } + .doc-layout { grid-template-columns: 15rem minmax(0, 1fr); } + .tut-layout { grid-template-columns: 15rem minmax(0, 1fr); } +} + +@media (max-width: 1100px) { + .hero { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 1080px) { + .api-layout { grid-template-columns: minmax(0, 1fr); } + .api-nav { + position: static; max-height: none; overflow: visible; + border: 1px solid var(--border); border-radius: var(--radius); + padding: .8rem 1rem; background: var(--panel); + } + .api-nav ul { + display: flex; flex-wrap: nowrap; gap: .4rem; + overflow-x: auto; padding-bottom: .3rem; + scrollbar-width: thin; + } + .api-nav li { flex: 0 0 auto; } + .api-nav li a { + border: 1px solid var(--border); border-radius: 999px; padding: .25rem .7rem; + white-space: nowrap; + } + .api-nav li a .nav-name { flex: 0 1 auto; } + .doc-layout { grid-template-columns: minmax(0, 1fr); } + .tut-layout { grid-template-columns: minmax(0, 1fr); } + .toc, .markdown { grid-column: 1; grid-row: auto; } + .toc { + position: static; max-height: none; border-right: 0; border-bottom: 1px solid var(--border); + padding: 0 0 .8rem; + } + .toc ul { display: flex; flex-wrap: wrap; gap: .35rem; } + .toc a { border: 1px solid var(--border); border-radius: 999px; padding: .2rem .7rem; } + .toc-l3 a { padding-left: .7rem; } +} + +@media (max-width: 900px) { + .head-inner { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: .55rem .75rem; + padding: .6rem 0; + } + .brand { grid-column: 1; grid-row: 1; } + .head-actions { + grid-column: 2; grid-row: 1; + margin-left: 0; + } + .nav-toggle { grid-column: 3; grid-row: 1; display: flex; margin-left: 0; } + .site-nav { + grid-column: 1 / -1; grid-row: 2; + flex-direction: column; gap: 0; + max-height: 0; overflow: hidden; transition: max-height .25s ease; + border-top: 1px solid transparent; + } + .site-nav.is-open { max-height: 20rem; overflow-y: auto; border-top-color: var(--border); } + .site-nav a { padding: .7rem .25rem; border-bottom: 1px solid var(--border); } + .site-nav a.is-active { border-bottom-color: var(--border); color: var(--accent); } +} + +@media (max-width: 640px) { + body { font-size: .9375rem; } + .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .module-grid { grid-template-columns: minmax(0, 1fr); } + .func-grid { grid-template-columns: minmax(0, 1fr); } + .tut-cards { grid-template-columns: minmax(0, 1fr); } + .install { width: 100%; } + .install code { font-size: var(--fs-xs); } + .hero-links .btn { flex: 1 1 auto; text-align: center; } + .page-head { padding-top: 1.6rem; } + .foot-inner { flex-direction: column; gap: 1rem; } + .to-top { right: 1rem; bottom: 1rem; } +} + +@media (max-width: 420px) { + .stats { grid-template-columns: minmax(0, 1fr); } +} + +/* ---------- 打印 ---------- */ + +@media print { + .site-head, .site-foot, .to-top, .code-copy, .anchor-btn, + .api-filter, .nav-toggle { display: none !important; } + body { background: #fff; color: #000; font-size: 12pt; } + .func, .panel, .stat { break-inside: avoid; border-color: #ccc; } + pre.code { white-space: pre-wrap; word-break: break-word; } + a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .8em; color: #555; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + * { transition: none !important; animation: none !important; } +} + +/* 触摸设备没有悬停,复制按钮要一直显示 */ +@media (hover: none) { + .code-copy { opacity: 1; } + .anchor-btn { opacity: 1; } +} diff --git a/website/templates/base.html b/website/templates/base.html new file mode 100644 index 0000000..1a15da7 --- /dev/null +++ b/website/templates/base.html @@ -0,0 +1,72 @@ +<!DOCTYPE html> +<html lang="{{lang}}"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{title}} + + + + + + + + + + + + + +
+
+ + + {{package_name}} + v{{version}} + + +
+ {{lang_switch_label}} + +
+ +
+
+ +
+{{content}} +
+ +
+
+
+

{{foot_meta}}

+
+ +
+
+ + + + + +{{extra_js}} + + diff --git a/website/tutorials.py b/website/tutorials.py new file mode 100644 index 0000000..975f761 --- /dev/null +++ b/website/tutorials.py @@ -0,0 +1,272 @@ +"""读取 tutorials/ 目录,为网站提供「教程」页的内容。 + +教程内容不手工维护:分组、标题、说明、依赖都从 tutorials/ 下的脚本 +与其 README.md 里读出来。仓库里新增一个示例脚本,网站自动跟着更新。 + +信息来自两处,互相补充: + + * 每个脚本开头的注释——标题、来源链接、一句话说明 + * tutorials/README.md 里的表格——更详细的说明与依赖说明 + +两处都没有时,退回到按文件名与 import 推断,保证页面不为空。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# 「# 来源 / Source: https://...」 +SOURCE_RE = re.compile(r"来源\s*/\s*Source\s*[::]\s*(\S+)") +# README 表格里的一行:| `01_ai_chat.py` | 说明 | 依赖 | +TABLE_ROW_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*$") +# 脚本里的 import,用来猜依赖 +IMPORT_RE = re.compile(r"^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE) + +# 根目录下的脚本(不在子目录里)单独归为一组 +ROOT_GROUP_TITLE = "开始使用" +ROOT_GROUP_SUBTITLE = "Getting started" + + +def _read_text(path: Path) -> str: + """读文本文件,编码不对时退回 latin-1,绝不抛异常。""" + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + try: + return path.read_text(encoding="gbk") + except (OSError, UnicodeDecodeError): + return "" + + +def _split_heading(heading: str) -> tuple[str, str]: + """把「一、日常场景 / Daily scenarios」拆成中文标题与英文副标题。""" + parts = re.split(r"\s*/\s*", heading.strip(), maxsplit=1) + title = parts[0].strip() + subtitle = parts[1].strip() if len(parts) > 1 else "" + return title, subtitle + + +def parse_readme(readme_path: Path) -> dict: + """解析 tutorials/README.md。 + + 返回: + files 文件名 -> {desc, req, section} + sections [(标题, 副标题, [文件名...])] + """ + files: dict[str, dict] = {} + sections: list[tuple[str, str, list[str]]] = [] + if not readme_path.exists(): + return {"files": files, "sections": sections} + + section_title = "" + section_subtitle = "" + section_files: list[str] = [] + + def flush() -> None: + if section_title or section_files: + sections.append((section_title, section_subtitle, list(section_files))) + + for line in _read_text(readme_path).splitlines(): + if line.startswith("## "): + flush() + section_title, section_subtitle = _split_heading(line[3:]) + section_files = [] + continue + match = TABLE_ROW_RE.match(line.strip()) + if not match: + continue + name, desc, req = match.groups() + files[name] = { + "desc": desc.strip(), + "req": req.strip(), + "section": section_title, + } + section_files.append(name) + flush() + return {"files": files, "sections": sections} + + +def parse_script(path: Path) -> dict: + """读脚本开头的注释块。 + + 约定:文件开头连续的注释里,第一行是标题,含「来源 / Source」的行是出处, + 其余行是补充说明。注释块在遇到第一行代码时结束。 + """ + title = "" + source = "" + notes: list[str] = [] + + for line in _read_text(path).splitlines(): + stripped = line.strip() + if not stripped: + continue + if not stripped.startswith("#"): + break + text = stripped.lstrip("#").strip() + if not text: + continue + match = SOURCE_RE.search(text) + if match: + source = match.group(1) + continue + if not title: + title = text + continue + notes.append(text) + return {"title": title, "source": source, "notes": notes} + + +def detect_requirements(code: str) -> list[str]: + """从 import 语句推断依赖(README 里没写时用)。""" + names = [] + for name in IMPORT_RE.findall(code): + if name in ("guan", "os", "sys", "time", "json", "re", "math", "cmath", "random"): + continue + if name not in names: + names.append(name) + return names + + +def _short_title(description: str) -> str: + """从一段说明里取出适合当标题的短句。 + + README 的说明常常是「try except 简写:`@guan.try_decorator`」这样, + 标题取第一个短句就够了,剩下的留给说明。 + """ + text = description.strip() + if not text: + return "" + head = re.split(r"[::,,。;;]", text, maxsplit=1)[0].strip() + head = head.replace("`", "").strip() + return head or text.replace("`", "").strip() + + +def _requirements_from_cell(cell: str) -> list[str]: + """把 README 表格里的依赖单元格拆成列表。 + + 只按逗号、顿号、分号拆——单元格里的斜杠常常是路径或模型名的一部分, + 拆开反而看不懂。 + """ + if not cell or cell.strip() in {"-", "—", "无", "N/A"}: + return [] + parts = re.split(r"[,,、;;]", cell) + return [part.strip(" `·") for part in parts if part.strip(" `·")] + + +def collect(tutorials_dir: Path) -> dict: + """收集 tutorials 目录下的全部示例脚本。 + + 返回值可以直接放进 api.json,也可以直接渲染成页面。 + """ + tutorials_dir = Path(tutorials_dir).resolve() + if not tutorials_dir.is_dir(): + return {"dir": tutorials_dir.name, "readme": "", "groups": [], "files": [], "count": 0} + + readme = tutorials_dir / "README.md" + readme_info = parse_readme(readme) + + scripts = sorted( + path for path in tutorials_dir.rglob("*.py") if path.is_file() + ) + + groups: dict[str, dict] = {} + files: list[dict] = [] + + for path in scripts: + relative = path.relative_to(tutorials_dir) + group_key = relative.parts[0] if len(relative.parts) > 1 else "" + info = parse_script(path) + code = _read_text(path) + entry = readme_info["files"].get(path.name, {}) + + # 标题与说明都来自脚本自身:首行注释是标题,其余注释是说明。 + # README 的表格只用来补标题与依赖,不再重复它写的那段说明。 + title = info["title"] or _short_title(entry.get("desc", "")) or relative.name + description = ";".join(info["notes"]) + + requirements = _requirements_from_cell(entry.get("req", "")) + if not requirements: + requirements = detect_requirements(code) + + # 链接用相对仓库根目录的路径,便于站点里直接下载脚本 + repo_relative = (Path(tutorials_dir.name) / relative).as_posix() + anchor = f"{group_key or 'root'}-{relative.stem}".replace("_", "-") + + item = { + "key": relative.as_posix(), + "path": repo_relative, + "group": group_key, + "title": title, + "description": description, + "requirements": requirements, + "source": info["source"], + "anchor": anchor, + "lines": len(code.splitlines()), + "code": code, + } + files.append(item) + + group = groups.setdefault( + group_key, + { + "key": group_key, + "title": ROOT_GROUP_TITLE if group_key == "" else group_key.replace("_", " "), + "subtitle": ROOT_GROUP_SUBTITLE if group_key == "" else "", + "anchor": f"group-{group_key or 'root'}", + "files": [], + }, + ) + group["files"].append(item) + + # 分组标题优先用 README 里的章节标题 + for key, group in groups.items(): + if not key: + continue + best: tuple[str, str, int] | None = None + for title, subtitle, names in readme_info["sections"]: + overlap = sum( + 1 for item in group["files"] if Path(item["key"]).name in names + ) + if overlap and (best is None or overlap > best[2]): + best = (title, subtitle, overlap) + if best: + group["title"], group["subtitle"] = best[0], best[1] + + ordered = sorted( + groups.values(), + key=lambda group: (group["key"] != "", group["title"]), + ) + + return { + "dir": tutorials_dir.name, + "readme": (tutorials_dir / "README.md").name if readme.exists() else "", + "groups": ordered, + "files": files, + "count": len(files), + } + + +def without_code(data: dict) -> dict: + """去掉源码的副本,用于放进 api.json(索引里用不着整段代码)。""" + return { + "dir": data.get("dir", ""), + "count": data.get("count", 0), + "groups": [ + { + "key": group["key"], + "title": group["title"], + "subtitle": group.get("subtitle", ""), + "anchor": group["anchor"], + "files": [ + {key: value for key, value in item.items() if key != "code"} + for item in group["files"] + ], + } + for group in data.get("groups", []) + ], + "files": [ + {key: value for key, value in item.items() if key != "code"} + for item in data.get("files", []) + ], + }