Coverage for src / competitive_verifier / documents / front_matter.py: 100%
66 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-05 16:00 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-05 16:00 +0000
1import pathlib
2from typing import Annotated, BinaryIO
4import yaml
5from pydantic import BaseModel, ConfigDict, Field
7from competitive_verifier.models import DocumentOutputMode, ForcePosixPath
9from .render_data import IndexRenderData, MultiCodePageData, PageRenderData
11_separator: bytes = b"---"
14class FrontMatter(BaseModel):
15 model_config = ConfigDict(extra="allow")
17 display: DocumentOutputMode | None = None
18 title: str | None = None
19 layout: str | None = Field(
20 default=None,
21 examples=["toppage", "document", "multidoc"],
22 )
23 documentation_of: str | Annotated[list[str], Field(min_length=1)] | None = None
24 keep_single: bool | None = None
25 data: PageRenderData | MultiCodePageData | IndexRenderData | None = None
26 redirect_from: list[str] | None = None
27 """For jekyll-redirect-from plugin
28 """
29 redirect_to: str | None = None
30 """For jekyll-redirect-from plugin
31 """
33 def model_dump_yml(self):
34 d = self.model_dump(
35 mode="json",
36 by_alias=True,
37 exclude_none=True,
38 )
39 return yaml.safe_dump(d, encoding="utf-8", line_break="\n")
42class Markdown(BaseModel):
43 path: ForcePosixPath | None = None
44 front_matter: FrontMatter | None
45 content: bytes
47 @classmethod
48 def make_default(cls, source_path: pathlib.Path):
49 return cls(
50 front_matter=FrontMatter(documentation_of=source_path.as_posix()),
51 content=b"",
52 )
54 @classmethod
55 def load_file(cls, path: pathlib.Path):
56 with path.open("rb") as fp:
57 return cls.load(fp, path)
59 @classmethod
60 def load(cls, fp: BinaryIO, path: pathlib.Path | None = None):
61 front_matter, content = split_front_matter(fp.read())
62 return Markdown(
63 path=path,
64 front_matter=front_matter,
65 content=content,
66 )
68 def dump_merged(self, fp: BinaryIO):
69 merge_front_matter(
70 fp,
71 front_matter=self.front_matter,
72 content=self.content,
73 )
76def split_front_matter_raw(content: bytes) -> tuple[bytes | None, bytes]:
77 lines = content.splitlines()
78 if len(lines) == 0 or lines[0].rstrip() != _separator:
79 return (None, content)
80 for i, line in enumerate(lines):
81 if i == 0:
82 continue
83 if line.rstrip() == _separator:
84 break
85 else:
86 return None, content
88 front_matter = b"\n".join(lines[1:i])
89 content = b"\n".join(lines[i + 1 :])
90 return front_matter, content
93def split_front_matter(content: bytes) -> tuple[FrontMatter | None, bytes]:
94 fm_bytes, content = split_front_matter_raw(content)
95 if fm_bytes is None:
96 return None, content
97 fy = yaml.safe_load(fm_bytes)
98 if fy:
99 return FrontMatter.model_validate(fy), content
100 return FrontMatter(), content
103def merge_front_matter(
104 fp: BinaryIO,
105 *,
106 front_matter: FrontMatter | None,
107 content: bytes,
108):
109 if front_matter:
110 fp.write(_separator)
111 fp.write(b"\n")
112 fp.write(front_matter.model_dump_yml())
113 fp.write(_separator)
114 fp.write(b"\n")
115 fp.write(content)