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/collect.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Introspection of the documented package: which modules, functions, classes and methods are documented, and where their
|
|
3
|
+
pages live on the website.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib
|
|
7
|
+
import inspect
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from qdocs.project import Project
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Entry:
|
|
18
|
+
"""A documented function, class, namespace or method."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
obj: Any # The function, class or method. None for modules.
|
|
22
|
+
module: str # The module (file) this entry is documented in, e.g. "analyze".
|
|
23
|
+
namespace: str # The public namespace, e.g. "qspec.analyze" or "qspec.models.Model".
|
|
24
|
+
url: str # The site url of the page, e.g. "/doc/functions/analyze/york_fit.html".
|
|
25
|
+
kind: str # "function", "class", "namespace", "method" or "module" (only used as a reference target).
|
|
26
|
+
owner: "Entry | None" = None # The class of a method.
|
|
27
|
+
methods: list["Method"] = field(default_factory=list) # The methods of a class.
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def qualname(self) -> str:
|
|
31
|
+
return f"{self.namespace}.{self.name}"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def func(self) -> Callable[..., Any] | None:
|
|
35
|
+
"""
|
|
36
|
+
The function whose signature and docstring describe this entry. For a class this is `__init__`, or `__new__`
|
|
37
|
+
if only that is overridden. None for a class that overrides neither.
|
|
38
|
+
"""
|
|
39
|
+
if self.kind != "class":
|
|
40
|
+
return self.obj
|
|
41
|
+
for name in ("__init__", "__new__"):
|
|
42
|
+
func = getattr(self.obj, name)
|
|
43
|
+
if func is not getattr(object, name):
|
|
44
|
+
return func
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Method:
|
|
50
|
+
"""A public method of a class, either defined in that class (page) or inherited (link to the defining class)."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
entry: Entry # The entry of the page that documents this method.
|
|
54
|
+
inherited_from: Entry | None = None # The documented class the method is inherited from.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Module:
|
|
59
|
+
name: str
|
|
60
|
+
url: str
|
|
61
|
+
entries: list[Entry]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _public_methods(cls: type) -> list[str]:
|
|
65
|
+
return [name for name, _ in inspect.getmembers(cls, inspect.isfunction) if not name.startswith("_")]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _defining_class(cls: type, name: str) -> type:
|
|
69
|
+
for c in cls.__mro__:
|
|
70
|
+
if name in vars(c):
|
|
71
|
+
return c
|
|
72
|
+
return cls
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def collect(project: Project) -> list[Module]:
|
|
76
|
+
"""
|
|
77
|
+
:param project: The documented project.
|
|
78
|
+
:returns: The documented modules with their functions and classes. Class methods are only documented on the page
|
|
79
|
+
of the class that defines them, as long as that class is documented itself.
|
|
80
|
+
"""
|
|
81
|
+
modules = []
|
|
82
|
+
classes = {} # class object -> Entry
|
|
83
|
+
for file in project.modules:
|
|
84
|
+
mod = importlib.import_module(f"{project.package}.{file}")
|
|
85
|
+
namespace = f"{project.package}.{file}" if file in project.namespaced else project.package
|
|
86
|
+
include = project.include.get(file)
|
|
87
|
+
names = sorted(n for n in mod.__all__ if callable(getattr(mod, n)) and (include is None or n in include))
|
|
88
|
+
entries = []
|
|
89
|
+
for name in names:
|
|
90
|
+
obj = getattr(mod, name)
|
|
91
|
+
if f"{namespace}.{name}" in project.namespaces:
|
|
92
|
+
entry = Entry(name, obj, file, namespace, f"/doc/functions/{file}/{name}.html", "namespace")
|
|
93
|
+
elif inspect.isclass(obj):
|
|
94
|
+
entry = Entry(name, obj, file, namespace, f"/doc/functions/{file}/{name}/{name}.html", "class")
|
|
95
|
+
classes[obj] = entry
|
|
96
|
+
else:
|
|
97
|
+
entry = Entry(name, obj, file, namespace, f"/doc/functions/{file}/{name}.html", "function")
|
|
98
|
+
entries.append(entry)
|
|
99
|
+
modules.append(Module(file, f"/doc/modules/{file}.html", entries))
|
|
100
|
+
|
|
101
|
+
# Methods are resolved after all classes are known, so inherited methods can link across modules.
|
|
102
|
+
for cls, entry in classes.items():
|
|
103
|
+
for name in _public_methods(cls):
|
|
104
|
+
definer = classes.get(_defining_class(cls, name))
|
|
105
|
+
if definer is None or definer is entry:
|
|
106
|
+
url = f"/doc/functions/{entry.module}/{entry.name}/{name}.html"
|
|
107
|
+
m_entry = Entry(name, getattr(cls, name), entry.module, entry.qualname, url, "method", owner=entry)
|
|
108
|
+
entry.methods.append(Method(name, m_entry))
|
|
109
|
+
else:
|
|
110
|
+
entry.methods.append(Method(name, None, inherited_from=definer)) # type: ignore
|
|
111
|
+
|
|
112
|
+
# Resolve the entries of inherited methods once all own methods exist.
|
|
113
|
+
for entry in classes.values():
|
|
114
|
+
for m in entry.methods:
|
|
115
|
+
if m.inherited_from is not None:
|
|
116
|
+
m.entry = next(_m.entry for _m in m.inherited_from.methods if _m.name == m.name)
|
|
117
|
+
return modules
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def iter_pages(modules: list[Module]) -> Iterator[Entry]:
|
|
121
|
+
"""Yield every entry that gets its own page."""
|
|
122
|
+
for module in modules:
|
|
123
|
+
for entry in module.entries:
|
|
124
|
+
yield entry
|
|
125
|
+
for m in entry.methods:
|
|
126
|
+
if m.inherited_from is None:
|
|
127
|
+
yield m.entry
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_index(project: Project, modules: list[Module]) -> dict[str, list[Entry]]:
|
|
131
|
+
"""
|
|
132
|
+
:param project: The documented project.
|
|
133
|
+
:param modules: The documented modules.
|
|
134
|
+
:returns: A map from every name an entry can be referenced by in docstrings to the matching entries.
|
|
135
|
+
Names are the short name (`fit`), `Class.method`, the fully qualified name (`qspec.models.fit`)
|
|
136
|
+
and the module names (`qspec.analyze`).
|
|
137
|
+
"""
|
|
138
|
+
index: dict[str, list[Entry]] = {}
|
|
139
|
+
package = project.package
|
|
140
|
+
|
|
141
|
+
def add(key: str, entry: Entry) -> None:
|
|
142
|
+
bucket = index.setdefault(key, [])
|
|
143
|
+
if entry not in bucket:
|
|
144
|
+
bucket.append(entry)
|
|
145
|
+
|
|
146
|
+
for entry in iter_pages(modules):
|
|
147
|
+
add(entry.name, entry)
|
|
148
|
+
add(entry.qualname, entry)
|
|
149
|
+
# Entries of modules in the package namespace are also reachable through their module, e.g. qspec.physics.f.
|
|
150
|
+
add(entry.qualname.replace(f"{package}.", f"{package}.{entry.module}.", 1), entry)
|
|
151
|
+
if entry.owner is not None:
|
|
152
|
+
add(f"{entry.owner.name}.{entry.name}", entry)
|
|
153
|
+
for module in modules:
|
|
154
|
+
add(f"{package}.{module.name}", Entry(module.name, None, module.name, package, module.url, "module"))
|
|
155
|
+
for entry in module.entries:
|
|
156
|
+
for m in entry.methods: # Inherited methods are also reachable as Subclass.method.
|
|
157
|
+
add(f"{entry.name}.{m.name}", m.entry)
|
|
158
|
+
add(f"{entry.qualname}.{m.name}", m.entry)
|
|
159
|
+
return index
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def source_location(project: Project, obj: Any) -> tuple[str, int, int]:
|
|
163
|
+
"""
|
|
164
|
+
:param project: The documented project.
|
|
165
|
+
:param obj: A function or class.
|
|
166
|
+
:returns: (path, start, stop) The path of the source file relative to the repository root (posix style)
|
|
167
|
+
and the first and last line of the object.
|
|
168
|
+
"""
|
|
169
|
+
lines, start = inspect.getsourcelines(obj)
|
|
170
|
+
path = os.path.relpath(inspect.getfile(obj), project.root).replace(os.sep, "/")
|
|
171
|
+
return path, start, start + len(lines) - 1
|
qdocs/docstring.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A parser for the reST field style of the docstrings:
|
|
3
|
+
|
|
4
|
+
Description, possibly over several paragraphs.
|
|
5
|
+
|
|
6
|
+
:param name: Description, continued on
|
|
7
|
+
indented lines.
|
|
8
|
+
:returns: (label) Description. The optional label in parentheses names the return value(s).
|
|
9
|
+
:raises ValueError: Description.
|
|
10
|
+
:raises (ValueError, TypeError): Description.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
FIELD = re.compile(r"^:(\w+)(?:\s+([^:]+?))?\s*:(?:\s+(.*))?$")
|
|
18
|
+
FIELD_KINDS = {
|
|
19
|
+
"param": "param",
|
|
20
|
+
"returns": "returns",
|
|
21
|
+
"return": "returns",
|
|
22
|
+
"raises": "raises",
|
|
23
|
+
"raise": "raises",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Returns:
|
|
29
|
+
label: str
|
|
30
|
+
description: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Docstring:
|
|
35
|
+
description: str = ""
|
|
36
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
returns: Returns | None = None
|
|
38
|
+
raises: list[tuple[str, str]] = field(default_factory=list)
|
|
39
|
+
warnings: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _split_label(text: str) -> tuple[str, str]:
|
|
43
|
+
"""Split a leading '(label)' from a returns description. A single name loses its parentheses."""
|
|
44
|
+
if not text.startswith("("):
|
|
45
|
+
return "out", text
|
|
46
|
+
j = text.find(")") + 1
|
|
47
|
+
label = text[:j]
|
|
48
|
+
if "," not in label:
|
|
49
|
+
label = label[1:-1]
|
|
50
|
+
return label, text[j:].strip()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse(doc: str | None) -> Docstring:
|
|
54
|
+
ret = Docstring()
|
|
55
|
+
if not doc:
|
|
56
|
+
return ret
|
|
57
|
+
|
|
58
|
+
description: list[str] = []
|
|
59
|
+
fields: list[tuple[str, str, list[str]]] = [] # (kind, argument, lines)
|
|
60
|
+
for line in inspect.cleandoc(doc).splitlines():
|
|
61
|
+
m = FIELD.match(line)
|
|
62
|
+
if m:
|
|
63
|
+
name, arg, text = m.group(1), (m.group(2) or "").strip(), m.group(3) or ""
|
|
64
|
+
kind = FIELD_KINDS.get(name)
|
|
65
|
+
if kind is None:
|
|
66
|
+
ret.warnings.append(f"unknown field ':{name}:'")
|
|
67
|
+
kind = name
|
|
68
|
+
fields.append((kind, arg, [text]))
|
|
69
|
+
elif fields:
|
|
70
|
+
fields[-1][2].append(line)
|
|
71
|
+
else:
|
|
72
|
+
description.append(line)
|
|
73
|
+
|
|
74
|
+
ret.description = "\n".join(description).strip()
|
|
75
|
+
for kind, arg, lines in fields:
|
|
76
|
+
# Continuation lines are indented relative to the field. Remove the common indentation.
|
|
77
|
+
text = lines[0] + ("\n" + inspect.cleandoc("\n".join(lines[1:])) if len(lines) > 1 else "")
|
|
78
|
+
text = text.strip()
|
|
79
|
+
if kind == "param":
|
|
80
|
+
if arg in ret.params:
|
|
81
|
+
ret.warnings.append(f"parameter '{arg}' documented twice")
|
|
82
|
+
ret.params[arg] = text
|
|
83
|
+
elif kind == "returns":
|
|
84
|
+
if ret.returns is not None:
|
|
85
|
+
ret.warnings.append("':returns:' documented twice")
|
|
86
|
+
ret.returns = Returns(*_split_label(text))
|
|
87
|
+
elif kind == "raises":
|
|
88
|
+
ret.raises.append((arg, text))
|
|
89
|
+
return ret
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate(doc: Docstring, params: list[str], is_class: bool) -> list[str]:
|
|
93
|
+
"""
|
|
94
|
+
:param doc: The parsed docstring.
|
|
95
|
+
:param params: The parameter names of the signature (without self).
|
|
96
|
+
:param is_class: Whether the docstring belongs to a class (no ':returns:' expected).
|
|
97
|
+
:returns: Warnings about mismatches between the docstring and the signature.
|
|
98
|
+
"""
|
|
99
|
+
warnings = list(doc.warnings)
|
|
100
|
+
for p in doc.params:
|
|
101
|
+
if p not in params:
|
|
102
|
+
warnings.append(f"documented parameter '{p}' is not in the signature")
|
|
103
|
+
for p in params:
|
|
104
|
+
if p not in doc.params:
|
|
105
|
+
warnings.append(f"parameter '{p}' is not documented")
|
|
106
|
+
if is_class and doc.returns is not None:
|
|
107
|
+
warnings.append("class documents ':returns:'")
|
|
108
|
+
return warnings
|
qdocs/markup.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversion of docstring prose to HTML. Supported markup:
|
|
3
|
+
|
|
4
|
+
- `code`
|
|
5
|
+
- [`name`] references to other documented objects
|
|
6
|
+
- $inline$ and $$block$$ LaTeX math (rendered by MathJax on the website)
|
|
7
|
+
- blank lines separate paragraphs, lines starting with '- ' form lists
|
|
8
|
+
- raw HTML, e.g. <a href="https://doi.org/...">...</a>, is passed through
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
BLOCK_MATH = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
|
|
16
|
+
INLINE_MATH = re.compile(r"(?<![\\$])\$(?!\$)(.+?)(?<![\\$])\$", re.DOTALL)
|
|
17
|
+
REF = re.compile(r"\[`([^`]+)`\]")
|
|
18
|
+
CODE = re.compile(r"`([^`]+)`")
|
|
19
|
+
PLACEHOLDER = re.compile(r"\x00(\d+)\x00")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _escape(text: str) -> str:
|
|
23
|
+
return html.escape(text, quote=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Converter:
|
|
27
|
+
def __init__(self, resolve: Callable[[str], str]) -> None:
|
|
28
|
+
"""
|
|
29
|
+
:param resolve: Returns the site url of a referenced name. Must raise a `KeyError` for unknown names.
|
|
30
|
+
"""
|
|
31
|
+
self.resolve = resolve
|
|
32
|
+
|
|
33
|
+
def __call__(self, text: str) -> str:
|
|
34
|
+
stash: list[tuple[str, str]] = [] # (kind, html)
|
|
35
|
+
|
|
36
|
+
def put(kind: str, value: str) -> str:
|
|
37
|
+
stash.append((kind, value))
|
|
38
|
+
return f"\x00{len(stash) - 1}\x00"
|
|
39
|
+
|
|
40
|
+
text = BLOCK_MATH.sub(lambda m: put("block", f"$${_escape(m.group(1))}$$"), text)
|
|
41
|
+
text = INLINE_MATH.sub(lambda m: put("inline", f"${_escape(m.group(1))}$"), text)
|
|
42
|
+
text = REF.sub(lambda m: put("inline", self._ref(m.group(1))), text)
|
|
43
|
+
text = CODE.sub(lambda m: put("inline", f"<code>{_escape(m.group(1))}</code>"), text)
|
|
44
|
+
|
|
45
|
+
out = []
|
|
46
|
+
for block in re.split(r"\n\s*\n", text.strip()):
|
|
47
|
+
out.extend(self._block(block, stash))
|
|
48
|
+
ret = "\n".join(out)
|
|
49
|
+
return PLACEHOLDER.sub(lambda m: stash[int(m.group(1))][1], ret)
|
|
50
|
+
|
|
51
|
+
def _ref(self, name: str) -> str:
|
|
52
|
+
url = self.resolve(name)
|
|
53
|
+
return f"<a href=\"{{{{ '{url}' | relative_url }}}}\"><code>{_escape(name)}</code></a>"
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _block(block: str, stash: list[tuple[str, str]]) -> list[str]:
|
|
57
|
+
"""Split a block into paragraphs, block math and lists."""
|
|
58
|
+
out = []
|
|
59
|
+
para: list[str] = []
|
|
60
|
+
items: list[list[str]] = []
|
|
61
|
+
|
|
62
|
+
def flush_para() -> None:
|
|
63
|
+
if para:
|
|
64
|
+
out.append("<p>" + "\n".join(para) + "</p>")
|
|
65
|
+
para.clear()
|
|
66
|
+
|
|
67
|
+
def flush_items() -> None:
|
|
68
|
+
if items:
|
|
69
|
+
out.append("<ul>" + "".join("<li>" + "\n".join(i) + "</li>" for i in items) + "</ul>")
|
|
70
|
+
items.clear()
|
|
71
|
+
|
|
72
|
+
for line in block.splitlines():
|
|
73
|
+
stripped = line.strip()
|
|
74
|
+
# Block math always stands on its own, even if written inside a sentence.
|
|
75
|
+
parts = re.split(r"(\x00\d+\x00)", stripped)
|
|
76
|
+
if any(stash[int(p[1:-1])][0] == "block" for p in parts if PLACEHOLDER.fullmatch(p)):
|
|
77
|
+
flush_items()
|
|
78
|
+
for p in parts:
|
|
79
|
+
if PLACEHOLDER.fullmatch(p) and stash[int(p[1:-1])][0] == "block":
|
|
80
|
+
flush_para()
|
|
81
|
+
out.append(f'<div class="math-block">{p}</div>')
|
|
82
|
+
elif p.strip():
|
|
83
|
+
para.append(p.strip())
|
|
84
|
+
continue
|
|
85
|
+
if stripped.startswith("- "):
|
|
86
|
+
flush_para()
|
|
87
|
+
items.append([stripped[2:]])
|
|
88
|
+
elif items and line[:1].isspace():
|
|
89
|
+
items[-1].append(stripped)
|
|
90
|
+
else:
|
|
91
|
+
flush_items()
|
|
92
|
+
if stripped:
|
|
93
|
+
para.append(stripped)
|
|
94
|
+
flush_para()
|
|
95
|
+
flush_items()
|
|
96
|
+
return out
|
qdocs/project.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The configuration of a documented package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Project:
|
|
11
|
+
"""
|
|
12
|
+
The package and the website it is documented on. The website is a Jekyll site in `docs` that uses the qdocs theme.
|
|
13
|
+
|
|
14
|
+
:param package: The name of the documented package, e.g. "qspec".
|
|
15
|
+
:param modules: The documented modules (files or folders) of the package, e.g. ["algebra", "analyze"].
|
|
16
|
+
:param root: The repository root. Source links on the website are relative to it.
|
|
17
|
+
:param namespaced: The modules that are their own namespace (qspec.models.Model).
|
|
18
|
+
The entries of the other modules live in the package namespace (qspec.fit).
|
|
19
|
+
:param include: The modules for which only the listed names are documented.
|
|
20
|
+
:param namespaces: The fully qualified names of classes that are documented as a namespace of attributes
|
|
21
|
+
(no signature, no methods), e.g. a registry class used like a module. Enums are documented this way, too.
|
|
22
|
+
:param type_aliases: The module whose type aliases and classes are linked to its module page in annotations.
|
|
23
|
+
:param branch: The branch of the source links.
|
|
24
|
+
:param docs: The Jekyll site. Defaults to `root/docs`.
|
|
25
|
+
:param examples: The example scripts with the tutorial code regions. Defaults to `root/examples`.
|
|
26
|
+
:param logo: The light logo of the site, relative to `docs`. The `logo` target derives the dark logo from it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
package: str
|
|
30
|
+
modules: list[str]
|
|
31
|
+
root: str
|
|
32
|
+
namespaced: set[str] = field(default_factory=set)
|
|
33
|
+
include: dict[str, set[str]] = field(default_factory=dict)
|
|
34
|
+
namespaces: set[str] = field(default_factory=set)
|
|
35
|
+
type_aliases: str | None = None
|
|
36
|
+
branch: str = "main"
|
|
37
|
+
docs: str = ""
|
|
38
|
+
examples: str = ""
|
|
39
|
+
logo: str = "assets/img/logo.svg"
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
self.root = os.path.abspath(self.root)
|
|
43
|
+
self.docs = os.path.abspath(self.docs or os.path.join(self.root, "docs"))
|
|
44
|
+
self.examples = os.path.abspath(self.examples or os.path.join(self.root, "examples"))
|
|
45
|
+
self.modules = sorted(self.modules)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: api
|
|
3
|
+
title: [[ title ]]
|
|
4
|
+
---
|
|
5
|
+
<dl class="py function">
|
|
6
|
+
<dt class="func-sig">
|
|
7
|
+
<span class="sig-prename">[[ prename ]].</span>
|
|
8
|
+
[%- if classname %]<span class="sig-classname">[[ classname ]]</span><span class="sig-prename">.</span>[% endif %]
|
|
9
|
+
<span class="[[ 'sig-classname' if is_class else 'sig-name' ]]">[[ name ]]</span>
|
|
10
|
+
[%- if has_sig %]
|
|
11
|
+
<span class="sig-paren"> ( </span>
|
|
12
|
+
[%- for p in sig %]
|
|
13
|
+
<em class="sig-param">
|
|
14
|
+
[%- if p.star %]<span class="o">[[ p.star ]]</span>[% endif %]<span class="n">[[ p.name ]]</span>
|
|
15
|
+
[%- if p.default is not none %]<span class="o">=</span>[[ p.default ]][% endif -%]
|
|
16
|
+
</em>
|
|
17
|
+
[%- if not loop.last %]
|
|
18
|
+
,
|
|
19
|
+
[%- endif %]
|
|
20
|
+
[%- endfor %]
|
|
21
|
+
<span class="sig-paren"> )</span>
|
|
22
|
+
[%- endif %]<a href="{{ site.github.repository_url | append: '/blob/[[ source.branch ]]/[[ source.path ]]#L[[ source.start ]]-L[[ source.stop ]]' }}"><span class="sig-source">[source]</span></a>
|
|
23
|
+
</dt>
|
|
24
|
+
<dd class="field-body">
|
|
25
|
+
[[ description ]]
|
|
26
|
+
<dl class="field-list">
|
|
27
|
+
[%- if params %]
|
|
28
|
+
<dt class="field-head">Parameters<span class="colon">:</span></dt>
|
|
29
|
+
<dd class="field-par">
|
|
30
|
+
<dl>
|
|
31
|
+
[%- for p in params %]
|
|
32
|
+
<dt>[[ p.name ]][% if p.type %]<span class="classifier"><em>[[ p.type ]]</em></span>[% endif %]</dt>
|
|
33
|
+
<dd class="field-desc">[[ p.description ]]</dd>
|
|
34
|
+
[%- endfor %]
|
|
35
|
+
</dl>
|
|
36
|
+
</dd>
|
|
37
|
+
[%- endif %]
|
|
38
|
+
[%- if attributes %]
|
|
39
|
+
<dt class="field-head">Attributes<span class="colon">:</span></dt>
|
|
40
|
+
<dd class="field-par">
|
|
41
|
+
<dl>
|
|
42
|
+
[%- for a in attributes %]
|
|
43
|
+
<dt>[[ a.name ]][% if a.type %]<span class="classifier"><em>[[ a.type ]]</em></span>[% endif %]
|
|
44
|
+
[%- if a.value is not none %]<span class="attr-value">[[ a.value ]]</span>[% endif %]</dt>
|
|
45
|
+
[%- if a.description %]
|
|
46
|
+
<dd class="field-desc">[[ a.description ]]</dd>
|
|
47
|
+
[%- endif %]
|
|
48
|
+
[%- endfor %]
|
|
49
|
+
</dl>
|
|
50
|
+
</dd>
|
|
51
|
+
[%- endif %]
|
|
52
|
+
[%- if returns %]
|
|
53
|
+
<dt class="field-head">Returns<span class="colon">:</span></dt>
|
|
54
|
+
<dd class="field-par">
|
|
55
|
+
<dl>
|
|
56
|
+
<dt>[[ returns.label ]][% if returns.type %]<span class="classifier"><em>[[ returns.type ]]</em></span>[% endif %]</dt>
|
|
57
|
+
<dd class="field-desc">[[ returns.description ]]</dd>
|
|
58
|
+
</dl>
|
|
59
|
+
</dd>
|
|
60
|
+
[%- endif %]
|
|
61
|
+
[%- if raises %]
|
|
62
|
+
<dt class="field-head">Raises<span class="colon">:</span></dt>
|
|
63
|
+
<dd class="field-par">
|
|
64
|
+
<dl>
|
|
65
|
+
[%- for r in raises %]
|
|
66
|
+
<dt>[[ r.error ]]</dt>
|
|
67
|
+
<dd class="field-desc">[[ r.description ]]</dd>
|
|
68
|
+
[%- endfor %]
|
|
69
|
+
</dl>
|
|
70
|
+
</dd>
|
|
71
|
+
[%- endif %]
|
|
72
|
+
</dl>
|
|
73
|
+
</dd>
|
|
74
|
+
</dl>
|
qdocs/templates/nav.html
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[#- The API navigation tree. Generated once and included by the api layout; script.js marks the current page. -#]
|
|
2
|
+
<ul class="extern">
|
|
3
|
+
[%- for module in modules %]
|
|
4
|
+
<li class="has-children">
|
|
5
|
+
<a href="{{ '[[ module.url ]]' | relative_url }}">[[ package ]].[[ module.name ]]</a>
|
|
6
|
+
<details>
|
|
7
|
+
<summary aria-label="Show the contents of [[ package ]].[[ module.name ]]">
|
|
8
|
+
<span class="toctree-toggle" role="presentation"><img src="{{ '/assets/img/chevron-down.svg' | relative_url }}" class="chevron" alt=""/></span>
|
|
9
|
+
</summary>
|
|
10
|
+
<ul class="extern-a">
|
|
11
|
+
[%- for entry in module.entries %]
|
|
12
|
+
[%- if entry.methods %]
|
|
13
|
+
<li class="has-children spaced"><a href="{{ '[[ entry.url ]]' | relative_url }}">[[ entry.qualname ]]</a>
|
|
14
|
+
<details>
|
|
15
|
+
<summary aria-label="Show the methods of [[ entry.name ]]">
|
|
16
|
+
<span class="toctree-toggle" role="presentation"><img src="{{ '/assets/img/chevron-down.svg' | relative_url }}" class="chevron" alt=""/></span>
|
|
17
|
+
</summary>
|
|
18
|
+
<ul class="extern-a">
|
|
19
|
+
[%- for m in entry.methods %]
|
|
20
|
+
[%- if m.inherited_from %]
|
|
21
|
+
<li class="indent-a"><a class="inherited" href="{{ '[[ m.entry.url ]]' | relative_url }}" title="Inherited from [[ m.inherited_from.qualname ]]">[[ entry.qualname ]].[[ m.name ]] <span class="nav-inherited">([[ m.inherited_from.name ]])</span></a></li>
|
|
22
|
+
[%- else %]
|
|
23
|
+
<li class="indent-a"><a href="{{ '[[ m.entry.url ]]' | relative_url }}">[[ entry.qualname ]].[[ m.name ]]</a></li>
|
|
24
|
+
[%- endif %]
|
|
25
|
+
[%- endfor %]
|
|
26
|
+
</ul>
|
|
27
|
+
</details>
|
|
28
|
+
</li>
|
|
29
|
+
[%- else %]
|
|
30
|
+
<li class="spaced"><a href="{{ '[[ entry.url ]]' | relative_url }}">[[ entry.qualname ]]</a></li>
|
|
31
|
+
[%- endif %]
|
|
32
|
+
[%- endfor %]
|
|
33
|
+
</ul>
|
|
34
|
+
</details>
|
|
35
|
+
</li>
|
|
36
|
+
[%- endfor %]
|
|
37
|
+
</ul>
|
qdocs/tutorials.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Highlighted tutorial code blocks from marked regions in the example scripts:
|
|
3
|
+
|
|
4
|
+
# docs: hfs-0
|
|
5
|
+
import qspec as qs
|
|
6
|
+
...
|
|
7
|
+
# docs: end
|
|
8
|
+
|
|
9
|
+
Each region becomes docs/_includes/code/<name>.html, which a tutorial page includes with
|
|
10
|
+
{% include code/<name>.html %}. A file may contain several regions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import glob
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import textwrap
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from pygments import highlight
|
|
20
|
+
from pygments.formatters import HtmlFormatter
|
|
21
|
+
from pygments.lexers import get_lexer_by_name
|
|
22
|
+
from pygments.styles import get_style_by_name
|
|
23
|
+
|
|
24
|
+
from qdocs.project import Project
|
|
25
|
+
|
|
26
|
+
STYLE = "catppuccin-latte"
|
|
27
|
+
STYLE_DARK = "catppuccin-mocha"
|
|
28
|
+
MARKER = re.compile(r"^\s*# docs: (\S+)\s*$")
|
|
29
|
+
INCLUDE = re.compile(r"{%\s*include\s+code/(\S+?)\.html\s*%}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_regions(paths: list[str]) -> tuple[dict[str, str], list[str]]:
|
|
33
|
+
"""
|
|
34
|
+
:param paths: The paths of the example scripts.
|
|
35
|
+
:returns: (regions, errors) The code of each region by name and a list of errors.
|
|
36
|
+
"""
|
|
37
|
+
regions: dict[str, str] = {}
|
|
38
|
+
errors = []
|
|
39
|
+
for path in paths:
|
|
40
|
+
name, start, lines = None, 0, []
|
|
41
|
+
with open(path, encoding="utf-8") as f:
|
|
42
|
+
for i, line in enumerate(f.read().splitlines(), start=1):
|
|
43
|
+
m = MARKER.match(line)
|
|
44
|
+
if m is None:
|
|
45
|
+
if name is not None:
|
|
46
|
+
lines.append(line)
|
|
47
|
+
elif m.group(1) == "end":
|
|
48
|
+
if name is None:
|
|
49
|
+
errors.append(f"{path}:{i}: '# docs: end' without an open region")
|
|
50
|
+
continue
|
|
51
|
+
if name in regions:
|
|
52
|
+
errors.append(f"{path}:{start}: duplicate region '{name}'")
|
|
53
|
+
regions[name] = textwrap.dedent("\n".join(lines)).strip("\n")
|
|
54
|
+
name, lines = None, []
|
|
55
|
+
else:
|
|
56
|
+
if name is not None:
|
|
57
|
+
errors.append(f"{path}:{i}: region '{m.group(1)}' starts inside region '{name}'")
|
|
58
|
+
name, start, lines = m.group(1), i, []
|
|
59
|
+
if name is not None:
|
|
60
|
+
errors.append(f"{path}:{start}: region '{name}' is not closed")
|
|
61
|
+
return regions, errors
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def to_html(code: str) -> str:
|
|
65
|
+
lexer = get_lexer_by_name("python", stripall=True)
|
|
66
|
+
formatter = HtmlFormatter(cssclass="py-source", style=get_style_by_name(STYLE))
|
|
67
|
+
return highlight(code, lexer, formatter)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build(project: Project, write: Callable[[str, str], None]) -> list[str]:
|
|
71
|
+
"""
|
|
72
|
+
:param project: The documented project.
|
|
73
|
+
:param write: A function `write(path, text)`.
|
|
74
|
+
:returns: A list of errors.
|
|
75
|
+
"""
|
|
76
|
+
regions, errors = find_regions(sorted(glob.glob(os.path.join(project.examples, "*.py"))))
|
|
77
|
+
out_dir = os.path.join(project.docs, "_includes", "code")
|
|
78
|
+
for f in glob.glob(os.path.join(out_dir, "*.html")):
|
|
79
|
+
os.remove(f)
|
|
80
|
+
for name, code in regions.items():
|
|
81
|
+
write(os.path.join(out_dir, f"{name}.html"), to_html(code))
|
|
82
|
+
print(f"Wrote {len(regions)} tutorial code blocks.")
|
|
83
|
+
|
|
84
|
+
used = set()
|
|
85
|
+
for page in sorted(glob.glob(os.path.join(project.docs, "tutorials", "*.html"))):
|
|
86
|
+
with open(page, encoding="utf-8") as f:
|
|
87
|
+
for name in INCLUDE.findall(f.read()):
|
|
88
|
+
used.add(name)
|
|
89
|
+
if name not in regions:
|
|
90
|
+
errors.append(f"{os.path.relpath(page, project.root)}: no region '{name}' in examples/")
|
|
91
|
+
for name in sorted(set(regions) - used):
|
|
92
|
+
print(f" note: region '{name}' is not included by any tutorial page")
|
|
93
|
+
return errors
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def pycode_css() -> str:
|
|
97
|
+
"""
|
|
98
|
+
:returns: The SCSS of the light (STYLE) and the dark (STYLE_DARK) code highlighting.
|
|
99
|
+
The dark style applies under the same conditions as the dark colors in assets/css/style.scss.
|
|
100
|
+
Both use the code block background of the page instead of the background of the style.
|
|
101
|
+
"""
|
|
102
|
+
# The selector argument is needed to also get the default text color of the style ('.py-source { color: ... }').
|
|
103
|
+
light = HtmlFormatter(cssclass="py-source", style=get_style_by_name(STYLE)).get_style_defs(".py-source")
|
|
104
|
+
dark = HtmlFormatter(cssclass="py-source", style=get_style_by_name(STYLE_DARK)).get_style_defs(".py-source")
|
|
105
|
+
bg = ".py-source { background-color: var(--pre-bg-color); }"
|
|
106
|
+
dark = textwrap.indent(f"{dark}\n{bg}", " ") # type: ignore
|
|
107
|
+
return (
|
|
108
|
+
f"// Light mode: {STYLE}.\n{light}\n{bg}\n\n"
|
|
109
|
+
f"// Dark mode: {STYLE_DARK}.\n"
|
|
110
|
+
f"@mixin pycode-dark {{\n{dark}\n}}\n\n"
|
|
111
|
+
':root[data-theme="dark"] { @include pycode-dark; }\n'
|
|
112
|
+
"@media (prefers-color-scheme: dark) {\n"
|
|
113
|
+
' :root:not([data-theme="light"]) { @include pycode-dark; }\n'
|
|
114
|
+
"}\n"
|
|
115
|
+
)
|