Coverage for sources/emcdproj/website.py: 95%
109 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-30 13:40 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-30 13:40 +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, decorators = ( __.standard_tyro_class, ),
37):
38 ''' Dispatches commands for static website maintenance. '''
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 ]
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 )
58class SurveyCommand(
59 _interfaces.CliCommand, decorators = ( __.standard_tyro_class, ),
60):
61 ''' Surveys release versions published in static website. '''
63 async def __call__(
64 self, auxdata: __.Globals, display: _interfaces.ConsoleDisplay
65 ) -> None:
66 # TODO: Implement.
67 pass
70class UpdateCommand(
71 _interfaces.CliCommand, decorators = ( __.standard_tyro_class, ),
72):
73 ''' Updates static website for particular release version. '''
75 version: __.typx.Annotated[
76 str,
77 __.typx.Doc( ''' Release version to update. ''' ),
78 __.tyro.conf.Positional,
79 ]
81 async def __call__(
82 self, auxdata: __.Globals, display: _interfaces.ConsoleDisplay
83 ) -> None:
84 update( auxdata, self.version )
87class Locations( metaclass = __.ImmutableDataclass ):
88 ''' Locations associated with website maintenance. '''
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
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.
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 )
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.
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( '.' )
168def _extract_coverage( locations: Locations ) -> int:
169 ''' Extracts coverage percentage from coverage report.
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 )
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 )
201def _update_coverage_badge(
202 locations: Locations, j2context: _jinja2.Environment
203) -> None:
204 ''' Updates coverage badge SVG.
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 ) )
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.
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 ) )
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.
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