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.
- emcd_projects-1.14.dist-info/METADATA +132 -0
- emcd_projects-1.14.dist-info/RECORD +24 -0
- emcd_projects-1.14.dist-info/WHEEL +4 -0
- emcd_projects-1.14.dist-info/entry_points.txt +2 -0
- emcd_projects-1.14.dist-info/licenses/LICENSE.txt +202 -0
- emcdproj/README.rst +93 -0
- emcdproj/__/__init__.py +33 -0
- emcdproj/__/application.py +50 -0
- emcdproj/__/distribution.py +90 -0
- emcdproj/__/imports.py +60 -0
- emcdproj/__/preparation.py +83 -0
- emcdproj/__/state.py +80 -0
- emcdproj/__init__.py +39 -0
- emcdproj/__main__.py +28 -0
- emcdproj/_typedecls/__builtins__.pyi +7 -0
- emcdproj/cli.py +115 -0
- emcdproj/data/.gitignore +0 -0
- emcdproj/data/templates/coverage.svg.jinja +35 -0
- emcdproj/data/templates/website.html.jinja +58 -0
- emcdproj/exceptions.py +69 -0
- emcdproj/filesystem.py +39 -0
- emcdproj/interfaces.py +41 -0
- emcdproj/py.typed +0 -0
- emcdproj/website.py +279 -0
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
|