From 1359c037170051920adb9062d6267be730f53088 Mon Sep 17 00:00:00 2001 From: Cliff Kerr Date: Fri, 17 Apr 2026 18:04:48 -0400 Subject: [PATCH 1/2] feat: auto-populate contents if not provided --- quartodoc/_pydantic_compat.py | 3 +- quartodoc/builder/blueprint.py | 74 ++++++++++++++++++++++++++++++++++ quartodoc/layout.py | 8 +++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/quartodoc/_pydantic_compat.py b/quartodoc/_pydantic_compat.py index 17907ec3..c3cf259c 100644 --- a/quartodoc/_pydantic_compat.py +++ b/quartodoc/_pydantic_compat.py @@ -5,6 +5,7 @@ Extra, PrivateAttr, ValidationError, + validator, ) # noqa except ImportError: - from pydantic import BaseModel, Field, Extra, PrivateAttr, ValidationError # noqa + from pydantic import BaseModel, Field, Extra, PrivateAttr, ValidationError, validator # noqa diff --git a/quartodoc/builder/blueprint.py b/quartodoc/builder/blueprint.py index e796a894..06e10bf1 100644 --- a/quartodoc/builder/blueprint.py +++ b/quartodoc/builder/blueprint.py @@ -1,9 +1,14 @@ from __future__ import annotations +import importlib.util import logging import json import yaml +from collections import OrderedDict +from pathlib import Path +from typing import Iterable, List, Optional, Set + from .._griffe_compat import dataclasses as dc from .._griffe_compat import ( GriffeLoader, @@ -44,6 +49,47 @@ from quartodoc._pydantic_compat import BaseModel +def _identify_files_to_document( + path: Path, + file_patterns: List[str], + ignore: Optional[Iterable[str]] = None, +) -> Set[Path]: + reversed_patterns = file_patterns.copy() + reversed_patterns.reverse() + + files_to_document: dict = OrderedDict() + for pattern in reversed_patterns: + for file in path.rglob(pattern=pattern): + files_to_document[file.with_suffix("")] = file + result = set(files_to_document.values()) + + if ignore: + for pattern in ignore: + result = result.difference(set(path.glob(pattern=pattern))) + + return {p.resolve() for p in result} + + +def _auto_contents_from_package(package_name: str) -> list[Auto]: + """Return Auto entries for every .py file in *package_name*, excluding __init__ files.""" + spec = importlib.util.find_spec(package_name) + if spec is None or not spec.submodule_search_locations: + return [] + + pkg_path = Path(list(spec.submodule_search_locations)[0]) + files = _identify_files_to_document( + pkg_path, + file_patterns=["*.py"], + ignore=["**/__init__.py", "**/__pycache__/**"], + ) + + contents = [] + for file in sorted(files): + parts = list(file.relative_to(pkg_path).with_suffix("").parts) + contents.append(Auto(name=".".join(parts))) + return contents + + def _auto_package(mod: dc.Module) -> list[Section]: """Create default sections for the given package.""" @@ -246,6 +292,34 @@ def enter(self, el: Layout): return super().enter(el) + @dispatch + def enter(self, el: Section): + if el.contents: + return el + + package = self.crnt_package + label = el.title or el.subtitle or "(untitled)" + + if not package: + _log.warning( + f"Section '{label}' has no contents and no package is configured." + " Cannot auto-populate contents." + ) + return el + + _log.warning( + f"Section '{label}' has no contents. Auto-populating from package '{package}'." + ) + + contents = _auto_contents_from_package(package) + if not contents: + _log.warning(f"No Python files found in package '{package}'.") + return el + + new = el.copy() + new.contents = contents + return super().enter(new) + @dispatch def exit(self, el: Section): """Transform top-level sections, so their contents are all Pages.""" diff --git a/quartodoc/layout.py b/quartodoc/layout.py index dcd8db62..a31d1d0c 100644 --- a/quartodoc/layout.py +++ b/quartodoc/layout.py @@ -7,7 +7,7 @@ from typing_extensions import Annotated from typing import Literal, Union, Optional -from ._pydantic_compat import BaseModel, Field, Extra, PrivateAttr +from ._pydantic_compat import BaseModel, Field, Extra, PrivateAttr, validator _log = logging.getLogger(__name__) @@ -124,6 +124,12 @@ class Page(_Structural): contents: ContentList + @validator("contents") + def _contents_not_empty(cls, v): + if not v: + raise ValueError("Page contents must not be empty.") + return v + @property def obj(self): # TODO: this is for the case where pages are put as members inside From ff34434d2bf2c05e03cf6d1db00275cbd2579054 Mon Sep 17 00:00:00 2001 From: Cliff Kerr Date: Fri, 17 Apr 2026 18:10:06 -0400 Subject: [PATCH 2/2] feat: autopopulate contents documentation --- docs/get-started/overview.qmd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/get-started/overview.qmd b/docs/get-started/overview.qmd index 656b0bf4..817160dc 100644 --- a/docs/get-started/overview.qmd +++ b/docs/get-started/overview.qmd @@ -161,6 +161,8 @@ quartodoc: The functions listed in `contents` are assumed to be imported from the package. +If no contents are provided, quartodoc will attempt to pull in all modules (i.e. `.py` files) from the package. + ## Learning more