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
« 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 -*-
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 track configuration and selection. '''
24from . import __
27TrackConfiguration: __.typx.TypeAlias = __.cabc.Mapping[ str, __.typx.Any ]
28TrackMap: __.typx.TypeAlias = __.immut.Dictionary[ str, __.typx.Any ]
31_VALID_SELECTORS: tuple[ str, ... ] = (
32 'latest-final', 'latest-prerelease', 'exact-tag' )
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 } ) )
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 )
56def load_default_configuration( project: __.Path ) -> TrackMap:
57 ''' Loads standard project configuration file or baked-in defaults.
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
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' ] )
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 )
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
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.
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
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
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.
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' )
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
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
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 ]
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