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
« 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 -*-
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 ) # 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( '.' )
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(
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 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 ) )
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.
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 ) )
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.
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