Compare commits

...
12 Commits
Author SHA1 Message Date
omicron ab14def478 add tests for config, fix failing test on Site 2026-09-08 04:37:13 +02:00
omicron ea14de6200 Move relative path check from Site.build to SiteConfig 2026-09-08 04:02:17 +02:00
omicron 4351ea8e0d Refactor config reading code 2026-09-05 03:19:12 +02:00
omicron f991d3b110 Expand test Site tests 2026-09-04 11:06:39 +02:00
omicron 7297adb966 Expand tests for Site 2026-09-03 03:47:56 +02:00
omicron 1fe2bbd1db Consolidate test site fixture into a single make_site 2026-09-03 03:10:13 +02:00
omicron 2835b77146 Add tests for output.Output and output.File 2026-09-03 02:55:38 +02:00
omicron 088fc4d463 Exclude abstract methods from coverage reports 2026-09-03 02:55:20 +02:00
omicron 9d5387a40a Add Makefile with targets for common actions 2026-08-28 01:58:52 +02:00
omicron 82f60f3d75 Set up e2e testing 2026-08-28 01:44:38 +02:00
omicron 6afc8585db Set up pytest and coverage tooling 2026-08-28 01:15:28 +02:00
omicron 34df950cbd Make site building output deterministic
In order to do snapshot testing of the site output we want the output to
always be the same regardless of how the filesystem tree was created
2026-08-28 01:08:58 +02:00
38 changed files with 795 additions and 79 deletions
+4
View File
@@ -2,3 +2,7 @@
__pycache__ __pycache__
*.egg-info *.egg-info
*.pyc *.pyc
.coverage
htmlcov/
tests/data/sites/*/output/
tests/data/sites/*/build.log
+29
View File
@@ -0,0 +1,29 @@
TEST_SITE := tests/data/sites/main
OUTPUT := $(TEST_SITE)/output
.PHONY: build build-drafts check test coverage clean serve
build:
OSSG_TYPECHECKED="1" ossg --site $(TEST_SITE) -v build
build-drafts:
OSSG_TYPECHECKED="1" ossg --site $(TEST_SITE) -v build --drafts
check:
mypy -p omicron.ssg
black --check omicron tests/unit tests/e2e
test:
pytest
coverage:
coverage run -m pytest tests/unit
coverage report
coverage html
@echo "file://$(CURDIR)/htmlcov/index.html"
clean:
ossg --site $(TEST_SITE) clean
serve:
python -m http.server --bind 127.0.0.1 --directory $(OUTPUT)
+2 -1
View File
@@ -2,7 +2,8 @@ import argparse
import logging import logging
import sys import sys
from omicron.ssg.output import OutputError from omicron.ssg.output import OutputError
from omicron.ssg.site import ConfigError, Site from omicron.ssg.site import Site
from omicron.ssg.config import ConfigError
from pathlib import Path from pathlib import Path
+101
View File
@@ -0,0 +1,101 @@
from dataclasses import dataclass
from pathlib import Path
import logging
import yaml
from urllib.parse import urlparse
log = logging.getLogger(__name__)
class ConfigError(RuntimeError):
pass
DEFAULT_ITEMS_PER_PAGE = 20
@dataclass
class SiteConfig:
name: str
origin: str
base_dir: Path
items_per_page: int
template: str
@staticmethod
def parse_base_url(value: str | None) -> tuple[str, Path]:
if not value:
return ("", Path("/"))
try:
parsed = urlparse(value)
except ValueError as e:
raise ConfigError("invalid url format in base_url") from e
if parsed.scheme and parsed.netloc:
origin = f"{parsed.scheme}://{parsed.netloc}"
elif parsed.netloc:
origin = f"//{parsed.netloc}"
else:
origin = ""
if parsed.path:
path = Path(parsed.path)
else:
path = Path("/")
if not path.is_absolute():
raise ConfigError("base_url config property must not be a relative path")
return (origin, path)
@staticmethod
def from_file(path: Path) -> SiteConfig:
log.debug("Reading config file %s", path)
try:
with open(path, "r") as f:
config = yaml.safe_load(f)
except FileNotFoundError as e:
raise ConfigError("config file missing, not a valid ossg directory") from e
except yaml.YAMLError as e:
raise ConfigError("config file must be a valid YAML file") from e
if not isinstance(config, dict):
raise ConfigError(
"config file must be a YAML mapping with string keys and int/str values"
)
for k, v in config.items():
if type(k) != str or type(v) not in [str, int]:
raise ConfigError(
"config file must be a YAML mapping with string keys and int/str values"
)
return SiteConfig.from_dict(config)
@staticmethod
def from_dict(config: dict[str, str | int]) -> SiteConfig:
name = config.get("name")
template = config.get("template")
base_url = config.get("base_url")
items_per_page = config.get("items_per_page", DEFAULT_ITEMS_PER_PAGE)
if name is None:
raise ConfigError("config file must contain a name property")
if template is None:
raise ConfigError("config file must contain a template property")
if base_url is not None and not isinstance(base_url, str):
raise ConfigError("base_url config property must be a string")
if not isinstance(name, str):
raise ConfigError("name config property must be a string")
if not isinstance(template, str):
raise ConfigError("template config property must be a string")
if not isinstance(items_per_page, int) or items_per_page < 1:
raise ConfigError(
"items_per_page config property must be a positive integer"
)
origin, base_dir = SiteConfig.parse_base_url(base_url)
return SiteConfig(
name=name,
origin=origin,
base_dir=base_dir,
items_per_page=items_per_page,
template=template,
)
+1 -1
View File
@@ -14,7 +14,7 @@ else:
class Directory(Output): class Directory(Output):
def __init__(self, site: Site, destination: Path): def __init__(self, site: Site, destination: Path):
super().__init__(site, destination) super().__init__(site, destination)
url_path = Path("/") / site.base_dir / destination url_path = Path("/") / site.config.base_dir / destination
# rebuild url because Output strips /index.html which is a valid dir # rebuild url because Output strips /index.html which is a valid dir
self.url = url_path.as_posix() self.url = url_path.as_posix()
+4 -3
View File
@@ -46,8 +46,9 @@ class Feed(Output):
return Path(label) / file return Path(label) / file
def write(self) -> None: def write(self) -> None:
feed_url = f"{self.site.origin}{self.url}" config = self.site.config
title = f"{self.site.name}{self.label}" if self.label else self.site.name feed_url = f"{config.origin}{self.url}"
title = f"{config.name}{self.label}" if self.label else config.name
updated = max( updated = max(
(c.updated or c.created for c in self.items), default=date.today() (c.updated or c.created for c in self.items), default=date.today()
) )
@@ -60,7 +61,7 @@ class Feed(Output):
for item in self.items: for item in self.items:
entry = SubElement(feed, "entry") entry = SubElement(feed, "entry")
item_url = f"{self.site.origin}{item.url}" item_url = f"{config.origin}{item.url}"
SubElement(entry, "title").text = item.title SubElement(entry, "title").text = item.title
SubElement(entry, "id").text = item_url SubElement(entry, "id").text = item_url
SubElement(entry, "link", href=item_url) SubElement(entry, "link", href=item_url)
+4 -5
View File
@@ -89,14 +89,13 @@ def make_index_pages(
def discover_index(site: Site) -> list[Index]: def discover_index(site: Site) -> list[Index]:
items_per_page = site.config.items_per_page
outputs: list[Index] = [] outputs: list[Index] = []
outputs.extend( outputs.extend(make_index_pages(site, site.content, "root", None, items_per_page))
make_index_pages(site, site.content, "root", None, site.items_per_page)
)
for section, items in site.by_section.items(): for section, items in site.by_section.items():
outputs.extend( outputs.extend(
make_index_pages(site, items, "section", section, site.items_per_page) make_index_pages(site, items, "section", section, items_per_page)
) )
for tag, items in site.by_tag.items(): for tag, items in site.by_tag.items():
outputs.extend(make_index_pages(site, items, "tag", tag, site.items_per_page)) outputs.extend(make_index_pages(site, items, "tag", tag, items_per_page))
return outputs return outputs
+1 -1
View File
@@ -26,7 +26,7 @@ class Output(ABC):
raise OutputError("destination path must be normalized") raise OutputError("destination path must be normalized")
self.destination = destination self.destination = destination
url = site.base_dir / destination url = site.config.base_dir / destination
if url.name == "index.html": if url.name == "index.html":
url = url.parent url = url.parent
self.url = url.as_posix() self.url = url.as_posix()
+1 -1
View File
@@ -27,7 +27,7 @@ class Sitemap(Output):
continue continue
url = SubElement(urlset, "url") url = SubElement(urlset, "url")
SubElement(url, "loc").text = f"{self.site.origin}{entry.url}" SubElement(url, "loc").text = f"{self.site.config.origin}{entry.url}"
if isinstance(entry, Content): if isinstance(entry, Content):
lastmod = entry.updated or entry.created lastmod = entry.updated or entry.created
SubElement(url, "lastmod").text = lastmod.isoformat() SubElement(url, "lastmod").text = lastmod.isoformat()
+10 -55
View File
@@ -1,7 +1,6 @@
import logging import logging
import shutil import shutil
from typing import Any, Literal, cast from typing import Literal
from urllib.parse import urlparse
from omicron.ssg.output import ( from omicron.ssg.output import (
Output, Output,
@@ -22,18 +21,14 @@ from omicron.ssg.output import (
discover_feeds, discover_feeds,
) )
from omicron.ssg.markdown import highlight_style from omicron.ssg.markdown import highlight_style
from omicron.ssg.config import SiteConfig, ConfigError
from pathlib import Path from pathlib import Path
import yaml
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class ConfigError(RuntimeError):
pass
class Site: class Site:
def __init__(self, site: Path, drafts: bool = False): def __init__(self, site: Path, drafts: bool = False):
self.site_path = site.resolve() self.site_path = site.resolve()
@@ -47,50 +42,11 @@ class Site:
self.by_section: dict[str, list[Content]] = {} self.by_section: dict[str, list[Content]] = {}
self.tags_by_count: list[tuple[str, int]] = [] self.tags_by_count: list[tuple[str, int]] = []
config = self.read_config() self.config = SiteConfig.from_file(site / "config.yml")
self.name: str = config["name"] self.template_path: Path = Site.resolve_template_path(self.config.template)
self.template: str = config["template"]
self.items_per_page: int = int(config.get("items_per_page", 20))
self.origin, self.base_dir = Site.parse_base_url(config.get("base_url"))
self.template_path: Path = Site.resolve_template_path(self.template)
self.output_path: Path = self.site_path / "output" self.output_path: Path = self.site_path / "output"
self.jinja_env = Environment(loader=FileSystemLoader(self.template_path)) self.jinja_env = Environment(loader=FileSystemLoader(self.template_path))
def read_config(self) -> dict[str, Any]:
config_path = self.site_path / "config.yml"
log.debug("Reading config file %s", config_path)
try:
with open(config_path, "r") as f:
config = yaml.safe_load(f)
except FileNotFoundError:
raise ConfigError("config file missing, not a valid ossg directory")
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 cast(dict[str, Any], config)
@staticmethod
def parse_base_url(value: str | None) -> tuple[str, Path]:
if not value:
return ("", Path("/"))
parsed = urlparse(value)
if parsed.scheme and parsed.netloc:
origin = f"{parsed.scheme}://{parsed.netloc}"
elif parsed.netloc:
origin = f"//{parsed.netloc}"
else:
origin = ""
if parsed.path:
path = Path(parsed.path)
else:
path = Path("/")
return (origin, path)
@staticmethod @staticmethod
def resolve_template_path(template: str) -> Path: def resolve_template_path(template: str) -> Path:
path = Path(__file__).parent / "templates" / template path = Path(__file__).parent / "templates" / template
@@ -137,12 +93,12 @@ class Site:
self.by_source[content.source] = content self.by_source[content.source] = content
def home_url(self) -> str: def home_url(self) -> str:
return self.base_dir.as_posix() return self.config.base_dir.as_posix()
def url_for(self, path: str) -> str: def url_for(self, path: str) -> str:
if Path(path).is_absolute(): if Path(path).is_absolute():
raise OutputError("path must be relative") raise OutputError("path must be relative")
return (self.base_dir / path).as_posix() return (self.config.base_dir / path).as_posix()
def section_url(self, section: str) -> str: def section_url(self, section: str) -> str:
path = Index.build_path("section", 1, section) path = Index.build_path("section", 1, section)
@@ -185,7 +141,8 @@ class Site:
self.other_outputs.append(pygments) self.other_outputs.append(pygments)
self.add_by_path(pygments) self.add_by_path(pygments)
content_path = self.site_path / "content" content_path = self.site_path / "content"
for file in content_path.rglob("*"): files = sorted(content_path.rglob("*"))
for file in files:
if file.is_file() and is_content(file): if file.is_file() and is_content(file):
content = create_content(self, file) content = create_content(self, file)
if content.draft and not self.drafts: if content.draft and not self.drafts:
@@ -203,7 +160,7 @@ class Site:
tags_page = discover_tags(self) tags_page = discover_tags(self)
self.add_by_path(tags_page) self.add_by_path(tags_page)
self.other_outputs.append(tags_page) self.other_outputs.append(tags_page)
if self.origin: if self.config.origin:
sitemap = Sitemap(self) sitemap = Sitemap(self)
self.add_by_path(sitemap) self.add_by_path(sitemap)
self.other_outputs.append(sitemap) self.other_outputs.append(sitemap)
@@ -221,13 +178,11 @@ class Site:
log.info("Removed %s", self.output_path) log.info("Removed %s", self.output_path)
def build(self) -> None: def build(self) -> None:
if not self.origin: if not self.config.origin:
log.warning( log.warning(
"base_url config value is missing a domain name, " "base_url config value is missing a domain name, "
"can't add canonical url to content" "can't add canonical url to content"
) )
if not self.base_dir.is_absolute():
raise ConfigError("base_url config value must be an absolute path")
self.discover() self.discover()
outputs: list[Output] = [*self.directories, *self.content] outputs: list[Output] = [*self.directories, *self.content]
for output in sorted(outputs, key=lambda o: o.destination.as_posix()): for output in sorted(outputs, key=lambda o: o.destination.as_posix()):
+2 -2
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ page.title }} — {{ site.name }}{% endblock %} {% block title %}{{ page.title }} — {{ site.config.name }}{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
<a href="{{ site.section_url(page.section) }}">{{ page.section }}</a> <a href="{{ site.section_url(page.section) }}">{{ page.section }}</a>
{{ page.title }} {{ page.title }}
</nav> </nav>
+5 -5
View File
@@ -3,22 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ site.name }}{% endblock %}</title> <title>{% block title %}{{ site.config.name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ site.url_for('assets/style.css') }}"> <link rel="stylesheet" href="{{ site.url_for('assets/style.css') }}">
<link rel="stylesheet" href="{{ site.url_for('assets/pygments.css') }}"> <link rel="stylesheet" href="{{ site.url_for('assets/pygments.css') }}">
{% if site.origin %}<link rel="canonical" href="{{ site.origin }}{{ page.url }}">{% endif %} {% if site.config.origin %}<link rel="canonical" href="{{ site.config.origin }}{{ page.url }}">{% endif %}
{% if site.feed_url("root", None) %}<link rel="alternate" type="application/atom+xml" title="{{ site.name }}" href="{{ site.feed_url('root', None) }}">{% endif %} {% if site.feed_url("root", None) %}<link rel="alternate" type="application/atom+xml" title="{{ site.config.name }}" href="{{ site.feed_url('root', None) }}">{% endif %}
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<header> <header>
{% block breadcrumb %}<a href="{{ site.home_url() }}">{{ site.name }}</a>{% endblock %} {% block breadcrumb %}<a href="{{ site.home_url() }}">{{ site.config.name }}</a>{% endblock %}
</header> </header>
<main> <main>
{% block main %}{% endblock %} {% block main %}{% endblock %}
</main> </main>
<footer> <footer>
<p>{{ site.name }}</p> <p>{{ site.config.name }}</p>
</footer> </footer>
</body> </body>
</html> </html>
+3 -3
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if page.label %}{{ page.label }} — {% endif %}{{ site.name }}{% if page.page_num > 1 %} — Page {{ page.page_num }}{% endif %}{% endblock %} {% block title %}{% if page.label %}{{ page.label }} — {% endif %}{{ site.config.name }}{% if page.page_num > 1 %} — Page {{ page.page_num }}{% endif %}{% endblock %}
{% block head %} {% block head %}
{% if page.page_num > 1 %}<meta name="robots" content="noindex, follow">{% endif %} {% if page.page_num > 1 %}<meta name="robots" content="noindex, follow">{% endif %}
@@ -12,12 +12,12 @@
{% block breadcrumb %} {% block breadcrumb %}
{% if page.kind == "section" %} {% if page.kind == "section" %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
{{ page.label }} {{ page.label }}
</nav> </nav>
{% elif page.kind == "tag" %} {% elif page.kind == "tag" %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
<a href="{{ site.tags_url() }}">Tags</a> <a href="{{ site.tags_url() }}">Tags</a>
{{ page.label }} {{ page.label }}
</nav> </nav>
+2 -2
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Tags — {{ site.name }}{% endblock %} {% block title %}Tags — {{ site.config.name }}{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
Tags Tags
</nav> </nav>
{% endblock %} {% endblock %}
+12
View File
@@ -17,7 +17,9 @@ dependencies = [
dev = [ dev = [
"beartype", "beartype",
"black", "black",
"coverage",
"mypy", "mypy",
"pytest",
"types-PyYAML", "types-PyYAML",
"types-Pygments", "types-Pygments",
] ]
@@ -31,6 +33,16 @@ target-version = ["py314"]
[tool.mypy] [tool.mypy]
strict = true strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["omicron"]
[tool.coverage.report]
show_missing = true
exclude_also = ["@(abc\\.)?abstractmethod"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["omicron.ssg*"] include = ["omicron.ssg*"]
@@ -0,0 +1,2 @@
name: Draft Content Test
template: plain
@@ -0,0 +1,9 @@
---
type: article
section: posts
slug: draft-post
title: Draft
date: 2026-08-28
draft: true
---
Draft.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: published
title: Published
date: 2026-08-28
---
Published.
@@ -0,0 +1,2 @@
name: Duplicate Slug Test
template: plain
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: hello
title: First
date: 2026-08-28
---
First.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: hello
title: Second
date: 2026-08-28
---
Second.
+3
View File
@@ -0,0 +1,3 @@
name: Main Test Site
base_url: http://127.0.0.1:8000
template: plain
@@ -0,0 +1,10 @@
---
type: article
section: posts
slug: first-post
title: First Post
date: 2026-08-28
tags: [meta]
---
Welcome to the test site. This site will contain some test content for the
static site generator, the content won't matter a lot.
+2
View File
@@ -0,0 +1,2 @@
name: Sections Test
template: plain
@@ -0,0 +1,8 @@
---
type: article
section: notes
slug: first-note
title: First Note
date: 2026-08-28
---
First note.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: first-post
title: First Post
date: 2026-08-28
---
First post.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: second-post
title: Second Post
date: 2026-08-28
---
Second post.
+2
View File
@@ -0,0 +1,2 @@
name: Tags Test
template: plain
+8
View File
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: no-tags
title: No Tags
date: 2026-08-28
---
No tags.
+9
View File
@@ -0,0 +1,9 @@
---
type: article
section: posts
slug: one-tag
title: One Tag
date: 2026-08-28
tags: [python]
---
One tag.
@@ -0,0 +1,9 @@
---
type: article
section: posts
slug: two-tags
title: Two Tags
date: 2026-08-28
tags: [python, testing]
---
Two tags.
+2
View File
@@ -0,0 +1,2 @@
name: test
template: plain
+31
View File
@@ -0,0 +1,31 @@
import shutil
import subprocess
from pathlib import Path
import pytest
MAIN_SITE = Path(__file__).parent.parent / "data" / "sites" / "main"
@pytest.fixture(scope="module")
def built_site(tmp_path_factory):
site_dir = tmp_path_factory.mktemp("main")
shutil.copytree(
MAIN_SITE,
site_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("output", "build.log"),
)
result = subprocess.run(
["ossg", "--site", str(site_dir), "build"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
return site_dir / "output"
def test_article_renders(built_site):
output = built_site / "posts" / "first-post" / "index.html"
assert output.exists()
assert "First Post" in output.read_text()
+28
View File
@@ -0,0 +1,28 @@
import shutil
from pathlib import Path
import pytest
from omicron.ssg.site import Site
DATA_SITES = Path(__file__).parent.parent / "data" / "sites"
@pytest.fixture
def site_dir(tmp_path):
def _make(name):
path = tmp_path / name
shutil.copytree(
DATA_SITES / name,
path,
ignore=shutil.ignore_patterns("output", "build.log"),
)
return path
return _make
@pytest.fixture
def make_site(site_dir):
def _make(name, **kwargs):
return Site(site_dir(name), **kwargs)
return _make
+188
View File
@@ -0,0 +1,188 @@
from pathlib import Path
import pytest
from omicron.ssg.config import SiteConfig, ConfigError, DEFAULT_ITEMS_PER_PAGE
@pytest.mark.parametrize(
"value, expected_origin, expected_path",
[
(None, "", Path("/")),
("http://example.com/blog", "http://example.com", Path("/blog")),
("http://example.com", "http://example.com", Path("/")),
("//example.com/blog", "//example.com", Path("/blog")),
("/blog", "", Path("/blog")),
],
)
def test_parse_base_url(value, expected_origin, expected_path):
origin, path = SiteConfig.parse_base_url(value)
assert origin == expected_origin
assert path == expected_path
@pytest.mark.parametrize(
"value, expected_message",
[
("https://[::1", "invalid url format"),
("relative/path", "relative path"),
],
)
def test_parse_base_url_error(value, expected_message):
with pytest.raises(ConfigError, match=expected_message):
origin, path = SiteConfig.parse_base_url(value)
def test_from_dict_minimal():
config = SiteConfig.from_dict({"name": "My Site", "template": "plain"})
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == DEFAULT_ITEMS_PER_PAGE
assert config.origin == ""
assert config.base_dir == Path("/")
def test_from_dict_full():
config = SiteConfig.from_dict(
{
"name": "My Site",
"template": "plain",
"base_url": "http://example.com/blog",
"items_per_page": 42,
}
)
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == 42
assert config.origin == "http://example.com"
assert config.base_dir == Path("/blog")
MISSING = object()
@pytest.mark.parametrize(
"overrides, expected_message",
[
({"name": MISSING}, "must contain a name property"),
({"template": MISSING}, "must contain a template property"),
({"name": 123}, "name config property must be a string"),
({"template": 123}, "template config property must be a string"),
({"base_url": 123}, "base_url config property must be a string"),
({"base_url": "relative/path"}, "relative path"),
({"base_url": "https://[::1"}, "invalid url format"),
(
{"items_per_page": "10"},
"items_per_page config property must be a positive integer",
),
(
{"items_per_page": 0},
"items_per_page config property must be a positive integer",
),
(
{"items_per_page": -1},
"items_per_page config property must be a positive integer",
),
],
)
def test_from_dict_error(overrides, expected_message):
config = {"name": "My Site", "template": "plain"}
for key, value in overrides.items():
if value is MISSING:
config.pop(key, None)
else:
config[key] = value
with pytest.raises(ConfigError, match=expected_message):
SiteConfig.from_dict(config)
def test_from_file_minimal(tmp_path):
path = tmp_path / "config.yml"
path.write_text("name: My Site\ntemplate: plain\n")
config = SiteConfig.from_file(path)
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == DEFAULT_ITEMS_PER_PAGE
assert config.origin == ""
assert config.base_dir == Path("/")
def test_from_file_full(tmp_path):
path = tmp_path / "config.yml"
path.write_text(
"name: My Site\n"
"template: plain\n"
"base_url: http://example.com/blog\n"
"items_per_page: 42\n"
)
config = SiteConfig.from_file(path)
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == 42
assert config.origin == "http://example.com"
assert config.base_dir == Path("/blog")
def test_from_file_missing(tmp_path):
path = tmp_path / "config.yml"
with pytest.raises(ConfigError, match="config file missing"):
SiteConfig.from_file(path)
@pytest.mark.parametrize(
"yaml_text, expected_message",
[
("- a\n- b\n", "must be a YAML mapping"),
("just a string\n", "must be a YAML mapping"),
("123: value\n", "must be a YAML mapping"),
("name: [1, 2]\n", "must be a YAML mapping"),
("name: true\n", "must be a YAML mapping"),
(":\n bad: yaml: here\n", "must be a valid YAML file"),
("template: plain\n", "must contain a name property"),
("name: My Site\n", "must contain a template property"),
(
"name: 123\ntemplate: plain\n",
"name config property must be a string",
),
(
"name: My Site\ntemplate: 123\n",
"template config property must be a string",
),
(
"name: My Site\ntemplate: plain\nbase_url: 123\n",
"base_url config property must be a string",
),
(
"name: My Site\ntemplate: plain\nbase_url: relative/path\n",
"relative path",
),
(
"name: My Site\ntemplate: plain\nbase_url: https://[::1\n",
"invalid url format",
),
(
'name: My Site\ntemplate: plain\nitems_per_page: "10"\n',
"items_per_page config property must be a positive integer",
),
(
"name: My Site\ntemplate: plain\nitems_per_page: 0\n",
"items_per_page config property must be a positive integer",
),
(
"name: My Site\ntemplate: plain\nitems_per_page: -1\n",
"items_per_page config property must be a positive integer",
),
],
)
def test_from_file_error(tmp_path, yaml_text, expected_message):
path = tmp_path / "config.yml"
path.write_text(yaml_text)
with pytest.raises(ConfigError, match=expected_message):
SiteConfig.from_file(path)
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from omicron.ssg.output.file import File
def test_write_copies_content(make_site, tmp_path):
site = make_site("thin_site")
source = tmp_path / "source.txt"
source.write_text("hello")
dest_dir = site.output_path / "assets"
dest_dir.mkdir(parents=True)
file = File(site, Path("assets/copy.txt"), source)
file.write()
dest = dest_dir / "copy.txt"
assert dest.read_text() == "hello"
def test_write_overwrites_existing_destination(make_site, tmp_path):
site = make_site("thin_site")
dest = site.output_path / "copy.txt"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text("old")
source = tmp_path / "source.txt"
source.write_text("new")
file = File(site, Path("copy.txt"), source)
file.write()
assert dest.read_text() == "new"
+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
import pytest
from omicron.ssg.output.file import File
from omicron.ssg.output.output import OutputError
def test_absolute_destination_raises(make_site, tmp_path):
site = make_site("thin_site")
with pytest.raises(OutputError):
File(site, Path("/copy.txt"), tmp_path / "source.txt")
def test_non_normalized_destination_raises(make_site, tmp_path):
site = make_site("thin_site")
with pytest.raises(OutputError):
File(site, Path("assets/../copy.txt"), tmp_path / "source.txt")
def test_site_raises_after_garbage_collection(make_site, tmp_path):
site = make_site("thin_site")
file = File(site, Path("copy.txt"), tmp_path / "source.txt")
del site
with pytest.raises(ReferenceError):
file.site
def test_url(make_site, tmp_path):
site = make_site("thin_site")
file = File(site, Path("assets/copy.txt"), tmp_path / "source.txt")
assert file.url == "/assets/copy.txt"
def test_url_strips_index_html(make_site, tmp_path):
site = make_site("thin_site")
file = File(site, Path("posts/index.html"), tmp_path / "source.txt")
assert file.url == "/posts"
+192
View File
@@ -0,0 +1,192 @@
from pathlib import Path
import pytest
from omicron.ssg.output import ContentError, Directory, OutputError
from omicron.ssg.site import ConfigError, Site
def test_discover_raises_on_duplicate_destination(make_site):
site = make_site("duplicate_slug")
with pytest.raises(ContentError):
site.discover()
def test_discover_creates_content_directories(make_site):
site = make_site("main")
site.discover()
assert isinstance(site.by_path[Path("posts")], Directory)
assert isinstance(site.by_path[Path("posts/first-post")], Directory)
def test_read_config_errors(site_dir):
path = site_dir("thin_site")
config_path = path / "config.yml"
config_path.unlink()
with pytest.raises(ConfigError, match="config file missing"):
Site(path)
config_path.write_text("- a\n- b\n")
with pytest.raises(ConfigError, match="YAML mapping with string keys"):
Site(path)
config_path.write_text("123: value\n")
with pytest.raises(ConfigError, match="YAML mapping with string keys"):
Site(path)
config_path.write_text("template: plain\n")
with pytest.raises(ConfigError, match="must contain a name property"):
Site(path)
config_path.write_text("name: test\n")
with pytest.raises(ConfigError, match="must contain a template property"):
Site(path)
def test_site_relative_base_url(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text("name: test\ntemplate: plain\nbase_url: blog\n")
with pytest.raises(ConfigError, match="relative path"):
site = Site(path)
def test_discover_skips_drafts_by_default(make_site):
site = make_site("draft_content")
site.discover()
assert len(site.content) == 1
assert site.content[0].title == "Published"
def test_discover_includes_drafts_when_enabled(make_site):
site = make_site("draft_content", drafts=True)
site.discover()
assert len(site.content) == 2
def test_clean_removes_output_directory(make_site):
site = make_site("main")
site.build()
assert site.output_path.exists()
site.clean()
assert not site.output_path.exists()
site.clean()
assert not site.output_path.exists()
def test_resolve_template_path_raises_for_missing_template():
with pytest.raises(ConfigError, match="not found"):
Site.resolve_template_path("does-not-exist")
def test_home_url(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text("name: test\ntemplate: plain\n")
assert Site(path).home_url() == "/"
(path / "config.yml").write_text(
"name: test\ntemplate: plain\nbase_url: http://example.com/blog\n"
)
assert Site(path).home_url() == "/blog"
def test_url_for(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text(
"name: test\ntemplate: plain\nbase_url: http://example.com/blog\n"
)
site = Site(path)
assert site.url_for("assets/style.css") == "/blog/assets/style.css"
with pytest.raises(OutputError, match="must be relative"):
site.url_for("/assets/style.css")
def test_section_url(site_dir):
path = site_dir("sections")
site = Site(path)
site.discover()
assert site.section_url("posts") == "/posts"
assert site.section_url("notes") == "/notes"
(path / "config.yml").write_text("name: test\ntemplate: plain\nbase_url: /blog\n")
site = Site(path)
site.discover()
assert site.section_url("posts") == "/blog/posts"
assert site.section_url("notes") == "/blog/notes"
def test_add_by_section(make_site):
site = make_site("sections")
site.discover()
assert set(site.by_section) == {"posts", "notes"}
assert {c.title for c in site.by_section["posts"]} == {"First Post", "Second Post"}
assert {c.title for c in site.by_section["notes"]} == {"First Note"}
def test_feed_url(site_dir):
path = site_dir("tags")
(path / "config.yml").write_text(
"name: test\ntemplate: plain\nbase_url: http://example.com\n"
)
site = Site(path)
site.discover()
assert site.feed_url("root", None) == "/atom.xml"
(path / "config.yml").write_text("name: test\ntemplate: plain\n")
site = Site(path)
site.discover()
assert site.feed_url("root", None) is None
def test_tag_url(site_dir):
path = site_dir("tags")
site = Site(path)
site.discover()
assert site.tag_url("python") == "/tags/python"
(path / "config.yml").write_text("name: test\ntemplate: plain\nbase_url: /blog\n")
site = Site(path)
site.discover()
assert site.tag_url("python") == "/blog/tags/python"
def test_tags_url(make_site):
site = make_site("tags")
site.discover()
assert site.tags_url() == "/tags"
def test_add_by_source(make_site):
site = make_site("tags")
site.discover()
assert site.by_source
for content in site.content:
assert site.by_source[content.source] is content
def test_tags_by_count(make_site):
site = make_site("tags")
site.discover()
assert site.tags_by_count == [("python", 2), ("testing", 1)]
def test_add_by_tag(make_site):
site = make_site("tags")
site.discover()
assert set(site.by_tag) == {"python", "testing"}
assert {c.title for c in site.by_tag["python"]} == {"One Tag", "Two Tags"}
assert {c.title for c in site.by_tag["testing"]} == {"Two Tags"}