doc-zero 0.1.1__tar.gz

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.
@@ -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,73 @@
1
+ # doc-zero
2
+
3
+ `doc-zero` streamlines the process of writing documentation for your project. It is
4
+ an opinionated and explicitly non-configurable tool that extracts information
5
+ from your Python codebase and generates nice documentation with minimal effort.
6
+
7
+ The main influence is the `elm` language tooling: no config, nice defaults and
8
+ it creates a very decent documentation out of the box. Rust has a similar
9
+ experience with RustDoc. In comparison, both Sphinx and MkDocs are very powerful
10
+ but somewhat clunky to use and configure.
11
+
12
+ ## How does it work?
13
+
14
+ `doc0` introspect your codebase and creates a Sphinx project under the hood. In
15
+ practice, if you have a relatively modern Python project (i.e., it assumes the
16
+ existence of pyproject.toml) just type
17
+
18
+ ```bash
19
+ $ doc0 build
20
+ ```
21
+
22
+ in the project root and it will create and build the documentation under
23
+ `<project-root>/docs`. In `doc0`, all your documentation resides either in the
24
+ README.md file in your repository or inside the source code.
25
+
26
+ You can also type
27
+
28
+ ```bash
29
+ $ doc0 serve
30
+ ```
31
+
32
+ and
33
+
34
+ ```bash
35
+ doc0 test
36
+ ```
37
+
38
+ to run either the live server or to test the doctests inside the documentation.
39
+
40
+
41
+ ## Adding the documentation to your project
42
+
43
+ Doc0 assumes your project is already documented using docstrings and that
44
+ you have a README.md file in the project root. It will use those assets to
45
+ generate the documentation and the necessary configurations to make it buildable
46
+ with Sphinx and ready to be hosted to readthedocs.io.
47
+
48
+ The first step is to install `doc0` as a development dependency in your project.
49
+ You can do this by running
50
+
51
+ ```bash
52
+ $ pip install doc0
53
+ ```
54
+
55
+ or the equivalent command for the package manager of choice.
56
+
57
+
58
+ Then, the following command generates the documentation:
59
+
60
+ ```bash
61
+ $ doc0 build
62
+ ```
63
+
64
+ `doc0` always creates a module documentation for your toplevel module. It will
65
+ also scan all sub-modules and generate a documentation page if they satisfy the
66
+ following conditions:
67
+
68
+ * The module is not private (i.e., it does not start with an underscore).
69
+ * The module has a docstring.
70
+ * The module defines a `__all__` variable that lists its public API.
71
+
72
+ `doc0` only includes the public API in the generated documentation.
73
+
@@ -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"]
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -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")