Coverage for sources/emcdproj/website.py: 95%
109 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 18:16 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 18:16 +0000
1# vim: set filetype=python fileencoding=utf-8:
2# -*- coding: utf-8 -*-
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#============================================================================#
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.
26from __future__ import annotations
28import jinja2 as _jinja2
30from . import __
31from . import exceptions as _exceptions
32from . import interfaces as _interfaces
35class CommandDispatcher(
36 _interfaces.CliCommand,
37 decorators = ( __.standard_tyro_class, ),
38):
39 ''' Dispatches commands for static website maintenance. '''
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 ]
52 async def __call__( self, auxdata: __.Globals ) -> None:
53 ictr( 1 )( self.command )
54 await self.command( auxdata = auxdata )
57class SurveyCommand(
58 _interfaces.CliCommand,
59 decorators = ( __.standard_tyro_class, ),
60):
61 ''' Surveys release versions published in static website. '''
63 async def __call__( self, auxdata: __.Globals ) -> None:
64 # TODO: Implement.
65 pass
68class UpdateCommand(
69 _interfaces.CliCommand,
70 decorators = ( __.standard_tyro_class, ),
71):
72 ''' Updates static website for particular release version. '''
74 version: __.typx.Annotated[
75 str,
76 __.typx.Doc( ''' Release version to update. ''' ),
77 __.tyro.conf.Positional,
78 ]
80 async def __call__( self, auxdata: __.Globals ) -> None:
81 update( auxdata, self.version )
84class Locations( metaclass = __.ImmutableDataclass ):
85 ''' Locations associated with website maintenance. '''
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
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.
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 )
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.
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( '.' )
165def _extract_coverage( locations: Locations ) -> int:
166 ''' Extracts coverage percentage from coverage report.
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 )
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 )
198def _update_coverage_badge( # pylint: disable=too-many-locals
199 locations: Locations, j2context: _jinja2.Environment
200) -> None:
201 ''' Updates coverage badge SVG.
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 ) )
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.
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 ) )
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.
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