doc-zero 0.2.1__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doc-zero
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Zero-configuration documentation generator for Python.
5
5
  Author: Fábio Macêdo Mendes
6
6
  Author-email: Fábio Macêdo Mendes <fabiomacedomendes@gmail.com>
@@ -29,6 +29,11 @@ Description-Content-Type: text/markdown
29
29
 
30
30
  # doc-zero
31
31
 
32
+ [![PyPI](https://img.shields.io/pypi/v/doc-zero.svg)](https://pypi.org/project/doc-zero/)
33
+ [![CI](https://github.com/fabiommendes/doc0/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fabiommendes/doc0/actions/workflows/ci.yml)
34
+ [![Documentation Status](https://readthedocs.org/projects/doc-zero/badge/?version=latest)](https://doc-zero.readthedocs.io/en/latest/)
35
+ [![Coverage Status](https://coveralls.io/repos/github/fabiommendes/doc0/badge.svg?branch=main)](https://coveralls.io/github/fabiommendes/doc0?branch=main)
36
+
32
37
  **Doc-zero** streamlines the process of writing documentation for your project. It is
33
38
  an opinionated and explicitly non-configurable tool that extracts information
34
39
  from your Python codebase and generates nice documentation with minimal effort.
@@ -100,3 +105,5 @@ following conditions:
100
105
 
101
106
  `doc-zero` only includes the public API in the generated documentation.
102
107
 
108
+
109
+ ## Adding extra documentation
@@ -1,5 +1,10 @@
1
1
  # doc-zero
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/doc-zero.svg)](https://pypi.org/project/doc-zero/)
4
+ [![CI](https://github.com/fabiommendes/doc0/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fabiommendes/doc0/actions/workflows/ci.yml)
5
+ [![Documentation Status](https://readthedocs.org/projects/doc-zero/badge/?version=latest)](https://doc-zero.readthedocs.io/en/latest/)
6
+ [![Coverage Status](https://coveralls.io/repos/github/fabiommendes/doc0/badge.svg?branch=main)](https://coveralls.io/github/fabiommendes/doc0?branch=main)
7
+
3
8
  **Doc-zero** streamlines the process of writing documentation for your project. It is
4
9
  an opinionated and explicitly non-configurable tool that extracts information
5
10
  from your Python codebase and generates nice documentation with minimal effort.
@@ -71,3 +76,5 @@ following conditions:
71
76
 
72
77
  `doc-zero` only includes the public API in the generated documentation.
73
78
 
79
+
80
+ ## Adding extra documentation
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from importlib.metadata import version as module_version
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Iterator, TypedDict
8
+
9
+ from .module import Module, find_public_modules
10
+ from .pyproject import PyProject
11
+ from .readme import readme_body
12
+ from .theme import resolve_theme
13
+ from .tree import DocFile, DocTree, WritePolicy
14
+ from .util import first_existing
15
+
16
+ type ModuleName = str
17
+
18
+ NOT_GIVEN: Any = object()
19
+ COPYRIGHT_RE = re.compile(
20
+ r"[cC]opyright\s+(?:\(c\)\s+)?(?P<year>\d+)\s*(:?,?\s+(?P<author>[^\n]+))?"
21
+ )
22
+ DEFAULT_EXTENSIONS = [
23
+ "sphinx.ext.autodoc",
24
+ "sphinx_mdinclude",
25
+ # "myst_parser",
26
+ ]
27
+ README_PLACEHOLDER = (
28
+ "This is the documentation for {name}. "
29
+ "Please include a README.md file in the documentation root directory."
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
+
57
+ @dataclass
58
+ class Doc0:
59
+ """
60
+ The root type representing the documentation of your project.
61
+ """
62
+
63
+ #: The pyproject.toml file for the project.
64
+ pyproject: PyProject
65
+
66
+ #: Base location for the documentation assets
67
+ doc_root: Path
68
+
69
+ #: The resolved Sphinx theme name to use for the documentation.
70
+ theme: str
71
+
72
+ @classmethod
73
+ def load(
74
+ cls,
75
+ root: Path | None = None,
76
+ /,
77
+ *,
78
+ theme: str | None = None,
79
+ docs: str = "docs",
80
+ ) -> Doc0:
81
+ """
82
+ Load project in the given path.
83
+
84
+ ``theme`` is the CLI-provided value, if any; the final theme is
85
+ resolved via ``doc0.theme.resolve_theme`` (CLI value, else
86
+ ``[tool.doc-zero] theme`` in pyproject.toml, else the default).
87
+ """
88
+ root = root or Path.cwd()
89
+ pyproject = PyProject(root=root)
90
+ resolved_theme = resolve_theme(theme, pyproject)
91
+
92
+ return Doc0(
93
+ doc_root=root / docs,
94
+ pyproject=pyproject,
95
+ theme=resolved_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
+ Generate the documentation content and write it to disk.
108
+ """
109
+ self.generate().write()
110
+
111
+ def generate(self) -> DocTree:
112
+ """
113
+ Compute the documentation tree as data, without writing anything to
114
+ disk (no directory is created, no file is read except the project's
115
+ inputs: pyproject.toml, README.md, LICENSE, and the source modules).
116
+ """
117
+ pyproject = self.pyproject
118
+ root = self.root
119
+ doc_root = self.doc_root
120
+
121
+ public_modules = find_public_modules(pyproject.find_root_modules())
122
+
123
+ conf = Conf.from_pyproject(pyproject, theme=self.theme, license_text=_read_license(root))
124
+ index = Index(
125
+ name=pyproject.name,
126
+ tutorials=_select_diataxis_entry(doc_root, "tutorial"),
127
+ how_to_guides=_select_diataxis_entry(doc_root, "how-to-guide"),
128
+ user_guides=_select_diataxis_entry(doc_root, "user-guide"),
129
+ explanations=_select_diataxis_entry(doc_root, "explanation"),
130
+ concepts=_select_diataxis_entry(doc_root, "concept"),
131
+ api_modules=[module.name for module in public_modules],
132
+ )
133
+
134
+ files: dict[Path, DocFile] = {
135
+ doc_root / "conf.py": DocFile(conf.render()),
136
+ doc_root / "index.rst": DocFile(index.render()),
137
+ doc_root / "_readme.md": DocFile(_readme_content(root, pyproject.name)),
138
+ doc_root / "api" / "_index.rst": DocFile(render_modules_index(public_modules)),
139
+ }
140
+ for module in public_modules:
141
+ files[doc_root / "api" / f"{module.name}.rst"] = DocFile(module.render())
142
+
143
+ docs_rel = doc_root.relative_to(root).as_posix()
144
+ files[doc_root / "requirements.txt"] = DocFile(
145
+ f"doc-zero>={module_version('doc-zero')}", policy=WritePolicy.IF_MISSING
146
+ )
147
+ files[root / ".readthedocs.yml"] = DocFile(
148
+ READTHEDOCS_TEMPLATE.format(docs=docs_rel), policy=WritePolicy.IF_MISSING
149
+ )
150
+
151
+ return DocTree(
152
+ files=files,
153
+ dirs=[doc_root / "_static"],
154
+ owned_dirs=[doc_root / "api"],
155
+ )
156
+
157
+ def build(self) -> int:
158
+ """
159
+ Build the documentation using sphinx.
160
+
161
+ Returns Sphinx's exit status: 0 on success.
162
+ """
163
+ from sphinx.cmd.build import main
164
+
165
+ self.init()
166
+ return main([str(self.doc_root), str(self.root / "dist" / "docs")])
167
+
168
+ def serve(self) -> None:
169
+ """
170
+ Start the live server.
171
+ """
172
+ from sphinx_autobuild.__main__ import main
173
+
174
+ self.init()
175
+ main([str(self.doc_root), str(self.root / "dist" / "docs")])
176
+
177
+ def test(self) -> None:
178
+ """
179
+ Execute all doctests.
180
+ """
181
+
182
+
183
+ @dataclass
184
+ class Index:
185
+ """
186
+ Content of the index.rst file.
187
+ """
188
+
189
+ name: str
190
+
191
+ # It uses the framework described at https://diataxis.fr
192
+ tutorials: Path | None = None
193
+ how_to_guides: Path | None = None
194
+ explanations: Path | None = None
195
+ user_guides: Path | None = None
196
+
197
+ # Reference is concepts + api documentation
198
+ concepts: Path | None = None
199
+ api_modules: list[str] = field(default_factory=list)
200
+
201
+ def render(self) -> str:
202
+ """
203
+ Render the index.rst file.
204
+ """
205
+ return "\n".join(self._iter_lines())
206
+
207
+ def _iter_lines(self) -> Iterator[str]:
208
+ yield f"Welcome to the {self.name} documentation!"
209
+ yield "=" * (len(self.name) + 30)
210
+ yield from [
211
+ ".. mdinclude:: _readme.md",
212
+ "",
213
+ "",
214
+ "Table of contents",
215
+ "-----------------",
216
+ "",
217
+ ".. toctree::",
218
+ " :maxdepth: 3",
219
+ "",
220
+ ]
221
+
222
+ if self.tutorials:
223
+ yield f" {self.tutorials.stem}"
224
+ if self.how_to_guides:
225
+ yield f" {self.how_to_guides.stem}"
226
+ if self.user_guides:
227
+ yield f" {self.user_guides.stem}"
228
+ if self.explanations:
229
+ yield f" {self.explanations.stem}"
230
+ if self.concepts:
231
+ yield f" {self.concepts.stem}"
232
+ if self.api_modules:
233
+ yield " api/_index"
234
+
235
+
236
+ @dataclass
237
+ class Conf:
238
+ """
239
+ Information to build the conf.py file.
240
+ """
241
+
242
+ project: str | None = None
243
+ author: str | None = None
244
+ email: str | None = None
245
+ year: int | None = None
246
+ extensions: list[str] = field(default_factory=DEFAULT_EXTENSIONS.copy)
247
+ theme: str = "default"
248
+
249
+ @staticmethod
250
+ def from_pyproject(
251
+ pyproject: PyProject,
252
+ /,
253
+ *,
254
+ theme: str,
255
+ license_text: str | None = None,
256
+ ) -> Conf:
257
+ """
258
+ Create a Conf object from a PyProject object and (optionally) the
259
+ text of the project's LICENSE file.
260
+
261
+ The first pyproject author's name/email are used, if any. The year
262
+ and, absent a pyproject author, the author name are taken from the
263
+ LICENSE's copyright notice, if one can be found. A LICENSE without a
264
+ recognizable copyright notice is not an error: the year is simply
265
+ omitted and the author falls back to "unknown author" at render
266
+ time.
267
+ """
268
+ project = pyproject.name
269
+ author: str | None = None
270
+ email: str | None = None
271
+ year: int | None = None
272
+
273
+ # Extract author information from the pyproject.toml file
274
+ try:
275
+ author_data = pyproject.authors[0]
276
+ author = author_data["name"]
277
+ email = author_data.get("email")
278
+ except (TypeError, IndexError): # empty authors list or invalid data
279
+ pass
280
+
281
+ # Read the year (and, absent a pyproject author, the author) from
282
+ # the Copyright notice in the LICENSE file.
283
+ if license_text is not None:
284
+ try:
285
+ copyright = find_copyright(license_text)
286
+ year = int(copyright["year"])
287
+ if author is None:
288
+ author = copyright["author"]
289
+ except ValueError:
290
+ pass
291
+
292
+ return Conf(
293
+ project=project,
294
+ author=author,
295
+ email=email,
296
+ year=year,
297
+ theme=theme,
298
+ )
299
+
300
+ def render(self) -> str:
301
+ return "\n".join(self._iter_lines())
302
+
303
+ def _iter_lines(self) -> Iterator[str]:
304
+ copyright = f"{self.year}, " if self.year else ""
305
+ copyright += self.author or "unknown author"
306
+ author = self.author or "unknown author"
307
+ if self.email:
308
+ author += f" <{self.email}>"
309
+
310
+ yield f"project = {self.project or 'unnamed project'!r}"
311
+ yield f"copyright = {copyright!r}"
312
+ yield f"author = {author!r}"
313
+ yield f"extensions = {self.extensions!r}"
314
+ yield "templates_path = ['_templates']"
315
+ yield f"html_theme = {self.theme!r}"
316
+ yield "html_static_path = ['_static']"
317
+ yield "exclude_patterns = ['_readme.md', 'requirements.txt']"
318
+
319
+
320
+ class Copyright(TypedDict):
321
+ year: int
322
+ author: str | None
323
+
324
+
325
+ def find_copyright(src: str) -> Copyright:
326
+ """
327
+ Find the copyright notice in the given source code.
328
+ """
329
+ match = COPYRIGHT_RE.search(src)
330
+ if not match:
331
+ raise ValueError("Copyright notice not found")
332
+ return {
333
+ "year": int(match.group("year")),
334
+ "author": match.group("author"),
335
+ }
336
+
337
+
338
+ def render_modules_index(modules: Iterable[Module]) -> str:
339
+ """
340
+ Render the index.rst file for the API documentation.
341
+ """
342
+ lines = [
343
+ "Modules",
344
+ "=======",
345
+ "",
346
+ ".. toctree::",
347
+ " :maxdepth: 2",
348
+ " :caption: Contents:",
349
+ "",
350
+ ]
351
+ for module in modules:
352
+ lines.append(f" {module.name}")
353
+ return "\n".join(lines)
354
+
355
+
356
+ def _select_diataxis_entry(doc_root: Path, name: str, plural: str | None = None) -> Path | None:
357
+ """
358
+ Select the first existing diataxis entry for ``name`` under
359
+ ``doc_root``: the plural directory, then ``<name>.rst``, then
360
+ ``<name>.md``.
361
+ """
362
+ plural = plural or f"{name}s"
363
+ return first_existing(
364
+ [
365
+ doc_root / plural,
366
+ doc_root / f"{name}.rst",
367
+ doc_root / f"{name}.md",
368
+ ]
369
+ )
370
+
371
+
372
+ def _read_license(root: Path) -> str | None:
373
+ """
374
+ Return the text of ``<root>/LICENSE``, or None if it doesn't exist.
375
+ """
376
+ license_path = root / "LICENSE"
377
+ if license_path.exists():
378
+ return license_path.read_text()
379
+ return None
380
+
381
+
382
+ def _readme_content(root: Path, project_name: str) -> str:
383
+ """
384
+ Compute the content of ``_readme.md`` from ``<root>/README.md``.
385
+
386
+ If the README is missing, a placeholder mentioning the project name is
387
+ used. Otherwise, see ``readme_body`` for what is kept.
388
+ """
389
+ readme_path = root / "README.md"
390
+ if not readme_path.exists():
391
+ return README_PLACEHOLDER.format(name=project_name)
392
+ return readme_body(readme_path.read_text())
@@ -10,7 +10,7 @@ from typing import Annotated, Any
10
10
  import typer
11
11
 
12
12
  from .base import Doc0
13
- from .util import maybe_map, validate_theme
13
+ from .theme import ThemeError
14
14
 
15
15
  __all__ = [
16
16
  "main",
@@ -26,6 +26,29 @@ app = typer.Typer(
26
26
  no_args_is_help=True,
27
27
  )
28
28
 
29
+ #: The `--theme` option shared by `build` and `serve`. Validation happens in
30
+ #: `Doc0.load` (via `doc0.theme.resolve_theme`), not here, so an invalid
31
+ #: value -- from the flag or from pyproject.toml -- is reported the same way.
32
+ ThemeOption = Annotated[str | None, typer.Option("--theme", help="Select the Sphinx theme")]
33
+
34
+
35
+ def _load(theme: str | None) -> Doc0:
36
+ """
37
+ Load the current project, converting an invalid theme into a clean
38
+ CLI usage error (exit code 2, no traceback) instead of a bare
39
+ ValueError.
40
+
41
+ ``typer.BadParameter`` is used rather than ``click.UsageError``: this
42
+ typer version vendors its own click fork internally (``typer._click``)
43
+ and only recognizes exceptions from that fork, not from the standalone
44
+ ``click`` package. ``typer.BadParameter`` is a public re-export of that
45
+ fork's ``UsageError`` subclass, so it's caught the same way.
46
+ """
47
+ try:
48
+ return Doc0.load(Path.cwd(), theme=theme)
49
+ except ThemeError as exc:
50
+ raise typer.BadParameter(str(exc)) from exc
51
+
29
52
 
30
53
  @app.command()
31
54
  def test() -> None:
@@ -37,40 +60,21 @@ def test() -> None:
37
60
 
38
61
 
39
62
  @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:
63
+ def build(theme: ThemeOption = None) -> None:
51
64
  """
52
65
  Build the documentation for the current project.
53
66
  """
54
- doc = Doc0.load(Path.cwd(), theme=theme)
55
- doc.build()
67
+ doc = _load(theme)
68
+ if status := doc.build():
69
+ raise typer.Exit(status)
56
70
 
57
71
 
58
72
  @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:
73
+ def serve(theme: ThemeOption = None) -> None:
70
74
  """
71
75
  Serve the documentation in the live server.
72
76
  """
73
- doc = Doc0.load(Path.cwd(), theme=theme)
77
+ doc = _load(theme)
74
78
  doc.serve()
75
79
 
76
80