Compare commits
2
Commits
ab14def478
...
b97487eb4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97487eb4b | ||
|
|
346e3d7e0c |
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, TypeAlias
|
||||||
from omicron.ssg.output.content import Content
|
from omicron.ssg.output.content import Content, ContentMeta
|
||||||
from omicron.ssg.output.file import File
|
from omicron.ssg.output.file import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import omicron.ssg.markdown as md
|
import omicron.ssg.markdown as md
|
||||||
@@ -14,22 +14,17 @@ else:
|
|||||||
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
|
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleMeta(ContentMeta):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Article(Content):
|
class Article(Content):
|
||||||
def __init__(self, site: Site, source: Path, meta: dict[str, Any]):
|
Meta: TypeAlias = ArticleMeta
|
||||||
if "tags" not in meta:
|
|
||||||
|
def __init__(self, site: Site, source: Path, meta: ArticleMeta):
|
||||||
|
if not meta.tags:
|
||||||
log.warning("no tags for article in '%s'", source)
|
log.warning("no tags for article in '%s'", source)
|
||||||
super().__init__(
|
super().__init__(site, source, meta)
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_reference(self, path: Path) -> Content | File:
|
def handle_reference(self, path: Path) -> Content | File:
|
||||||
site = self.site
|
site = self.site
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import re
|
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.output import Output, OutputError, is_normalized
|
||||||
from omicron.ssg.output.file import File
|
from omicron.ssg.output.file import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import date
|
import datetime
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from omicron.ssg.site import Site
|
from omicron.ssg.site import Site
|
||||||
@@ -15,37 +17,101 @@ class ContentError(OutputError):
|
|||||||
pass
|
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):
|
class Content(Output):
|
||||||
|
Meta: TypeAlias = ContentMeta
|
||||||
_SECTION_RE = re.compile(r"^[a-z0-9_-]+(/[a-z0-9_-]+)*$")
|
_SECTION_RE = re.compile(r"^[a-z0-9_-]+(/[a-z0-9_-]+)*$")
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, site: Site, source: Path, meta: ContentMeta):
|
||||||
self,
|
super().__init__(site, Content.build_path(meta.section, meta.slug))
|
||||||
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()
|
|
||||||
if not source.is_absolute():
|
if not source.is_absolute():
|
||||||
raise ContentError("content source path must be absolute")
|
raise ContentError("content source path must be absolute")
|
||||||
if not is_normalized(source):
|
if not is_normalized(source):
|
||||||
raise ContentError("content source path must be normalized")
|
raise ContentError("content source path must be normalized")
|
||||||
self.source = source
|
self.source = source
|
||||||
self.section: str = section
|
self.section = meta.section
|
||||||
self.title: str = title
|
self.title = meta.title
|
||||||
self.created = created
|
self.created = meta.date
|
||||||
self.updated = updated
|
self.updated = meta.updated
|
||||||
self.tags: set[str] = tags
|
self.tags = meta.tags
|
||||||
self.summary: str | None = summary
|
self.summary = meta.summary
|
||||||
self.draft: bool = draft
|
self.draft = meta.draft
|
||||||
self.in_sitemap = True
|
self.in_sitemap = True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ def create_content(site: Site, path: Path) -> Content:
|
|||||||
else:
|
else:
|
||||||
raise ContentError(f"Unhandled file type for '{path}'")
|
raise ContentError(f"Unhandled file type for '{path}'")
|
||||||
|
|
||||||
if meta["type"] == "article":
|
content_type = meta.pop("type", None)
|
||||||
return Article(site, path, meta)
|
if content_type == "article":
|
||||||
|
return Article(site, path, Article.Meta.from_dict(meta))
|
||||||
else:
|
else:
|
||||||
raise ContentError(f"Invalid type '{meta['type']}' for '{path}'")
|
raise ContentError(f"Invalid type '{content_type}' for '{path}'")
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user