emcd-projects 1.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
emcdproj/website.py ADDED
@@ -0,0 +1,279 @@
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
+
26
+ from __future__ import annotations
27
+
28
+ import jinja2 as _jinja2
29
+
30
+ from . import __
31
+ from . import exceptions as _exceptions
32
+ from . import interfaces as _interfaces
33
+
34
+
35
+ class 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
+
57
+ class 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
+
68
+ class 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
+
84
+ class 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 ):
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
+
130
+ def 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
+
165
+ def _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
+
184
+ def _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
+
198
+ def _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
+
232
+ def _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
+
248
+ def _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