Coverage for sources/emcdproj/website/tracks.py: 80%

136 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 track configuration and selection. ''' 

22 

23 

24from . import __ 

25 

26 

27TrackConfiguration: __.typx.TypeAlias = __.cabc.Mapping[ str, __.typx.Any ] 

28TrackMap: __.typx.TypeAlias = __.immut.Dictionary[ str, __.typx.Any ] 

29 

30 

31_VALID_SELECTORS: tuple[ str, ... ] = ( 

32 'latest-final', 'latest-prerelease', 'exact-tag' ) 

33 

34 

35DEFAULT_TRACKS: TrackMap = ( 

36 __.immut.Dictionary( { 

37 'stable': __.immut.Dictionary( { 

38 'tag-namespace': 'v*', 

39 'selector': 'latest-final', 

40 'alias': 'stable', 

41 } ), 

42 'development': __.immut.Dictionary( { 

43 'tag-namespace': 'v*', 

44 'selector': 'latest-prerelease', 

45 'alias': 'development', 

46 } ), 

47 } ) ) 

48 

49 

50def matches_namespace( tag: str, namespace: str ) -> bool: 

51 ''' Checks whether tag matches track namespace pattern. 

52 

53 Version labels may be bare ('1.0') while namespaces are 

54 tag-shaped ('v*'); the 'v'-prefixed form is tried as fallback, 

55 but only when the label parses as a package version, so 

56 non-version labels (aliases, docs tags) never match spuriously. 

57 ''' 

58 from fnmatch import fnmatchcase 

59 if fnmatchcase( tag, namespace ): return True 

60 if tag.startswith( 'v' ): return False 

61 if not fnmatchcase( f'v{tag}', namespace ): return False 

62 return _parse_package_version( tag ) is not None 

63 

64 

65def load_default_configuration( project: __.Path ) -> TrackMap: 

66 ''' Loads standard project configuration file or baked-in defaults. 

67 

68 Honors default file absence only on confirmed missing-file errors; 

69 present non-regular, unreadable, or malformed configuration fails 

70 before publication rather than silently falling back. 

