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 ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ The website theme and generator of qspec and qunits.
3
+ """
4
+
5
+ from qdocs.build import ApiBuilder, Attribute, main
6
+ from qdocs.project import Project
7
+
8
+ __all__ = ["ApiBuilder", "Attribute", "Project", "main"]
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)