Coverage for sources/emcdproj/website/publication.py: 82%

323 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-14 16:02 +0000

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''' Publication building, promotion, and synchronization. ''' 

22 

23 

24from . import __ 

25from . import inventory as _inventory 

26from . import locations as _locations 

27from . import tracks as _tracks 

28 

29 

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. 

36 

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}" ) 

71 

72 

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. 

84 

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 ) 

118 

119 

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. 

124 

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 ) 

131 

132 

133def validate_version_label( version: str ) -> None: 

134 ''' Validates version label before any destructive operation. 

135 

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 

159 

160 

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] 

194 

195 

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 ) 

206 

207 

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. 

213 

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 

242 

243 

244def extract_coverage( locations: _locations.Locations ) -> int: 

245 ''' Extracts coverage percentage from coverage report. 

246 

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 ) 

261 

262 

263def fetch_publication_branch_and_tarball( 

264 locations: _locations.Locations, 

265) -> str: 

266 ''' Fetches publication branch and tarball, returning destination SHA. 

267 

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 

302 

303 

304def _clear_stale_publication_state( locations: _locations.Locations ) -> None: 

305 ''' Clears stale local archive and publication ancestry. 

306 

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' ) 

330 

331 

332def _remote_has_publication( locations: _locations.Locations ) -> bool: 

333 ''' Checks whether origin holds a publication branch. 

334 

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( ) ) 

353 

354 

355def _generate_coverage_badge_svg( 

356 locations: _locations.Locations, j2context: __.jinja2.Environment 

357) -> str: 

358 ''' Generates coverage badge SVG content. 

359 

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 ) 

384 

385 

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. 

393 

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 ) 

425 

426 

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' ) 

433 

434 

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 

456 

457 

458def _resolve_release_tag( 

459 locations: _locations.Locations, version: str 

460) -> str: 

461 ''' Resolves release tag to commit SHA, or empty when absent. 

462 

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 '' 

479 

480 

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 ) 

489 

490 

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. 

497 

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 ) 

514 

515 

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 

540 

541 

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 ) 

561 

562 

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 ) 

579 

580 

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 ) 

593 

594 

595def update_coverage_badge( 

596 locations: _locations.Locations, j2context: __.jinja2.Environment 

597) -> None: 

598 ''' Updates coverage badge SVG. 

599 

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 ) 

606 

607 

608def update_publication_branch( 

609 locations: _locations.Locations, version: str, expected_sha: str = '' 

610) -> None: 

611 ''' Updates publication branch with new tarball. 

612 

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' ] ) 

651 

652 

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. 

659 

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 ) ) 

667 

668 

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. 

675 

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 ) 

684 

685 

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. 

692 

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 

714 

715 

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 )