Coverage for src / competitive_verifier / documents / builder.py: 98%

83 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-06-05 08:59 +0900

1import pathlib 

2import shutil 

3from logging import getLogger 

4from urllib.parse import urlparse 

5 

6from pydantic import BaseModel 

7 

8import competitive_verifier_resources 

9from competitive_verifier import git, github 

10from competitive_verifier.models import VerificationInput, VerifyCommandResult 

11 

12from .config import ConfigYaml, load_config_yml 

13from .front_matter import Markdown 

14from .render import RenderJob 

15 

16logger = getLogger(__name__) 

17 

18_DOC_USAGE_SAMPLE_REPOSITORY = "NotLeonian/competitive-verifier" 

19 

20_MINIMAL_THEME = "jekyll-theme-minimal" 

21_MINIMAL_REMOTE_THEME_SLUGS = frozenset( 

22 { 

23 "pages-themes/minimal", 

24 } 

25) 

26 

27 

28def remote_theme_slug(remote_theme: str) -> str: 

29 value = remote_theme.strip() 

30 

31 parsed = urlparse(value) 

32 if parsed.scheme and parsed.netloc: 

33 value = parsed.path 

34 

35 value = value.split("?", 1)[0] 

36 value = value.split("#", 1)[0] 

37 value = value.strip("/") 

38 value = value.split("@", 1)[0] 

39 

40 value = value.removesuffix(".git") 

41 

42 return value.lower() 

43 

44 

45def uses_minimal_theme(config_yml: ConfigYaml) -> bool: 

46 if config_yml.remote_theme: 

47 return remote_theme_slug(config_yml.remote_theme) in _MINIMAL_REMOTE_THEME_SLUGS 

48 

49 return config_yml.theme == _MINIMAL_THEME 

50 

51 

52class DocumentBuilder(BaseModel): 

53 verifications: VerificationInput 

54 result: VerifyCommandResult 

55 docs_dir: pathlib.Path 

56 destination_dir: pathlib.Path 

57 include: list[str] | None 

58 exclude: list[str] | None 

59 

60 def build(self) -> bool: 

61 logger.info("Working directory: %s", pathlib.Path.cwd().as_posix()) 

62 logger.info("Generate documents...") 

63 

64 # implementation 

65 result = self.impl() 

66 

67 logger.info("Generated.") 

68 logger.info( 

69 competitive_verifier_resources.doc_usage( 

70 markdown_dir_path=self.destination_dir, 

71 repo_name=github.env.get_repository() or _DOC_USAGE_SAMPLE_REPOSITORY, 

72 sample_repo_name=_DOC_USAGE_SAMPLE_REPOSITORY, 

73 ) 

74 ) 

75 

76 return result 

77 

78 def impl(self) -> bool: 

79 self.destination_dir.mkdir(parents=True, exist_ok=True) 

80 

81 config_yml = load_config_yml(self.docs_dir) 

82 logger.info("_config.yml: %s", config_yml) 

83 

84 index_md_path = self.docs_dir / "index.md" 

85 index_md = Markdown.load_file(index_md_path) if index_md_path.exists() else None 

86 

87 # Write code documents. 

88 self.write_code_docs( 

89 config_yml=config_yml, 

90 index_md=index_md, 

91 static_dir=self.docs_dir / "static", 

92 ) 

93 

94 # Write _config.yml 

95 (self.destination_dir / "_config.yml").write_bytes(config_yml.model_dump_yml()) 

96 

97 # Copy static files 

98 self.copy_static_files( 

99 static_dir=self.docs_dir / "static", 

100 config_yml=config_yml, 

101 ) 

102 return True 

103 

104 def copy_static_files( 

105 self, 

106 *, 

107 static_dir: pathlib.Path, 

108 config_yml: ConfigYaml, 

109 ): 

110 logger.info("Copy library static files...") 

111 for path, content in competitive_verifier_resources.jekyll_files().items(): 

112 file_dst = self.destination_dir / path 

113 logger.debug("Writing to %s", file_dst.as_posix()) 

114 file_dst.parent.mkdir(parents=True, exist_ok=True) 

115 file_dst.write_bytes(content) 

116 

117 if uses_minimal_theme(config_yml): 

118 logger.info("Copy jekyll-theme-minimal overrides...") 

119 for ( 

120 path, 

121 content, 

122 ) in competitive_verifier_resources.jekyll_theme_override_files( 

123 "jekyll-theme-minimal", 

124 ).items(): 

125 file_dst = self.destination_dir / path 

126 logger.debug("Writing to %s", file_dst.as_posix()) 

127 file_dst.parent.mkdir(parents=True, exist_ok=True) 

128 file_dst.write_bytes(content) 

129 

130 logger.info("Copy user static files...") 

131 try: 

132 if static_dir.is_dir(): 

133 shutil.copytree( 

134 static_dir, 

135 self.destination_dir, 

136 dirs_exist_ok=True, 

137 ) 

138 except Exception: 

139 logger.exception("Failed to copy user static files.") 

140 

141 def write_code_docs( 

142 self, 

143 *, 

144 config_yml: ConfigYaml, 

145 index_md: Markdown | None, 

146 static_dir: pathlib.Path | None, 

147 ): 

148 logger.info("Write document files...") 

149 

150 exclude = (self.exclude or []) + (config_yml.exclude or []) 

151 if static_dir and static_dir.is_relative_to("."): 

152 exclude.append(self.docs_dir.relative_to(".").as_posix()) 

153 

154 sources = git.ls_files(*(self.include or [])) 

155 if exclude: 

156 sources -= git.ls_files(*exclude) 

157 

158 for job in RenderJob.enumerate_jobs( 

159 sources=sources, 

160 verifications=self.verifications, 

161 result=self.result, 

162 config=config_yml, 

163 index_md=index_md, 

164 ): 

165 logger.debug(job) 

166 dst = self.destination_dir / job.destination_name 

167 dst.parent.mkdir(parents=True, exist_ok=True) 

168 logger.info("writing to %s", dst.as_posix()) 

169 with dst.open("wb") as fp: 

170 job.write_to(fp)