Source code for sphinx.project
"""Utility function and classes for Sphinx projects."""
from __future__ import annotations
import contextlib
import os
from pathlib import Path
from typing import TYPE_CHECKING
from sphinx.locale import __
from sphinx.util import logging
from sphinx.util._pathlib import _StrPath
from sphinx.util.matching import get_matching_files
from sphinx.util.osutil import path_stabilize
if TYPE_CHECKING:
from collections.abc import Iterable
logger = logging.getLogger(__name__)
EXCLUDE_PATHS = ['**/_sources', '.#*', '**/.#*', '*.lproj/**']
[docs]
class Project:
"""A project is the source code set of the Sphinx document(s)."""
def __init__(
self, srcdir: str | os.PathLike[str], source_suffix: Iterable[str]
) -> None:
#: Source directory.
self.srcdir = _StrPath(srcdir)
#: source_suffix. Same as :confval:`source_suffix`.
self.source_suffix = tuple(source_suffix)
self._first_source_suffix = next(iter(self.source_suffix), '')
#: The name of documents belonging to this project.
self.docnames: set[str] = set()
# Bijective mapping between docnames and (srcdir relative) paths.
self._path_to_docname: dict[Path, str] = {}
self._docname_to_path: dict[str, Path] = {}
[docs]
def restore(self, other: Project) -> None:
"""Take over a result of last build."""
self.docnames = other.docnames
self._path_to_docname = other._path_to_docname
self._docname_to_path = other._docname_to_path
[docs]
def discover(
self, exclude_paths: Iterable[str] = (), include_paths: Iterable[str] = ('**',)
) -> set[str]:
"""Find all document files in the source directory and put them in
:attr:`docnames`.
"""
self.docnames.clear()
self._path_to_docname.clear()
self._docname_to_path.clear()
for filename in get_matching_files(
self.srcdir,
include_paths,
[*exclude_paths, *EXCLUDE_PATHS],
):
if docname := self.path2doc(filename):
if docname in self.docnames:
files = [
str(f.relative_to(self.srcdir))
for f in self.srcdir.glob(f'{docname}.*')
]
logger.warning(
__(
'multiple files found for the document "%s": %s\n'
'Use %r for the build.'
),
docname,
', '.join(files),
self.doc2path(docname, absolute=True),
once=True,
)
elif os.access(self.srcdir / filename, os.R_OK):
self.docnames.add(docname)
path = Path(filename)
self._path_to_docname[path] = docname
self._docname_to_path[docname] = path
else:
logger.warning(
__('Ignored unreadable document %r.'),
filename,
location=docname,
)
return self.docnames
[docs]
def path2doc(self, filename: str | os.PathLike[str]) -> str | None:
"""Return the docname for the filename if the file is a document.
*filename* should be absolute or relative to the source directory.
"""
path = Path(filename)
try:
return self._path_to_docname[path]
except KeyError:
if path.is_absolute():
with contextlib.suppress(ValueError):
path = path.relative_to(self.srcdir)
for suffix in self.source_suffix:
if path.name.endswith(suffix):
return path_stabilize(path).removesuffix(suffix)
# the file does not have a docname
return None
[docs]
def doc2path(self, docname: str, absolute: bool) -> _StrPath:
"""Return the filename for the document name.
If *absolute* is True, return as an absolute path.
Else, return as a relative path to the source directory.
"""
try:
filename = self._docname_to_path[docname]
except KeyError:
# Backwards compatibility: the document does not exist
filename = Path(docname + self._first_source_suffix)
if absolute:
return _StrPath(self.srcdir / filename)
return _StrPath(filename)