Compare commits

..
2 Commits
Author SHA1 Message Date
omicron b97487eb4b Add tests for frontmatter 2026-10-02 00:59:19 +02:00
omicron 346e3d7e0c Move frontmatter validation into ContentMeta 2026-10-02 00:28:42 +02:00
4 changed files with 199 additions and 44 deletions

No files matched your search

+11 -16
View File
@@ -1,7 +1,7 @@
import logging
from urllib.parse import urlparse
from typing import TYPE_CHECKING, Any
from omicron.ssg.output.content import Content
from typing import TYPE_CHECKING, TypeAlias
from omicron.ssg.output.content import Content, ContentMeta
from omicron.ssg.output.file import File
from pathlib import Path
import omicron.ssg.markdown as md
@@ -14,22 +14,17 @@ else:
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
class ArticleMeta(ContentMeta):
pass
class Article(Content):
def __init__(self, site: Site, source: Path, meta: dict[str, Any]):
if "tags" not in meta:
Meta: TypeAlias = ArticleMeta
def __init__(self, site: Site, source: Path, meta: ArticleMeta):
if not meta.tags:
log.warning("no tags for article in '%s'", source)
super().__init__(
site,
source,
meta["section"],
meta["slug"],
meta["title"],
meta["date"],
meta.get("updated", None),
set(meta["tags"]) if "tags" in meta else None,
meta.get("summary", None),
meta.get("draft", False),
)
super().__init__(site, source, meta)
def handle_reference(self, path: Path) -> Content | File:
site = self.site
+91 -25
View File
@@ -1,9 +1,11 @@
import re
from typing import TYPE_CHECKING
from abc import ABC
from dataclasses import dataclass, field, fields
from typing import TYPE_CHECKING, Any, Self, TypeAlias
from omicron.ssg.output.output import Output, OutputError, is_normalized
from omicron.ssg.output.file import File
from pathlib import Path
from datetime import date
import datetime
if TYPE_CHECKING:
from omicron.ssg.site import Site
@@ -15,37 +17,101 @@ class ContentError(OutputError):
pass
@dataclass(kw_only=True)
class ContentMeta(ABC):
section: str
slug: str
title: str
date: datetime.date
updated: datetime.date | None = None
tags: set[str] = field(default_factory=set)
summary: str | None = None
draft: bool = False
@staticmethod
def validate_dict(data: dict[str, Any]) -> None:
section = data.get("section")
slug = data.get("slug")
title = data.get("title")
content_date = data.get("date")
if section is None:
raise ContentError("frontmatter must contain a section property")
if slug is None:
raise ContentError("frontmatter must contain a slug property")
if title is None:
raise ContentError("frontmatter must contain a title property")
if content_date is None:
raise ContentError("frontmatter must contain a date property")
if not isinstance(section, str):
raise ContentError("section frontmatter property must be a string")
if not isinstance(slug, str):
raise ContentError("slug frontmatter property must be a string")
if not isinstance(title, str):
raise ContentError("title frontmatter property must be a string")
if not isinstance(content_date, datetime.date):
raise ContentError("date frontmatter property must be a date")
updated = data.get("updated")
if updated is not None and not isinstance(updated, datetime.date):
raise ContentError("updated frontmatter property must be a date")
if "tags" in data and (
not isinstance(data["tags"], (list, set))
or not all(isinstance(tag, str) for tag in data["tags"])
):
raise ContentError("tags frontmatter property must be a list of strings")
summary = data.get("summary")
if summary is not None and not isinstance(summary, str):
raise ContentError("summary frontmatter property must be a string")
draft = data.get("draft")
if draft is not None and not isinstance(draft, bool):
raise ContentError("draft frontmatter property must be a boolean")
@staticmethod
def coerce_dict(data: dict[str, Any]) -> None:
if "tags" in data and isinstance(data["tags"], list):
data["tags"] = set(data["tags"])
@classmethod
def reject_extra_fields(cls, data: dict[str, Any]) -> None:
field_names = {f.name for f in fields(cls)}
unexpected = data.keys() - field_names
if unexpected:
raise ContentError(
f"unexpected frontmatter field(s): {', '.join(sorted(unexpected))}"
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
cls.validate_dict(data)
cls.coerce_dict(data)
cls.reject_extra_fields(data)
return cls(**data)
class Content(Output):
Meta: TypeAlias = ContentMeta
_SECTION_RE = re.compile(r"^[a-z0-9_-]+(/[a-z0-9_-]+)*$")
def __init__(
self,
site: Site,
source: Path,
section: str,
slug: str,
title: str,
created: date,
updated: date | None = None,
tags: set[str] | None = None,
summary: str | None = None,
draft: bool = False,
):
super().__init__(site, Content.build_path(section, slug))
if tags is None:
tags = set()
def __init__(self, site: Site, source: Path, meta: ContentMeta):
super().__init__(site, Content.build_path(meta.section, meta.slug))
if not source.is_absolute():
raise ContentError("content source path must be absolute")
if not is_normalized(source):
raise ContentError("content source path must be normalized")
self.source = source
self.section: str = section
self.title: str = title
self.created = created
self.updated = updated
self.tags: set[str] = tags
self.summary: str | None = summary
self.draft: bool = draft
self.section = meta.section
self.title = meta.title
self.created = meta.date
self.updated = meta.updated
self.tags = meta.tags
self.summary = meta.summary
self.draft = meta.draft
self.in_sitemap = True
@staticmethod
+4 -3
View File
@@ -44,7 +44,8 @@ def create_content(site: Site, path: Path) -> Content:
else:
raise ContentError(f"Unhandled file type for '{path}'")
if meta["type"] == "article":
return Article(site, path, meta)
content_type = meta.pop("type", None)
if content_type == "article":
return Article(site, path, Article.Meta.from_dict(meta))
else:
raise ContentError(f"Invalid type '{meta['type']}' for '{path}'")
raise ContentError(f"Invalid type '{content_type}' for '{path}'")
+93
View File
@@ -0,0 +1,93 @@
import datetime
import pytest
import yaml
from omicron.ssg.output.article import ArticleMeta
from omicron.ssg.output.content import ContentError
@pytest.fixture
def valid_data():
return yaml.safe_load("""
section: posts
slug: hello
title: Hello
date: 2024-01-01
""")
@pytest.fixture
def full_data():
return yaml.safe_load("""
section: posts
slug: hello
title: Hello
date: 2024-01-01
updated: 2024-02-01
tags: [python, testing]
summary: A short summary.
draft: true
""")
@pytest.mark.parametrize("key", ["section", "slug", "title", "date"])
def test_from_dict_missing_required_key(valid_data, key):
del valid_data[key]
with pytest.raises(ContentError, match=f"must contain a {key} property"):
ArticleMeta.from_dict(valid_data)
@pytest.mark.parametrize(
"key, value, expected_type",
[
("section", 1, "string"),
("slug", 1, "string"),
("title", 1, "string"),
("date", "2024-01-01", "date"),
("updated", "2024-01-01", "date"),
("tags", "python", "list of strings"),
("tags", [1, "python"], "list of strings"),
("summary", 1, "string"),
("draft", "yes", "boolean"),
],
)
def test_from_dict_wrong_type(valid_data, key, value, expected_type):
valid_data[key] = value
with pytest.raises(
ContentError, match=f"{key} frontmatter property must be a {expected_type}"
):
ArticleMeta.from_dict(valid_data)
def test_from_dict_unexpected_field(valid_data):
valid_data["bogus"] = "value"
with pytest.raises(ContentError, match="unexpected frontmatter field.*bogus"):
ArticleMeta.from_dict(valid_data)
def test_from_dict_minimal(valid_data):
meta = ArticleMeta.from_dict(valid_data)
assert meta.section == "posts"
assert meta.slug == "hello"
assert meta.title == "Hello"
assert meta.date == datetime.date(2024, 1, 1)
assert meta.updated is None
assert meta.tags == set()
assert meta.summary is None
assert meta.draft is False
def test_from_dict_full(full_data):
meta = ArticleMeta.from_dict(full_data)
assert meta.section == "posts"
assert meta.slug == "hello"
assert meta.title == "Hello"
assert meta.date == datetime.date(2024, 1, 1)
assert meta.updated == datetime.date(2024, 2, 1)
assert meta.tags == {"python", "testing"}
assert meta.summary == "A short summary."
assert meta.draft is True