diff --git a/omicron/ssg/cli.py b/omicron/ssg/cli.py index cee1fcf..069dd6a 100644 --- a/omicron/ssg/cli.py +++ b/omicron/ssg/cli.py @@ -14,7 +14,7 @@ def setup_logging(verbose: bool = False, log_file: str | None = None) -> None: logger.addHandler(console) -def main(): +def main() -> None: setup_logging(verbose=True) site = Site(Path(".")) site.build() diff --git a/omicron/ssg/content.py b/omicron/ssg/content.py index 8e47f88..f10b550 100644 --- a/omicron/ssg/content.py +++ b/omicron/ssg/content.py @@ -3,9 +3,9 @@ import re from abc import ABC, abstractmethod from pathlib import Path from datetime import date -import typing +from typing import TYPE_CHECKING, Any, cast -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from omicron.ssg.site import Site import yaml @@ -34,19 +34,26 @@ def _highlight_code(code: str, lang: str, attrs: str) -> str: return pygments_highlight(code, lexer, HtmlFormatter()) -def read_frontmatter(path) -> dict: +def read_frontmatter(path: Path) -> dict[str, Any]: lines = [] with path.open("r", encoding="utf-8") as f: if f.readline() != "---\n": raise ContentError(f"Frontmatter opening delimiter not found for {path}") while (line := f.readline()) != "---\n": if line == "": - raise ContentError(f"Frontmatter closing delimiter not found for {path}") + raise ContentError( + f"Frontmatter closing delimiter not found for {path}" + ) lines.append(line) - return yaml.safe_load("".join(lines)) + data = yaml.safe_load("".join(lines)) + if not isinstance(data, dict) or not all(isinstance(k, str) for k in data): + raise ContentError( + f"Frontmatter must be a YAML mapping with string keys in {path}" + ) + return cast(dict[str, Any], data) -def create_content(path) -> Content: +def create_content(path: Path) -> Content: if path.suffix in FRONTMATTER_CONTENT: meta = read_frontmatter(path) elif path.suffix == ".yml": @@ -93,7 +100,9 @@ class Content(ABC): f.readline() # opening --- while (line := f.readline()) != "---\n": if line == "": - raise ContentError(f"Frontmatter closing delimiter not found for {self.source}") + raise ContentError( + f"Frontmatter closing delimiter not found for {self.source}" + ) return f.read() @abstractmethod @@ -102,7 +111,7 @@ class Content(ABC): class Article(Content): - def __init__(self, path: Path, meta: dict): + def __init__(self, path: Path, meta: dict[str, Any]): if "tags" not in meta: log.warning("no tags for article in '%s'", path) super().__init__( diff --git a/omicron/ssg/site.py b/omicron/ssg/site.py index 5f6c81b..bbaea42 100644 --- a/omicron/ssg/site.py +++ b/omicron/ssg/site.py @@ -1,5 +1,5 @@ import logging -from typing import Any +from typing import Any, cast from omicron.ssg.content import ( Content, @@ -39,11 +39,13 @@ class Site: log.debug("Reading config file %s", config_path) with open(config_path, "r") as f: config = yaml.safe_load(f) + if not isinstance(config, dict) or not all(isinstance(k, str) for k in config): + raise ConfigError("config file must be a YAML mapping with string keys") if "name" not in config: raise ConfigError("name value missing from config") if "template" not in config: raise ConfigError("template value missing from config") - return config + return cast(dict[str, Any], config) @staticmethod def resolve_template_path(template: str) -> Path: @@ -84,7 +86,7 @@ class Site: self.content.append(content) log.info("Discovered %d content items", len(self.content)) - def build(self): + def build(self) -> None: self.discover() self.output_path.mkdir(parents=True, exist_ok=True) (self.output_path / "pygments.css").write_text( diff --git a/pyproject.toml b/pyproject.toml index d2444d1..9410503 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,19 @@ dependencies = [ "PyYAML", ] +[project.optional-dependencies] +dev = [ + "mypy", + "types-PyYAML", + "types-Pygments", +] + [project.scripts] ossg = "omicron.ssg.cli:main" +[tool.mypy] +strict = true + [tool.setuptools.packages.find] where = ["."] include = ["omicron.ssg*"]