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

133 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 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 from fnmatch import fnmatchcase 

53 return fnmatchcase( tag, namespace ) 

54 

55 

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

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

58 

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

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

61 before publication rather than silently falling back. 

62 ''' 

63 import stat 

64 candidate = ( 

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

66 try: status = candidate.stat( ) 

67 except FileNotFoundError: return DEFAULT_TRACKS 

68 except OSError as exception: 

69 raise __.exceptions.TrackConfigurationInvalidity( 

70 'configuration', 'unreadable configuration' ) from exception 

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

72 raise __.exceptions.TrackConfigurationInvalidity( 

73 'configuration', 'non-regular configuration' ) 

74 try: return load_track_configuration( candidate ) 

75 except __.exceptions.FileAwol as exception: 

76 raise __.exceptions.TrackConfigurationInvalidity( 

77 'configuration', 'unreadable configuration' ) from exception 

78 

79 

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

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

82 path = __.Path( location ) 

83 try: content = path.read_bytes( ) 

84 except OSError as exception: 

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

86 try: data = toml_loads( content ) 

87 except Exception as exception: 

88 raise __.exceptions.TrackConfigurationInvalidity( 

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

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

91 raise __.exceptions.TrackConfigurationInvalidity( 

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

93 return validate_track_configuration( data[ 'tracks' ] ) 

94 

95 

96def validate_track_configuration( 

97 tracks: TrackConfiguration, 

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

99) -> TrackMap: 

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

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

102 aliases: set[ str ] = set( ) 

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

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

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

106 except KeyError as exception: 

107 raise __.exceptions.TrackConfigurationInvalidity( 

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

109 try: selector = track[ 'selector' ] 

110 except KeyError as exception: 

111 raise __.exceptions.TrackConfigurationInvalidity( 

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

113 try: alias = track[ 'alias' ] 

114 except KeyError as exception: 

115 raise __.exceptions.TrackConfigurationInvalidity( 

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

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

118 raise __.exceptions.TrackConfigurationInvalidity( name, 

119 'invalid selector' ) 

120 canonical = validate_alias( alias ) 

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

122 raise __.exceptions.TrackConfigurationInvalidity( canonical, 

123 'duplicate alias' ) 

124 aliases.add( canonical ) 

125 namespaces.append( ( name, namespace ) ) 

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

127 'tag-namespace': namespace, 

128 'selector': selector, 

129 'alias': canonical, 

130 } ) 

131 selectors: dict[ str, str ] = { 

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

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

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

135 if _namespaces_collide( 

136 namespace_a, selectors[ name_a ], 

137 namespace_b, selectors[ name_b ], 

138 ): 

139 raise __.exceptions.TrackConfigurationInvalidity( 

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

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

142 reject_ambiguous_known_tags( 

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

144 return __.immut.Dictionary( normalized ) 

145 

146 

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

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

149 import re 

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

151 raise __.exceptions.TrackConfigurationInvalidity( alias, 

152 'empty alias' ) 

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

154 raise __.exceptions.TrackConfigurationInvalidity( alias, 

155 'absolute path rejected' ) 

156 normalized = alias.strip( '/' ) 

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

158 raise __.exceptions.TrackConfigurationInvalidity( alias, 

159 'empty alias' ) 

160 if ( 

161 '..' in normalized 

162 or '/' in normalized 

163 or '\\' in normalized 

164 or normalized.startswith( '.' ) 

165 ): 

166 raise __.exceptions.TrackConfigurationInvalidity( alias, 

167 'traversal rejected' ) 

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

169 raise __.exceptions.TrackConfigurationInvalidity( alias, 

170 'must be kebab-case' ) 

171 return normalized 

172 

173 

174def _namespaces_collide( 

175 namespace_a: str, selector_a: str, 

176 namespace_b: str, selector_b: str, 

177) -> bool: 

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

179 

180 Only exact-tag pairs can collide ambiguously; overlapping 

181 latest-selector aliases are legitimate shared pointers. 

182 ''' 

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

184 return False 

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

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

187 namespace_a == namespace_b 

188 and selector_a == selector_b 

189 ): 

190 return True 

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

192 { selector_a, selector_b } 

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

194 ): 

195 return False 

196 probes = ( 

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

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

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

200 for probe in probes: 

201 if ( 

202 matches_namespace( probe, namespace_a ) 

203 and matches_namespace( probe, namespace_b ) 

204 and _selector_claims( probe, selector_a ) 

205 and _selector_claims( probe, selector_b ) 

206 ): 

207 return True 

208 return False 

209 

210 

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

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

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

214 try: 

215 from packaging.version import Version 

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

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

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

219 return version.is_prerelease 

220 

221 

222def reject_ambiguous_known_tags( 

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

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

225) -> None: 

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

227 

228 Overlapping latest-selector aliases are legitimate pointers at 

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

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

231 ''' 

232 for tag in known_tags: 

233 claimants = [ 

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

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

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

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

238 if len( claimants ) > 1: 

239 raise __.exceptions.TrackConfigurationInvalidity( 

240 tag, 'claimed by multiple tracks' ) 

241 

242 

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

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

245 from packaging.version import InvalidVersion, Version 

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

247 except InvalidVersion: return False 

248 return True 

249 

250 

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

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

253 from packaging.version import InvalidVersion, Version 

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

255 except InvalidVersion: return None 

256 

257 

258def select_latest( 

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

260 namespace: str, selector: str, track: str, 

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

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

263 from packaging.version import Version 

264 matched = [ 

265 version for version in versions 

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

267 if selector == 'exact-tag': 

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

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

270 raise __.exceptions.TrackConfigurationInvalidity( 

271 track, 'ambiguous exact-tag matches' ) 

272 return matched[ 0 ] 

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

274 for version in matched: 

275 parsed = _parse_package_version( version ) 

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

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

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

279 continue 

280 package_versions.append( ( version, parsed ) ) 

281 if not package_versions: return __.absent 

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

283 return package_versions[ 0 ][ 0 ] 

284 

285 

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

287 ''' Parses TOML content. ''' 

288 from tomli import loads as parse 

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

290 return result