71 ''' 

72 import stat 

73 candidate = ( 

74 project / '.auxiliary' / 'configuration' / 'website.toml' ) 

75 try: status = candidate.stat( ) 

76 except FileNotFoundError: return DEFAULT_TRACKS 

77 except OSError as exception: 

78 raise __.exceptions.TrackConfigurationInvalidity( 

79 'configuration', 'unreadable configuration' ) from exception 

80 if not stat.S_ISREG( status.st_mode ): 

81 raise __.exceptions.TrackConfigurationInvalidity( 

82 'configuration', 'non-regular configuration' ) 

83 try: return load_track_configuration( candidate ) 

84 except __.exceptions.FileAwol as exception: 

85 raise __.exceptions.TrackConfigurationInvalidity( 

86 'configuration', 'unreadable configuration' ) from exception 

87 

88 

89def load_track_configuration( location: __.Path | str ) -> TrackMap: 

90 ''' Loads and validates track configuration from TOML file. ''' 

91 path = __.Path( location ) 

92 try: content = path.read_bytes( ) 

93 except OSError as exception: 

94 raise __.exceptions.FileAwol( path ) from exception 

95 try: data = toml_loads( content ) 

96 except Exception as exception: 

97 raise __.exceptions.TrackConfigurationInvalidity( 

98 str( path ), 'unparseable TOML' ) from exception 

99 if 'tracks' not in data: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 raise __.exceptions.TrackConfigurationInvalidity( 

101 str( path ), "missing 'tracks' table" ) 

102 return validate_track_configuration( data[ 'tracks' ] ) 

103 

104 

105def validate_track_configuration( 

106 tracks: TrackConfiguration, 

107 known_tags: __.Absential[ __.cabc.Sequence[ str ] ] = __.absent, 

108) -> TrackMap: 

109 ''' Validates track namespaces, selectors, and aliases. ''' 

110 normalized: dict[ str, __.immut.Dictionary[ str, str ] ] = { } 

111 aliases: set[ str ] = set( ) 

112 namespaces: list[ tuple[ str, str ] ] = [ ] 

113 for name, track in tracks.items( ): 

114 try: namespace = track[ 'tag-namespace' ] 

115 except KeyError as exception: 

116 raise __.exceptions.TrackConfigurationInvalidity( 

117 name, "missing 'tag-namespace'" ) from exception 

118 try: selector = track[ 'selector' ] 

119 except KeyError as exception: 

120 raise __.exceptions.TrackConfigurationInvalidity( 

121 name, "missing 'selector'" ) from exception 

122 try: alias = track[ 'alias' ] 

123 except KeyError as exception: 

124 raise __.exceptions.TrackConfigurationInvalidity( 

125 name, "missing 'alias'" ) from exception 

126 if selector not in _VALID_SELECTORS: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 raise __.exceptions.TrackConfigurationInvalidity( name, 

128 'invalid selector' ) 

129 canonical = validate_alias( alias ) 

130 if canonical in aliases: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true

131 raise __.exceptions.TrackConfigurationInvalidity( canonical, 

132 'duplicate alias' ) 

133 aliases.add( canonical ) 

134 namespaces.append( ( name, namespace ) ) 

135 normalized[ name ] = __.immut.Dictionary( { 

136 'tag-namespace': namespace, 

137 'selector': selector, 

138 'alias': canonical, 

139 } ) 

140 selectors: dict[ str, str ] = { 

141 name: tracks[ name ][ 'selector' ] for name in normalized } 

142 for i, ( name_a, namespace_a ) in enumerate( namespaces ): 

143 for name_b, namespace_b in namespaces[ i + 1 : ]: 

144 if _namespaces_collide( 

145 namespace_a, selectors[ name_a ], 

146 namespace_b, selectors[ name_b ], 

147 ): 

148 raise __.exceptions.TrackConfigurationInvalidity( 

149 name_a, f"collides with '{name_b}'" ) 

150 if not __.is_absent( known_tags ): 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true

151 reject_ambiguous_known_tags( 

152 normalized, known_tags ) # type: ignore[arg-type] 

153 return __.immut.Dictionary( normalized ) 

154 

155 

156def validate_alias( alias: str ) -> str: 

157 ''' Validates alias and returns canonical single-component form. ''' 

158 import re 

159 if not alias: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true

160 raise __.exceptions.TrackConfigurationInvalidity( alias, 

161 'empty alias' ) 

162 if alias.startswith( '/' ) or alias.startswith( '\\' ): 

163 raise __.exceptions.TrackConfigurationInvalidity( alias, 

164 'absolute path rejected' ) 

165 normalized = alias.strip( '/' ) 

166 if not normalized: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true

167 raise __.exceptions.TrackConfigurationInvalidity( alias, 

168 'empty alias' ) 

169 if ( 

170 '..' in normalized 

171 or '/' in normalized 

172 or '\\' in normalized 

173 or normalized.startswith( '.' ) 

174 ): 

175 raise __.exceptions.TrackConfigurationInvalidity( alias, 

176 'traversal rejected' ) 

177 if not re.match( r'^[a-z0-9][a-z0-9-]*$', normalized ): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true

178 raise __.exceptions.TrackConfigurationInvalidity( alias, 

179 'must be kebab-case' ) 

180 return normalized 

181 

182 

183def _namespaces_collide( 

184 namespace_a: str, selector_a: str, 

185 namespace_b: str, selector_b: str, 

186) -> bool: 

187 ''' Checks whether two tracks can claim the same tag. 

188 

189 Only exact-tag pairs can collide ambiguously; overlapping 

190 latest-selector aliases are legitimate shared pointers. 

