Coverage for sources/emcdproj/website.py: 95%

109 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-05-27 20:29 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Static website maintenance utilities for projects. ''' 

22# TODO: Support separate section for current documentation: stable, latest. 

23# TODO? Separate coverage SVG files for each release. 

24 

25 

26from __future__ import annotations 

27 

28import jinja2 as _jinja2 

29 

30from . import __ 

31from . import exceptions as _exceptions 

32from . import interfaces as _interfaces 

33 

34 

35class CommandDispatcher( 

36 _interfaces.CliCommand, decorators = ( __.standard_tyro_class, ), 

37): 

38 ''' Dispatches commands for static website maintenance. ''' 

39 

40 command: __.typx.Union[ 

41 __.typx.Annotated[ 

42 SurveyCommand, 

43 __.tyro.conf.subcommand( 'survey', prefix_name = False ), 

44 ], 

45 __.typx.Annotated[ 

46 UpdateCommand, 

47 __.tyro.conf.subcommand( 'update', prefix_name = False ), 

48 ], 

49 ] 

50 

51 async def __call__( 

52 self, auxdata: __.Globals, display: _interfaces.ConsoleDisplay 

53 ) -> None: 

54 ictr( 1 )( self.command ) 

55 await self.command( auxdata = auxdata, display = display ) 

56 

57 

58class SurveyCommand( 

59 _interfaces.CliCommand, decorators = ( __.standard_tyro_class, ), 

60): 

61 ''' Surveys release versions published in static website. ''' 

62 

63 async def __call__( 

64 self, auxdata: __.Globals, display: _interfaces.ConsoleDisplay 

65 ) -> None: 

66 # TODO: Implement. 

67 pass 

68 

69 

70class UpdateCommand( 

71 _interfaces.CliCommand, decorators = ( __.standard_tyro_class, ), 

72): 

73 ''' Updates static website for particular release version. ''' 

74 

75 version: __.typx.Annotated[ 

76 str, 

77 __.typx.Doc( ''' Release version to update. ''' ), 

78 __.tyro.conf.Positional, 

79 ] 

80 

81 async def __call__( 

82 self, auxdata: __.Globals, display: _interfaces.ConsoleDisplay 

83 ) -> None: 

84 update( auxdata, self.version ) 

85 

86 

87class Locations( metaclass = __.ImmutableDataclass ): 

88 ''' Locations associated with website maintenance. ''' 

89 

90 project: __.Path 

91 auxiliary: __.Path 

92 publications: __.Path 

93 archive: __.Path 

94 artifacts: __.Path 

95 website: __.Path 

96 coverage: __.Path 

97 index: __.Path 

98 versions: __.Path 

99 templates: __.Path 

100 

101 @classmethod 

102 def from_project_anchor( 

103 selfclass, 

104 auxdata: __.Globals, 

105 anchor: __.Absential[ __.Path ] = __.absent, 

106 ) -> __.typx.Self: 

107 ''' Produces locations from project anchor, if provided. 

108 

109 If project anchor is not given, then attempt to discover it. 

110 ''' 

111 if __.is_absent( anchor ): 111 ↛ 114line 111 didn't jump to line 114 because the condition on line 111 was never true

112 # TODO: Discover missing anchor via directory traversal, 

113 # seeking VCS markers. 

114 project = __.Path( ).resolve( strict = True ) 

115 else: project = anchor.resolve( strict = True ) 

116 auxiliary = project / '.auxiliary' 

117 publications = auxiliary / 'publications' 

118 templates = auxdata.distribution.provide_data_location( 'templates' ) 

119 return selfclass( 

120 project = project, 

121 auxiliary = auxiliary, 

122 publications = publications, 

123 archive = publications / 'website.tar.xz', 

124 artifacts = auxiliary / 'artifacts', 

125 website = auxiliary / 'artifacts/website', 

126 coverage = auxiliary / 'artifacts/website/coverage.svg', 

127 index = auxiliary / 'artifacts/website/index.html', 

128 versions = auxiliary / 'artifacts/website/versions.json', 

129 templates = templates ) 

130 

131 

132 

133def update( 

134 auxdata: __.Globals, 

135 version: str, *, 

136 project_anchor: __.Absential[ __.Path ] = __.absent 

137) -> None: 

138 ''' Updates project website with latest documentation and coverage. 

139 

140 Processes the specified version, copies documentation artifacts, 

141 updates version information, and generates coverage badges. 

142 ''' 

143 ictr( 2 )( version ) 

144 # TODO: Validate version string format. 

145 from tarfile import open as tarfile_open 

146 locations = Locations.from_project_anchor( auxdata, project_anchor ) 

147 locations.publications.mkdir( exist_ok = True, parents = True ) 

148 if locations.website.is_dir( ): __.shutil.rmtree( locations.website ) 

149 locations.website.mkdir( exist_ok = True, parents = True ) 

150 if locations.archive.is_file( ): 

151 with tarfile_open( locations.archive, 'r:xz' ) as archive: 

152 archive.extractall( path = locations.website ) # noqa: S202 

153 available_species = _update_available_species( locations, version ) 

154 j2context = _jinja2.Environment( 

155 loader = _jinja2.FileSystemLoader( locations.templates ), 

156 autoescape = True ) 

157 index_data = _update_versions_json( locations, version, available_species ) 

158 _update_index_html( locations, j2context, index_data ) 

159 if ( locations.artifacts / 'coverage-pytest' ).is_dir( ): 

160 _update_coverage_badge( locations, j2context ) 

161 ( locations.website / '.nojekyll' ).touch( ) 

162 from .filesystem import chdir 

163 with chdir( locations.website ): # noqa: SIM117 

164 with tarfile_open( locations.archive, 'w:xz' ) as archive: 

165 archive.add( '.' ) 

166 

167 

168def _extract_coverage( locations: Locations ) -> int: 

169 ''' Extracts coverage percentage from coverage report. 

