Skip to content

Commit 3368332

Browse files
authored
Merge pull request #2155 from DerekMelchin/bug-python-api-ref-generator
Add Python API Reference Generator
2 parents f9e7c0c + adb373a commit 3368332

File tree

10 files changed

+1107
-0
lines changed

10 files changed

+1107
-0
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Update Python API Reference
2+
3+
on:
4+
schedule:
5+
- cron: "0 23 * * 5" # Runs at 23:00 UTC every Friday
6+
workflow_dispatch: # Run on manual trigger
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-24.04
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v3
15+
16+
- name: Free space
17+
run: df -h && rm -rf /opt/hostedtoolcache* && df -h
18+
19+
- name: Install dependencies (pip)
20+
run: |
21+
python -m pip install --upgrade pip
22+
pip install quantconnect-stubs mkdocs
23+
24+
- name: Run the Python API Reference Generator
25+
run: cd code-generators/python_api_reference && python main.py
26+
27+
- name: Build site with MkDocs
28+
run: mkdocs build --clean
29+
30+
- name: Configure AWS Credentials
31+
uses: aws-actions/configure-aws-credentials@v1
32+
with:
33+
aws-access-key-id: ${{ secrets.AWS_KEY }}
34+
aws-secret-access-key: ${{ secrets.AWS_SECRET }}
35+
aws-region: us-west-1
36+
37+
- name: Sync site to S3
38+
run: aws s3 sync ./site s3://${{ secrets.AWS_BUCKET }}/python-api-reference --delete
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import ast
2+
from typing import List, Dict, Tuple
3+
4+
def is_overload_decorator(dec: ast.expr) -> bool:
5+
return (
6+
(isinstance(dec, ast.Name) and dec.id == "overload") or
7+
(isinstance(dec, ast.Attribute) and dec.attr == "overload")
8+
)
9+
10+
def func_has_overload_decorator(fn: ast.FunctionDef) -> bool:
11+
return any(is_overload_decorator(d) for d in fn.decorator_list)
12+
13+
def walk_classes(node: ast.AST, qual_prefix: str = "") -> List[Tuple[str, ast.ClassDef]]:
14+
"""Walk an AST node and collect all class definitions with their
15+
qualified names.
16+
17+
Args:
18+
node: The AST node to traverse (a Module or ClassDef).
19+
qual_prefix: The qualified name prefix for nested classes
20+
(internal use)
21+
22+
Returns:
23+
List of tuples containing (qualified_name, class_definition)
24+
pairs.
25+
26+
Example:
27+
For code like:
28+
```
29+
class Outer:
30+
class Inner:
31+
pass
32+
```
33+
Returns: [("Outer", <ClassDef>), ("Outer.Inner", <ClassDef>)]
34+
"""
35+
out: List[Tuple[str, ast.ClassDef]] = []
36+
# Iterate through each class in the file.
37+
for child in getattr(node, "body", []):
38+
if isinstance(child, ast.ClassDef):
39+
qualified_name = f"{qual_prefix}.{child.name}" if qual_prefix else child.name
40+
# Add this class to the list of results.
41+
out.append((qualified_name, child))
42+
# Recurse, incase the class contains other class defintiions.
43+
out.extend(walk_classes(child, qualified_name))
44+
return out
45+
46+
def group_methods_by_name(def_: ast.ClassDef) -> Dict[str, List[ast.FunctionDef]]:
47+
"""Create a dictionary where keys are method names and values are
48+
lists of function definitions with that name.
49+
"""
50+
defs_by_method_name: Dict[str, List[ast.FunctionDef]] = {}
51+
# Iterate through each method definition in the class body.
52+
for node in def_.body:
53+
if isinstance(node, ast.FunctionDef):
54+
# Append the method defintiion to the list of definitions for
55+
# this method name.
56+
defs_by_method_name.setdefault(node.name, []).append(node)
57+
return defs_by_method_name
58+
59+
def format_bases(class_def: ast.ClassDef) -> str:
60+
# If the class doesn't inherit from a base class, return an empty
61+
# string.
62+
if not class_def.bases:
63+
return ""
64+
bases = [ast.unparse(node).strip() for node in class_def.bases]
65+
return f"({', '.join(bases)})"
66+
67+
def _indent(level: int) -> str:
68+
return " " * level
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import ast
2+
import re
3+
from typing import List, Tuple
4+
from collections import OrderedDict
5+
6+
# Define some regex to help with parsing the docstrings.
7+
_PARAM_LINE_RE = re.compile(r"^\s*:param\s+([A-Za-z_]\w*)\s*:\s*(.*)$")
8+
_RET_LINE_RE = re.compile(r"^\s*:returns?\s*:\s*(.*)$")
9+
_STOP_BLOCK_RE = re.compile(r"^\s*:(param|type|returns?|rtype)\b")
10+
11+
12+
class Docstring:
13+
14+
def create_from_overloads(
15+
self, overload_fns: List[ast.FunctionDef], class_name: str,
16+
method_name: str) -> str:
17+
intro_blocks: List[List[str]] = []
18+
seen_intro_keys = set()
19+
params_union: "OrderedDict[str, str]" = OrderedDict()
20+
returns_set: List[str] = []
21+
22+
# Iterate through each overload.
23+
for fn in overload_fns:
24+
# Get the docstring of the overload.
25+
doc = (ast.get_docstring(fn) or "").strip()
26+
# If there is no docstring in the overload, just continue.
27+
if not doc:
28+
continue
29+
# Decompose the docstring into its 3 parts.
30+
intro, param_lines, return_lines = self._split_doc(doc)
31+
# If there is an introduction in the docstring...
32+
if intro:
33+
# Make the introduction look nice.
34+
key = self._normalize_block(intro)
35+
# Only save unique introduction text blocks.
36+
if key and key not in seen_intro_keys:
37+
seen_intro_keys.add(key)
38+
intro_blocks.append(intro)
39+
# Iterate through each parameter description in the
40+
# docstring.
41+
for pl in param_lines:
42+
m = _PARAM_LINE_RE.match(pl)
43+
if not m:
44+
continue
45+
# Get the parameter name and description.
46+
name, desc = m.group(1), m.group(2).strip()
47+
if name not in params_union:
48+
params_union[name] = desc
49+
elif not params_union[name] and desc:
50+
params_union[name] = desc
51+
# Iterate through each return description in the docstring.
52+
for rl in return_lines:
53+
m = _RET_LINE_RE.match(rl)
54+
if not m:
55+
continue
56+
desc = m.group(1).strip()
57+
if desc and desc not in returns_set:
58+
returns_set.append(desc)
59+
# Create an empty list to store the lines we'll write.
60+
out_lines: List[str] = []
61+
# If there was only one unique description we found, use it.
62+
if len(intro_blocks) == 1:
63+
out_lines.extend(intro_blocks[0])
64+
# Otherwise, just list all of the unique descriptions.
65+
elif len(intro_blocks) > 1:
66+
out_lines.append("Signature descriptions:")
67+
out_lines.append("")
68+
for i, block in enumerate(intro_blocks):
69+
first = True
70+
for ln in block:
71+
if first:
72+
out_lines.append(f"- {ln}")
73+
first = False
74+
else:
75+
out_lines.append(f" {ln}")
76+
if i < len(intro_blocks) - 1:
77+
out_lines.append("")
78+
79+
# Write the parameter descriptions.
80+
if params_union:
81+
if out_lines:
82+
out_lines.append("")
83+
for name, desc in params_union.items():
84+
out_lines.append(f":param {name}: {desc}")
85+
86+
# Write the return description.
87+
if returns_set:
88+
# If there was only one, use it.
89+
if len(returns_set) == 1:
90+
if out_lines:
91+
out_lines.append("")
92+
out_lines.append(f":returns: {returns_set[0]}")
93+
# Otherwise, list out all the cases.
94+
else:
95+
combined = "; ".join(f"Case {i+1}: [{desc}]" for i, desc in enumerate(returns_set))
96+
if out_lines:
97+
out_lines.append("")
98+
out_lines.append(f":returns: Depends on the signature used. {combined}")
99+
100+
return "\n".join(out_lines).rstrip()
101+
102+
def _split_doc(self, doc: str) -> Tuple[List[str], List[str], List[str]]:
103+
# Split the docstring into lines.
104+
lines = [ln.rstrip() for ln in doc.splitlines()]
105+
# Create empty lists to store the intro, param descriptions, and
106+
# return description.
107+
intro: List[str] = []
108+
param_lines: List[str] = []
109+
return_lines: List[str] = []
110+
in_intro = True
111+
# Iterate through each line in the docstring.
112+
for line in lines:
113+
# Collect the intro description in the docstring.
114+
if in_intro:
115+
if not line.strip():
116+
in_intro = False
117+
continue
118+
if _STOP_BLOCK_RE.match(line):
119+
in_intro = False
120+
else:
121+
intro.append(line)
122+
continue
123+
# Collect the :param: lines in the doctring.
124+
if _PARAM_LINE_RE.match(line):
125+
param_lines.append(line.strip())
126+
continue
127+
# Collect the :return: line in the docstring.
128+
if _RET_LINE_RE.match(line):
129+
return_lines.append(line.strip())
130+
return intro, param_lines, return_lines
131+
132+
def _normalize_block(self, lines: List[str]) -> str:
133+
text = "\n".join(lines).strip()
134+
return "\n".join(" ".join(l.split()) for l in text.splitlines()).strip()

0 commit comments

Comments
 (0)