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

109 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-09 03:38 +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 ) 

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

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( # pylint: disable=too-many-locals 

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 # pylint: disable=magic-value-comparison 

211 color = ( 

212 'red' if coverage < 50 else ( 

213 'yellow' if coverage < 80 else 'green' ) ) 

214 # pylint: enable=magic-value-comparison 

215 label_text = 'coverage' 

216 value_text = f"{coverage}%" 

217 label_width = len( label_text ) * 6 + 10 

218 value_width = len( value_text ) * 6 + 15 

219 total_width = label_width + value_width 

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

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

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

223 file.write( template.render( 

224 color = color, 

225 total_width = total_width, 

226 label_text = label_text, 

227 value_text = value_text, 

228 label_width = label_width, 

229 value_width = value_width ) ) 

230 

231 

232def _update_index_html( 

233 locations: Locations, 

234 j2context: _jinja2.Environment, 

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

236) -> None: 

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

238 

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

240 associated documentation and coverage reports. 

241 ''' 

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

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

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

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

246 

247 

248def _update_versions_json( 

249 locations: Locations, 

250 version: str, 

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

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

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

254 

255 Maintains a JSON file tracking all versions and their available 

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

257 the latest version marked separately. 

258 ''' 

259 # TODO: Add validation of version string format. 

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

261 from packaging.version import Version 

262 if not locations.versions.is_file( ): 

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

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

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

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

267 data = __.json.load( file ) 

268 versions = data[ 'versions' ] 

269 versions[ version ] = species 

270 versions = dict( sorted( 

271 versions.items( ), 

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

273 reverse = True ) ) 

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

275 data[ 'versions' ] = versions 

276 file.seek( 0 ) 

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

278 file.truncate( ) 

279 return data