Source code for projspec.proj.documentation

import os
import re

import toml
import yaml

from projspec.proj import ProjectSpec
from projspec.proj.base import ParseFailed
from projspec.utils import AttrDict, PickleableTomlDecoder


[docs] class MDBook(ProjectSpec): """mdBook is a command line tool to create books with Markdown. mdBook is used by the Rust programming language project, and The Rust Programming Language book is an example. """ icon = "📖" # to get generated docs output for a rust lib, use `rustdoc` # https://doc.rust-lang.org/rustdoc/what-is-rustdoc.html spec_doc = "https://rust-lang.github.io/mdBook/format/configuration/index.html" def match(self) -> bool: return "book.toml" in self.proj.basenames def parse(self) -> None: from projspec.artifact.base import FileArtifact from projspec.artifact.process import Server from projspec.content.metadata import DescriptiveMetadata try: with self.proj.get_file("book.toml", text=False) as f: cfg = toml.loads(f.read().decode(), decoder=PickleableTomlDecoder()) except (OSError, toml.TomlDecodeError) as exc: raise ParseFailed(f"Could not read book.toml: {exc}") from exc book = cfg.get("book", {}) meta: dict[str, str] = {} for key in ("title", "description", "language"): if val := book.get(key): meta[key] = str(val) authors = book.get("authors", []) if authors: meta["authors"] = ", ".join(authors) self._contents = AttrDict( descriptive_metadata=DescriptiveMetadata(proj=self.proj, meta=meta) ) # build-dir defaults to "book/" relative to the book root build_dir = cfg.get("build", {}).get("build-dir", "book") if not build_dir.startswith("/"): build_dir = f"{self.proj.url}/{build_dir}" arts = AttrDict() # mdbook build → produces static HTML in build-dir arts["book"] = FileArtifact( proj=self.proj, cmd=["mdbook", "build"], fn=f"{build_dir}/index.html", ) # mdbook serve → live-reloading local server arts["server"] = Server( proj=self.proj, cmd=["mdbook", "serve"], ) self._artifacts = arts @staticmethod def _create(path: str) -> None: """Scaffold a minimal but valid mdBook project.""" name = os.path.basename(path) # book.toml — required configuration file with open(f"{path}/book.toml", "wt") as f: f.write( f"[book]\n" f'title = "{name}"\n' f"authors = []\n" f'description = ""\n' f"\n" f"[build]\n" f'build-dir = "book"\n' ) # src/SUMMARY.md — required entry point os.makedirs(f"{path}/src", exist_ok=True) with open(f"{path}/src/SUMMARY.md", "wt") as f: f.write(f"# Summary\n\n- [Introduction](./introduction.md)\n") # src/introduction.md — first chapter with open(f"{path}/src/introduction.md", "wt") as f: f.write(f"# Introduction\n\nWelcome to {name}.\n")
[docs] class RTD(ProjectSpec): """Documentation to be processes by ReadTheDocs RTD is commonly used by open-source python projects and others. Documentation is typically built automatically from github repos using sphinx or mkdocs. General description of the platform: https://docs.readthedocs.com/platform/stable/ """ icon = "📖" spec_doc = "https://docs.readthedocs.com/platform/stable/config-file/v2.html" def match(self) -> bool: return any(re.match("[.]?readthedocs.y[a]?ml", _) for _ in self.proj.basenames) def parse(self) -> None: import yaml from projspec.artifact.base import FileArtifact from projspec.content.environment import Environment, Precision, Stack # Locate and read the config file cfg_name = next( _ for _ in self.proj.basenames if re.match("[.]?readthedocs.y[a]?ml", _) ) try: with self.proj.get_file(cfg_name) as f: cfg = yaml.safe_load(f) except (OSError, yaml.YAMLError) as exc: raise ParseFailed(f"Could not read {cfg_name}: {exc}") from exc if not isinstance(cfg, dict): raise ParseFailed(f"{cfg_name} did not parse to a mapping") conts = AttrDict() arts = AttrDict() conda_env_path = cfg.get("conda", {}).get("environment") if conda_env_path: try: with self.proj.fs.open(f"{self.proj.url}/{conda_env_path}", "rt") as f: env_data = yaml.safe_load(f) conts["environment"] = AttrDict( default=Environment( proj=self.proj, stack=Stack.CONDA, precision=Precision.SPEC, packages=env_data.get("dependencies", []), channels=env_data.get("channels", []), ) ) except (OSError, yaml.YAMLError): pass else: # Collect requirements files listed under python.install[*].requirements req_packages: list[str] = [] for install in cfg.get("python", {}).get("install", []): req_path = install.get("requirements") if not req_path: continue try: with self.proj.get_file(req_path, text=False) as f: lines = [ ln.strip() for ln in f.read().decode().splitlines() if ln.strip() and not ln.strip().startswith("#") ] req_packages.extend(lines) except OSError: pass if req_packages: # Add python version constraint if declared py_ver = cfg.get("build", {}).get("tools", {}).get("python") if py_ver: req_packages.append(f"python =={py_ver}.*") precision = ( Precision.LOCK if all( "==" in p for p in req_packages if not p.startswith("python") ) else Precision.SPEC ) conts["environment"] = AttrDict( default=Environment( proj=self.proj, stack=Stack.PIP, precision=precision, packages=req_packages, ) ) # NB: classically, the docs dir has Makefile enabling `make html`. if "sphinx" in cfg: conf_py = cfg["sphinx"].get("configuration", "docs/conf.py") docs_dir = conf_py.rsplit("/", 1)[0] if "/" in conf_py else "." arts["docs"] = FileArtifact( proj=self.proj, cmd=[ "sphinx-build", "-b", "html", docs_dir, f"{docs_dir}/_build/html", ], fn=f"{self.proj.url}/{docs_dir}/_build/html/index.html", ) elif "mkdocs" in cfg: arts["docs"] = FileArtifact( proj=self.proj, cmd=["mkdocs", "build"], fn=f"{self.proj.url}/site/index.html", ) self._contents = conts self._artifacts = arts @staticmethod def _create(path: str) -> None: """Scaffold a minimal RTD project using Sphinx.""" # .readthedocs.yaml — RTD configuration with open(f"{path}/.readthedocs.yaml", "wt") as f: f.write( "version: 2\n" "\n" "build:\n" " os: ubuntu-24.04\n" " tools:\n" ' python: "3.12"\n' "\n" "sphinx:\n" " configuration: docs/conf.py\n" "\n" "python:\n" " install:\n" " - requirements: docs/requirements.txt\n" ) # docs/conf.py — minimal Sphinx configuration os.makedirs(f"{path}/docs", exist_ok=True) name = os.path.basename(path) with open(f"{path}/docs/conf.py", "wt") as f: f.write( f'project = "{name}"\n' f"extensions = []\n" f'html_theme = "alabaster"\n' ) # docs/index.rst — root document with open(f"{path}/docs/index.rst", "wt") as f: f.write(f"{name}\n{'=' * len(name)}\n\n.. toctree::\n :maxdepth: 2\n") # docs/requirements.txt — build dependencies with open(f"{path}/docs/requirements.txt", "wt") as f: f.write("sphinx\n")
[docs] class MkDocs(ProjectSpec): """MkDocs documentation project.""" icon = "📄" spec_doc = "https://www.mkdocs.org/user-guide/configuration/" _NAMES = {"mkdocs.yml", "mkdocs.yaml"} def match(self) -> bool: return bool(self._NAMES.intersection(self.proj.basenames)) def parse(self) -> None: from projspec.artifact.infra import StaticSite from projspec.artifact.process import Server from projspec.content.metadata import DescriptiveMetadata fname = next(n for n in self._NAMES if n in self.proj.basenames) try: with self.proj.get_file(fname) as f: cfg = yaml.safe_load(f) except Exception as exc: raise ParseFailed(f"Could not read {fname}: {exc}") from exc cfg = cfg or {} meta: dict[str, str] = {} for key in ("site_name", "site_description", "site_author", "repo_url"): if val := cfg.get(key): meta[key] = str(val) conts = AttrDict() if meta: conts["descriptive_metadata"] = DescriptiveMetadata( proj=self.proj, meta=meta ) site_dir = cfg.get("site_dir", "site") arts = AttrDict( docs=StaticSite( proj=self.proj, cmd=["mkdocs", "build"], fn=f"{self.proj.url}/{site_dir}/index.html", ), serve=Server( proj=self.proj, cmd=["mkdocs", "serve"], ), ) self._contents = conts self._artifacts = arts @staticmethod def _create(path: str) -> None: """Scaffold a minimal MkDocs project.""" name = os.path.basename(path) with open(os.path.join(path, "mkdocs.yml"), "wt") as f: f.write( f"site_name: {name}\n" "\n" "nav:\n" " - Home: index.md\n" "\n" "theme:\n" " name: material\n" ) docs_dir = os.path.join(path, "docs") os.makedirs(docs_dir, exist_ok=True) with open(os.path.join(docs_dir, "index.md"), "wt") as f: f.write(f"# {name}\n\nWelcome to the documentation.\n")
[docs] class Sphinx(ProjectSpec): """Sphinx documentation project (standalone, without ReadTheDocs config).""" icon = "📜" spec_doc = "https://www.sphinx-doc.org/en/master/usage/configuration.html" def match(self) -> bool: if "conf.py" in self.proj.basenames: return True # Check docs/conf.py docs_conf = f"{self.proj.url}/docs/conf.py" try: return self.proj.fs.isfile(docs_conf) except Exception: return False def parse(self) -> None: from projspec.artifact.infra import StaticSite from projspec.artifact.process import Server from projspec.content.metadata import DescriptiveMetadata # Find conf.py if "conf.py" in self.proj.basenames: conf_path = self.proj.basenames["conf.py"] docs_dir = self.proj.url else: conf_path = f"{self.proj.url}/docs/conf.py" docs_dir = f"{self.proj.url}/docs" meta: dict[str, str] = {} try: with self.proj.fs.open(conf_path, "rt") as f: content = f.read() for var in ("project", "author", "release", "version"): m = re.search( rf'^{var}\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE ) if m: meta[var] = m.group(1) except Exception: pass conts = AttrDict() if meta: conts["descriptive_metadata"] = DescriptiveMetadata( proj=self.proj, meta=meta ) build_dir = f"{docs_dir}/_build/html" arts = AttrDict( docs=StaticSite( proj=self.proj, cmd=["sphinx-build", "-b", "html", docs_dir, build_dir], fn=f"{build_dir}/index.html", ), autobuild=Server( proj=self.proj, cmd=["sphinx-autobuild", docs_dir, build_dir], ), ) self._contents = conts self._artifacts = arts @staticmethod def _create(path: str) -> None: """Scaffold a minimal Sphinx docs project.""" name = os.path.basename(path) docs_dir = os.path.join(path, "docs") os.makedirs(docs_dir, exist_ok=True) with open(os.path.join(docs_dir, "conf.py"), "wt") as f: f.write( f'project = "{name}"\n' "extensions = []\n" 'html_theme = "alabaster"\n' ) with open(os.path.join(docs_dir, "index.rst"), "wt") as f: f.write(f"{name}\n{'=' * len(name)}\n\n.. toctree::\n :maxdepth: 2\n") with open(os.path.join(docs_dir, "requirements.txt"), "wt") as f: f.write("sphinx\n")
[docs] class Docusaurus(ProjectSpec): """Docusaurus documentation/website project.""" icon = "🐉" spec_doc = "https://docusaurus.io/docs/configuration" _CONFIG_NAMES = { "docusaurus.config.js", "docusaurus.config.ts", "docusaurus.config.mjs", } def match(self) -> bool: return bool(self._CONFIG_NAMES.intersection(self.proj.basenames)) def parse(self) -> None: from projspec.artifact.infra import StaticSite from projspec.artifact.process import Server from projspec.content.metadata import DescriptiveMetadata fname = next(n for n in self._CONFIG_NAMES if n in self.proj.basenames) meta: dict[str, str] = {} try: with self.proj.get_file(fname) as f: content = f.read() for key in ( "title", "tagline", "url", "organizationName", "projectName", ): m = re.search(rf'{key}\s*:\s*["\']([^"\']+)["\']', content) if m: meta[key] = m.group(1) except Exception: pass conts = AttrDict() if meta: conts["descriptive_metadata"] = DescriptiveMetadata( proj=self.proj, meta=meta ) pkg_mgr = "yarn" if "yarn.lock" in self.proj.basenames else "npm" arts = AttrDict( build=StaticSite( proj=self.proj, cmd=[pkg_mgr, "run", "build"], fn=f"{self.proj.url}/build/index.html", ), start=Server( proj=self.proj, cmd=[pkg_mgr, "run", "start"], ), ) self._contents = conts self._artifacts = arts @staticmethod def _create(path: str) -> None: """Scaffold a minimal Docusaurus project via npx.""" from projspec.utils import run_subprocess name = os.path.basename(path) run_subprocess( [ "npx", "create-docusaurus@latest", name, "classic", "--skip-install", ], cwd=os.path.dirname(path) or ".", output=False, )