191 ''' 

192 if selector_a != 'exact-tag' and selector_b != 'exact-tag': 

193 return False 

194 if namespace_a == '*' or namespace_b == '*': return True 

195 if ( 195 ↛ 199line 195 didn't jump to line 199 because the condition on line 195 was never true

196 namespace_a == namespace_b 

197 and selector_a == selector_b 

198 ): 

199 return True 

200 if ( 200 ↛ 204line 200 didn't jump to line 204 because the condition on line 200 was never true

201 { selector_a, selector_b } 

202 == { 'latest-final', 'latest-prerelease' } 

203 ): 

204 return False 

205 probes = ( 

206 'v1.0', 'v1.0a1', 'v2.0rc1', 'v10.0', '1.0', '1.0a1', '2.0', 

207 'docs-1', 'docs-2', 'docs-3', 'docs-1.9', 'docs-3.1', 

208 'release-1.0', 'stable', 'development', 'nightly', 'latest' ) 

209 for probe in probes: 

210 if ( 

211 matches_namespace( probe, namespace_a ) 

212 and matches_namespace( probe, namespace_b ) 

213 and _selector_claims( probe, selector_a ) 

214 and _selector_claims( probe, selector_b ) 

215 ): 

216 return True 

217 return False 

218 

219 

220def _selector_claims( tag: str, selector: str ) -> bool: 

221 ''' Checks whether selector can claim tag. ''' 

222 if selector == 'exact-tag': return True 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was always true

223 try: 

224 from packaging.version import Version 

225 version = Version( tag.lstrip( 'v' ) ) 

226 except Exception: return selector == 'latest-final' 

227 if selector == 'latest-final': return not version.is_prerelease 

228 return version.is_prerelease 

229 

230 

231def reject_ambiguous_known_tags( 

232 tracks: __.cabc.Mapping[ str, __.cabc.Mapping[ str, str ] ], 

233 known_tags: __.cabc.Sequence[ str ], 

234) -> None: 

235 ''' Rejects known tags claimed by multiple exact-tag tracks. 

236 

237 Overlapping latest-selector aliases are legitimate pointers at 

238 shared versions (e.g. stable and floating major); only exact-tag 

239 multi-claims are ambiguous about which track owns the tag. 

240 ''' 

241 for tag in known_tags: 

242 claimants = [ 

243 name for name, track in tracks.items( ) 

244 if track[ 'selector' ] == 'exact-tag' 

245 and matches_namespace( tag, track[ 'tag-namespace' ] ) 

246 and _selector_claims( tag, track[ 'selector' ] ) ] 

247 if len( claimants ) > 1: 

248 raise __.exceptions.TrackConfigurationInvalidity( 

249 tag, 'claimed by multiple tracks' ) 

250 

251 

252def is_package_version( label: __.typx.Any ) -> bool: 

253 ''' Checks whether label parses as a package version. ''' 

254 from packaging.version import InvalidVersion, Version 

255 try: Version( str( label ).lstrip( 'v' ) ) 

256 except InvalidVersion: return False 

257 return True 

258 

259 

260def _parse_package_version( label: __.typx.Any ) -> __.typx.Any: 

261 ''' Parses label as package version, returning None when not parseable. ''' 

262 from packaging.version import InvalidVersion, Version 

263 try: return Version( str( label ).lstrip( 'v' ) ) 

264 except InvalidVersion: return None 

265 

266 

267def select_latest( 

268 versions: __.cabc.Iterable[ __.typx.Any ], 

269 namespace: str, selector: str, track: str, 

270) -> __.Absential[ __.typx.Any ]: 

271 ''' Selects version for track from namespace and selector. ''' 

272 from packaging.version import Version 

273 matched = [ 

274 version for version in versions 

275 if matches_namespace( str( version ), namespace ) ] 

276 if selector == 'exact-tag': 

277 if not matched: return __.absent 277 ↛ exitline 277 didn't return from function 'select_latest' because the return on line 277 wasn't executed

278 if len( matched ) > 1: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 raise __.exceptions.TrackConfigurationInvalidity( 

280 track, 'ambiguous exact-tag matches' ) 

281 return matched[ 0 ] 

282 package_versions: list[ tuple[ __.typx.Any, Version ] ] = [ ] 

283 for version in matched: 

284 parsed = _parse_package_version( version ) 

285 if parsed is None: continue 285 ↛ 283line 285 didn't jump to line 283 because the continue on line 285 wasn't executed

286 if selector == 'latest-final' and parsed.is_prerelease: continue 286 ↛ 283line 286 didn't jump to line 283 because the continue on line 286 wasn't executed

287 if selector == 'latest-prerelease' and not parsed.is_prerelease: 

288 continue 

289 package_versions.append( ( version, parsed ) ) 

290 if not package_versions: return __.absent 

291 package_versions.sort( key = lambda entry: entry[ 1 ], reverse = True ) 

292 return package_versions[ 0 ][ 0 ] 

293 

294 

295def toml_loads( content: bytes ) -> dict[ str, __.typx.Any ]: 

296 ''' Parses TOML content. ''' 

297 from tomli import loads as parse 

298 result: dict[ str, __.typx.Any ] = parse( content.decode( 'utf-8' ) ) 

299 return result