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

1import pathlib 

2from typing import Annotated, BinaryIO 

3 

4import yaml 

5from pydantic import BaseModel, ConfigDict, Field 

6 

7from competitive_verifier.models import DocumentOutputMode, ForcePosixPath 

8 

9from .render_data import IndexRenderData, MultiCodePageData, PageRenderData 

10 

11_separator: bytes = b"---" 

12 

13 

14class FrontMatter(BaseModel): 

15 model_config = ConfigDict(extra="allow") 

16 

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 """ 

32 

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") 

40 

41 

42class Markdown(BaseModel): 

43 path: ForcePosixPath | None = None 

44 front_matter: FrontMatter | None 

45 content: bytes 

46 

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 ) 

53 

54 @classmethod 

55 def load_file(cls, path: pathlib.Path): 

56 with path.open("rb") as fp: 

57 return cls.load(fp, path) 

58 

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 ) 

67 

68 def dump_merged(self, fp: BinaryIO): 

69 merge_front_matter( 

70 fp, 

71 front_matter=self.front_matter, 

72 content=self.content, 

73 ) 

74 

75 

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 

87 

88 front_matter = b"\n".join(lines[1:i]) 

89 content = b"\n".join(lines[i + 1 :]) 

90 return front_matter, content 

91 

92 

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 

101 

102 

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)