Coverage for sources/emcdproj/website/publication.py: 82%
319 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 21:41 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-11 21:41 +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''' Publication building, promotion, and synchronization. '''
24from . import __
25from . import inventory as _inventory
26from . import locations as _locations
27from . import tracks as _tracks
30def survey(
31 auxdata: __.Globals, *,
32 project_anchor: __.Absential[ __.Path ] = __.absent,
33 use_extant: bool = False
34) -> None:
35 ''' Surveys release versions published in static website.
37 Lists all versions from the versions manifest, showing their
38 available documentation types and highlighting the latest version.
39 '''
40 locations = _locations.Locations.from_project_anchor(
41 auxdata, project_anchor )
42 if use_extant:
43 fetch_publication_branch_and_tarball( locations )
44 # Extract the fetched tarball to view published versions
45 if locations.archive.is_file( ):
46 from tarfile import open as tarfile_open
47 if locations.website.is_dir( ):
48 __.shutil.rmtree( locations.website )
49 locations.website.mkdir( exist_ok = True, parents = True )
50 with tarfile_open( locations.archive, 'r:xz' ) as archive:
51 safe_extractall( archive, locations.website )
52 if not locations.versions.is_file( ):
53 context = "published" if use_extant else "local"
54 print( f"No versions manifest found for {context} website. "
55 f"Run 'website update' first." )
56 return
57 with locations.versions.open( 'r' ) as file:
58 data = __.json.load( file )
59 versions = data.get( 'versions', { } )
60 latest = data.get( 'latest_version' )
61 if not versions:
62 context = "published" if use_extant else "local"
63 print( f"No versions found in {context} manifest." )
64 return
65 context = "Published" if use_extant else "Local"
66 print( f"{context} versions:" )
67 for version, species in versions.items( ):
68 marker = " (latest)" if version == latest else ""
69 species_list = ', '.join( species ) if species else "none"
70 print( f" {version}{marker}: {species_list}" )
73def update( # noqa: PLR0913
74 auxdata: __.Globals,
75 version: str, *,
76 project_anchor: __.Absential[ __.Path ] = __.absent,
77 use_extant: bool = False,
78 production: bool = False,
79 tracks: _tracks.TrackConfiguration = _tracks.DEFAULT_TRACKS,
80 configuration: __.Absential[ __.Path | str ] = __.absent,
81 legacy_inventory: __.Absential[ __.Path | str ] = __.absent,
82) -> None:
83 ''' Updates project website with latest documentation and coverage.
85 Processes the specified version, copies documentation artifacts,
86 updates version information, and generates coverage badges.
87 Track configuration resolves from the explicit argument, then
88 the standard project configuration file, then baked-in defaults.
89 '''
90 ictr( 2 )( version )
91 validate_version_label( version )
92 locations = _locations.Locations.from_project_anchor(
93 auxdata, project_anchor )
94 if not __.is_absent( configuration ): 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 tracks = _tracks.load_track_configuration( # type: ignore[arg-type]
96 configuration )
97 elif tracks is _tracks.DEFAULT_TRACKS:
98 tracks = _tracks.load_default_configuration( locations.project )
99 tracks = _tracks.validate_track_configuration( tracks )
100 locations.publications.mkdir( exist_ok = True, parents = True )
101 # --production implies --use-extant to prevent clobbering existing versions
102 expected_sha = ''
103 if use_extant or production: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 expected_sha = fetch_publication_branch_and_tarball( locations )
105 try:
106 _build_publication_tree(
107 locations, version, tracks, production, legacy_inventory )
108 except Exception:
109 _restore_publication_tree( locations )
110 raise
111 from tarfile import open as tarfile_open
112 from ..filesystem import chdir
113 with chdir( locations.website ): # noqa: SIM117
114 with tarfile_open( locations.archive, 'w:xz' ) as archive:
115 archive.add( '.' )
116 if production: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 update_publication_branch( locations, version, expected_sha )
120def _promote_track_aliases(
121 locations: _locations.Locations, data: dict[ __.typx.Any, __.typx.Any ]
122) -> None:
123 ''' Promotes every resolved track alias from its selected version.
125 All aliases, including custom stable aliases, emit from the
126 resolved selections, so out-of-order publishes never downgrade
127 an alias to an older version.
128 '''
129 for alias, version in data.get( 'track_versions', { } ).items( ):
130 copy_version_to_alias( locations, version, alias )
133def validate_version_label( version: str ) -> None:
134 ''' Validates version label before any destructive operation.
136 Published versions are package versions and must parse as PEP 440
137 (with optional leading `v`); anything else fails fast here rather
138 than crashing version ordering later.
139 '''
140 if not version:
141 raise __.exceptions.PublicationSourceInvalidity( version,
142 'empty version' )
143 if (
144 version.startswith( '/' )
145 or version.startswith( '\\' )
146 or '..' in version.replace( '\\', '/' ).split( '/' )
147 or '/' in version
148 or '\\' in version
149 or version in ( '.', '..' )
150 or version.startswith( '.' )
151 ):
152 raise __.exceptions.PublicationSourceInvalidity( version,
153 'traversal rejected' )
154 from packaging.version import InvalidVersion, Version
155 try: Version( version.lstrip( 'v' ) )
156 except InvalidVersion as exception:
157 raise __.exceptions.PublicationSourceInvalidity(
158 version, 'unparseable version' ) from exception
161def _build_publication_tree(
162 locations: _locations.Locations,
163 version: str,
164 tracks: _tracks.TrackConfiguration,
165 strict: bool = False,
166 legacy_inventory: __.Absential[ __.Path | str ] = __.absent,
167) -> None:
168 ''' Builds version tree, aliases, and indexes for publication. '''
169 baseline = _inventory.snapshot_archive( locations.archive )
170 _restore_publication_tree( locations )
171 available_species = update_available_species( locations, version )
172 j2context = __.jinja2.Environment(
173 loader = __.jinja2.FileSystemLoader( locations.templates ),
174 autoescape = True )
175 index_data = update_versions_json(
176 locations, version, available_species )
177 _enhance_index_data_with_stable_dev( index_data, tracks )
178 _tracks.reject_ambiguous_known_tags(
179 tracks, list( index_data.get( 'versions', { } ).keys( ) ) )
180 _inventory.reject_alias_collisions( index_data, version )
181 _promote_track_aliases( locations, index_data )
182 update_index_html( locations, j2context, index_data )
183 if ( locations.artifacts / 'coverage-pytest' ).is_dir( ):
184 update_coverage_badge( locations, j2context )
185 update_version_coverage_badge( locations, j2context, version )
186 ( locations.website / '.nojekyll' ).touch( )
187 record_provenance( locations, version, tracks, strict )
188 _validate_publication_tree( locations, version, index_data )
189 _inventory.verify_retained_history( locations, baseline, version, tracks )
190 if not __.is_absent( legacy_inventory ):
191 _inventory.validate_legacy_inventory(
192 locations, legacy_inventory ) # type: ignore[arg-type]
195def copy_version_to_alias(
196 locations: _locations.Locations, version: str, alias: str
197) -> None:
198 ''' Copies version tree to alias directory. '''
199 canonical = _tracks.validate_alias( alias )
200 source = locations.website / version
201 destination = locations.website / canonical
202 if source == destination: return 202 ↛ exitline 202 didn't return from function 'copy_version_to_alias' because the return on line 202 wasn't executed
203 if destination.is_dir( ): __.shutil.rmtree( destination )
204 if source.is_dir( ): __.shutil.copytree( source, destination )
207def _enhance_index_data_with_stable_dev(
208 data: dict[ __.typx.Any, __.typx.Any ],
209 tracks: _tracks.TrackConfiguration = _tracks.DEFAULT_TRACKS,
210) -> None:
211 ''' Enhances index data with stable/development version information.
213 Selects versions per configured track namespaces and selectors,
214 defaulting to latest final release for stable and latest prerelease
215 for development exactly as before.
216 '''
217 versions = data.get( 'versions', { } )
218 if not versions: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 data[ 'stable_dev_versions' ] = { }
220 data[ 'track_versions' ] = { }
221 return
222 track_versions: dict[ __.typx.Any, __.typx.Any ] = { }
223 for name, track in tracks.items( ):
224 selected = _tracks.select_latest(
225 versions.keys( ), track.get( 'tag-namespace', '' ),
226 track.get( 'selector', 'exact-tag' ), name )
227 if not __.is_absent( selected ):
228 track_versions[ track.get( 'alias', name ) ] = selected
229 data[ 'track_versions' ] = track_versions
230 stable_version = track_versions.get( 'stable' )
231 development_version = track_versions.get( 'development' )
232 stable_dev_versions: dict[ str, tuple[ str, ... ] ] = { }
233 if stable_version is not None:
234 stable_dev_versions[ 'stable (current)' ] = versions[ stable_version ]
235 data[ 'stable_version' ] = stable_version
236 if development_version is not None: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 stable_dev_versions[ 'development (current)' ] = (
238 versions[ development_version ] )
239 data[ 'development_version' ] = development_version
240 data[ 'stable_dev_versions' ] = stable_dev_versions
243def extract_coverage( locations: _locations.Locations ) -> int:
244 ''' Extracts coverage percentage from coverage report.
246 Reads the coverage XML report and calculates the overall line coverage
247 percentage, rounded down to the nearest integer.
248 '''
249 location = locations.artifacts / 'coverage-pytest/coverage.xml'
250 if not location.exists( ): raise __.exceptions.FileAwol( location )
251 from defusedxml import ElementTree
252 root = ElementTree.parse( location ).getroot( ) # pyright: ignore
253 if root is None:
254 raise __.exceptions.FileEmpty( location ) # pragma: no cover
255 line_rate = root.get( 'line-rate' )
256 if not line_rate:
257 raise __.exceptions.FileDataAwol(
258 location, 'line-rate' ) # pragma: no cover
259 return __.math.floor( float( line_rate ) * 100 )
262def fetch_publication_branch_and_tarball(
263 locations: _locations.Locations,
264) -> str:
265 ''' Fetches publication branch and tarball, returning destination SHA.
267 Returns the fetched destination commit only when that exact SHA's
268 archive was loaded, or an empty string when remote branch absence
269 is confirmed. Raises on query failure, failed fetch with a present
270 remote branch, and unrestorable archives, so builds never proceed
271 silently on stale bases. The returned SHA threads through build to
272 push unchanged as the compare-and-swap baseline.
273 '''
274 fetch = _run_git(
275 locations, [ 'fetch', 'origin', 'publication:publication' ],
276 check = False )
277 if fetch.returncode != 0:
278 if _remote_has_publication( locations ): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 raise __.exceptions.PublicationSourceInvalidity(
280 'publication', 'fetch failed with remote branch present' )
281 _clear_stale_publication_state( locations )
282 return ''
283 probe = _run_git(
284 locations,
285 [ 'rev-parse', '--verify', '--quiet', 'publication' ],
286 check = False )
287 destination = probe.stdout.strip( ) if probe.returncode == 0 else ''
288 if not destination: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 raise __.exceptions.PublicationSourceInvalidity(
290 'publication', 'unresolvable destination' )
291 if locations.archive.is_file( ): 291 ↛ 293line 291 didn't jump to line 293 because the condition on line 291 was always true
292 locations.archive.unlink( )
293 checkout = _run_git(
294 locations,
295 [ 'checkout', destination, '--', str( locations.archive ) ],
296 check = False )
297 if checkout.returncode != 0 or not locations.archive.is_file( ): 297 ↛ 300line 297 didn't jump to line 300 because the condition on line 297 was always true
298 raise __.exceptions.PublicationSourceInvalidity(
299 'publication', 'archive unrestorable from destination' )
300 return destination
303def _clear_stale_publication_state( locations: _locations.Locations ) -> None:
304 ''' Clears stale local archive and publication ancestry.
306 Runs on confirmed remote absence so an absent destination never
307 publishes unrelated stale local state. Establishes absence and
308 raises when either removal fails.
309 '''
310 if locations.archive.is_file( ) or locations.archive.is_symlink( ):
311 try: locations.archive.unlink( )
312 except OSError as exception:
313 raise __.exceptions.PublicationSourceInvalidity(
314 'archive', 'stale archive irremovable' ) from exception
315 elif locations.archive.exists( ): 315 ↛ 318line 315 didn't jump to line 318 because the condition on line 315 was always true
316 raise __.exceptions.PublicationSourceInvalidity(
317 'archive', 'stale archive irremovable' )
318 with __.ctxl.suppress( Exception ):
319 _run_git(
320 locations, [ 'branch', '--delete', '--force', 'publication' ],
321 check = False )
322 probe = _run_git(
323 locations,
324 [ 'show-ref', '--verify', '--quiet', 'refs/heads/publication' ],
325 check = False )
326 if probe.returncode == 0:
327 raise __.exceptions.PublicationSourceInvalidity(
328 'publication', 'stale ancestry irremovable' )
331def _remote_has_publication( locations: _locations.Locations ) -> bool:
332 ''' Checks whether origin holds a publication branch.
334 Returns false only when absence is confirmed (no remote or empty
335 remote branch list). Raises on query failure so callers fail
336 closed instead of guessing absence.
337 '''
338 remote = _run_git(
339 locations, [ 'remote', 'get-url', 'origin' ], check = False )
340 if remote.returncode != 0: return False
341 try:
342 probe = _run_git(
343 locations, [ 'ls-remote', '--heads', 'origin', 'publication' ],
344 check = False )
345 except Exception as exception:
346 raise __.exceptions.PublicationSourceInvalidity(
347 'publication', 'remote probe failed' ) from exception
348 if probe.returncode != 0:
349 raise __.exceptions.PublicationSourceInvalidity(
350 'publication', 'remote probe failed' )
351 return bool( probe.stdout.strip( ) )
354def _generate_coverage_badge_svg(
355 locations: _locations.Locations, j2context: __.jinja2.Environment
356) -> str:
357 ''' Generates coverage badge SVG content.
359 Returns the rendered SVG content for a coverage badge based on the
360 current coverage percentage. Colors indicate coverage quality:
361 - red: < 50%
362 - yellow: 50-79%
363 - green: >= 80%
364 '''
365 coverage = extract_coverage( locations )
366 color = (
367 'red' if coverage < 50 else ( # noqa: PLR2004
368 'yellow' if coverage < 80 else 'green' ) ) # noqa: PLR2004
369 label_text = 'coverage'
370 value_text = f"{coverage}%"
371 label_width = len( label_text ) * 6 + 10
372 value_width = len( value_text ) * 6 + 15
373 total_width = label_width + value_width
374 template = j2context.get_template( 'coverage.svg.jinja' )
375 # TODO: Add error handling for template rendering failures.
376 return template.render(
377 color = color,
378 total_width = total_width,
379 label_text = label_text,
380 value_text = value_text,
381 label_width = label_width,
382 value_width = value_width )
385def record_provenance(
386 locations: _locations.Locations,
387 version: str,
388 tracks: _tracks.TrackConfiguration,
389 strict: bool = False,
390) -> None:
391 ''' Records tag-to-commit provenance for publication.
393 Maintains one record per retained release. Refuses to record when
394 source identity is unavailable in strict mode, so production never
395 promotes with empty provenance.
396 '''
397 import datetime
398 commit = _resolve_source_identity( locations, version, strict )
399 aliases: list[ str ] = [ ]
400 for name, track in tracks.items( ):
401 alias = track.get( 'alias', name )
402 if alias not in aliases: aliases.append( alias )
403 data: dict[ str, __.typx.Any ] = { }
404 provenance_file = locations.website / 'provenance.json'
405 if provenance_file.is_file( ):
406 try:
407 with provenance_file.open( 'r' ) as file:
408 loaded: dict[ str, __.typx.Any ] = __.json.load( file )
409 except ValueError as exception:
410 raise __.exceptions.PublicationSourceInvalidity(
411 'provenance', 'unparseable record' ) from exception
412 if not isinstance( loaded, dict ): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true
413 raise __.exceptions.PublicationSourceInvalidity(
414 'provenance', 'malformed record' )
415 data = loaded
416 data[ version ] = {
417 'commit': commit,
418 'timestamp': datetime.datetime.now(
419 datetime.timezone.utc ).isoformat( ),
420 'aliases': sorted( aliases ),
421 }
422 with provenance_file.open( 'w' ) as file:
423 __.json.dump( data, file, indent = 4 )
426def reject_symlinks_in_tree( root: __.Path ) -> None:
427 ''' Rejects symlinks anywhere under publication tree. '''
428 for path in sorted( root.rglob( '*' ) ):
429 if path.is_symlink( ):
430 raise __.exceptions.PublicationSourceInvalidity(
431 str( path ), 'symlink rejected' )
434def _resolve_source_identity(
435 locations: _locations.Locations, version: str, strict: bool
436) -> str:
437 ''' Resolves source commit, checking release tag in strict mode. '''
438 commit = ''
439 with __.ctxl.suppress( Exception ):
440 result = _run_git(
441 locations, [ 'rev-parse', 'HEAD' ], check = False )
442 commit = result.stdout.strip( )
443 if strict and not commit:
444 raise __.exceptions.PublicationSourceInvalidity(
445 version, 'source identity unavailable' )
446 if strict:
447 tag_sha = _resolve_release_tag( locations, version )
448 if not tag_sha:
449 raise __.exceptions.PublicationSourceInvalidity(
450 version, 'unresolvable release tag' )
451 if tag_sha != commit:
452 raise __.exceptions.PublicationSourceInvalidity(
453 version, 'source identity mismatch' )
454 return commit
457def _resolve_release_tag(
458 locations: _locations.Locations, version: str
459) -> str:
460 ''' Resolves release tag to commit SHA, or empty when absent.
462 Peels exact tag references to commits, so annotated and signed
463 tags compare by commit identity. Branches or other revisions
464 sharing the name never resolve.
465 '''
466 candidates = [ version ]
467 if not version.startswith( 'v' ): 467 ↛ 469line 467 didn't jump to line 469 because the condition on line 467 was always true
468 candidates.append( 'v' + version )
469 for candidate in candidates:
470 probe = _run_git(
471 locations,
472 [ 'rev-parse', '--verify', '--quiet',
473 f"refs/tags/{candidate}^{{commit}}" ],
474 check = False )
475 if probe.returncode == 0 and probe.stdout.strip( ):
476 return probe.stdout.strip( )
477 return ''
480def _restore_publication_tree( locations: _locations.Locations ) -> None:
481 ''' Restores prior publication tree from archive, if present. '''
482 from tarfile import open as tarfile_open
483 if locations.website.is_dir( ): __.shutil.rmtree( locations.website )
484 locations.website.mkdir( exist_ok = True, parents = True )
485 if locations.archive.is_file( ):
486 with tarfile_open( locations.archive, 'r:xz' ) as archive:
487 safe_extractall( archive, locations.website )
490def _run_git(
491 locations: _locations.Locations,
492 arguments: __.cabc.Sequence[ str ],
493 check: bool = True,
494) -> __.typx.Any:
495 ''' Runs git in the project without ambient repository redirection.
497 Strips variables that would redirect git at another repository
498 (e.g. `GIT_INDEX_FILE` exported by outer commit hooks) while
499 preserving identity, transport, and editor settings.
500 '''
501 environment = {
502 key: value for key, value in __.os.environ.items( )
503 if key not in (
504 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE',
505 'GIT_PREFIX', 'GIT_COMMON_DIR' ) }
506 return __.subprocess.run(
507 [ 'git', *arguments ],
508 cwd = locations.project,
509 check = check,
510 capture_output = True,
511 text = True,
512 env = environment )
515def safe_extractall( archive: __.typx.Any, destination: __.Path ) -> None:
516 ''' Extracts archive after rejecting dangerous members. '''
517 for member in archive.getmembers( ):
518 name = member.name
519 if name.startswith( '/' ) or name.startswith( '\\' ):
520 raise __.exceptions.PublicationSourceInvalidity(
521 name, 'absolute path rejected' )
522 parts = name.replace( '\\', '/' ).split( '/' )
523 if '..' in parts:
524 raise __.exceptions.PublicationSourceInvalidity(
525 name, 'traversal rejected' )
526 if member.issym( ) or member.islnk( ):
527 raise __.exceptions.PublicationSourceInvalidity(
528 name, 'link rejected' )
529 if (
530 member.ischr( ) or member.isblk( )
531 or member.isfifo( ) or member.isdev( )
532 ):
533 raise __.exceptions.PublicationSourceInvalidity(
534 name, 'special file rejected' )
535 if not ( member.isfile( ) or member.isdir( ) ): 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true
536 raise __.exceptions.PublicationSourceInvalidity(
537 name, 'non-regular rejected' )
538 archive.extractall( path = destination ) # noqa: S202
541def _validate_publication_tree(
542 locations: _locations.Locations,
543 version: str,
544 index_data: dict[ __.typx.Any, __.typx.Any ],
545) -> None:
546 ''' Validates built tree before alias promotion and archiving. '''
547 version_dir = locations.website / version
548 if not version_dir.is_dir( ): 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true
549 raise __.exceptions.PublicationSourceInvalidity( version,
550 'version tree absent' )
551 for requisite in ( locations.index, locations.versions ):
552 if not requisite.is_file( ): 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true
553 raise __.exceptions.PublicationSourceInvalidity(
554 str( requisite ), 'requisite absent' )
555 for alias in index_data.get( 'track_versions', { } ):
556 if not ( locations.website / alias ).is_dir( ): 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true
557 raise __.exceptions.PublicationSourceInvalidity( alias,
558 'alias tree absent' )
559 reject_symlinks_in_tree( locations.website )
562def _sort_versions(
563 versions: dict[ __.typx.Any, __.typx.Any ],
564) -> dict[ __.typx.Any, __.typx.Any ]:
565 ''' Sorts package versions descending, docs tags after by name. '''
566 from packaging.version import Version
567 package_entries: list[ tuple[ __.typx.Any, __.typx.Any ] ] = [
568 entry for entry in versions.items( )
569 if _tracks.is_package_version( entry[ 0 ] ) ]
570 docs_entries: list[ tuple[ __.typx.Any, __.typx.Any ] ] = [
571 entry for entry in versions.items( )
572 if not _tracks.is_package_version( entry[ 0 ] ) ]
573 package_entries.sort(
574 key = lambda entry: Version( str( entry[ 0 ] ).lstrip( 'v' ) ),
575 reverse = True )
576 docs_entries.sort( key = lambda entry: str( entry[ 0 ] ) )
577 return dict( package_entries + docs_entries )
580def update_available_species(
581 locations: _locations.Locations, version: str
582) -> tuple[ str, ... ]:
583 available_species: list[ str ] = [ ]
584 for species in ( 'coverage-pytest', 'sphinx-html' ):
585 origin = locations.artifacts / species
586 if not origin.is_dir( ): continue
587 destination = locations.website / version / species
588 if destination.is_dir( ): __.shutil.rmtree( destination )
589 __.shutil.copytree( origin, destination )
590 available_species.append( species )
591 return tuple( available_species )
594def update_coverage_badge(
595 locations: _locations.Locations, j2context: __.jinja2.Environment
596) -> None:
597 ''' Updates coverage badge SVG.
599 Generates a color-coded coverage badge based on the current coverage
600 percentage and writes it to the main coverage.svg location.
601 '''
602 svg_content = _generate_coverage_badge_svg( locations, j2context )
603 with locations.coverage.open( 'w' ) as file:
604 file.write( svg_content )
607def update_publication_branch(
608 locations: _locations.Locations, version: str, expected_sha: str = ''
609) -> None:
610 ''' Updates publication branch with new tarball.
612 Pushes with compare-and-swap against the expected destination SHA
613 captured from the same fetch that supplied the archive. Fails
614 without lost updates when the destination ref advances concurrently;
615 caller refetches, rebuilds, and revalidates or fails. Never
616 refetches here, so a stale build cannot acquire a fresh lease.
617 An empty SHA means confirmed remote absence and pushes with an
618 expected-absent lease, so concurrent creation fails instead of
619 merging with unrelated local ancestry.
620 '''
621 _run_git( locations, [ 'add', str( locations.archive ) ] )
622 # Commit to publication branch without checkout
623 # Get current tree hash
624 tree_result = _run_git( locations, [ 'write-tree' ] )
625 tree_hash = tree_result.stdout.strip( )
626 # Check if publication branch exists
627 publication_exists = _run_git(
628 locations,
629 [ 'show-ref', '--verify', '--quiet', 'refs/heads/publication' ],
630 check = False ).returncode == 0
631 lease_expectation = expected_sha
632 commit_result = _run_git(
633 locations,
634 [ 'commit-tree', tree_hash,
635 *( ( '-p', 'publication' ) if publication_exists else ( ) ),
636 '-m', f"Update documents for publication. ({version})" ] )
637 commit_hash = commit_result.stdout.strip( )
638 _run_git(
639 locations, [ 'branch', '--force', 'publication', commit_hash ] )
640 if lease_expectation:
641 _run_git(
642 locations,
643 [ 'push', '--force-with-lease=publication:'
644 + lease_expectation, 'origin', 'publication:publication' ] )
645 else:
646 _run_git(
647 locations,
648 [ 'push', '--force-with-lease=publication:',
649 'origin', 'publication:publication' ] )
652def update_index_html(
653 locations: _locations.Locations,
654 j2context: __.jinja2.Environment,
655 data: dict[ __.typx.Any, __.typx.Any ],
656) -> None:
657 ''' Updates index.html with version information.
659 Generates the main index page showing all available versions and their
660 associated documentation and coverage reports.
661 '''
662 template = j2context.get_template( 'website.html.jinja' )
663 # TODO: Add error handling for template rendering failures.
664 with locations.index.open( 'w' ) as file:
665 file.write( template.render( **data ) )
668def update_version_coverage_badge(
669 locations: _locations.Locations,
670 j2context: __.jinja2.Environment,
671 version: str
672) -> None:
673 ''' Updates version-specific coverage badge SVG.
675 Generates a coverage badge for the specific version and places it
676 in the version's subtree. This allows each version to have its own
677 coverage badge accessible at version/coverage.svg.
678 '''
679 svg_content = _generate_coverage_badge_svg( locations, j2context )
680 version_coverage_path = locations.website / version / 'coverage.svg'
681 with version_coverage_path.open( 'w' ) as file:
682 file.write( svg_content )
685def update_versions_json(
686 locations: _locations.Locations,
687 version: str,
688 species: tuple[ str, ... ],
689) -> dict[ __.typx.Any, __.typx.Any ]:
690 ''' Updates versions.json with new version information.
692 Maintains a JSON file tracking all versions and their available
693 documentation types. Versions are sorted in descending order, with
694 the latest version marked separately.
695 '''
696 # TODO: Add validation of version string format.
697 # TODO: Consider file locking for concurrent update protection.
698 if not locations.versions.is_file( ):
699 data: dict[ __.typx.Any, __.typx.Any ] = { 'versions': { } }
700 with locations.versions.open( 'w' ) as file:
701 __.json.dump( data, file, indent = 4 )
702 with locations.versions.open( 'r+' ) as file:
703 data = __.json.load( file )
704 versions = data[ 'versions' ]
705 versions[ version ] = species
706 versions = _sort_versions( versions )
707 data[ 'latest_version' ] = next( iter( versions ) )
708 data[ 'versions' ] = versions
709 file.seek( 0 )
710 __.json.dump( data, file, indent = 4 )
711 file.truncate( )
712 return data