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 ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Base core module for the doc0 package.
3
+ """
4
+
5
+ from .base import Doc0
6
+ from .module import Module, ModuleSpec
7
+ from .pyproject import PyProject
8
+
9
+ __all__ = ["Doc0", "Module", "PyProject", "ModuleSpec"]
doc0/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ doc-zero = doc0.cli:main
3
+ doc0 = doc0.cli:main
4
+