From 52351a67411ef7d9c212f7cf492ec0d4689eac6b Mon Sep 17 00:00:00 2001 From: Jonathan Sick Date: Tue, 8 Nov 2022 15:35:43 -0500 Subject: [PATCH] Configure Sphinx via technote.toml This establishes the basic system for configuring the sphinx build using technote.toml. The technote.sphinxconf module provides the base Sphinx configuration for conf.py. The TechnoteSphinxConfig class contains an instance of the TechnoteTable model representing the technote.toml file, and also provides helper methods for setting variables in the conf.py/technote.sphinxconf context. --- demo/conf.py | 5 +- demo/technote.toml | 16 ++++ src/technote/config.py | 154 ++++++++++++++++++++++++++++++++++++- src/technote/sphinxconf.py | 77 +++++++++++++++++++ tests/config_test.py | 9 +-- 5 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 demo/technote.toml create mode 100644 src/technote/sphinxconf.py diff --git a/demo/conf.py b/demo/conf.py index b865442..df0d3fe 100644 --- a/demo/conf.py +++ b/demo/conf.py @@ -1,4 +1 @@ -project = "Demo technote" -author = "Rubin Observatory" -exclude_patterns = ["_build", "README.rst", "README.md", "Makefile"] -html_theme = "technote" +from technote.sphinxconf import * # noqa: F401 F403 diff --git a/demo/technote.toml b/demo/technote.toml new file mode 100644 index 0000000..a4c97f9 --- /dev/null +++ b/demo/technote.toml @@ -0,0 +1,16 @@ +[technote] +id = "SQR-000" +series_id = "SQR" +title = "The technical note system" +canonical_url = "https://sqr-000.lsst.io" +github_url = "https://github.com/lsst-sqre/sqr-000" +github_default_branch = "master" +date_created = "2015-11-18" +date_updated = "2015-11-23" + +[[technote.authors]] +name = { given_names = "Jonathan", family_names = "Sick" } +orcid = "https://orcid.org/0000-0003-3001-676X" +affiliations = [ + { name = "Rubin Observatory", ror = "https://ror.org/048g3cy84" } +] diff --git a/src/technote/config.py b/src/technote/config.py index 191c28d..808c3ba 100644 --- a/src/technote/config.py +++ b/src/technote/config.py @@ -16,9 +16,17 @@ from __future__ import annotations import re +import sys +from dataclasses import dataclass from datetime import date from enum import Enum -from typing import Any, Dict, List, Optional, Tuple +from pathlib import Path +from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union + +if sys.version_info < (3, 11): + import tomli as tomllib +else: + import tomllib from pydantic import ( BaseModel, @@ -26,9 +34,11 @@ Extra, Field, HttpUrl, + ValidationError, root_validator, validator, ) +from sphinx.errors import ConfigError from .metadata.orcid import Orcid from .metadata.ror import Ror @@ -105,7 +115,15 @@ class SphinxTable(BaseModel): description="Additional Sphinx extensions to use in the build.", ) - intersphinx: IntersphinxTable + intersphinx: IntersphinxTable = Field( + default_factory=IntersphinxTable, + description="Intersphinx configurations.", + ) + + linkcheck: LinkcheckTable = Field( + default_factory=LinkcheckTable, + description="Link check builder settings.", + ) class Organization(BaseModel): @@ -174,6 +192,14 @@ class PersonName(BaseModel): ), ) + @property + def plain_text_name(self) -> str: + """The name in plain text.""" + if self.name is not None: + return self.name + else: + return f"{self.given_names} {self.family_names}" + @validator("family_names", "given_names", "name") def clean_whitespace(cls, v: Optional[str]) -> Optional[str]: if v: @@ -416,10 +442,132 @@ class TechnoteTable(BaseModel): default_factory=list, ) - sphinx: SphinxTable = Field(default_factory=lambda: SphinxTable) + sphinx: SphinxTable = Field(default_factory=SphinxTable) class TechnoteToml(BaseModel, extra=Extra.ignore): """A model of a ``technote.toml`` configuration file.""" technote: TechnoteTable + + @classmethod + def parse_toml(cls, content: str) -> TechnoteToml: + """Load a ``technote.toml`` file from the project directory. + + Parameters + ---------- + content + The string content of a ``technote.toml`` file. + + Returns + ------- + TechnoteToml + The parsed `TechnoteToml`. + """ + return cls.parse_obj(tomllib.loads(content)) + + +@dataclass +class TechnoteSphinxConfig: + """A wrapper around `TechnoteToml` that assists in setting Sphinx + configurations in a conf.py file (via `technote.sphinxconf`). + """ + + toml: TechnoteToml + """The parse ``technote.toml`` file.""" + + @classmethod + def find_and_load(cls) -> TechnoteSphinxConfig: + """Find the ``technote.toml`` file in the current Sphinx build and + load it. + + Returns + ------- + TechnoteSphinxConfig + The technote configuration, useful for configuring the Sphinx + project. + """ + path = Path("technote.toml") + if not path.is_file: + raise ConfigError("Cannot find the technote.toml file.") + return cls.load(path.read_text()) + + @classmethod + def load(cls, toml_content: str) -> TechnoteSphinxConfig: + """Load the content of a ``technote.toml`` file. + + Parameters + ---------- + content + The string content of a ``technote.toml`` file. + + Returns + ------- + TechnoteSphinxConfig + The sphinx configuration wrapper class around `TechnoteToml`. + """ + try: + parsed_toml = TechnoteToml.parse_toml(toml_content) + except ValidationError as e: + message = ( + f"Syntax or validation issue in technote.toml:\n\n" f"{str(e)}" + ) + raise ConfigError(message) + + return cls(toml=parsed_toml) + + @property + def title(self) -> str: + if self.toml.technote.title: + return self.toml.technote.title + else: + return "FIXME introspect title" + + @property + def author(self) -> str: + if self.toml.technote.authors: + return ", ".join( + a.name.plain_text_name for a in self.toml.technote.authors + ) + else: + return "" + + def append_extensions(self, extensions: List[str]) -> None: + """Append user-configured extensions to an existing list.""" + for new_ext in self.toml.technote.sphinx.extensions: + if new_ext not in extensions: + extensions.append(new_ext) + + def extend_intersphinx_mapping( + self, mapping: MutableMapping[str, Tuple[str, Union[str, None]]] + ) -> None: + """Extend the ``intersphinx_mapping`` dictionary with configured + projects. + """ + for ( + project, + url, + ) in self.toml.technote.sphinx.intersphinx.projects.items(): + mapping[project] = (str(url), None) + + def append_linkcheck_ignore(self, link_patterns: List[str]) -> None: + """Append URL patterns for sphinx.linkcheck.ignore to existing + patterns. + """ + link_patterns.extend(self.toml.technote.sphinx.linkcheck.ignore) + + def append_nitpick_ignore( + self, nitpick_ignore: List[Tuple[str, str]] + ) -> None: + nitpick_ignore.extend(self.toml.technote.sphinx.nitpick_ignore) + + def append_nitpick_ignore_regex( + self, nitpick_ignore_regex: List[Tuple[str, str]] + ) -> None: + nitpick_ignore_regex.extend( + self.toml.technote.sphinx.nitpick_ignore_regex + ) + + @property + def nitpicky(self) -> bool: + return self.toml.technote.sphinx.nitpicky diff --git a/src/technote/sphinxconf.py b/src/technote/sphinxconf.py new file mode 100644 index 0000000..5a73900 --- /dev/null +++ b/src/technote/sphinxconf.py @@ -0,0 +1,77 @@ +"""Sphinx configuration for technotes. + +To use this configuration in a Technote project, write a conf.py containing:: + + from technote.sphinxconf import * +""" + +from __future__ import annotations + +from typing import Dict, List, Tuple, Union + +from .config import TechnoteSphinxConfig + +# Restrict to only Sphinx configurations +__all__ = [ + # SPHINX + "project", + "author", + "exclude_patterns", + "html_theme", + "extensions", + "nitpicky", + "nitpick_ignore", + "nitpick_ignore_regex", + # INTERSPHINX + "intersphinx_mapping", + "intersphinx_timeout", + "intersphinx_cache_limit", + # LINKCHECK + "linkcheck_retries", + "linkcheck_timeout", + "linkcheck_ignore", +] + +_t = TechnoteSphinxConfig.find_and_load() + +# ============================================================================ +# SPHINX General sphinx settings +# ============================================================================ + +project = _t.title +author = _t.author +exclude_patterns = ["_build", "README.rst", "README.md", "Makefile"] +html_theme = "technote" + +extensions: List[str] = [] +_t.append_extensions(extensions) + +# Nitpicky settings and ignored errors +nitpicky = _t.nitpicky + +nitpick_ignore: List[Tuple[str, str]] = [] +_t.append_nitpick_ignore(nitpick_ignore) + +nitpick_ignore_regex: List[Tuple[str, str]] = [] +_t.append_nitpick_ignore_regex(nitpick_ignore_regex) + +# ============================================================================ +# INTERSPHINX Intersphinx settings +# ============================================================================ + +intersphinx_mapping: Dict[str, Tuple[str, Union[str, None]]] = {} +_t.extend_intersphinx_mapping(intersphinx_mapping) + +intersphinx_timeout = 10.0 # seconds + +intersphinx_cache_limit = 5 # days + + +# ============================================================================ +# LINKCHECK Link check builder settings +# ============================================================================ + +linkcheck_retries = 2 +linkcheck_timeout = 15 +linkcheck_ignore: List[str] = [] +_t.append_linkcheck_ignore(linkcheck_ignore) diff --git a/tests/config_test.py b/tests/config_test.py index f40c973..3e42b7f 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -2,13 +2,6 @@ from __future__ import annotations -import sys - -if sys.version_info < (3, 11): - import tomli as tomllib -else: - import tomllib - from technote.config import TechnoteToml sample_toml = """ @@ -34,5 +27,5 @@ def test_toml_parsing() -> None: """Test TechnoteToml by parsing a sample document that should be well-formatted. """ - technote_toml = TechnoteToml.parse_obj(tomllib.loads(sample_toml)) + technote_toml = TechnoteToml.parse_toml(sample_toml) assert technote_toml.technote.id == "SQR-000"