Coverage for sources/emcdproj/website/tracks.py: 80%
136 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 01:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 01:42 +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.
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
65def load_default_configuration( project: __.Path ) -> TrackMap:
66 ''' Loads standard project configuration file or baked-in defaults.
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
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' ] )
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 )
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
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.
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
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
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.
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' )
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
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
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 ]
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