doc-zero 0.1.1__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.
- doc0/__init__.py +9 -0
- doc0/__main__.py +4 -0
- doc0/base.py +445 -0
- doc0/cli.py +101 -0
- doc0/exports.py +243 -0
- doc0/module.py +181 -0
- doc0/py.typed +0 -0
- doc0/pyproject.py +167 -0
- doc0/util.py +61 -0
- doc_zero-0.1.1.dist-info/METADATA +102 -0
- doc_zero-0.1.1.dist-info/RECORD +13 -0
- doc_zero-0.1.1.dist-info/WHEEL +4 -0
- doc_zero-0.1.1.dist-info/entry_points.txt +4 -0
doc0/__init__.py
ADDED
doc0/__main__.py
ADDED
doc0/base.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from importlib.metadata import version as module_version
|
|
7
|
+
from logging import getLogger
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterable, Iterator, TypedDict
|
|
10
|
+
|
|
11
|
+
from .module import Module
|
|
12
|
+
from .pyproject import PyProject
|
|
13
|
+
from .util import first_existing
|
|
14
|
+
|
|
15
|
+
type ModuleName = str
|
|
16
|
+
|
|
17
|
+
NOT_GIVEN: Any = object()
|
|
18
|
+
COPYRIGHT_RE = re.compile(
|
|
19
|
+
r"[cC]opyright\s+(?:\(c\)\s+)?(?P<year>\d+)\s*(:?,?\s+(?P<author>[^\n]+))?"
|
|
20
|
+
)
|
|
21
|
+
DEFAULT_EXTENSIONS = [
|
|
22
|
+
"sphinx.ext.autodoc",
|
|
23
|
+
"sphinx_mdinclude",
|
|
24
|
+
# "myst_parser",
|
|
25
|
+
]
|
|
26
|
+
SPHINX_THEME_ALIASES = {
|
|
27
|
+
"rtd": "sphinx_rtd_theme",
|
|
28
|
+
"readthedocs": "sphinx_rtd_theme",
|
|
29
|
+
"default": "alabaster",
|
|
30
|
+
}
|
|
31
|
+
READTHEDOCS_TEMPLATE = """
|
|
32
|
+
# Read the Docs configuration file
|
|
33
|
+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
|
34
|
+
|
|
35
|
+
# Required
|
|
36
|
+
version: 2
|
|
37
|
+
|
|
38
|
+
# Set the OS, Python version, and other tools you might need
|
|
39
|
+
build:
|
|
40
|
+
os: ubuntu-24.04
|
|
41
|
+
tools:
|
|
42
|
+
python: "3.13"
|
|
43
|
+
|
|
44
|
+
# Build documentation in the "docs/" directory with Sphinx
|
|
45
|
+
sphinx:
|
|
46
|
+
configuration: docs/conf.py
|
|
47
|
+
|
|
48
|
+
# Optionally, but recommended,
|
|
49
|
+
# declare the Python requirements required to build your documentation
|
|
50
|
+
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
|
51
|
+
python:
|
|
52
|
+
install:
|
|
53
|
+
- requirements: docs/requirements.txt
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
log = getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Doc0:
|
|
61
|
+
"""
|
|
62
|
+
The root type representing the documentation of your project.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
#: The pyproject.toml file for the project.
|
|
66
|
+
pyproject: PyProject
|
|
67
|
+
|
|
68
|
+
#: Base location for the documentation assets
|
|
69
|
+
doc_root: Path
|
|
70
|
+
|
|
71
|
+
#: The theme to use for the documentation.
|
|
72
|
+
theme: str
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def load(
|
|
76
|
+
cls,
|
|
77
|
+
root: Path | None = None,
|
|
78
|
+
/,
|
|
79
|
+
*,
|
|
80
|
+
theme: str | None = None,
|
|
81
|
+
docs: str = "docs",
|
|
82
|
+
) -> Doc0:
|
|
83
|
+
"""
|
|
84
|
+
Load project in the given path.
|
|
85
|
+
"""
|
|
86
|
+
root = root or Path.cwd()
|
|
87
|
+
pyproject = PyProject(root=root)
|
|
88
|
+
|
|
89
|
+
if theme is None:
|
|
90
|
+
theme = pyproject.get("tool.doc-zero.theme", default="default", type=str)
|
|
91
|
+
|
|
92
|
+
return Doc0(
|
|
93
|
+
doc_root=root / docs,
|
|
94
|
+
pyproject=pyproject,
|
|
95
|
+
theme=theme,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def root(self) -> Path:
|
|
100
|
+
"""
|
|
101
|
+
The root of the project.
|
|
102
|
+
"""
|
|
103
|
+
return self.pyproject.root
|
|
104
|
+
|
|
105
|
+
def init(self) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Assure that the documentation is initialized.
|
|
108
|
+
|
|
109
|
+
Call .generate() if the documentation is not initialized.
|
|
110
|
+
"""
|
|
111
|
+
self.doc_root.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
(self.doc_root / "_static").mkdir(exist_ok=True)
|
|
113
|
+
|
|
114
|
+
# Write/overwrite docs/conf.py.
|
|
115
|
+
conf_path = self.doc_root / "conf.py"
|
|
116
|
+
conf = Conf.from_pyproject(self.pyproject, theme=self.theme)
|
|
117
|
+
conf_path.write_text(conf.render())
|
|
118
|
+
|
|
119
|
+
# Write docs/index.rst and docs/api/*
|
|
120
|
+
self.write_rst_files()
|
|
121
|
+
self.write_readme_md()
|
|
122
|
+
|
|
123
|
+
# Write the Read the Docs configuration file, if it doesn't exist.
|
|
124
|
+
rtd_path = self.root / ".readthedocs.yml"
|
|
125
|
+
if not rtd_path.exists():
|
|
126
|
+
rtd_path.write_text(READTHEDOCS_TEMPLATE)
|
|
127
|
+
|
|
128
|
+
# Write the requirements.txt file for Read the Docs, if it doesn't exist.
|
|
129
|
+
req_path = self.root / "docs" / "requirements.txt"
|
|
130
|
+
if not req_path.exists():
|
|
131
|
+
req_path.write_text(f"doc0>={module_version('doc0')}")
|
|
132
|
+
|
|
133
|
+
def build(self) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Build the documentation using sphinx.
|
|
136
|
+
"""
|
|
137
|
+
from sphinx.cmd.build import main
|
|
138
|
+
|
|
139
|
+
self.init()
|
|
140
|
+
main([str(self.doc_root), str(self.root / "dist" / "docs")])
|
|
141
|
+
|
|
142
|
+
def serve(self) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Start the live server.
|
|
145
|
+
"""
|
|
146
|
+
from sphinx_autobuild.__main__ import main
|
|
147
|
+
|
|
148
|
+
self.init()
|
|
149
|
+
main([str(self.doc_root), str(self.root / "dist" / "docs")])
|
|
150
|
+
|
|
151
|
+
def test(self) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Execute all doctests.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
#
|
|
157
|
+
# Write parts of the documentation
|
|
158
|
+
#
|
|
159
|
+
def write_rst_files(self) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Write the index, API docs and process the User guide.
|
|
162
|
+
"""
|
|
163
|
+
self.doc_root.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
roots = list(self.pyproject.find_root_modules())
|
|
165
|
+
public_modules = [root.load_module() for root in roots]
|
|
166
|
+
|
|
167
|
+
for root in roots:
|
|
168
|
+
for sub_module in root.iter_submodules(skip_private=True):
|
|
169
|
+
mod = sub_module.load_module()
|
|
170
|
+
docstring = mod.docstring
|
|
171
|
+
if docstring is None:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
if mod.exports is None:
|
|
175
|
+
msg = "Module %s has no __all__ attribute" % sub_module.name
|
|
176
|
+
log.warning(msg)
|
|
177
|
+
elif not mod.exports:
|
|
178
|
+
msg = "Module %s do not export any symbols" % sub_module.name
|
|
179
|
+
log.warning(msg)
|
|
180
|
+
|
|
181
|
+
public_modules.append(mod)
|
|
182
|
+
|
|
183
|
+
index = Index.load(self.pyproject.name, self.doc_root, public_modules)
|
|
184
|
+
index_path = self.doc_root / "index.rst"
|
|
185
|
+
index_path.write_text(index.render())
|
|
186
|
+
|
|
187
|
+
# Clean the docs/api directory
|
|
188
|
+
api_dir = self.doc_root / "api"
|
|
189
|
+
if api_dir.exists():
|
|
190
|
+
shutil.rmtree(api_dir)
|
|
191
|
+
api_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
|
|
193
|
+
# Create the API documentation for each public module and the index.rst file.
|
|
194
|
+
for module in public_modules:
|
|
195
|
+
module_path = self.doc_root / "api" / f"{module.name}.rst"
|
|
196
|
+
module_path.write_text(module.render())
|
|
197
|
+
(self.doc_root / "api" / "_index.rst").write_text(
|
|
198
|
+
render_modules_index(public_modules)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def write_readme_md(self) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Write the README.md file for the documentation.
|
|
204
|
+
"""
|
|
205
|
+
readme_path = self.root / "README.md"
|
|
206
|
+
if not readme_path.exists():
|
|
207
|
+
src = f"This is the documentation for {self.pyproject.name}. Please include a README.md file in the documentation root directory."
|
|
208
|
+
readme_path.write_text(src)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
src = readme_path.read_text()
|
|
212
|
+
parts = re.split(r"<!--\s*doc0-start\s*-->", src, maxsplit=1)
|
|
213
|
+
if len(parts) == 1:
|
|
214
|
+
src = remove_md_title(src)
|
|
215
|
+
else:
|
|
216
|
+
src = parts[1]
|
|
217
|
+
|
|
218
|
+
(self.doc_root / "_readme.md").write_text(src)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class Index:
|
|
223
|
+
"""
|
|
224
|
+
Content of the index.rst file.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
name: str
|
|
228
|
+
|
|
229
|
+
# It uses the framework described at https://diataxis.fr
|
|
230
|
+
tutorials: Path | None = None
|
|
231
|
+
how_to_guides: Path | None = None
|
|
232
|
+
explanations: Path | None = None
|
|
233
|
+
|
|
234
|
+
# Reference is concepts + api documentation
|
|
235
|
+
concepts: Path | None = None
|
|
236
|
+
api_modules: list[str] = field(default_factory=list)
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def load(name: str, root: Path, modules: Iterable[Module]):
|
|
240
|
+
"""
|
|
241
|
+
Load the index.rst configuration from the given root path and modules.
|
|
242
|
+
|
|
243
|
+
It will search the root path for the tutorials, how-to guides and
|
|
244
|
+
explanations directories in order to fill-in the appropriate fields.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
module_names = [mod.name for mod in modules]
|
|
248
|
+
|
|
249
|
+
def select(name: str, plural: str | None = None) -> Path | None:
|
|
250
|
+
"""
|
|
251
|
+
Select the first existing path for the given name.
|
|
252
|
+
"""
|
|
253
|
+
plural = plural or name + "s"
|
|
254
|
+
return first_existing(
|
|
255
|
+
[
|
|
256
|
+
root / plural,
|
|
257
|
+
root / f"{name}.rst",
|
|
258
|
+
root / f"{name}.md",
|
|
259
|
+
]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
tutorials = select("tutorial")
|
|
263
|
+
how_to_guides = select("how-to-guide")
|
|
264
|
+
explanations = select("explanation")
|
|
265
|
+
concepts = select("concept")
|
|
266
|
+
|
|
267
|
+
return Index(
|
|
268
|
+
name=name,
|
|
269
|
+
tutorials=tutorials,
|
|
270
|
+
how_to_guides=how_to_guides,
|
|
271
|
+
explanations=explanations,
|
|
272
|
+
concepts=concepts,
|
|
273
|
+
api_modules=module_names,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def render(self) -> str:
|
|
277
|
+
"""
|
|
278
|
+
Render the index.rst file.
|
|
279
|
+
"""
|
|
280
|
+
return "\n".join(self._iter_lines())
|
|
281
|
+
|
|
282
|
+
def _iter_lines(self) -> Iterator[str]:
|
|
283
|
+
yield f"Welcome to the {self.name} documentation!"
|
|
284
|
+
yield "=" * (len(self.name) + 30)
|
|
285
|
+
yield from [
|
|
286
|
+
".. mdinclude:: _readme.md",
|
|
287
|
+
"",
|
|
288
|
+
"",
|
|
289
|
+
"Table of contents",
|
|
290
|
+
"-----------------",
|
|
291
|
+
"",
|
|
292
|
+
".. toctree::",
|
|
293
|
+
" :maxdepth: 3",
|
|
294
|
+
"",
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
if self.tutorials:
|
|
298
|
+
yield f" {self.tutorials.stem}"
|
|
299
|
+
if self.how_to_guides:
|
|
300
|
+
yield f" {self.how_to_guides.stem}"
|
|
301
|
+
if self.explanations:
|
|
302
|
+
yield f" {self.explanations.stem}"
|
|
303
|
+
if self.concepts:
|
|
304
|
+
yield f" {self.concepts.stem}"
|
|
305
|
+
if self.api_modules:
|
|
306
|
+
yield " api/_index"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass
|
|
310
|
+
class Conf:
|
|
311
|
+
"""
|
|
312
|
+
Information to build the conf.py file.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
project: str | None = None
|
|
316
|
+
author: str | None = None
|
|
317
|
+
email: str | None = None
|
|
318
|
+
year: int | None = None
|
|
319
|
+
extensions: list[str] = field(default_factory=DEFAULT_EXTENSIONS.copy)
|
|
320
|
+
theme: str = "default"
|
|
321
|
+
extra_options: dict[str, Any] = field(default_factory=dict)
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def from_pyproject(
|
|
325
|
+
pyproject: PyProject,
|
|
326
|
+
/,
|
|
327
|
+
*,
|
|
328
|
+
theme: str,
|
|
329
|
+
author: str | None = None,
|
|
330
|
+
email: str | None = None,
|
|
331
|
+
year: int | None = None,
|
|
332
|
+
extensions: Iterable[str] = DEFAULT_EXTENSIONS,
|
|
333
|
+
root: Path | None = None,
|
|
334
|
+
) -> Conf:
|
|
335
|
+
"""
|
|
336
|
+
Create Conf object from a PyProject object.
|
|
337
|
+
"""
|
|
338
|
+
project = pyproject.name
|
|
339
|
+
extensions = list(extensions or [])
|
|
340
|
+
root = root or pyproject.root
|
|
341
|
+
|
|
342
|
+
# Extract author information from the pyproject.toml file
|
|
343
|
+
try:
|
|
344
|
+
author_data = pyproject.authors[0]
|
|
345
|
+
author = author or author_data["name"]
|
|
346
|
+
if not email:
|
|
347
|
+
email = author_data.get("email")
|
|
348
|
+
except (TypeError, IndexError): # empty authors list or invalid data
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
# Read the year from the Copyright notice in the LICENSE file.
|
|
352
|
+
if (licence_file := Path(root / "LICENSE")).exists():
|
|
353
|
+
copyright = find_copyright(licence_file.read_text())
|
|
354
|
+
if year is None:
|
|
355
|
+
try:
|
|
356
|
+
year = int(copyright["year"])
|
|
357
|
+
except ValueError:
|
|
358
|
+
pass
|
|
359
|
+
if author is None:
|
|
360
|
+
author = copyright["author"]
|
|
361
|
+
|
|
362
|
+
return Conf(
|
|
363
|
+
project=project,
|
|
364
|
+
author=author,
|
|
365
|
+
email=email,
|
|
366
|
+
year=year,
|
|
367
|
+
extensions=extensions,
|
|
368
|
+
theme=theme,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def render(self) -> str:
|
|
372
|
+
return "\n".join(self._iter_lines())
|
|
373
|
+
|
|
374
|
+
def _iter_lines(self) -> Iterator[str]:
|
|
375
|
+
theme = SPHINX_THEME_ALIASES.get(self.theme, self.theme)
|
|
376
|
+
copyright = f"{self.year}, " if self.year else ""
|
|
377
|
+
copyright += self.author or "unknown author"
|
|
378
|
+
author = self.author or "unknown author"
|
|
379
|
+
if self.email:
|
|
380
|
+
author += f" <{self.email}>"
|
|
381
|
+
|
|
382
|
+
yield f"project = {self.project or 'unnamed project'!r}"
|
|
383
|
+
yield f"copyright = {copyright!r}"
|
|
384
|
+
yield f"author = {author!r}"
|
|
385
|
+
yield f"extensions = {self.extensions!r}"
|
|
386
|
+
yield "templates_path = ['_templates']"
|
|
387
|
+
yield f"html_theme = {theme!r}"
|
|
388
|
+
yield "html_static_path = ['_static']"
|
|
389
|
+
yield "exclude_patterns = ['_readme.md', 'requirements.txt']"
|
|
390
|
+
for key, value in sorted(self.extra_options.items()):
|
|
391
|
+
yield f"{key} = {value!r}"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class Copyright(TypedDict):
|
|
395
|
+
year: int
|
|
396
|
+
author: str | None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def find_copyright(src: str) -> Copyright:
|
|
400
|
+
"""
|
|
401
|
+
Find the copyright notice in the given source code.
|
|
402
|
+
"""
|
|
403
|
+
match = COPYRIGHT_RE.search(src)
|
|
404
|
+
if not match:
|
|
405
|
+
raise ValueError("Copyright notice not found")
|
|
406
|
+
return {
|
|
407
|
+
"year": int(match.group("year")),
|
|
408
|
+
"author": match.group("author"),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def render_modules_index(modules: Iterable[Module]) -> str:
|
|
413
|
+
"""
|
|
414
|
+
Render the index.rst file for the API documentation.
|
|
415
|
+
"""
|
|
416
|
+
lines = [
|
|
417
|
+
"Modules",
|
|
418
|
+
"=======",
|
|
419
|
+
"",
|
|
420
|
+
".. toctree::",
|
|
421
|
+
" :maxdepth: 2",
|
|
422
|
+
" :caption: Contents:",
|
|
423
|
+
"",
|
|
424
|
+
]
|
|
425
|
+
for module in modules:
|
|
426
|
+
lines.append(f" {module.name}")
|
|
427
|
+
return "\n".join(lines)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def remove_md_title(src: str) -> str:
|
|
431
|
+
"""
|
|
432
|
+
Remove the title from the given markdown source code.
|
|
433
|
+
"""
|
|
434
|
+
lines = src.splitlines()
|
|
435
|
+
if not lines:
|
|
436
|
+
return src
|
|
437
|
+
|
|
438
|
+
# Remove the first line if it is a title
|
|
439
|
+
if lines[0].startswith("#"):
|
|
440
|
+
lines.pop(0)
|
|
441
|
+
# Remove the second line if it is a title underline
|
|
442
|
+
if lines and re.match(r"^=+$", lines[0]):
|
|
443
|
+
lines.pop(0)
|
|
444
|
+
|
|
445
|
+
return "\n".join(lines).lstrip("\n")
|
doc0/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for the doc0 package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from .base import Doc0
|
|
13
|
+
from .util import maybe_map, validate_theme
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"main",
|
|
17
|
+
#: Standalone commands
|
|
18
|
+
"test",
|
|
19
|
+
"build",
|
|
20
|
+
"serve",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
name="doc0",
|
|
25
|
+
help="Generate documentation with zero configuration.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def test() -> None:
|
|
32
|
+
"""
|
|
33
|
+
Run all doctests for the project.
|
|
34
|
+
"""
|
|
35
|
+
doc = Doc0.load(Path.cwd())
|
|
36
|
+
doc.test()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def build(
|
|
41
|
+
theme: Annotated[
|
|
42
|
+
str | None,
|
|
43
|
+
typer.Option(
|
|
44
|
+
...,
|
|
45
|
+
"--theme",
|
|
46
|
+
help="Select the Sphinx theme",
|
|
47
|
+
callback=maybe_map(validate_theme),
|
|
48
|
+
),
|
|
49
|
+
] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Build the documentation for the current project.
|
|
53
|
+
"""
|
|
54
|
+
doc = Doc0.load(Path.cwd(), theme=theme)
|
|
55
|
+
doc.build()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def serve(
|
|
60
|
+
theme: Annotated[
|
|
61
|
+
str | None,
|
|
62
|
+
typer.Option(
|
|
63
|
+
...,
|
|
64
|
+
"--theme",
|
|
65
|
+
help="Select the Sphinx theme",
|
|
66
|
+
callback=maybe_map(validate_theme),
|
|
67
|
+
),
|
|
68
|
+
] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Serve the documentation in the live server.
|
|
72
|
+
"""
|
|
73
|
+
doc = Doc0.load(Path.cwd(), theme=theme)
|
|
74
|
+
doc.serve()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _debug(*args: Any, **kwargs: Any) -> None: # pragma: no cover
|
|
78
|
+
import rich
|
|
79
|
+
from rich.panel import Panel
|
|
80
|
+
|
|
81
|
+
if not args and not kwargs:
|
|
82
|
+
rich.print(sys._getframe(1).f_locals)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if args:
|
|
86
|
+
rich.print(*args)
|
|
87
|
+
|
|
88
|
+
if kwargs:
|
|
89
|
+
for k, v in kwargs.items():
|
|
90
|
+
rich.print(Panel(str(v), title=k, border_style="b"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main() -> None:
|
|
94
|
+
"""
|
|
95
|
+
Start the main CLI application for the doc0 package.
|
|
96
|
+
"""
|
|
97
|
+
app()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Debug hack for efficienet print-based debugging ;)
|
|
101
|
+
builtins.dbg = _debug # type: ignore
|
doc0/exports.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Static parsing of ``__all__`` list/tuple literals.
|
|
2
|
+
#
|
|
3
|
+
# Recovers declaration order and ``#:``-comment section headers from a
|
|
4
|
+
# module's source, when ``__all__`` is a plain, statically analyzable
|
|
5
|
+
# list/tuple of string literals. Anything else (computed values, string
|
|
6
|
+
# concatenation, conditional assignment, mismatches with the module's actual
|
|
7
|
+
# runtime ``__all__``, ...) is reported as "not statically determinable" so
|
|
8
|
+
# callers can fall back to a simpler, unordered listing.
|
|
9
|
+
#
|
|
10
|
+
# Section syntax::
|
|
11
|
+
#
|
|
12
|
+
# __all__ = [
|
|
13
|
+
# "Foo",
|
|
14
|
+
# "Bar",
|
|
15
|
+
# #: Utility functions
|
|
16
|
+
# "make_foo",
|
|
17
|
+
# #: Advanced
|
|
18
|
+
# #:
|
|
19
|
+
# #: These require care -- see the guide before using them.
|
|
20
|
+
# #: They are not stable across releases.
|
|
21
|
+
# "make_bar",
|
|
22
|
+
# ]
|
|
23
|
+
#
|
|
24
|
+
# A section is opened by a standalone comment line (nothing but whitespace
|
|
25
|
+
# before the ``#``) whose text starts with ``#:``. That line's text (after
|
|
26
|
+
# the marker) is the section title. Every standalone comment line
|
|
27
|
+
# immediately following it -- with no blank source line or entry in
|
|
28
|
+
# between -- is that section's body, rendered verbatim as paragraph text; a
|
|
29
|
+
# blank comment line (``#:`` or ``#`` with nothing else) is a paragraph
|
|
30
|
+
# break, and continuation lines don't need the ``#:`` marker themselves
|
|
31
|
+
# (plain ``#`` works once a section has been opened). Entries before the
|
|
32
|
+
# first section-opening comment form a leading, unlabeled section.
|
|
33
|
+
#
|
|
34
|
+
# A standalone comment block whose *first* line is not a ``#:`` line is
|
|
35
|
+
# just incidental commentary: it's ignored, and does not start a section --
|
|
36
|
+
# even if a `#:` line appears later in that same contiguous block. Only a
|
|
37
|
+
# `#:` line reached with no other comment line directly above it opens a
|
|
38
|
+
# section.
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import ast
|
|
43
|
+
import io
|
|
44
|
+
import tokenize
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from types import ModuleType
|
|
48
|
+
|
|
49
|
+
__all__ = ["Section", "parse_export_sections"]
|
|
50
|
+
|
|
51
|
+
_SECTION_MARKER = "#:"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Section:
|
|
56
|
+
"""
|
|
57
|
+
A named (or unlabeled) group of exported symbol names, in declaration
|
|
58
|
+
order.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
#: The section title, taken from a ``#:``-opened comment block
|
|
62
|
+
#: preceding this group's first entry. None for the leading,
|
|
63
|
+
#: unlabeled group of entries that appear before any such block (or
|
|
64
|
+
#: for the whole list, if it has no section comments at all).
|
|
65
|
+
title: str | None
|
|
66
|
+
|
|
67
|
+
#: Body paragraph lines for this section, verbatim from the comment
|
|
68
|
+
#: block (empty strings mark paragraph breaks). Always empty when
|
|
69
|
+
#: title is None.
|
|
70
|
+
body: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
#: Exported symbol names belonging to this section, in declaration order.
|
|
73
|
+
names: list[str] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_export_sections(
|
|
77
|
+
source_path: Path, module: ModuleType
|
|
78
|
+
) -> list[Section] | None:
|
|
79
|
+
"""
|
|
80
|
+
Parse ``__all__`` in the given source file into ordered, ``#:``-delimited
|
|
81
|
+
sections, if -- and only if -- it can be statically and reliably
|
|
82
|
+
determined.
|
|
83
|
+
|
|
84
|
+
Returns None when ``__all__`` is missing, is not a single top-level
|
|
85
|
+
plain list/tuple of string literals, or its statically-parsed names
|
|
86
|
+
don't exactly match the module's actual runtime ``__all__`` (e.g.
|
|
87
|
+
because it's built dynamically, conditionally, or mutated after
|
|
88
|
+
definition) -- callers should fall back to whatever default listing
|
|
89
|
+
they'd otherwise use in that case.
|
|
90
|
+
"""
|
|
91
|
+
runtime_all = getattr(module, "__all__", None)
|
|
92
|
+
if runtime_all is None:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
source = source_path.read_text()
|
|
97
|
+
except OSError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
node = _find_all_literal(source)
|
|
101
|
+
if node is None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
names = _extract_string_literal_names(node)
|
|
105
|
+
if names is None or names != list(runtime_all):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
return _split_into_sections(source, node, names)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _find_all_literal(source: str) -> ast.List | ast.Tuple | None:
|
|
112
|
+
"""
|
|
113
|
+
Find a single, top-level, unconditional ``__all__ = [...]`` (or
|
|
114
|
+
``(...)``) assignment and return its list/tuple node, or None if
|
|
115
|
+
there isn't exactly one such simple assignment.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
tree = ast.parse(source)
|
|
119
|
+
except SyntaxError:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
found: ast.List | ast.Tuple | None = None
|
|
123
|
+
for stmt in tree.body:
|
|
124
|
+
if "__all__" not in _assignment_targets(stmt):
|
|
125
|
+
continue
|
|
126
|
+
value = stmt.value # type: ignore[union-attr]
|
|
127
|
+
if not isinstance(value, (ast.List, ast.Tuple)) or found is not None:
|
|
128
|
+
# Not a plain literal, or a second __all__ assignment: too
|
|
129
|
+
# ambiguous to trust statically.
|
|
130
|
+
return None
|
|
131
|
+
found = value
|
|
132
|
+
|
|
133
|
+
return found
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _assignment_targets(stmt: ast.stmt) -> list[str]:
|
|
137
|
+
if isinstance(stmt, ast.Assign):
|
|
138
|
+
return [target.id for target in stmt.targets if isinstance(target, ast.Name)]
|
|
139
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
140
|
+
return [stmt.target.id]
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _extract_string_literal_names(node: ast.List | ast.Tuple) -> list[str] | None:
|
|
145
|
+
names = []
|
|
146
|
+
for element in node.elts:
|
|
147
|
+
if not (isinstance(element, ast.Constant) and isinstance(element.value, str)):
|
|
148
|
+
return None
|
|
149
|
+
names.append(element.value)
|
|
150
|
+
return names
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _split_into_sections(
|
|
154
|
+
source: str, node: ast.List | ast.Tuple, names: list[str]
|
|
155
|
+
) -> list[Section]:
|
|
156
|
+
"""
|
|
157
|
+
For each entry, look at the contiguous run of standalone comment
|
|
158
|
+
lines immediately preceding it (if any) to decide whether it opens a
|
|
159
|
+
new section, then group entries accordingly.
|
|
160
|
+
"""
|
|
161
|
+
comment_lines = _standalone_comment_lines(source, node)
|
|
162
|
+
|
|
163
|
+
sections: list[Section] = []
|
|
164
|
+
current = Section(title=None)
|
|
165
|
+
sections.append(current)
|
|
166
|
+
|
|
167
|
+
previous_end_line = node.lineno # exclusive lower bound for the next backward scan
|
|
168
|
+
|
|
169
|
+
for element, name in zip(node.elts, names):
|
|
170
|
+
run = _preceding_comment_run(comment_lines, previous_end_line, element.lineno)
|
|
171
|
+
if run and run[0].startswith(_SECTION_MARKER):
|
|
172
|
+
title = run[0][len(_SECTION_MARKER) :].strip()
|
|
173
|
+
body = [_strip_comment_marker(line) for line in run[1:]]
|
|
174
|
+
body = _strip_blank_edges(body)
|
|
175
|
+
current = Section(title=title, body=body)
|
|
176
|
+
sections.append(current)
|
|
177
|
+
current.names.append(name)
|
|
178
|
+
previous_end_line = element.lineno + 1
|
|
179
|
+
|
|
180
|
+
return [section for section in sections if section.names]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _strip_blank_edges(body: list[str]) -> list[str]:
|
|
184
|
+
"""
|
|
185
|
+
Drop leading/trailing blank paragraph-break lines (they're just the
|
|
186
|
+
blank-line-after-title artifact, not meaningful spacing); internal
|
|
187
|
+
blank lines between paragraphs are kept.
|
|
188
|
+
"""
|
|
189
|
+
start = 0
|
|
190
|
+
end = len(body)
|
|
191
|
+
while start < end and not body[start]:
|
|
192
|
+
start += 1
|
|
193
|
+
while end > start and not body[end - 1]:
|
|
194
|
+
end -= 1
|
|
195
|
+
return body[start:end]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _strip_comment_marker(text: str) -> str:
|
|
199
|
+
if text.startswith(_SECTION_MARKER):
|
|
200
|
+
return text[len(_SECTION_MARKER) :].strip()
|
|
201
|
+
return text[1:].strip() # plain "#" comment
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _preceding_comment_run(
|
|
205
|
+
comment_lines: dict[int, str], lower_bound: int, before_line: int
|
|
206
|
+
) -> list[str]:
|
|
207
|
+
"""
|
|
208
|
+
Collect the contiguous run of standalone comment lines ending right
|
|
209
|
+
before `before_line`, without crossing `lower_bound` (the line right
|
|
210
|
+
after the previous entry, or the list's opening line).
|
|
211
|
+
"""
|
|
212
|
+
run: list[str] = []
|
|
213
|
+
line_no = before_line - 1
|
|
214
|
+
while line_no >= lower_bound and line_no in comment_lines:
|
|
215
|
+
run.append(comment_lines[line_no])
|
|
216
|
+
line_no -= 1
|
|
217
|
+
run.reverse()
|
|
218
|
+
return run
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _standalone_comment_lines(
|
|
222
|
+
source: str, node: ast.List | ast.Tuple
|
|
223
|
+
) -> dict[int, str]:
|
|
224
|
+
"""
|
|
225
|
+
Return {line_number: raw_comment_text} for every comment that is the
|
|
226
|
+
only non-whitespace content on its line, within the span of `node`.
|
|
227
|
+
"""
|
|
228
|
+
end_line = node.end_lineno or node.lineno
|
|
229
|
+
lines: dict[int, str] = {}
|
|
230
|
+
|
|
231
|
+
for tok in tokenize.generate_tokens(io.StringIO(source).readline):
|
|
232
|
+
if tok.type != tokenize.COMMENT:
|
|
233
|
+
continue
|
|
234
|
+
line_no, col = tok.start
|
|
235
|
+
if not (node.lineno <= line_no <= end_line):
|
|
236
|
+
continue
|
|
237
|
+
if tok.line[:col].strip():
|
|
238
|
+
# Something other than whitespace precedes the comment on its
|
|
239
|
+
# line: a trailing same-line comment, not standalone.
|
|
240
|
+
continue
|
|
241
|
+
lines[line_no] = tok.string
|
|
242
|
+
|
|
243
|
+
return lines
|
doc0/module.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
from .exports import Section, parse_export_sections
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ModuleSpec:
|
|
15
|
+
#: The Python module name
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
#: The path to the module source file. Packages point to a folder, modules to a file.
|
|
19
|
+
path: Path
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_package(self) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Return True if the module is a package.
|
|
25
|
+
"""
|
|
26
|
+
return self.path.is_dir()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def source_path(self) -> Path:
|
|
30
|
+
"""
|
|
31
|
+
Return the path to the source file for the module.
|
|
32
|
+
"""
|
|
33
|
+
if self.is_package:
|
|
34
|
+
return self.path / "__init__.py"
|
|
35
|
+
return self.path
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not self.path.exists():
|
|
39
|
+
raise ValueError(f"Module path {self.path} does not exist.")
|
|
40
|
+
|
|
41
|
+
if self.path.name == "__init__.py":
|
|
42
|
+
super().__setattr__("path", self.path.parent)
|
|
43
|
+
|
|
44
|
+
elif not self.path.is_dir() and self.path.suffix != ".py":
|
|
45
|
+
raise ValueError(f"Module path {self.path} is not a Python file.")
|
|
46
|
+
|
|
47
|
+
def load_module(self) -> Module:
|
|
48
|
+
"""
|
|
49
|
+
Load the module from the spec.
|
|
50
|
+
|
|
51
|
+
This executes the module code, if not already loaded.
|
|
52
|
+
"""
|
|
53
|
+
import importlib.util
|
|
54
|
+
|
|
55
|
+
if self.name in sys.modules:
|
|
56
|
+
module = sys.modules[self.name]
|
|
57
|
+
else:
|
|
58
|
+
if self.is_package:
|
|
59
|
+
submodule_search_locations = [str(self.path)]
|
|
60
|
+
else:
|
|
61
|
+
submodule_search_locations = None
|
|
62
|
+
spec = importlib.util.spec_from_file_location(
|
|
63
|
+
name=self.name,
|
|
64
|
+
location=self.source_path,
|
|
65
|
+
submodule_search_locations=submodule_search_locations,
|
|
66
|
+
)
|
|
67
|
+
if spec is None:
|
|
68
|
+
raise ImportError(f"Cannot load module {self.name} from {self.path}")
|
|
69
|
+
module = importlib.util.module_from_spec(spec)
|
|
70
|
+
if spec.loader is None:
|
|
71
|
+
raise ImportError(f"Cannot load module {self.name} from {self.path}")
|
|
72
|
+
spec.loader.exec_module(module)
|
|
73
|
+
|
|
74
|
+
return Module(source_path=self.source_path, name=self.name, module=module)
|
|
75
|
+
|
|
76
|
+
def iter_submodules(self, skip_private: bool = False) -> Iterator[ModuleSpec]:
|
|
77
|
+
"""
|
|
78
|
+
Iterate over all sub-modules in the project.
|
|
79
|
+
"""
|
|
80
|
+
if self.is_package:
|
|
81
|
+
for path in self.path.iterdir():
|
|
82
|
+
if skip_private and path.name.startswith("_"):
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if path.is_dir():
|
|
86
|
+
spec = ModuleSpec(name=f"{self.name}.{path.name}", path=path)
|
|
87
|
+
if (path / "__init__.py").exists():
|
|
88
|
+
yield spec
|
|
89
|
+
yield from spec.iter_submodules(skip_private=skip_private)
|
|
90
|
+
|
|
91
|
+
elif path.suffix == ".py":
|
|
92
|
+
yield ModuleSpec(name=f"{self.name}.{path.stem}", path=path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Module:
|
|
97
|
+
"""
|
|
98
|
+
A Python module with its source and objects.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
#: Path to the source file
|
|
102
|
+
source_path: Path
|
|
103
|
+
|
|
104
|
+
#: Python name for the module.
|
|
105
|
+
name: str
|
|
106
|
+
|
|
107
|
+
#: Loaded python module
|
|
108
|
+
module: ModuleType
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def docstring(self) -> str | None:
|
|
112
|
+
"""
|
|
113
|
+
Return the module docstring.
|
|
114
|
+
"""
|
|
115
|
+
return self.module.__doc__
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def exports(self) -> list[str] | None:
|
|
119
|
+
"""
|
|
120
|
+
Return the list of exported symbols from the module.
|
|
121
|
+
"""
|
|
122
|
+
exports = getattr(self.module, "__all__", None)
|
|
123
|
+
if exports is None:
|
|
124
|
+
return None
|
|
125
|
+
return list(exports)
|
|
126
|
+
|
|
127
|
+
def render(self) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Render the module documentation as reStructuredText.
|
|
130
|
+
|
|
131
|
+
If ``__all__`` can be statically parsed into ordered, ``#:``-delimited
|
|
132
|
+
sections (see ``doc0.exports``), members are listed explicitly, in
|
|
133
|
+
declaration order, grouped under their sections. Otherwise, falls
|
|
134
|
+
back to a single ``automodule`` block listing all members in
|
|
135
|
+
whatever order Sphinx's autodoc picks.
|
|
136
|
+
"""
|
|
137
|
+
return "\n".join(self._iter_lines())
|
|
138
|
+
|
|
139
|
+
def _iter_lines(self) -> Iterator[str]:
|
|
140
|
+
yield self.name
|
|
141
|
+
yield "=" * len(self.name)
|
|
142
|
+
yield ""
|
|
143
|
+
|
|
144
|
+
sections = parse_export_sections(self.source_path, self.module)
|
|
145
|
+
if sections is None:
|
|
146
|
+
yield f".. automodule:: {self.name}"
|
|
147
|
+
yield " :members:"
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
yield f".. automodule:: {self.name}"
|
|
151
|
+
yield ""
|
|
152
|
+
|
|
153
|
+
for section in sections:
|
|
154
|
+
yield from self._iter_section_lines(section)
|
|
155
|
+
|
|
156
|
+
def _iter_section_lines(self, section: Section) -> Iterator[str]:
|
|
157
|
+
if section.title:
|
|
158
|
+
yield section.title
|
|
159
|
+
yield "-" * len(section.title)
|
|
160
|
+
yield ""
|
|
161
|
+
|
|
162
|
+
for line in section.body:
|
|
163
|
+
yield line
|
|
164
|
+
if section.body:
|
|
165
|
+
yield ""
|
|
166
|
+
|
|
167
|
+
for name in section.names:
|
|
168
|
+
yield from self._iter_member_lines(name)
|
|
169
|
+
yield ""
|
|
170
|
+
|
|
171
|
+
def _iter_member_lines(self, name: str) -> Iterator[str]:
|
|
172
|
+
qualname = f"{self.name}.{name}"
|
|
173
|
+
obj = getattr(self.module, name, None)
|
|
174
|
+
|
|
175
|
+
if inspect.isclass(obj):
|
|
176
|
+
yield f".. autoclass:: {qualname}"
|
|
177
|
+
yield " :members:"
|
|
178
|
+
elif inspect.isroutine(obj):
|
|
179
|
+
yield f".. autofunction:: {qualname}"
|
|
180
|
+
else:
|
|
181
|
+
yield f".. autodata:: {qualname}"
|
doc0/py.typed
ADDED
|
File without changes
|
doc0/pyproject.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterable, NotRequired, TypedDict, overload
|
|
8
|
+
from warnings import warn
|
|
9
|
+
|
|
10
|
+
from .module import ModuleSpec
|
|
11
|
+
|
|
12
|
+
log = getLogger(__name__)
|
|
13
|
+
type TomlValue = str | int | float | bool | None | list[Any] | dict[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PyProject:
|
|
18
|
+
"""
|
|
19
|
+
A Python project with its source and documentation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
#: Path to the root of the project
|
|
23
|
+
root: Path
|
|
24
|
+
|
|
25
|
+
#: Raw data from the pyproject.toml file
|
|
26
|
+
data: dict = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Load the pyproject.toml file if it exists.
|
|
31
|
+
"""
|
|
32
|
+
pyproject_path = self.root / "pyproject.toml"
|
|
33
|
+
if pyproject_path.exists():
|
|
34
|
+
with pyproject_path.open("rb") as f:
|
|
35
|
+
self.data = tomllib.load(f)
|
|
36
|
+
else:
|
|
37
|
+
warn("pyproject.toml not found")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def project(self) -> dict[str, TomlValue]:
|
|
41
|
+
return self.data.get("project", {})
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
return self.get("project.name", type=str)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def version(self) -> str:
|
|
49
|
+
return self.get("project.version", type=str)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def description(self) -> str:
|
|
53
|
+
return self.get("project.description", type=str)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def authors(self) -> list[Author]:
|
|
57
|
+
data = self.get("project.authors", type=list)
|
|
58
|
+
return [Author(**item) for item in data]
|
|
59
|
+
|
|
60
|
+
def __getitem__(self, key: str) -> TomlValue:
|
|
61
|
+
data = self.data
|
|
62
|
+
for part in key.split("."):
|
|
63
|
+
try:
|
|
64
|
+
data = data[part]
|
|
65
|
+
except KeyError:
|
|
66
|
+
raise KeyError(key)
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
def get[T](self, key: str, /, *, default: T | None = None, type: type[T]) -> T: ...
|
|
71
|
+
|
|
72
|
+
@overload
|
|
73
|
+
def get(self, key: str, /, default: TomlValue = None) -> TomlValue: ...
|
|
74
|
+
|
|
75
|
+
def get(self, key: str, /, default: Any = None, *, type: Any = None) -> Any:
|
|
76
|
+
"""
|
|
77
|
+
Get configuration key and possibly assert it has the given type.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
key: The key to get, using dot notation for nested keys.
|
|
81
|
+
default: The default value to return if the key is not found.
|
|
82
|
+
type: The type to assert the value has. If None, no assertion is made.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
value = self[key]
|
|
86
|
+
except KeyError:
|
|
87
|
+
value = default
|
|
88
|
+
if type is not None and value is not None and not isinstance(value, type):
|
|
89
|
+
msg = f"Expected {key} to be of type {type.__name__}, got {value.__class__.__name__}"
|
|
90
|
+
raise TypeError(msg)
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
def find_root_modules(self) -> Iterable[ModuleSpec]:
|
|
94
|
+
"""
|
|
95
|
+
Find all root modules in the project.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
An iterable of root module specifications.
|
|
99
|
+
"""
|
|
100
|
+
# We try various heuristics to find the root modules of the project.
|
|
101
|
+
# The first is to look for explicit configuration in the pyproject.toml
|
|
102
|
+
# file.
|
|
103
|
+
if self._is_uv_build_system():
|
|
104
|
+
log.info("uv build system detected")
|
|
105
|
+
yield from self._find_uv_root_modules()
|
|
106
|
+
elif self._is_src_layout():
|
|
107
|
+
yield from self._find_src_root_modules()
|
|
108
|
+
elif self._is_toplevel_package_layout():
|
|
109
|
+
log.info("toplevel package layout detected")
|
|
110
|
+
yield from self._find_toplevel_package_root_modules()
|
|
111
|
+
else:
|
|
112
|
+
raise RuntimeError("Could not determine the layout of the project")
|
|
113
|
+
|
|
114
|
+
def _is_uv_build_system(self) -> bool:
|
|
115
|
+
build_system: dict[str, str] = self.data.get("build-system", {})
|
|
116
|
+
return build_system.get("build-backend") == "uv_build"
|
|
117
|
+
|
|
118
|
+
def _is_src_layout(self) -> bool:
|
|
119
|
+
# Check if there is a src directory with a package inside it.
|
|
120
|
+
src_dir = self.root / "src"
|
|
121
|
+
if not src_dir.is_dir():
|
|
122
|
+
return False
|
|
123
|
+
for item in src_dir.iterdir():
|
|
124
|
+
if item.is_dir() and (item / "__init__.py").exists():
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def _is_toplevel_package_layout(self) -> bool:
|
|
129
|
+
package_dir = self.root / self.name
|
|
130
|
+
return package_dir.is_dir() and (package_dir / "__init__.py").exists()
|
|
131
|
+
|
|
132
|
+
def _find_uv_root_modules(self) -> Iterable[ModuleSpec]:
|
|
133
|
+
uv_conf = self.get("tool.uv.build-backend", type=dict)
|
|
134
|
+
root = self.root / uv_conf.get("module-root", "")
|
|
135
|
+
name = uv_conf.get("module-name")
|
|
136
|
+
if name is None:
|
|
137
|
+
return
|
|
138
|
+
if isinstance(name, str) and "," in name:
|
|
139
|
+
name = [part.strip() for part in name.split(",")]
|
|
140
|
+
|
|
141
|
+
if isinstance(name, list):
|
|
142
|
+
yield from (ModuleSpec(name=part, path=root / part) for part in name)
|
|
143
|
+
elif isinstance(name, str):
|
|
144
|
+
yield ModuleSpec(name=name, path=root / name)
|
|
145
|
+
else:
|
|
146
|
+
msg = f"Invalid option: tool.uv.build-backend.module-name={name!r}"
|
|
147
|
+
raise ValueError(msg)
|
|
148
|
+
|
|
149
|
+
def _find_src_root_modules(self) -> Iterable[ModuleSpec]:
|
|
150
|
+
src_dir = self.root / "src"
|
|
151
|
+
for item in src_dir.iterdir():
|
|
152
|
+
if item.is_dir() and (item / "__init__.py").exists():
|
|
153
|
+
yield ModuleSpec(name=item.name, path=item)
|
|
154
|
+
elif item.suffix == ".py":
|
|
155
|
+
yield ModuleSpec(name=item.stem, path=item)
|
|
156
|
+
|
|
157
|
+
def _find_toplevel_package_root_modules(self) -> Iterable[ModuleSpec]:
|
|
158
|
+
package_dir = self.root / self.name
|
|
159
|
+
yield ModuleSpec(name=self.name, path=package_dir)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
#
|
|
163
|
+
# Auxiliary types
|
|
164
|
+
#
|
|
165
|
+
class Author(TypedDict):
|
|
166
|
+
name: str
|
|
167
|
+
email: NotRequired[str]
|
doc0/util.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Callable, Iterable, overload
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def validate_theme(theme: str) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Validate the provided theme for Sphinx documentation.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
theme (str): The name of the Sphinx theme to validate.
|
|
11
|
+
|
|
12
|
+
Raises
|
|
13
|
+
ValueError: If the provided theme is not a valid Sphinx theme.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
>>> validate_theme("alabaster")
|
|
17
|
+
'alabaster'
|
|
18
|
+
>>> validate_theme("bad theme")
|
|
19
|
+
Traceback (most recent call last):
|
|
20
|
+
...
|
|
21
|
+
ValueError: 'bad theme' is not a valid Sphinx theme.
|
|
22
|
+
"""
|
|
23
|
+
parts = theme.split(".")
|
|
24
|
+
for part in parts:
|
|
25
|
+
if not part.isidentifier():
|
|
26
|
+
raise ValueError(f"'{theme}' is not a valid Sphinx theme.")
|
|
27
|
+
|
|
28
|
+
return theme
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@overload
|
|
32
|
+
def maybe_map[T, R](fn: Callable[[T], R], value: T | None, /) -> R | None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@overload
|
|
36
|
+
def maybe_map[T, R](fn: Callable[[T], R], /) -> Callable[[T | None], R | None]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def maybe_map[T, R](fn: Callable[[T], R], /, *args: Any) -> Any:
|
|
40
|
+
"""
|
|
41
|
+
Apply a function to a value if it is not None, otherwise return None.
|
|
42
|
+
|
|
43
|
+
Curried.
|
|
44
|
+
"""
|
|
45
|
+
if args:
|
|
46
|
+
value = args[0]
|
|
47
|
+
if value is not None:
|
|
48
|
+
return fn(value)
|
|
49
|
+
else:
|
|
50
|
+
return lambda value: maybe_map(fn, value)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def first_existing(paths: Iterable[Path]) -> Path | None:
|
|
55
|
+
"""
|
|
56
|
+
Return the first existing path from the provided iterable of paths.
|
|
57
|
+
"""
|
|
58
|
+
for path in paths:
|
|
59
|
+
if path.exists():
|
|
60
|
+
return path
|
|
61
|
+
return None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: doc-zero
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Zero-configuration documentation generator for Python.
|
|
5
|
+
Author: Fábio Macêdo Mendes
|
|
6
|
+
Author-email: Fábio Macêdo Mendes <fabiomacedomendes@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Dist: myst-parser>=5.1.0
|
|
16
|
+
Requires-Dist: rich>=15.0.0
|
|
17
|
+
Requires-Dist: sphinx>=9.1.0
|
|
18
|
+
Requires-Dist: sphinx-autobuild>=2025.8.25
|
|
19
|
+
Requires-Dist: sphinx-mdinclude>=0.6.2
|
|
20
|
+
Requires-Dist: sphinx-rtd-theme>=3.1.0
|
|
21
|
+
Requires-Dist: typer>=0.27.0
|
|
22
|
+
Maintainer: Fábio Macêdo Mendes
|
|
23
|
+
Maintainer-email: Fábio Macêdo Mendes <fabiomacedomendes@gmail.com>
|
|
24
|
+
Requires-Python: >=3.13
|
|
25
|
+
Project-URL: Homepage, http://github.com/fabiommendes/zero-doc
|
|
26
|
+
Project-URL: Repository, http://github.com/fabiommendes/zero-doc
|
|
27
|
+
Project-URL: Documentation, https://zero-doc.readthedocs.io/
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# doc-zero
|
|
31
|
+
|
|
32
|
+
`doc-zero` streamlines the process of writing documentation for your project. It is
|
|
33
|
+
an opinionated and explicitly non-configurable tool that extracts information
|
|
34
|
+
from your Python codebase and generates nice documentation with minimal effort.
|
|
35
|
+
|
|
36
|
+
The main influence is the `elm` language tooling: no config, nice defaults and
|
|
37
|
+
it creates a very decent documentation out of the box. Rust has a similar
|
|
38
|
+
experience with RustDoc. In comparison, both Sphinx and MkDocs are very powerful
|
|
39
|
+
but somewhat clunky to use and configure.
|
|
40
|
+
|
|
41
|
+
## How does it work?
|
|
42
|
+
|
|
43
|
+
`doc0` introspect your codebase and creates a Sphinx project under the hood. In
|
|
44
|
+
practice, if you have a relatively modern Python project (i.e., it assumes the
|
|
45
|
+
existence of pyproject.toml) just type
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
$ doc0 build
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
in the project root and it will create and build the documentation under
|
|
52
|
+
`<project-root>/docs`. In `doc0`, all your documentation resides either in the
|
|
53
|
+
README.md file in your repository or inside the source code.
|
|
54
|
+
|
|
55
|
+
You can also type
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
$ doc0 serve
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
and
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
doc0 test
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
to run either the live server or to test the doctests inside the documentation.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Adding the documentation to your project
|
|
71
|
+
|
|
72
|
+
Doc0 assumes your project is already documented using docstrings and that
|
|
73
|
+
you have a README.md file in the project root. It will use those assets to
|
|
74
|
+
generate the documentation and the necessary configurations to make it buildable
|
|
75
|
+
with Sphinx and ready to be hosted to readthedocs.io.
|
|
76
|
+
|
|
77
|
+
The first step is to install `doc0` as a development dependency in your project.
|
|
78
|
+
You can do this by running
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
$ pip install doc0
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
or the equivalent command for the package manager of choice.
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Then, the following command generates the documentation:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
$ doc0 build
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`doc0` always creates a module documentation for your toplevel module. It will
|
|
94
|
+
also scan all sub-modules and generate a documentation page if they satisfy the
|
|
95
|
+
following conditions:
|
|
96
|
+
|
|
97
|
+
* The module is not private (i.e., it does not start with an underscore).
|
|
98
|
+
* The module has a docstring.
|
|
99
|
+
* The module defines a `__all__` variable that lists its public API.
|
|
100
|
+
|
|
101
|
+
`doc0` only includes the public API in the generated documentation.
|
|
102
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
doc0/__init__.py,sha256=V8M8QzlEMiXC2LCbJXP3bIZawLl-6j3Y-O4g-rlrvGU,200
|
|
2
|
+
doc0/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
|
|
3
|
+
doc0/base.py,sha256=lyk28CqvYRGJYZy5IW0ZmgD6439cwZhvpHgNipH9Ny0,12917
|
|
4
|
+
doc0/cli.py,sha256=XjoF-hyLmA9AmWFsu1nmFWqi0_eDHGEYIpS9_be6f3g,1909
|
|
5
|
+
doc0/exports.py,sha256=jYO9M9Yr379lJ_qbIKHMs9c1J4WWclCUPvAm8uYWUD0,8459
|
|
6
|
+
doc0/module.py,sha256=xpTp01MNv4yl8rFG-2-f7c7_vmv3vo7IjDSAjTeXvg8,5520
|
|
7
|
+
doc0/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
doc0/pyproject.py,sha256=bjFhi6k0nfm0usDDmJ_WW9HTNkMGvqpkZt_6Q3jjNzY,5667
|
|
9
|
+
doc0/util.py,sha256=2d8Kqf0dHKa7A-Z_yqnvOCu4G6hGxWsMvyR1rjOLfmc,1530
|
|
10
|
+
doc_zero-0.1.1.dist-info/WHEEL,sha256=4OL6Foqnnp3xRY5wMkjgc25_i5YJC6dKsC6LPcjqEoU,80
|
|
11
|
+
doc_zero-0.1.1.dist-info/entry_points.txt,sha256=2xR_j_sMlnOjy6dEWy0t_pfWU8Hp1UhNEDlWjhy7CEk,65
|
|
12
|
+
doc_zero-0.1.1.dist-info/METADATA,sha256=bO7c0mYXUL59x5qCesB6qCJse2dlD7TmsZdybeYtFtc,3354
|
|
13
|
+
doc_zero-0.1.1.dist-info/RECORD,,
|