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.
- napt/__init__.py +91 -0
- napt/build/__init__.py +47 -0
- napt/build/manager.py +1087 -0
- napt/build/packager.py +315 -0
- napt/build/template.py +301 -0
- napt/cli.py +602 -0
- napt/config/__init__.py +42 -0
- napt/config/loader.py +465 -0
- napt/core.py +385 -0
- napt/detection.py +630 -0
- napt/discovery/__init__.py +86 -0
- napt/discovery/api_github.py +445 -0
- napt/discovery/api_json.py +452 -0
- napt/discovery/base.py +244 -0
- napt/discovery/url_download.py +304 -0
- napt/discovery/web_scrape.py +467 -0
- napt/exceptions.py +149 -0
- napt/io/__init__.py +42 -0
- napt/io/download.py +357 -0
- napt/io/upload.py +37 -0
- napt/logging.py +230 -0
- napt/policy/__init__.py +50 -0
- napt/policy/updates.py +126 -0
- napt/psadt/__init__.py +43 -0
- napt/psadt/release.py +309 -0
- napt/requirements.py +566 -0
- napt/results.py +143 -0
- napt/state/__init__.py +58 -0
- napt/state/tracker.py +371 -0
- napt/validation.py +467 -0
- napt/versioning/__init__.py +115 -0
- napt/versioning/keys.py +309 -0
- napt/versioning/msi.py +725 -0
- napt-0.3.1.dist-info/METADATA +114 -0
- napt-0.3.1.dist-info/RECORD +38 -0
- napt-0.3.1.dist-info/WHEEL +4 -0
- napt-0.3.1.dist-info/entry_points.txt +3 -0
- napt-0.3.1.dist-info/licenses/LICENSE +202 -0
napt/versioning/keys.py
ADDED
|
@@ -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
|