Coverage for sources/emcdproj/website/publication.py: 82%
323 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 22:01 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 22:01 +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 _write_versions_json( locations, index_data )
179 _tracks.reject_ambiguous_known_tags(
180 tracks, list( index_data.get( 'versions', { } ).keys( ) ) )
181 _inventory.reject_alias_collisions( index_data, version )
182 _promote_track_aliases( locations, index_data )
183 update_index_html( locations, j2context, index_data )
184 if ( locations.artifacts / 'coverage-pytest' ).is_dir( ):
185 update_coverage_badge( locations, j2context )
186 update_version_coverage_badge( locations, j2context, version )
187 ( locations.website / '.nojekyll' ).touch( )
188 record_provenance( locations, version, tracks, strict )
189 _validate_publication_tree( locations, version, index_data )
190 _inventory.verify_retained_history( locations, baseline, version, tracks )
191 if not __.is_absent( legacy_inventory ):
192 _inventory.validate_legacy_inventory(
193 locations, legacy_inventory ) # type: ignore[arg-type]
196def copy_version_to_alias(
197 locations: _locations.Locations, version: str, alias: str
198) -> None:
199 ''' Copies version tree to alias directory. '''
200 canonical = _tracks.validate_alias( alias )
201 source = locations.website / version
202 destination = locations.website / canonical
203 if source == destination: return 203 ↛ exitline 203 didn't return from function 'copy_version_to_alias' because the return on line 203 wasn't executed
204 if destination.is_dir( ): __.shutil.rmtree( destination )
205 if source.is_dir( ): __.shutil.copytree( source, destination )
208def _enhance_index_data_with_stable_dev(
209 data: dict[ __.typx.Any, __.typx.Any ],
210 tracks: _tracks.TrackConfiguration = _tracks.DEFAULT_TRACKS,
211) -> None:
212 ''' Enhances index data with stable/development version information.
214 Selects versions per configured track namespaces and selectors,
215 defaulting to latest final release for stable and latest prerelease
216 for development exactly as before.
217 '''
218 versions = data.get( 'versions', { } )
219 if not versions: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true
220 data[ 'stable_dev_versions' ] = { }
221 data[ 'track_versions' ] = { }
222 return
223 track_versions: dict[ __.typx.Any, __.typx.Any ] = { }
224 for name, track in tracks.items( ):
225 selected = _tracks.select_latest(
226 versions.keys( ), track.get( 'tag-namespace', '' ),
227 track.get( 'selector', 'exact-tag' ), name )
228 if not __.is_absent( selected ):
229 track_versions[ track.get( 'alias', name ) ] = selected
230 data[ 'track_versions' ] = track_versions
231 stable_version = track_versions.get( 'stable' )
232 development_version = track_versions.get( 'development' )
233 stable_dev_versions: dict[ str, tuple[ str, ... ] ] = { }
234 if stable_version is not None:
235 stable_dev_versions[ 'stable (current)' ] = versions[ stable_version ]
236 data[ 'stable_version' ] = stable_version
237 if development_version is not None: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 stable_dev_versions[ 'development (current)' ] = (
239 versions[ development_version ] )
240 data[ 'development_version' ] = development_version
241 data[ 'stable_dev_versions' ] = stable_dev_versions
244def extract_coverage( locations: _locations.Locations ) -> int:
245 ''' Extracts coverage percentage from coverage report.
247 Reads the coverage XML report and calculates the overall line coverage
248 percentage, rounded down to the nearest integer.
249 '''
250 location = locations.artifacts / 'coverage-pytest/coverage.xml'
251 if not location.exists( ): raise __.exceptions.FileAwol( location )
252 from defusedxml import ElementTree
253 root = ElementTree.parse( location ).getroot( ) # pyright: ignore
254 if root is None:
255 raise __.exceptions.FileEmpty( location ) # pragma: no cover
256 line_rate = root.get( 'line-rate' )
257 if not line_rate:
258 raise __.exceptions.FileDataAwol(
259 location, 'line-rate' ) # pragma: no cover
260 return __.math.floor( float( line_rate ) * 100 )
263def fetch_publication_branch_and_tarball(
264 locations: _locations.Locations,
265) -> str:
266 ''' Fetches publication branch and tarball, returning destination SHA.
268 Returns the fetched destination commit only when that exact SHA's
269 archive was loaded, or an empty string when remote branch absence
270 is confirmed. Raises on query failure, failed fetch with a present
271 remote branch, and unrestorable archives, so builds never proceed
272 silently on stale bases. The returned SHA threads through build to
273 push unchanged as the compare-and-swap baseline.
274 '''
275 fetch = _run_git(
276 locations, [ 'fetch', 'origin', 'publication:publication' ],
277 check = False )
278 if fetch.returncode != 0:
279 if _remote_has_publication( locations ): 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true
280 raise __.exceptions.PublicationSourceInvalidity(
281 'publication', 'fetch failed with remote branch present' )
282 _clear_stale_publication_state( locations )
283 return ''
284 probe = _run_git(
285 locations,
286 [ 'rev-parse', '--verify', '--quiet', 'publication' ],
287 check = False )
288 destination = probe.stdout.strip( ) if probe.returncode == 0 else ''
289 if not destination: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 raise __.exceptions.PublicationSourceInvalidity(
291 'publication', 'unresolvable destination' )
292 if locations.archive.is_file( ): 292 ↛ 294line 292 didn't jump to line 294 because the condition on line 292 was always true
293 locations.archive.unlink( )
294 checkout = _run_git(
295 locations,
296 [ 'checkout', destination, '--', str( locations.archive ) ],
297 check = False )
298 if checkout.returncode != 0 or not locations.archive.is_file( ): 298 ↛ 301line 298 didn't jump to line 301 because the condition on line 298 was always true
299 raise __.exceptions.PublicationSourceInvalidity(
300 'publication', 'archive unrestorable from destination' )
301 return destination
304def _clear_stale_publication_state( locations: _locations.Locations ) -> None:
305 ''' Clears stale local archive and publication ancestry.
307 Runs on confirmed remote absence so an absent destination never
308 publishes unrelated stale local state. Establishes absence and
309 raises when either removal fails.
310 '''
311 if locations.archive.is_file( ) or locations.archive.is_symlink( ):
312 try: locations.archive.unlink( )
313 except OSError as exception:
314 raise __.exceptions.PublicationSourceInvalidity(
315 'archive', 'stale archive irremovable' ) from exception
316 elif locations.archive.exists( ): 316 ↛ 319line 316 didn't jump to line 319 because the condition on line 316 was always true
317 raise __.exceptions.PublicationSourceInvalidity(
318 'archive', 'stale archive irremovable' )
319 with __.ctxl.suppress( Exception ):
320 _run_git(
321 locations, [ 'branch', '--delete', '--force', 'publication' ],
322 check = False )
323 probe = _run_git(
324 locations,
325 [ 'show-ref', '--verify', '--quiet', 'refs/heads/publication' ],
326 check = False )
327 if probe.returncode == 0:
328 raise __.exceptions.PublicationSourceInvalidity(
329 'publication', 'stale ancestry irremovable' )
332def _remote_has_publication( locations: _locations.Locations ) -> bool:
333 ''' Checks whether origin holds a publication branch.
335 Returns false only when absence is confirmed (no remote or empty
336 remote branch list). Raises on query failure so callers fail
337 closed instead of guessing absence.
338 '''
339 remote = _run_git(
340 locations, [ 'remote', 'get-url', 'origin' ], check = False )
341 if remote.returncode != 0: return False
342 try:
343 probe = _run_git(
344 locations, [ 'ls-remote', '--heads', 'origin', 'publication' ],
345 check = False )
346 except Exception as exception:
347 raise __.exceptions.PublicationSourceInvalidity(
348 'publication', 'remote probe failed' ) from exception
349 if probe.returncode != 0:
350 raise __.exceptions.PublicationSourceInvalidity(
351 'publication', 'remote probe failed' )
352 return bool( probe.stdout.strip( ) )
355def _generate_coverage_badge_svg(
356 locations: _locations.Locations, j2context: __.jinja2.Environment
357) -> str:
358 ''' Generates coverage badge SVG content.
360 Returns the rendered SVG content for a coverage badge based on the
361 current coverage percentage. Colors indicate coverage quality:
362 - red: < 50%
363 - yellow: 50-79%
364 - green: >= 80%
365 '''
366 coverage = extract_coverage( locations )
367 color = (
368 'red' if coverage < 50 else ( # noqa: PLR2004
369 'yellow' if coverage < 80 else 'green' ) ) # noqa: PLR2004
370 label_text = 'coverage'
371 value_text = f"{coverage}%"
372 label_width = len( label_text ) * 6 + 10
373 value_width = len( value_text ) * 6 + 15
374 total_width = label_width + value_width
375 template = j2context.get_template( 'coverage.svg.jinja' )
376 # TODO: Add error handling for template rendering failures.
377 return template.render(
378 color = color,
379 total_width = total_width,
380 label_text = label_text,
381 value_text = value_text,
382 label_width = label_width,
383 value_width = value_width )
386def record_provenance(
387 locations: _locations.Locations,
388 version: str,
389 tracks: _tracks.TrackConfiguration,
390 strict: bool = False,
391) -> None:
392 ''' Records tag-to-commit provenance for publication.
394 Maintains one record per retained release. Refuses to record when
395 source identity is unavailable in strict mode, so production never
396 promotes with empty provenance.
397 '''
398 import datetime
399 commit = _resolve_source_identity( locations, version, strict )
400 aliases: list[ str ] = [ ]
401 for name, track in tracks.items( ):
402 alias = track.get( 'alias', name )
403 if alias not in aliases: aliases.append( alias )
404 data: dict[ str, __.typx.Any ] = { }
405 provenance_file = locations.website / 'provenance.json'
406 if provenance_file.is_file( ):
407 try:
408 with provenance_file.open( 'r' ) as file:
409 loaded: dict[ str, __.typx.Any ] = __.json.load( file )
410 except ValueError as exception:
411 raise __.exceptions.PublicationSourceInvalidity(
412 'provenance', 'unparseable record' ) from exception
413 if not isinstance( loaded, dict ): 413 ↛ 414line 413 didn't jump to line 414 because the condition on line 413 was never true
414 raise __.exceptions.PublicationSourceInvalidity(
415 'provenance', 'malformed record' )
416 data = loaded
417 data[ version ] = {
418 'commit': commit,
419 'timestamp': datetime.datetime.now(
420 datetime.timezone.utc ).isoformat( ),
421 'aliases': sorted( aliases ),
422 }
423 with provenance_file.open( 'w' ) as file:
424 __.json.dump( data, file, indent = 4 )
427def reject_symlinks_in_tree( root: __.Path ) -> None:
428 ''' Rejects symlinks anywhere under publication tree. '''
429 for path in sorted( root.rglob( '*' ) ):
430 if path.is_symlink( ):
431 raise __.exceptions.PublicationSourceInvalidity(
432 str( path ), 'symlink rejected' )
435def _resolve_source_identity(
436 locations: _locations.Locations, version: str, strict: bool
437) -> str:
438 ''' Resolves source commit, checking release tag in strict mode. '''
439 commit = ''
440 with __.ctxl.suppress( Exception ):
441 result = _run_git(
442 locations, [ 'rev-parse', 'HEAD' ], check = False )
443 commit = result.stdout.strip( )
444 if strict and not commit:
445 raise __.exceptions.PublicationSourceInvalidity(
446 version, 'source identity unavailable' )
447 if strict:
448 tag_sha = _resolve_release_tag( locations, version )
449 if not tag_sha:
450 raise __.exceptions.PublicationSourceInvalidity(
451 version, 'unresolvable release tag' )
452 if tag_sha != commit:
453 raise __.exceptions.PublicationSourceInvalidity(
454 version, 'source identity mismatch' )
455 return commit
458def _resolve_release_tag(
459 locations: _locations.Locations, version: str
460) -> str:
461 ''' Resolves release tag to commit SHA, or empty when absent.
463 Peels exact tag references to commits, so annotated and signed
464 tags compare by commit identity. Branches or other revisions
465 sharing the name never resolve.
466 '''
467 candidates = [ version ]
468 if not version.startswith( 'v' ): 468 ↛ 470line 468 didn't jump to line 470 because the condition on line 468 was always true
469 candidates.append( 'v' + version )
470 for candidate in candidates:
471 probe = _run_git(
472 locations,
473 [ 'rev-parse', '--verify', '--quiet',
474 f"refs/tags/{candidate}^{{commit}}" ],
475 check = False )
476 if probe.returncode == 0 and probe.stdout.strip( ):
477 return probe.stdout.strip( )
478 return ''
481def _restore_publication_tree( locations: _locations.Locations ) -> None:
482 ''' Restores prior publication tree from archive, if present. '''
483 from tarfile import open as tarfile_open
484 if locations.website.is_dir( ): __.shutil.rmtree( locations.website )
485 locations.website.mkdir( exist_ok = True, parents = True )
486 if locations.archive.is_file( ):
487 with tarfile_open( locations.archive, 'r:xz' ) as archive:
488 safe_extractall( archive, locations.website )
491def _run_git(
492 locations: _locations.Locations,
493 arguments: __.cabc.Sequence[ str ],
494 check: bool = True,
495) -> __.typx.Any:
496 ''' Runs git in the project without ambient repository redirection.
498 Strips variables that would redirect git at another repository
499 (e.g. `GIT_INDEX_FILE` exported by outer commit hooks) while
500 preserving identity, transport, and editor settings.
501 '''
502 environment = {
503 key: value for key, value in __.os.environ.items( )
504 if key not in (
505 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE',
506 'GIT_PREFIX', 'GIT_COMMON_DIR' ) }
507 return __.subprocess.run(
508 [ 'git', *arguments ],
509 cwd = locations.project,
510 check = check,
511 capture_output = True,
512 text = True,
513 env = environment )
516def safe_extractall( archive: __.typx.Any, destination: __.Path ) -> None:
517 ''' Extracts archive after rejecting dangerous members. '''
518 for member in archive.getmembers( ):
519 name = member.name
520 if name.startswith( '/' ) or name.startswith( '\\' ):
521 raise __.exceptions.PublicationSourceInvalidity(
522 name, 'absolute path rejected' )
523 parts = name.replace( '\\', '/' ).split( '/' )
524 if '..' in parts:
525 raise __.exceptions.PublicationSourceInvalidity(
526 name, 'traversal rejected' )
527 if member.issym( ) or member.islnk( ):
528 raise __.exceptions.PublicationSourceInvalidity(
529 name, 'link rejected' )
530 if (
531 member.ischr( ) or member.isblk( )
532 or member.isfifo( ) or member.isdev( )
533 ):
534 raise __.exceptions.PublicationSourceInvalidity(
535 name, 'special file rejected' )
536 if not ( member.isfile( ) or member.isdir( ) ): 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 raise __.exceptions.PublicationSourceInvalidity(
538 name, 'non-regular rejected' )
539 archive.extractall( path = destination ) # noqa: S202
542def _validate_publication_tree(
543 locations: _locations.Locations,
544 version: str,
545 index_data: dict[ __.typx.Any, __.typx.Any ],
546) -> None:
547 ''' Validates built tree before alias promotion and archiving. '''
548 version_dir = locations.website / version
549 if not version_dir.is_dir( ): 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true
550 raise __.exceptions.PublicationSourceInvalidity( version,
551 'version tree absent' )
552 for requisite in ( locations.index, locations.versions ):
553 if not requisite.is_file( ): 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true
554 raise __.exceptions.PublicationSourceInvalidity(
555 str( requisite ), 'requisite absent' )
556 for alias in index_data.get( 'track_versions', { } ):
557 if not ( locations.website / alias ).is_dir( ): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true
558 raise __.exceptions.PublicationSourceInvalidity( alias,
559 'alias tree absent' )
560 reject_symlinks_in_tree( locations.website )
563def _sort_versions(
564 versions: dict[ __.typx.Any, __.typx.Any ],
565) -> dict[ __.typx.Any, __.typx.Any ]:
566 ''' Sorts package versions descending, docs tags after by name. '''
567 from packaging.version import Version
568 package_entries: list[ tuple[ __.typx.Any, __.typx.Any ] ] = [
569 entry for entry in versions.items( )
570 if _tracks.is_package_version( entry[ 0 ] ) ]
571 docs_entries: list[ tuple[ __.typx.Any, __.typx.Any ] ] = [
572 entry for entry in versions.items( )
573 if not _tracks.is_package_version( entry[ 0 ] ) ]
574 package_entries.sort(
575 key = lambda entry: Version( str( entry[ 0 ] ).lstrip( 'v' ) ),
576 reverse = True )
577 docs_entries.sort( key = lambda entry: str( entry[ 0 ] ) )
578 return dict( package_entries + docs_entries )
581def update_available_species(
582 locations: _locations.Locations, version: str
583) -> tuple[ str, ... ]:
584 available_species: list[ str ] = [ ]
585 for species in ( 'coverage-pytest', 'sphinx-html' ):
586 origin = locations.artifacts / species
587 if not origin.is_dir( ): continue
588 destination = locations.website / version / species
589 if destination.is_dir( ): __.shutil.rmtree( destination )
590 __.shutil.copytree( origin, destination )
591 available_species.append( species )
592 return tuple( available_species )
595def update_coverage_badge(
596 locations: _locations.Locations, j2context: __.jinja2.Environment
597) -> None:
598 ''' Updates coverage badge SVG.
600 Generates a color-coded coverage badge based on the current coverage
601 percentage and writes it to the main coverage.svg location.
602 '''
603 svg_content = _generate_coverage_badge_svg( locations, j2context )
604 with locations.coverage.open( 'w' ) as file:
605 file.write( svg_content )
608def update_publication_branch(
609 locations: _locations.Locations, version: str, expected_sha: str = ''
610) -> None:
611 ''' Updates publication branch with new tarball.
613 Pushes with compare-and-swap against the expected destination SHA
614 captured from the same fetch that supplied the archive. Fails
615 without lost updates when the destination ref advances concurrently;
616 caller refetches, rebuilds, and revalidates or fails. Never
617 refetches here, so a stale build cannot acquire a fresh lease.
618 An empty SHA means confirmed remote absence and pushes with an
619 expected-absent lease, so concurrent creation fails instead of
620 merging with unrelated local ancestry.
621 '''
622 _run_git( locations, [ 'add', str( locations.archive ) ] )
623 # Commit to publication branch without checkout
624 # Get current tree hash
625 tree_result = _run_git( locations, [ 'write-tree' ] )
626 tree_hash = tree_result.stdout.strip( )
627 # Check if publication branch exists
628 publication_exists = _run_git(
629 locations,
630 [ 'show-ref', '--verify', '--quiet', 'refs/heads/publication' ],
631 check = False ).returncode == 0
632 lease_expectation = expected_sha
633 commit_result = _run_git(
634 locations,
635 [ 'commit-tree', tree_hash,
636 *( ( '-p', 'publication' ) if publication_exists else ( ) ),
637 '-m', f"Update documents for publication. ({version})" ] )
638 commit_hash = commit_result.stdout.strip( )
639 _run_git(
640 locations, [ 'branch', '--force', 'publication', commit_hash ] )
641 if lease_expectation:
642 _run_git(
643 locations,
644 [ 'push', '--force-with-lease=publication:'
645 + lease_expectation, 'origin', 'publication:publication' ] )
646 else:
647 _run_git(
648 locations,
649 [ 'push', '--force-with-lease=publication:',
650 'origin', 'publication:publication' ] )
653def update_index_html(
654 locations: _locations.Locations,
655 j2context: __.jinja2.Environment,
656 data: dict[ __.typx.Any, __.typx.Any ],
657) -> None:
658 ''' Updates index.html with version information.
660 Generates the main index page showing all available versions and their
661 associated documentation and coverage reports.
662 '''
663 template = j2context.get_template( 'website.html.jinja' )
664 # TODO: Add error handling for template rendering failures.
665 with locations.index.open( 'w' ) as file:
666 file.write( template.render( **data ) )
669def update_version_coverage_badge(
670 locations: _locations.Locations,
671 j2context: __.jinja2.Environment,
672 version: str
673) -> None:
674 ''' Updates version-specific coverage badge SVG.
676 Generates a coverage badge for the specific version and places it
677 in the version's subtree. This allows each version to have its own
678 coverage badge accessible at version/coverage.svg.
679 '''
680 svg_content = _generate_coverage_badge_svg( locations, j2context )
681 version_coverage_path = locations.website / version / 'coverage.svg'
682 with version_coverage_path.open( 'w' ) as file:
683 file.write( svg_content )
686def update_versions_json(
687 locations: _locations.Locations,
688 version: str,
689 species: tuple[ str, ... ],
690) -> dict[ __.typx.Any, __.typx.Any ]:
691 ''' Updates versions.json with new version information.
693 Maintains a JSON file tracking all versions and their available
694 documentation types. Versions are sorted in descending order, with
695 the latest version marked separately.
696 '''
697 # TODO: Add validation of version string format.
698 # TODO: Consider file locking for concurrent update protection.
699 if not locations.versions.is_file( ):
700 data: dict[ __.typx.Any, __.typx.Any ] = { 'versions': { } }
701 with locations.versions.open( 'w' ) as file:
702 __.json.dump( data, file, indent = 4 )
703 with locations.versions.open( 'r+' ) as file:
704 data = __.json.load( file )
705 versions = data[ 'versions' ]
706 versions[ version ] = species
707 versions = _sort_versions( versions )
708 data[ 'latest_version' ] = next( iter( versions ) )
709 data[ 'versions' ] = versions
710 file.seek( 0 )
711 __.json.dump( data, file, indent = 4 )
712 file.truncate( )
713 return data
716def _write_versions_json(
717 locations: _locations.Locations,
718 data: dict[ __.typx.Any, __.typx.Any ],
719) -> None:
720 ''' Persists enhanced index data (tracks, aliases) to versions.json. '''
721 with locations.versions.open( 'w' ) as file:
722 __.json.dump( data, file, indent = 4 )