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/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">&nbsp;(&nbsp;</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
+ ,&nbsp;
19
+ [%- endif %]
20
+ [%- endfor %]
21
+ <span class="sig-paren">&nbsp;)</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>
@@ -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
+ )