170 

171 Reads the coverage XML report and calculates the overall line coverage 

172 percentage, rounded down to the nearest integer. 

173 ''' 

174 location = locations.artifacts / 'coverage-pytest/coverage.xml' 

175 if not location.exists( ): raise _exceptions.FileAwol( location ) 

176 from defusedxml import ElementTree 

177 root = ElementTree.parse( location ).getroot( ) # pyright: ignore 

178 if root is None: 

179 raise _exceptions.FileEmpty( location ) # pragma: no cover 

180 line_rate = root.get( 'line-rate' ) 

181 if not line_rate: 

182 raise _exceptions.FileDataAwol( 

183 location, 'line-rate' ) # pragma: no cover 

184 return __.math.floor( float( line_rate ) * 100 ) 

185 

186 

187def _update_available_species( 

188 locations: Locations, version: str 

189) -> tuple[ str, ... ]: 

190 available_species: list[ str ] = [ ] 

191 for species in ( 'coverage-pytest', 'sphinx-html' ): 

192 origin = locations.artifacts / species 

193 if not origin.is_dir( ): continue 

194 destination = locations.website / version / species 

195 if destination.is_dir( ): __.shutil.rmtree( destination ) 

196 __.shutil.copytree( origin, destination ) 

197 available_species.append( species ) 

198 return tuple( available_species ) 

199 

200 

201def _update_coverage_badge( 

202 locations: Locations, j2context: _jinja2.Environment 

203) -> None: 

204 ''' Updates coverage badge SVG. 

205 

206 Generates a color-coded coverage badge based on the current coverage 

207 percentage. Colors indicate coverage quality: 

208 - red: < 50% 

209 - yellow: 50-79% 

210 - green: >= 80% 

211 ''' 

212 coverage = _extract_coverage( locations ) 

213 color = ( 

214 'red' if coverage < 50 else ( # noqa: PLR2004 

215 'yellow' if coverage < 80 else 'green' ) ) # noqa: PLR2004 

216 label_text = 'coverage' 

217 value_text = f"{coverage}%" 

218 label_width = len( label_text ) * 6 + 10 

219 value_width = len( value_text ) * 6 + 15 

220 total_width = label_width + value_width 

221 template = j2context.get_template( 'coverage.svg.jinja' ) 

222 # TODO: Add error handling for template rendering failures. 

223 with locations.coverage.open( 'w' ) as file: 

224 file.write( template.render( 

225 color = color, 

226 total_width = total_width, 

227 label_text = label_text, 

228 value_text = value_text, 

229 label_width = label_width, 

230 value_width = value_width ) ) 

231 

232 

233def _update_index_html( 

234 locations: Locations, 

235 j2context: _jinja2.Environment, 

236 data: dict[ __.typx.Any, __.typx.Any ], 

237) -> None: 

238 ''' Updates index.html with version information. 

239 

240 Generates the main index page showing all available versions and their 

241 associated documentation and coverage reports. 

242 ''' 

243 template = j2context.get_template( 'website.html.jinja' ) 

244 # TODO: Add error handling for template rendering failures. 

245 with locations.index.open( 'w' ) as file: 

246 file.write( template.render( **data ) ) 

247 

248 

249def _update_versions_json( 

250 locations: Locations, 

251 version: str, 

252 species: tuple[ str, ... ], 

253) -> dict[ __.typx.Any, __.typx.Any ]: 

254 ''' Updates versions.json with new version information. 

255 

256 Maintains a JSON file tracking all versions and their available 

257 documentation types. Versions are sorted in descending order, with 

258 the latest version marked separately. 

259 ''' 

260 # TODO: Add validation of version string format. 

261 # TODO: Consider file locking for concurrent update protection. 

262 from packaging.version import Version 

263 if not locations.versions.is_file( ): 

264 data: dict[ __.typx.Any, __.typx.Any ] = { 'versions': { } } 

265 with locations.versions.open( 'w' ) as file: 

266 __.json.dump( data, file, indent = 4 ) 

267 with locations.versions.open( 'r+' ) as file: 

268 data = __.json.load( file ) 

269 versions = data[ 'versions' ] 

270 versions[ version ] = species 

271 versions = dict( sorted( 

272 versions.items( ), 

273 key = lambda entry: Version( entry[ 0 ] ), 

274 reverse = True ) ) 

275 data[ 'latest_version' ] = next( iter( versions ) ) 

276 data[ 'versions' ] = versions 

277 file.seek( 0 ) 

278 __.json.dump( data, file, indent = 4 ) 

279 file.truncate( ) 

280 return data