napt 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ # Copyright 2025 Roger Cibrian
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Core version comparison utilities for NAPT.
16
+
17
+ This module is format-agnostic: it does NOT download or read files.
18
+ It only parses and compares version strings consistently across sources
19
+ (MSI, EXE, generic strings).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ import re
26
+ from typing import Literal
27
+
28
+ # ----------------------------
29
+ # Shared DTO
30
+ # ----------------------------
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DiscoveredVersion:
35
+ """Represents a version string discovered from a source.
36
+
37
+ Attributes:
38
+ version: Raw version string (e.g., "140.0.7339.128").
39
+ source: Where it came from (e.g., "regex_in_url", "msi").
40
+
41
+ """
42
+
43
+ version: str
44
+ source: str
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class VersionInfo:
49
+ """Represents version metadata obtained without downloading the installer.
50
+
51
+ Used by version-first strategies (web_scrape, api_github, api_json)
52
+ that can determine version and download URL without fetching the installer.
53
+
54
+ Attributes:
55
+ version: Raw version string (e.g., "140.0.7339.128").
56
+ download_url: URL to download the installer.
57
+ source: Strategy name for logging (e.g., "web_scrape", "api_github").
58
+
59
+ """
60
+
61
+ version: str
62
+ download_url: str
63
+ source: str
64
+
65
+
66
+ # ----------------------------
67
+ # Comparison core (robust)
68
+ # ----------------------------
69
+
70
+ SourceHint = Literal["msi", "exe", "string"]
71
+
72
+ # Known prerelease tag ordering (lower = older)
73
+ _PRE_TAG_RANK: dict[str, float] = {
74
+ "dev": 0,
75
+ "d": 0,
76
+ "alpha": 1,
77
+ "a": 1,
78
+ "pre": 1,
79
+ "preview": 1,
80
+ "ea": 1,
81
+ "beta": 2,
82
+ "b": 2,
83
+ "rc": 3,
84
+ }
85
+ _UNKNOWN_PRE_RANK = 2.5 # unknown prerelease tags sort between beta and rc
86
+ _POST_TAGS = {"post", "p", "rev", "r", "hotfix", "hf"}
87
+
88
+ _NUM_SEP = re.compile(r"[._-]")
89
+
90
+
91
+ def _ints_from_text(text: str) -> tuple[int, ...]:
92
+ """Parse numeric components only (for MSI/EXE).
93
+ Raises ValueError if any non-numeric token is encountered to avoid
94
+ silently mapping "1.2a" -> (1,2,0).
95
+ """
96
+ parts = [p for p in _NUM_SEP.split(text) if p]
97
+ nums: list[int] = []
98
+ for p in parts:
99
+ if not p.isdigit():
100
+ raise ValueError(f"non-numeric version component {p!r} in {text!r}")
101
+ nums.append(int(p))
102
+ return tuple(nums) if nums else (0,)
103
+
104
+
105
+ def _clip_for_source(nums: tuple[int, ...], source: SourceHint) -> tuple[int, ...]:
106
+ """Trim version tuple by source semantics (MSI=3 parts, EXE=4 parts)."""
107
+ if source == "msi":
108
+ return nums[:3] or (0,)
109
+ if source == "exe":
110
+ return nums[:4] or (0,)
111
+ return nums
112
+
113
+
114
+ def _pad_equal(
115
+ a: tuple[int, ...], b: tuple[int, ...]
116
+ ) -> tuple[tuple[int, ...], tuple[int, ...]]:
117
+ """Pad tuples with zeros so they align for element-wise comparison."""
118
+ n = max(len(a), len(b))
119
+ return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
120
+
121
+
122
+ def _split_pre_tokens(pre: str) -> tuple[tuple[int, object], ...]:
123
+ """Split prerelease suffix into tokens with numeric awareness.
124
+ Example: "rc.10-x" -> [("rc"), 10, "x"] encoded as:
125
+ (0, int) for numeric tokens (sort before text)
126
+ (1, str) for text tokens (lowercased)
127
+ """
128
+ tokens = re.split(r"[.\-]", pre)
129
+ out: list[tuple[int, object]] = []
130
+ for t in tokens:
131
+ if not t:
132
+ continue
133
+ if t.isdigit():
134
+ out.append((0, int(t)))
135
+ else:
136
+ out.append((1, t.lower()))
137
+ return tuple(out)
138
+
139
+
140
+ def _find_pre_segment(s: str) -> tuple[float | None, tuple[tuple[int, object], ...]]:
141
+ """Detect a prerelease tag in the given suffix string.
142
+ Returns (rank, tokens) or (None, ()) if not a prerelease.
143
+ """
144
+ m = re.search(r"(?i)\b([A-Za-z]+)[._-]?([0-9A-Za-z.\-]*)", s)
145
+ if not m:
146
+ return None, ()
147
+ tag = m.group(1).lower()
148
+ rest = m.group(2) or ""
149
+ if tag in _POST_TAGS:
150
+ return None, ()
151
+ rank = _PRE_TAG_RANK.get(tag, _UNKNOWN_PRE_RANK)
152
+ tokens = _split_pre_tokens(rest) if rest else ()
153
+ tokens = ((1, tag),) + tokens # ensure "rc" < "rc.1" < "rc.2"
154
+ return float(rank), tokens
155
+
156
+
157
+ def _find_post_segment(s: str) -> int:
158
+ """Return a positive number if a post-release tag is found; else 0."""
159
+ m = re.search(r"(?i)\b(post|p|rev|r|hotfix|hf)[._-]?(\d+)?\b", s)
160
+ if not m:
161
+ return 0
162
+ return int(m.group(2)) if m.group(2) else 1
163
+
164
+
165
+ def _strip_build_meta(s: str) -> str:
166
+ """Drop '+build' metadata (ignored in ordering)."""
167
+ i = s.find("+")
168
+ return s if i == -1 else s[:i]
169
+
170
+
171
+ def _leading_release_tuple(s: str) -> tuple[int, ...]:
172
+ """Extract the leading numeric "release" tuple from a version-like string.
173
+
174
+ Behavior:
175
+ - Trim whitespace, lowercase, and drop a leading "v" (e.g., "v1.2.3" -> "1.2.3").
176
+ - Split on [._-] and take numeric tokens until the first non-numeric.
177
+ If a token starts with digits ("2rc1"), take the leading digits (2) and stop.
178
+ - If no digits found at all, return (0,) as a sentinel meaning "not version-like".
179
+ """
180
+ s2 = s.lstrip().lower()
181
+ if s2.startswith("v"):
182
+ s2 = s2[1:]
183
+ parts = _NUM_SEP.split(s2)
184
+
185
+ nums: list[int] = []
186
+ for p in parts:
187
+ if not p:
188
+ continue
189
+ if p.isdigit():
190
+ nums.append(int(p))
191
+ continue
192
+ m = re.match(r"(\d+)", p)
193
+ if m:
194
+ nums.append(int(m.group(1)))
195
+ break
196
+ return tuple(nums) if nums else (0,)
197
+
198
+
199
+ def _semver_like_key_robust(
200
+ s: str,
201
+ ) -> tuple[tuple[int, ...], float, tuple[tuple[int, object], ...], int]:
202
+ """Build a semver-like key:
203
+ (release_tuple, pre_rank_or_4.0, pre_tokens, post_num)
204
+
205
+ Final releases use pre_rank=4.0 so they compare newer than any prerelease
206
+ with the same core release tuple.
207
+ """
208
+ base = _strip_build_meta(s)
209
+ release = _leading_release_tuple(base)
210
+
211
+ # Only analyze suffix after the numeric core to avoid matching vendor names.
212
+ suffix = base
213
+ if release != (0,):
214
+ core_re = r"^\s*v?" + r"\.".join(str(n) for n in release)
215
+ m = re.match(core_re, base)
216
+ if m:
217
+ suffix = base[m.end() :]
218
+
219
+ pre_rank, pre_tokens = _find_pre_segment(suffix)
220
+ post_num = _find_post_segment(suffix)
221
+
222
+ if pre_rank is None:
223
+ return (release, 4.0, (), post_num) # final release
224
+ return (release, pre_rank, pre_tokens, post_num)
225
+
226
+
227
+ def version_key_any(s: str, *, source: SourceHint = "string") -> tuple:
228
+ """Compute a comparable key for any version string.
229
+
230
+ - MSI/EXE: purely numeric (truncated to 3/4 parts).
231
+ - Generic string: semver-like robust key; if no numeric prefix,
232
+ fallback to ("text", raw).
233
+ """
234
+ if source in ("msi", "exe"):
235
+ nums = _clip_for_source(_ints_from_text(s), source)
236
+ return ("num", nums)
237
+
238
+ key = _semver_like_key_robust(s)
239
+ release = key[0]
240
+ if release != (0,):
241
+ # IMPORTANT: We do NOT include the raw string as a tiebreaker.
242
+ # This makes "v1.2.3" == "1.2.3" when the parsed keys are equal.
243
+ return ("semverish", key)
244
+
245
+ return ("text", s)
246
+
247
+
248
+ def compare_any(
249
+ a: str,
250
+ b: str,
251
+ *,
252
+ source: SourceHint = "string",
253
+ ) -> int:
254
+ """Compare two versions with a source hint.
255
+ Returns -1 if a < b, 0 if equal, 1 if a > b.
256
+ """
257
+ from napt.logging import get_global_logger
258
+
259
+ logger = get_global_logger()
260
+
261
+ if source in ("msi", "exe"):
262
+ try:
263
+ aa = _clip_for_source(_ints_from_text(a), source)
264
+ bb = _clip_for_source(_ints_from_text(b), source)
265
+ aa, bb = _pad_equal(aa, bb)
266
+ result = (aa > bb) - (aa < bb)
267
+ except ValueError:
268
+ # If vendor sneaks letters into numeric fields, fallback to generic parsing.
269
+ ka = version_key_any(a, source="string")
270
+ kb = version_key_any(b, source="string")
271
+ result = (ka > kb) - (ka < kb)
272
+ else:
273
+ ka = version_key_any(a, source="string")
274
+ kb = version_key_any(b, source="string")
275
+ result = (ka > kb) - (ka < kb)
276
+
277
+ # Log comparison result if verbose mode is enabled
278
+ if result < 0:
279
+ logger.verbose("VERSION", f"{a!r} is older than {b!r} (source={source})")
280
+ elif result > 0:
281
+ logger.verbose("VERSION", f"{a!r} is newer than {b!r} (source={source})")
282
+ else:
283
+ logger.verbose("VERSION", f"{a!r} is the same as {b!r} (source={source})")
284
+ return result
285
+
286
+
287
+ def is_newer_any(
288
+ remote: str,
289
+ current: str | None,
290
+ *,
291
+ source: SourceHint = "string",
292
+ ) -> bool:
293
+ """Decide if 'remote' should be considered newer than 'current'.
294
+ Returns True iff remote > current under the given source semantics.
295
+ """
296
+ from napt.logging import get_global_logger
297
+
298
+ logger = get_global_logger()
299
+
300
+ if current is None:
301
+ logger.verbose(
302
+ "VERSION",
303
+ f"No current version. Treat {remote!r} as newer (source={source})",
304
+ )
305
+ return True
306
+
307
+ cmpv = compare_any(remote, current, source=source)
308
+ # Note: compare_any() already logs the comparison result, so we don't need to log again here
309
+ return cmpv > 0