qdocs 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qdocs/__init__.py +8 -0
- qdocs/__main__.py +75 -0
- qdocs/annotations.py +140 -0
- qdocs/build.py +316 -0
- qdocs/collect.py +171 -0
- qdocs/docstring.py +108 -0
- qdocs/markup.py +96 -0
- qdocs/project.py +45 -0
- qdocs/templates/function.html +74 -0
- qdocs/templates/nav.html +37 -0
- qdocs/tutorials.py +115 -0
- qdocs-0.2.0.dist-info/METADATA +199 -0
- qdocs-0.2.0.dist-info/RECORD +15 -0
- qdocs-0.2.0.dist-info/WHEEL +4 -0
- qdocs-0.2.0.dist-info/licenses/LICENSE +21 -0
qdocs/__init__.py
ADDED
qdocs/__main__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Commands for the theme itself. Run from a checkout of the qdocs repository:
|
|
3
|
+
|
|
4
|
+
python -m qdocs css
|
|
5
|
+
python -m qdocs preview <docs> [build | serve] [jekyll options]
|
|
6
|
+
|
|
7
|
+
- css: the Pygments styles _sass/qdocs/pycode.scss.
|
|
8
|
+
- preview: builds or serves a site with the theme of this checkout instead of its remote_theme, e.g. to try theme
|
|
9
|
+
changes before they are pushed. The site and the theme are copied into a temporary folder, rerun after changes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
|
|
20
|
+
from qdocs import tutorials
|
|
21
|
+
from qdocs.build import write
|
|
22
|
+
|
|
23
|
+
THEME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
24
|
+
THEME_DIRS = ["_layouts", "_includes", "_sass", "assets"]
|
|
25
|
+
PYCODE_FILE = os.path.join(THEME, "_sass", "qdocs", "pycode.scss")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def preview(docs: str, command: str, options: list[str]) -> int:
|
|
29
|
+
docs = os.path.abspath(docs)
|
|
30
|
+
site = os.path.join(tempfile.gettempdir(), "qdocs-preview", os.path.basename(os.path.dirname(docs)))
|
|
31
|
+
if os.path.exists(site):
|
|
32
|
+
shutil.rmtree(site)
|
|
33
|
+
for d in THEME_DIRS:
|
|
34
|
+
shutil.copytree(os.path.join(THEME, d), os.path.join(site, d))
|
|
35
|
+
ignore = shutil.ignore_patterns("_site", ".jekyll-cache", ".jekyll-metadata")
|
|
36
|
+
shutil.copytree(docs, site, ignore=ignore, dirs_exist_ok=True) # Files of the site override the theme.
|
|
37
|
+
|
|
38
|
+
config = os.path.join(site, "_config.yml")
|
|
39
|
+
with open(config, encoding="utf-8") as f:
|
|
40
|
+
text = re.sub(r"^remote_theme:.*$", "", f.read(), flags=re.MULTILINE)
|
|
41
|
+
write(config, text)
|
|
42
|
+
|
|
43
|
+
env = dict(os.environ, BUNDLE_GEMFILE=os.path.join(docs, "Gemfile"))
|
|
44
|
+
# The copy is not a git repository, site.github needs the repository of the site.
|
|
45
|
+
origin = subprocess.run(
|
|
46
|
+
["git", "-C", docs, "remote", "get-url", "origin"], capture_output=True, text=True, check=False
|
|
47
|
+
).stdout
|
|
48
|
+
m = re.search(r"github\.com[:/](.+?)(?:\.git)?\s*$", origin)
|
|
49
|
+
if m and "PAGES_REPO_NWO" not in env:
|
|
50
|
+
env["PAGES_REPO_NWO"] = m.group(1)
|
|
51
|
+
args = ["bundle", "exec", "jekyll", command, "-d", os.path.join(site, "_site"), *options]
|
|
52
|
+
print(f"Running {' '.join(args)} in {site}")
|
|
53
|
+
return subprocess.call(args, cwd=site, env=env, shell=sys.platform == "win32")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
parser = argparse.ArgumentParser(description="Commands for the qdocs theme.")
|
|
58
|
+
sub = parser.add_subparsers(dest="target", required=True)
|
|
59
|
+
sub.add_parser("css", help="Generate _sass/qdocs/pycode.scss.")
|
|
60
|
+
p = sub.add_parser("preview", help="Build or serve a site with the theme of this checkout.")
|
|
61
|
+
p.add_argument("docs", help="The Jekyll site, e.g. ../qspec/docs.")
|
|
62
|
+
p.add_argument("command", nargs="?", default="serve", choices=["build", "serve"])
|
|
63
|
+
args, options = parser.parse_known_args()
|
|
64
|
+
|
|
65
|
+
if not os.path.isdir(os.path.join(THEME, "_sass", "qdocs")):
|
|
66
|
+
sys.exit("The theme files are not next to the qdocs package. Run this from a checkout of the repository.")
|
|
67
|
+
if args.target == "css":
|
|
68
|
+
write(PYCODE_FILE, tutorials.pycode_css())
|
|
69
|
+
print(f"Wrote {os.path.relpath(PYCODE_FILE, THEME)}.")
|
|
70
|
+
else:
|
|
71
|
+
sys.exit(preview(args.docs, args.command, options))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
qdocs/annotations.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type annotations exactly as written in the source code, with documented classes and type aliases linked.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import html
|
|
7
|
+
import importlib
|
|
8
|
+
import inspect
|
|
9
|
+
import re
|
|
10
|
+
import textwrap
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def module_aliases(module: str) -> set[str]:
|
|
18
|
+
"""
|
|
19
|
+
:param module: The fully qualified name of a module.
|
|
20
|
+
:returns: The type aliases (`type x = ...`, `x = ...`) and classes defined in the module.
|
|
21
|
+
"""
|
|
22
|
+
tree = ast.parse(inspect.getsource(importlib.import_module(module)))
|
|
23
|
+
names = set()
|
|
24
|
+
for node in tree.body:
|
|
25
|
+
if isinstance(node, ast.TypeAlias):
|
|
26
|
+
names.add(node.name.id) # type: ignore
|
|
27
|
+
elif isinstance(node, ast.Assign):
|
|
28
|
+
names.update(t.id for t in node.targets if isinstance(t, ast.Name) and not t.id.startswith("_"))
|
|
29
|
+
elif isinstance(node, ast.ClassDef):
|
|
30
|
+
names.add(node.name)
|
|
31
|
+
return names - {"__all__"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_quotes(text: str) -> str:
|
|
35
|
+
if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
|
|
36
|
+
return text[1:-1]
|
|
37
|
+
return text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def source_annotations(func: Callable[..., Any]) -> tuple[dict[str, str], str | None]:
|
|
41
|
+
"""
|
|
42
|
+
:param func: A function or method.
|
|
43
|
+
:returns: (params, returns) The annotation source text of each annotated parameter
|
|
44
|
+
and of the return value (`None` if not annotated).
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
source = textwrap.dedent(inspect.getsource(func))
|
|
48
|
+
node = ast.parse(source).body[0]
|
|
49
|
+
except (OSError, TypeError, SyntaxError, IndexError):
|
|
50
|
+
return _fallback(func)
|
|
51
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
52
|
+
return _fallback(func)
|
|
53
|
+
|
|
54
|
+
args = node.args
|
|
55
|
+
params = {}
|
|
56
|
+
for arg in [*args.posonlyargs, *args.args, args.vararg, *args.kwonlyargs, args.kwarg]:
|
|
57
|
+
if arg is not None and arg.annotation is not None:
|
|
58
|
+
params[arg.arg] = _strip_quotes(ast.get_source_segment(source, arg.annotation)) # type: ignore
|
|
59
|
+
returns = None
|
|
60
|
+
if node.returns is not None:
|
|
61
|
+
returns = _strip_quotes(ast.get_source_segment(source, node.returns)) # type: ignore
|
|
62
|
+
return params, returns
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def class_attributes(cls: type) -> dict[str, tuple[str | None, str]]:
|
|
66
|
+
"""
|
|
67
|
+
:param cls: A class.
|
|
68
|
+
:returns: (annotation, description) The annotation source text (`None` if not annotated) and the docstring below
|
|
69
|
+
the attribute ("" if there is none) of each attribute that is assigned or annotated in the body of the class or
|
|
70
|
+
of its bases. A subclass inherits the annotation and the docstring of an attribute it only reassigns.
|
|
71
|
+
"""
|
|
72
|
+
ret: dict[str, tuple[str | None, str]] = {}
|
|
73
|
+
for c in reversed(cls.__mro__[:-1]): # Bases first, without object.
|
|
74
|
+
try:
|
|
75
|
+
source = textwrap.dedent(inspect.getsource(c))
|
|
76
|
+
node = ast.parse(source).body[0]
|
|
77
|
+
except (OSError, TypeError, SyntaxError, IndexError):
|
|
78
|
+
continue # Builtins and classes created at runtime.
|
|
79
|
+
if not isinstance(node, ast.ClassDef):
|
|
80
|
+
continue
|
|
81
|
+
for stmt, after in zip(node.body, [*node.body[1:], None]):
|
|
82
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
83
|
+
names = [stmt.target.id]
|
|
84
|
+
annotation = _strip_quotes(ast.get_source_segment(source, stmt.annotation)) # type: ignore
|
|
85
|
+
elif isinstance(stmt, ast.Assign):
|
|
86
|
+
names, annotation = [t.id for t in stmt.targets if isinstance(t, ast.Name)], None
|
|
87
|
+
else:
|
|
88
|
+
continue
|
|
89
|
+
doc = ""
|
|
90
|
+
if isinstance(after, ast.Expr) and isinstance(getattr(after.value, "value", None), str):
|
|
91
|
+
doc = inspect.cleandoc(after.value.value) # type: ignore
|
|
92
|
+
for name in names:
|
|
93
|
+
base_annotation, base_doc = ret.get(name, (None, ""))
|
|
94
|
+
ret[name] = (annotation or base_annotation, doc or base_doc)
|
|
95
|
+
return ret
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fallback(func: Callable[..., Any]) -> tuple[dict[str, str], str | None]:
|
|
99
|
+
sig = inspect.signature(func)
|
|
100
|
+
params = {
|
|
101
|
+
name: inspect.formatannotation(p.annotation)
|
|
102
|
+
for name, p in sig.parameters.items()
|
|
103
|
+
if p.annotation is not inspect.Parameter.empty
|
|
104
|
+
}
|
|
105
|
+
returns = None
|
|
106
|
+
if sig.return_annotation is not inspect.Signature.empty:
|
|
107
|
+
returns = inspect.formatannotation(sig.return_annotation)
|
|
108
|
+
return params, returns
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Linker:
|
|
112
|
+
def __init__(self, class_urls: dict[str, str], aliases: set[str] = frozenset(), aliases_url: str = "") -> None: # type: ignore
|
|
113
|
+
"""
|
|
114
|
+
:param class_urls: A map from class names (short and fully qualified) to their site urls.
|
|
115
|
+
:param aliases: The names of type aliases, linked to `aliases_url`.
|
|
116
|
+
:param aliases_url: The site url of the module page that describes the type aliases.
|
|
117
|
+
"""
|
|
118
|
+
self.class_urls = class_urls
|
|
119
|
+
self.aliases = aliases
|
|
120
|
+
self.aliases_url = aliases_url
|
|
121
|
+
|
|
122
|
+
def __call__(self, annotation: str) -> str:
|
|
123
|
+
"""
|
|
124
|
+
:param annotation: The annotation source text.
|
|
125
|
+
:returns: The HTML of the annotation with documented classes and type aliases linked.
|
|
126
|
+
"""
|
|
127
|
+
out = []
|
|
128
|
+
last = 0
|
|
129
|
+
for m in IDENTIFIER.finditer(annotation):
|
|
130
|
+
name = m.group(0)
|
|
131
|
+
url = self.class_urls.get(name)
|
|
132
|
+
if url is None and name in self.aliases:
|
|
133
|
+
url = self.aliases_url
|
|
134
|
+
if url is None:
|
|
135
|
+
continue
|
|
136
|
+
out.append(html.escape(annotation[last : m.start()]))
|
|
137
|
+
out.append(f"<a href=\"{{{{ '{url}' | relative_url }}}}\">{html.escape(name)}</a>")
|
|
138
|
+
last = m.end()
|
|
139
|
+
out.append(html.escape(annotation[last:]))
|
|
140
|
+
return "".join(out)
|
qdocs/build.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates the generated parts of a website that uses the qdocs theme. A site calls `main` from its build script:
|
|
3
|
+
|
|
4
|
+
python docs/_gen/build.py [api | tutorials | logo | all]
|
|
5
|
+
|
|
6
|
+
- api: the API pages in docs/doc/functions/ and the navigation docs/_includes/api-nav.html.
|
|
7
|
+
- tutorials: the highlighted code blocks docs/_includes/code/*.html from the marked regions in examples/*.py.
|
|
8
|
+
- logo: the dark mode logo (logo_dark.svg) from the light one (logo.svg).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import enum
|
|
13
|
+
import html
|
|
14
|
+
import inspect
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
23
|
+
|
|
24
|
+
from qdocs import annotations, collect, docstring, markup, tutorials
|
|
25
|
+
from qdocs.project import Project
|
|
26
|
+
|
|
27
|
+
# Jinja delimiters that do not clash with the Liquid syntax ({{ }}, {% %}) that Jekyll renders afterward.
|
|
28
|
+
env = Environment(
|
|
29
|
+
loader=FileSystemLoader(os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")),
|
|
30
|
+
block_start_string="[%",
|
|
31
|
+
block_end_string="%]",
|
|
32
|
+
variable_start_string="[[",
|
|
33
|
+
variable_end_string="]]",
|
|
34
|
+
comment_start_string="[#",
|
|
35
|
+
comment_end_string="#]",
|
|
36
|
+
undefined=StrictUndefined,
|
|
37
|
+
keep_trailing_newline=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# The colors of the light logo and their replacements in the dark logo.
|
|
41
|
+
DARK_LOGO_COLORS = {"#000000": "#e8e6ed", "#808080": "#a9a6b0"}
|
|
42
|
+
|
|
43
|
+
# The value of an attribute that has none on the class, e.g. an instance attribute or a property.
|
|
44
|
+
EMPTY = inspect.Parameter.empty
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Attribute:
|
|
49
|
+
"""An attribute listed on the page of a class."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
value: Any = EMPTY
|
|
53
|
+
type: str | None = None # The annotation source text.
|
|
54
|
+
description: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write(path: str, text: str) -> None:
|
|
58
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
59
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
60
|
+
f.write(text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _default(value: Any) -> tuple[str, str]:
|
|
64
|
+
"""
|
|
65
|
+
:returns: (text, color_class) The HTML text of a default value and the CSS class that colors it.
|
|
66
|
+
"""
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return str(value), "bool"
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
return html.escape(repr(value)), "string" # Quotes and control characters as in the source.
|
|
71
|
+
if isinstance(value, (int, float, tuple)):
|
|
72
|
+
return html.escape(str(value)), "num"
|
|
73
|
+
if callable(value):
|
|
74
|
+
return html.escape(value.__name__), ""
|
|
75
|
+
return html.escape(str(value)), ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_enum(obj: Any) -> bool:
|
|
79
|
+
return inspect.isclass(obj) and issubclass(obj, enum.Enum)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ApiBuilder:
|
|
83
|
+
"""
|
|
84
|
+
Builds the API pages of a project. Subclass it to customize how attributes and values are shown,
|
|
85
|
+
see `attributes` and `format_value`.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, project: Project) -> None:
|
|
89
|
+
self.project = project
|
|
90
|
+
self.modules = collect.collect(project)
|
|
91
|
+
self.index = collect.build_index(project, self.modules)
|
|
92
|
+
self.errors: list[str] = []
|
|
93
|
+
self.warnings: dict[str, list[str]] = {}
|
|
94
|
+
self._current = ""
|
|
95
|
+
|
|
96
|
+
class_urls = {}
|
|
97
|
+
for module in self.modules:
|
|
98
|
+
for entry in module.entries:
|
|
99
|
+
if entry.kind == "class":
|
|
100
|
+
class_urls[entry.name] = entry.url
|
|
101
|
+
class_urls[entry.qualname] = entry.url
|
|
102
|
+
aliases, aliases_url = set(), ""
|
|
103
|
+
if project.type_aliases is not None:
|
|
104
|
+
aliases = annotations.module_aliases(f"{project.package}.{project.type_aliases}")
|
|
105
|
+
aliases_url = f"/doc/modules/{project.type_aliases}.html"
|
|
106
|
+
self.link_types = annotations.Linker(class_urls, aliases, aliases_url)
|
|
107
|
+
self.convert = markup.Converter(self._resolve)
|
|
108
|
+
|
|
109
|
+
def _resolve(self, name: str) -> str:
|
|
110
|
+
entries = self.index.get(name, [])
|
|
111
|
+
if len(entries) == 1:
|
|
112
|
+
return entries[0].url
|
|
113
|
+
if not entries:
|
|
114
|
+
self.errors.append(f"{self._current}: unknown reference [`{name}`]")
|
|
115
|
+
else:
|
|
116
|
+
options = ", ".join(e.qualname for e in entries)
|
|
117
|
+
self.errors.append(f"{self._current}: ambiguous reference [`{name}`], use one of {options}")
|
|
118
|
+
return "#"
|
|
119
|
+
|
|
120
|
+
def attributes(self, entry: collect.Entry) -> list[Attribute]:
|
|
121
|
+
"""
|
|
122
|
+
:param entry: A class, namespace or enum.
|
|
123
|
+
:returns: The attributes listed on the page of the entry. These are the members of an enum, and the public
|
|
124
|
+
class attributes, annotated instance attributes (including `__slots__`) and properties of a class, sorted by
|
|
125
|
+
name. Types and descriptions are the annotations and the docstrings below the attributes in the class body
|
|
126
|
+
(or of its bases), and the return annotations and docstrings of the properties.
|
|
127
|
+
"""
|
|
128
|
+
cls = entry.obj
|
|
129
|
+
source = annotations.class_attributes(cls)
|
|
130
|
+
if _is_enum(cls):
|
|
131
|
+
return [Attribute(m.name, m.value, description=source.get(m.name, (None, ""))[1]) for m in cls]
|
|
132
|
+
|
|
133
|
+
ret = []
|
|
134
|
+
for name, value in inspect.getmembers(cls):
|
|
135
|
+
if name.startswith("_") or inspect.isroutine(value):
|
|
136
|
+
continue
|
|
137
|
+
static = inspect.getattr_static(cls, name)
|
|
138
|
+
annotation, description = source.get(name, (None, ""))
|
|
139
|
+
if isinstance(static, property):
|
|
140
|
+
doc = docstring.parse(inspect.getdoc(static))
|
|
141
|
+
returns = annotations.source_annotations(static.fget)[1] if static.fget else None
|
|
142
|
+
desc = doc.description or (doc.returns.description if doc.returns else "")
|
|
143
|
+
ret.append(Attribute(name, type=returns, description=desc))
|
|
144
|
+
elif inspect.isdatadescriptor(static) or inspect.ismethoddescriptor(static):
|
|
145
|
+
if name in source: # A slot of an annotated attribute, not a descriptor of a builtin base.
|
|
146
|
+
ret.append(Attribute(name, type=annotation, description=description))
|
|
147
|
+
else:
|
|
148
|
+
ret.append(Attribute(name, value, annotation, description))
|
|
149
|
+
# Annotated instance attributes without a class attribute.
|
|
150
|
+
for name, (annotation, description) in source.items():
|
|
151
|
+
if annotation is not None and not name.startswith("_") and not hasattr(cls, name):
|
|
152
|
+
ret.append(Attribute(name, type=annotation, description=description))
|
|
153
|
+
return sorted(ret, key=lambda a: a.name)
|
|
154
|
+
|
|
155
|
+
def format_value(self, value: Any) -> str:
|
|
156
|
+
"""
|
|
157
|
+
:param value: A default value of a parameter or the value of an attribute.
|
|
158
|
+
:returns: The HTML of the value. Parts are colored with the classes string, bool, num, class and func
|
|
159
|
+
inside a `default_value` span.
|
|
160
|
+
"""
|
|
161
|
+
if isinstance(value, enum.Enum):
|
|
162
|
+
return (
|
|
163
|
+
f'<span class="default_value"><span class="class">{html.escape(type(value).__name__)}</span>.'
|
|
164
|
+
f'<span class="func">{html.escape(value.name)}</span></span>'
|
|
165
|
+
)
|
|
166
|
+
text, color_class = _default(value)
|
|
167
|
+
return f'<span class="default_value{" " + color_class if color_class else ""}">{text}</span>'
|
|
168
|
+
|
|
169
|
+
def page(self, entry: collect.Entry) -> str:
|
|
170
|
+
self._current = entry.qualname
|
|
171
|
+
is_namespace = entry.kind == "namespace" or _is_enum(entry.obj)
|
|
172
|
+
is_class = entry.kind in ("class", "namespace")
|
|
173
|
+
path, start, stop = collect.source_location(self.project, entry.obj)
|
|
174
|
+
source = dict(path=path, start=start, stop=stop, branch=self.project.branch)
|
|
175
|
+
if entry.kind == "method":
|
|
176
|
+
prename, classname = entry.owner.namespace, entry.owner.name # type: ignore
|
|
177
|
+
else:
|
|
178
|
+
prename, classname = entry.namespace, ""
|
|
179
|
+
context: dict[str, Any] = dict(
|
|
180
|
+
title=entry.qualname,
|
|
181
|
+
prename=prename,
|
|
182
|
+
classname=classname,
|
|
183
|
+
name=entry.name,
|
|
184
|
+
is_class=is_class,
|
|
185
|
+
has_sig=False,
|
|
186
|
+
source=source,
|
|
187
|
+
sig=[],
|
|
188
|
+
attributes=[],
|
|
189
|
+
params=[],
|
|
190
|
+
returns=None,
|
|
191
|
+
raises=[],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Namespaces and classes without their own __init__ or __new__ have no signature.
|
|
195
|
+
if is_namespace or (entry.kind == "class" and entry.func is None):
|
|
196
|
+
doc = docstring.parse(inspect.getdoc(entry.obj))
|
|
197
|
+
warnings = docstring.validate(doc, [], True)
|
|
198
|
+
else:
|
|
199
|
+
doc, warnings = self._signature(entry, context)
|
|
200
|
+
if is_class:
|
|
201
|
+
context["attributes"] = [
|
|
202
|
+
dict(
|
|
203
|
+
name=a.name,
|
|
204
|
+
type=self.link_types(a.type) if a.type else None,
|
|
205
|
+
value=None if a.value is EMPTY else self.format_value(a.value),
|
|
206
|
+
description=self.convert(a.description),
|
|
207
|
+
)
|
|
208
|
+
for a in self.attributes(entry)
|
|
209
|
+
]
|
|
210
|
+
if warnings:
|
|
211
|
+
self.warnings[entry.qualname] = warnings
|
|
212
|
+
context["description"] = self.convert(doc.description)
|
|
213
|
+
context["raises"] = [dict(error=html.escape(err), description=self.convert(desc)) for err, desc in doc.raises]
|
|
214
|
+
return env.get_template("function.html").render(**context)
|
|
215
|
+
|
|
216
|
+
def _signature(self, entry: collect.Entry, context: dict[str, Any]) -> tuple[docstring.Docstring, list[str]]:
|
|
217
|
+
"""
|
|
218
|
+
Add the signature, the parameters and the returns of a function, class or method to the page context.
|
|
219
|
+
|
|
220
|
+
:returns: (doc, warnings) The parsed docstring and the warnings about it.
|
|
221
|
+
"""
|
|
222
|
+
func = entry.func
|
|
223
|
+
assert func is not None, "classes without __init__ or __new__ have no signature"
|
|
224
|
+
is_class = entry.kind == "class"
|
|
225
|
+
sig = inspect.signature(entry.obj)
|
|
226
|
+
params = [(n, p) for n, p in sig.parameters.items() if n != "self"]
|
|
227
|
+
context["has_sig"] = True
|
|
228
|
+
|
|
229
|
+
doc = docstring.parse((func.__doc__ or entry.obj.__doc__) if is_class else inspect.getdoc(func))
|
|
230
|
+
param_types, return_type = annotations.source_annotations(func)
|
|
231
|
+
|
|
232
|
+
for name, p in params:
|
|
233
|
+
star = {p.VAR_POSITIONAL: "*", p.VAR_KEYWORD: "**"}.get(p.kind, "")
|
|
234
|
+
default = None if p.default is p.empty else self.format_value(p.default)
|
|
235
|
+
context["sig"].append(dict(star=star, name=name, default=default))
|
|
236
|
+
|
|
237
|
+
context["params"] = [
|
|
238
|
+
dict(
|
|
239
|
+
name=name,
|
|
240
|
+
type=self.link_types(param_types[name]) if name in param_types else None,
|
|
241
|
+
description=self.convert(doc.params.get(name, "")),
|
|
242
|
+
)
|
|
243
|
+
for name, _ in params
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
# Functions without a return value and without a returns description get no Returns section.
|
|
247
|
+
if not is_class and ((doc.returns and doc.returns.description) or return_type not in (None, "None")):
|
|
248
|
+
label, desc = (doc.returns.label, doc.returns.description) if doc.returns else ("out", "")
|
|
249
|
+
context["returns"] = dict(
|
|
250
|
+
label=label,
|
|
251
|
+
type=self.link_types(return_type) if return_type else None,
|
|
252
|
+
description=self.convert(desc),
|
|
253
|
+
)
|
|
254
|
+
# A __new__ returns the instance, so it may document ':returns:'.
|
|
255
|
+
no_returns = is_class and func.__name__ != "__new__"
|
|
256
|
+
return doc, docstring.validate(doc, [n for n, _ in params], no_returns)
|
|
257
|
+
|
|
258
|
+
def build(self) -> list[str]:
|
|
259
|
+
"""
|
|
260
|
+
:returns: A list of errors.
|
|
261
|
+
"""
|
|
262
|
+
functions_dir = os.path.join(self.project.docs, "doc", "functions")
|
|
263
|
+
nav_file = os.path.join(self.project.docs, "_includes", "api-nav.html")
|
|
264
|
+
if os.path.exists(functions_dir):
|
|
265
|
+
shutil.rmtree(functions_dir)
|
|
266
|
+
n = 0
|
|
267
|
+
for entry in collect.iter_pages(self.modules):
|
|
268
|
+
write(os.path.join(self.project.docs, entry.url.lstrip("/")), self.page(entry))
|
|
269
|
+
n += 1
|
|
270
|
+
write(nav_file, env.get_template("nav.html").render(package=self.project.package, modules=self.modules))
|
|
271
|
+
print(f"Wrote {n} API pages and {os.path.relpath(nav_file, self.project.root)}.")
|
|
272
|
+
|
|
273
|
+
if self.warnings:
|
|
274
|
+
print(f"\nDocstring warnings ({sum(len(w) for w in self.warnings.values())}):")
|
|
275
|
+
for name, warnings in self.warnings.items():
|
|
276
|
+
for w in warnings:
|
|
277
|
+
print(f" {name}: {w}")
|
|
278
|
+
return self.errors
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def dark_logo(svg: str) -> str:
|
|
282
|
+
"""
|
|
283
|
+
:param svg: The SVG of the light logo.
|
|
284
|
+
:returns: The SVG of the dark logo, with the colors in `DARK_LOGO_COLORS` replaced.
|
|
285
|
+
"""
|
|
286
|
+
pattern = re.compile("|".join(re.escape(c) for c in DARK_LOGO_COLORS), re.IGNORECASE)
|
|
287
|
+
return pattern.sub(lambda m: DARK_LOGO_COLORS[m.group(0).lower()], svg)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def main(project: Project, builder: type[ApiBuilder] = ApiBuilder, argv: list[str] | None = None) -> None:
|
|
291
|
+
"""
|
|
292
|
+
:param project: The documented project.
|
|
293
|
+
:param builder: The class that builds the API pages, a subclass of `ApiBuilder` for customizations.
|
|
294
|
+
:param argv: The command line arguments. Defaults to `sys.argv[1:]`.
|
|
295
|
+
"""
|
|
296
|
+
parser = argparse.ArgumentParser(description=f"Generate the {project.package} website.")
|
|
297
|
+
parser.add_argument("target", nargs="?", default="all", choices=["api", "tutorials", "logo", "all"])
|
|
298
|
+
args = parser.parse_args(argv)
|
|
299
|
+
sys.path.insert(0, project.root)
|
|
300
|
+
|
|
301
|
+
errors = []
|
|
302
|
+
if args.target in ("api", "all"):
|
|
303
|
+
errors += builder(project).build()
|
|
304
|
+
if args.target in ("tutorials", "all"):
|
|
305
|
+
errors += tutorials.build(project, write)
|
|
306
|
+
if args.target in ("logo", "all"):
|
|
307
|
+
light = os.path.join(project.docs, project.logo)
|
|
308
|
+
dark = light.replace(".svg", "_dark.svg")
|
|
309
|
+
with open(light, encoding="utf-8") as f:
|
|
310
|
+
write(dark, dark_logo(f.read()))
|
|
311
|
+
print(f"Wrote {os.path.relpath(dark, project.root)}.")
|
|
312
|
+
if errors:
|
|
313
|
+
print(f"\nErrors ({len(errors)}):")
|
|
314
|
+
for e in errors:
|
|
315
|
+
print(f" {e}")
|
|
316
|
+
sys.exit(1)
|