Skip to content

Commit 1afb8c9

Browse files
committed
Add script to partially automate Python API documentation
1 parent 250f561 commit 1afb8c9

File tree

9 files changed

+1654
-3
lines changed

9 files changed

+1654
-3
lines changed
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
"""
5+
MIT License
6+
7+
Copyright (c) 2025 Devon (scarletcafe) R
8+
9+
Permission is hereby granted, free of charge, to any person obtaining a copy
10+
of this software and associated documentation files (the "Software"), to deal
11+
in the Software without restriction, including without limitation the rights
12+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13+
copies of the Software, and to permit persons to whom the Software is
14+
furnished to do so, subject to the following conditions:
15+
16+
The above copyright notice and this permission notice shall be included in all
17+
copies or substantial portions of the Software.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25+
SOFTWARE.
26+
"""
27+
28+
import dataclasses
29+
import functools
30+
import inspect
31+
import pathlib
32+
import typing
33+
34+
from lxml import etree
35+
from jinja2 import Environment
36+
from jinja2.environment import Template
37+
from jinja2.loaders import BaseLoader
38+
39+
import tasauria
40+
41+
42+
SCRIPTS_ROOT = pathlib.Path(__file__).parent
43+
DOCUMENTATION_FOLDER = SCRIPTS_ROOT.parent
44+
REPOSITORY_ROOT = DOCUMENTATION_FOLDER.parent
45+
46+
SOURCE_FOLDER = DOCUMENTATION_FOLDER / 'source'
47+
TEMPLATE_FOLDER = DOCUMENTATION_FOLDER / 'templates'
48+
49+
TASAURIA_FOLDER = REPOSITORY_ROOT / 'tasauria'
50+
51+
assert SOURCE_FOLDER.is_dir()
52+
assert TEMPLATE_FOLDER.is_dir()
53+
assert TASAURIA_FOLDER.is_dir()
54+
55+
56+
ENVIRONMENT = Environment(loader=BaseLoader())
57+
58+
INSTALLED_TASAURIA = pathlib.Path(tasauria.__file__)
59+
60+
61+
if not INSTALLED_TASAURIA.is_relative_to(TASAURIA_FOLDER):
62+
raise RuntimeError(
63+
"""
64+
The installed `tasauria` package did not resolve to the repository's version.
65+
66+
You should either:
67+
68+
- run this script from the root of the repo with `tasauria` NOT installed:
69+
`pip uninstall tasauria`
70+
`python documentation/scripts/generate_api_reference.py`
71+
72+
- run the script with `tasauria` installed in build-editable mode:
73+
`pip install -e .`
74+
`python documentation/scripts/generate_api_reference.py`
75+
"""
76+
)
77+
78+
79+
CLIENT = tasauria.TASauria
80+
81+
LANGUAGES = {'en', 'ja'}
82+
83+
FUNCTION_NAME_OVERRIDES: dict[str, typing.Optional[str]] = {
84+
'__init__': 'TASauria',
85+
'__aenter__': None,
86+
}
87+
88+
FUNCTION_TITLE_OVERRIDES: dict[str, dict[str, str]] = {
89+
'__init__': {
90+
'en': '`TASauria(...)` (constructor)',
91+
'ja': '`TASauria(...)` (コンストラクタ)',
92+
},
93+
'__aenter__': {
94+
'en': '`async with TASauria(...) as emu:`',
95+
'ja': '`async with TASauria(...) as emu:`'
96+
}
97+
}
98+
99+
100+
@dataclasses.dataclass
101+
class DocumentableFunction:
102+
function: typing.Callable[..., typing.Any]
103+
title: dict[str, str]
104+
name: typing.Optional[str]
105+
description: dict[str, str]
106+
107+
def document(self, language: str) -> str:
108+
return inspect.cleandoc(
109+
f"""
110+
### ⚙️ {self.title[language]}
111+
112+
::: code-group
113+
{self.code_groups(language)}
114+
:::
115+
116+
{self.description.get(language, self.description.get('en', 'No description available.'))}
117+
"""
118+
)
119+
120+
def code_groups(self, language: str) -> str:
121+
code_groups: list[str] = []
122+
123+
if self.name is not None:
124+
code_groups.append(f"```python [Function signature]\n{self.function_signature()}\n```")
125+
126+
return '\n'.join(code_groups)
127+
128+
def function_signature(self) -> str:
129+
argument_lines: list[str] = []
130+
signature = inspect.signature(self.function)
131+
132+
for argument_name, argument in signature.parameters.items():
133+
if argument_name == 'self':
134+
continue
135+
136+
argument_lines.append(f" {argument},")
137+
138+
if not argument_lines:
139+
argument_lines.append(" # no arguments")
140+
141+
return f"{self.name}(\n" + "\n".join(argument_lines) + "\n)"
142+
143+
144+
FUNCTIONS_BY_SECTION: dict[typing.Optional[str], list[DocumentableFunction]] = {}
145+
146+
print("Processing client functions")
147+
148+
for attribute_key in dir(CLIENT):
149+
if attribute_key not in FUNCTION_TITLE_OVERRIDES and attribute_key.startswith('_'):
150+
continue
151+
152+
attribute_value = getattr(CLIENT, attribute_key)
153+
154+
if not inspect.isfunction(attribute_value):
155+
continue
156+
157+
function_name = FUNCTION_NAME_OVERRIDES[attribute_key] if attribute_key in FUNCTION_NAME_OVERRIDES else f"emu.{attribute_key}"
158+
159+
function_title = FUNCTION_TITLE_OVERRIDES.get(attribute_key, {
160+
language: f"`.{attribute_key}(...)`"
161+
for language in LANGUAGES
162+
})
163+
164+
print(f"-> {function_title.get('en', attribute_key)}")
165+
166+
description: dict[str, str] = {}
167+
section: typing.Optional[str] = None
168+
169+
documentation = inspect.getdoc(attribute_value)
170+
171+
if documentation:
172+
root = etree.fromstring(f"<root>\n{documentation}\n</root>")
173+
174+
section_tags = root.xpath('/root/section')
175+
176+
if section_tags:
177+
section = typing.cast(str, section_tags[0].text)
178+
179+
for node in root.xpath('/root/description'):
180+
language = node.get('language', 'en')
181+
content = node.text.strip()
182+
183+
description[language] = content
184+
185+
section_dict = FUNCTIONS_BY_SECTION[section] = FUNCTIONS_BY_SECTION.get(section, [])
186+
section_dict.append(
187+
DocumentableFunction(
188+
function=attribute_value,
189+
title=function_title,
190+
name=function_name,
191+
description=description
192+
)
193+
)
194+
195+
196+
print("Processing sections")
197+
198+
for section, section_content in FUNCTIONS_BY_SECTION.items():
199+
print(f'-> {section}')
200+
201+
def sort_criteria(function: DocumentableFunction):
202+
_lines, lineno = inspect.getsourcelines(function.function)
203+
204+
return lineno
205+
206+
section_content.sort(key=sort_criteria)
207+
208+
209+
def get_section_language(language: str, section: str) -> str:
210+
functions = FUNCTIONS_BY_SECTION.get(section, None)
211+
212+
if not functions:
213+
return "Documentation not available..."
214+
215+
return "\n\n".join(
216+
function.document(language)
217+
for function in functions
218+
)
219+
220+
221+
print("Generating files")
222+
223+
for language in LANGUAGES:
224+
print(f'-> {language}')
225+
226+
with open(TEMPLATE_FOLDER / f'api_reference.{language}.md', 'r', encoding='utf-8') as fp:
227+
template: Template = ENVIRONMENT.from_string(fp.read())
228+
229+
with open(SOURCE_FOLDER / language / 'python-api' / 'client-api-reference.md', 'w', encoding='utf-8') as fp:
230+
output = template.render({
231+
"section": functools.partial(get_section_language, language)
232+
})
233+
234+
# Jinja loves obliterating trailing newlines
235+
if not output.endswith('\n'):
236+
output += '\n'
237+
238+
fp.write(output)

documentation/source/en/http-ws-api/test-commands.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Unlike most other commands, this does not actually sync with the emulator and so
4343
This command is provided mostly for the purpose of testing that the server is functioning correctly.
4444

4545
The path argument can either be present or not present, i.e. `/test/echo` and `/test/echo/foobar` both trigger the command.
46-
If the path argument is provided, it's included in the response as `pathMessage`.
46+
If the path argument is provided, it's included in the response as `pathMessage`. If the path argument is not provided, `pathMessage` will be an empty string.
4747

4848

4949
## Wait
@@ -62,7 +62,7 @@ If the path argument is provided, it's included in the response as `pathMessage`
6262
/* The time the waiting started as an ISO datetime */
6363
timeStarted: string,
6464
/* The time the waiting finished as an ISO datetime */
65-
timeStarted: string,
65+
timeStopped: string,
6666
}
6767
```
6868
```json [Example arguments]

0 commit comments

Comments
 (0)