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

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 _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] 

193 

194 

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 ) 

205 

206 

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. 

212 

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 

241 

242 

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

244 ''' Extracts coverage percentage from coverage report. 

245 

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 ) 

260 

261 

262def fetch_publication_branch_and_tarball( 

263 locations: _locations.Locations, 

264) -> str: 

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

266 

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 

301 

302 

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

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

305 

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

329 

330 

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

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

333 

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

352 

353 

354def _generate_coverage_badge_svg( 

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

356) -> str: 

357 ''' Generates coverage badge SVG content. 

358 

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 ) 

383 

384 

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. 

392 

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 ) 

424 

425 

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

432 

433 

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 

455 

456 

457def _resolve_release_tag( 

458 locations: _locations.Locations, version: str 

459) -> str: 

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

461 

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

478 

479 

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 ) 

488 

489 

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. 

496 

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 ) 

513 

514 

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 

539 

540 

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 ) 

560 

561 

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 ) 

578 

579 

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 ) 

592 

593 

594def update_coverage_badge( 

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

596) -> None: 

597 ''' Updates coverage badge SVG. 

598 

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 ) 

605 

606 

607def update_publication_branch( 

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

609) -> None: 

610 ''' Updates publication branch with new tarball. 

611 

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

650 

651 

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. 

658 

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

666 

667 

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. 

674 

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 ) 

683 

684 

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. 

691 

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