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

109 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-10 21:48 +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, 

37 decorators = ( __.standard_tyro_class, ), 

38): 

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

40 

41 command: __.typx.Union[ 

42 __.typx.Annotated[ 

43 SurveyCommand, 

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

45 ], 

46 __.typx.Annotated[ 

47 UpdateCommand, 

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

49 ], 

50 ] 

51 

52 async def __call__( self, auxdata: __.Globals ) -> None: 

53 ictr( 1 )( self.command ) 

54 await self.command( auxdata = auxdata ) 

55 

56 

57class SurveyCommand( 

58 _interfaces.CliCommand, 

59 decorators = ( __.standard_tyro_class, ), 

60): 

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

62 

63 async def __call__( self, auxdata: __.Globals ) -> None: 

64 # TODO: Implement. 

65 pass 

66 

67 

68class UpdateCommand( 

69 _interfaces.CliCommand, 

70 decorators = ( __.standard_tyro_class, ), 

71): 

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

73 

74 version: __.typx.Annotated[ 

75 str, 

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

77 __.tyro.conf.Positional, 

78 ] 

79 

80 async def __call__( self, auxdata: __.Globals ) -> None: 

81 update( auxdata, self.version ) 

82 

83 

84class Locations( metaclass = __.ImmutableDataclass ): 

85 ''' Locations associated with website maintenance. ''' 

86 

87 project: __.Path 

88 auxiliary: __.Path 

89 publications: __.Path 

90 archive: __.Path 

91 artifacts: __.Path 

92 website: __.Path 

93 coverage: __.Path 

94 index: __.Path 

95 versions: __.Path 

96 templates: __.Path 

97 

98 @classmethod 

99 def from_project_anchor( 

100 selfclass, 

101 auxdata: __.Globals, 

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

103 ) -> __.typx.Self: 

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

105 

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

107 ''' 

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

109 # TODO: Discover missing anchor via directory traversal, 

110 # seeking VCS markers. 

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

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

113 auxiliary = project / '.auxiliary' 

114 publications = auxiliary / 'publications' 

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

116 return selfclass( 

117 project = project, 

118 auxiliary = auxiliary, 

119 publications = publications, 

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

121 artifacts = auxiliary / 'artifacts', 

122 website = auxiliary / 'artifacts/website', 

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

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

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

126 templates = templates ) 

127 

128 

129 

130def update( 

131 auxdata: __.Globals, 

132 version: str, *, 

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

134) -> None: 

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

136 

137 Processes the specified version, copies documentation artifacts, 

138 updates version information, and generates coverage badges. 

139 ''' 

140 ictr( 2 )( version ) 

141 # TODO: Validate version string format. 

142 from tarfile import open as tarfile_open 

143 locations = Locations.from_project_anchor( auxdata, project_anchor ) 

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

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

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

147 if locations.archive.is_file( ): 

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

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

150 available_species = _update_available_species( locations, version ) 

151 j2context = _jinja2.Environment( 

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

153 autoescape = True ) 

154 index_data = _update_versions_json( locations, version, available_species ) 

155 _update_index_html( locations, j2context, index_data ) 

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

157 _update_coverage_badge( locations, j2context ) 

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

159 from .filesystem import chdir 

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

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

162 archive.add( '.' ) 

163 

164 

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

166 ''' Extracts coverage percentage from coverage report. 

167 

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

169 percentage, rounded down to the nearest integer. 

170 ''' 

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

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

173 from defusedxml import ElementTree 

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

175 if root is None: 

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

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

178 if not line_rate: 

179 raise _exceptions.FileDataAwol( 

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

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

182 

183 

184def _update_available_species( 

185 locations: Locations, version: str 

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

187 available_species: list[ str ] = [ ] 

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

189 origin = locations.artifacts / species 

190 if not origin.is_dir( ): continue 

191 destination = locations.website / version / species 

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

193 __.shutil.copytree( origin, destination ) 

194 available_species.append( species ) 

195 return tuple( available_species ) 

196 

197 

198def _update_coverage_badge( 

199 locations: Locations, j2context: _jinja2.Environment 

200) -> None: 

201 ''' Updates coverage badge SVG. 

202 

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

204 percentage. Colors indicate coverage quality: 

205 - red: < 50% 

206 - yellow: 50-79% 

207 - green: >= 80% 

208 ''' 

209 coverage = _extract_coverage( locations ) 

210 color = ( 

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

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

213 label_text = 'coverage' 

214 value_text = f"{coverage}%" 

215 label_width = len( label_text ) * 6 + 10 

216 value_width = len( value_text ) * 6 + 15 

217 total_width = label_width + value_width 

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

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

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

221 file.write( template.render( 

222 color = color, 

223 total_width = total_width, 

224 label_text = label_text, 

225 value_text = value_text, 

226 label_width = label_width, 

227 value_width = value_width ) ) 

228 

229 

230def _update_index_html( 

231 locations: Locations, 

232 j2context: _jinja2.Environment, 

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

234) -> None: 

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

236 

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

238 associated documentation and coverage reports. 

239 ''' 

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

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

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

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

244 

245 

246def _update_versions_json( 

247 locations: Locations, 

248 version: str, 

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

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

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

252 

253 Maintains a JSON file tracking all versions and their available 

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

255 the latest version marked separately. 

256 ''' 

257 # TODO: Add validation of version string format. 

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

259 from packaging.version import Version 

260 if not locations.versions.is_file( ): 

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

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

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

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

265 data = __.json.load( file ) 

266 versions = data[ 'versions' ] 

267 versions[ version ] = species 

268 versions = dict( sorted( 

269 versions.items( ), 

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

271 reverse = True ) ) 

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

273 data[ 'versions' ] = versions 

274 file.seek( 0 ) 

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

276 file.truncate( ) 

277 return data