forceupdate 0.1.0__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.
- forceupdate/__init__.py +9 -0
- forceupdate/__main__.py +5 -0
- forceupdate/advisories/__init__.py +30 -0
- forceupdate/advisories/cvss.py +95 -0
- forceupdate/advisories/osv.py +584 -0
- forceupdate/checks/__init__.py +49 -0
- forceupdate/checks/base.py +98 -0
- forceupdate/checks/deprecated.py +115 -0
- forceupdate/checks/outdated.py +183 -0
- forceupdate/checks/priority.py +82 -0
- forceupdate/checks/vulns.py +201 -0
- forceupdate/cli.py +309 -0
- forceupdate/detect/__init__.py +5 -0
- forceupdate/detect/languages.py +266 -0
- forceupdate/detect/manifests.py +478 -0
- forceupdate/engine.py +313 -0
- forceupdate/models.py +510 -0
- forceupdate/net/__init__.py +20 -0
- forceupdate/net/cache.py +217 -0
- forceupdate/net/http.py +270 -0
- forceupdate/parsers/__init__.py +43 -0
- forceupdate/parsers/base.py +22 -0
- forceupdate/parsers/json_spans.py +123 -0
- forceupdate/parsers/lockfiles.py +231 -0
- forceupdate/parsers/npm.py +1257 -0
- forceupdate/parsers/python.py +1421 -0
- forceupdate/parsers/toml_spans.py +297 -0
- forceupdate/registries/__init__.py +52 -0
- forceupdate/registries/base.py +294 -0
- forceupdate/registries/npm.py +153 -0
- forceupdate/registries/pypi.py +242 -0
- forceupdate/report/__init__.py +359 -0
- forceupdate/report/json.py +34 -0
- forceupdate/report/markdown.py +169 -0
- forceupdate/report/terminal.py +376 -0
- forceupdate/sources/__init__.py +1 -0
- forceupdate/sources/base.py +45 -0
- forceupdate/sources/local.py +183 -0
- forceupdate/update/__init__.py +8 -0
- forceupdate/update/constraints.py +317 -0
- forceupdate/update/planner.py +531 -0
- forceupdate/versions/__init__.py +40 -0
- forceupdate/versions/base.py +229 -0
- forceupdate/versions/npm.py +432 -0
- forceupdate/versions/pypi.py +220 -0
- forceupdate-0.1.0.dist-info/METADATA +79 -0
- forceupdate-0.1.0.dist-info/RECORD +50 -0
- forceupdate-0.1.0.dist-info/WHEEL +4 -0
- forceupdate-0.1.0.dist-info/entry_points.txt +2 -0
- forceupdate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Retrouver où sont écrites les chaînes d'un document TOML.
|
|
2
|
+
|
|
3
|
+
``tomllib`` donne les valeurs d'un document, pas leur position. Ce module parcourt
|
|
4
|
+
le texte d'un document que ``tomllib`` a déjà accepté, et rend, pour chaque chaîne
|
|
5
|
+
écrite sur une seule ligne, sa ligne et ses colonnes. C'est ce qui donne
|
|
6
|
+
l'emplacement exact d'une contrainte dans un ``pyproject.toml`` ou un ``Pipfile``,
|
|
7
|
+
pour la réécrire sans toucher au reste (E10).
|
|
8
|
+
|
|
9
|
+
Une chaîne est repérée par son chemin dans le document, le même que dans ce que
|
|
10
|
+
rend ``tomllib`` : des clés et des indices de tableaux. ``dependencies = ["a", "b"]``
|
|
11
|
+
dans ``[project]`` donne ``("project", "dependencies", 1)`` pour ``"b"`` ; le
|
|
12
|
+
troisième ``[[package]]`` d'un lockfile donne ``("package", 2, …)``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import bisect
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from forceupdate.models import Location
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
TomlPath = tuple[str | int, ...]
|
|
24
|
+
"""Le chemin d'une valeur : clés et indices de tableaux, comme dans ``tomllib``."""
|
|
25
|
+
|
|
26
|
+
_BARE_KEY_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-")
|
|
27
|
+
_ESCAPE_LENGTHS = {"u": 6, "U": 10, "x": 4}
|
|
28
|
+
"""Longueur écrite des échappements qui ne font pas deux caractères (``\\u00e9``…)."""
|
|
29
|
+
_SIMPLE_ESCAPES = {"b": "\b", "t": "\t", "n": "\n", "f": "\f", "r": "\r", "e": "\x1b"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class StringSpan:
|
|
34
|
+
"""Une chaîne TOML écrite sur une seule ligne, et sa position.
|
|
35
|
+
|
|
36
|
+
Sert aussi aux chaînes JSON (``json_spans``) : leurs échappements sont ceux de
|
|
37
|
+
TOML, plus les paires de substituts (``\\ud83d\\ude00``), qui font un seul caractère.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
line: int
|
|
41
|
+
"""Sa ligne, à partir de 1."""
|
|
42
|
+
column: int
|
|
43
|
+
"""Colonne du premier caractère après le guillemet ouvrant, à partir de 0."""
|
|
44
|
+
raw: str
|
|
45
|
+
"""Le contenu tel qu'il est écrit, sans les guillemets (échappements compris)."""
|
|
46
|
+
literal: bool
|
|
47
|
+
"""Vrai pour une chaîne littérale (``'…'``), qui n'a pas d'échappements."""
|
|
48
|
+
|
|
49
|
+
def location(self, start: int, end: int) -> Location | None:
|
|
50
|
+
"""L'emplacement des caractères ``start`` à ``end`` (exclu) de la valeur décodée.
|
|
51
|
+
|
|
52
|
+
``None`` si ce morceau contient un échappement (``\\"``…) : le texte écrit
|
|
53
|
+
n'y est pas la valeur, on ne saurait pas le réécrire.
|
|
54
|
+
"""
|
|
55
|
+
offsets = self._offsets()
|
|
56
|
+
if not 0 <= start <= end < len(offsets):
|
|
57
|
+
return None
|
|
58
|
+
first, last = offsets[start], offsets[end]
|
|
59
|
+
if last - first != end - start:
|
|
60
|
+
return None
|
|
61
|
+
return Location(line=self.line, column=self.column + first, end_column=self.column + last)
|
|
62
|
+
|
|
63
|
+
def _offsets(self) -> list[int]:
|
|
64
|
+
"""Pour chaque caractère de la valeur décodée, sa colonne dans ``raw`` ; plus la fin."""
|
|
65
|
+
if self.literal:
|
|
66
|
+
return list(range(len(self.raw) + 1))
|
|
67
|
+
offsets = []
|
|
68
|
+
index = 0
|
|
69
|
+
while index < len(self.raw):
|
|
70
|
+
offsets.append(index)
|
|
71
|
+
if self.raw[index] == "\\":
|
|
72
|
+
length = _ESCAPE_LENGTHS.get(self.raw[index + 1 : index + 2], 2)
|
|
73
|
+
if length == 6 and _starts_surrogate_pair(self.raw, index):
|
|
74
|
+
length = 12
|
|
75
|
+
index += length
|
|
76
|
+
else:
|
|
77
|
+
index += 1
|
|
78
|
+
offsets.append(len(self.raw))
|
|
79
|
+
return offsets
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _starts_surrogate_pair(raw: str, index: int) -> bool:
|
|
83
|
+
"""Vrai si ``raw[index:]`` commence par deux ``\\uXXXX`` qui forment une paire de
|
|
84
|
+
substituts (JSON) : ``json`` les décode en un seul caractère."""
|
|
85
|
+
if not raw.startswith("\\u", index + 6):
|
|
86
|
+
return False
|
|
87
|
+
try:
|
|
88
|
+
high = int(raw[index + 2 : index + 6], 16)
|
|
89
|
+
low = int(raw[index + 8 : index + 12], 16)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return False
|
|
92
|
+
return 0xD800 <= high <= 0xDBFF and 0xDC00 <= low <= 0xDFFF
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def string_spans(text: str) -> dict[TomlPath, StringSpan]:
|
|
96
|
+
"""Les chaînes sur une ligne du document TOML ``text``, par chemin.
|
|
97
|
+
|
|
98
|
+
``text`` doit avoir été accepté par ``tomllib`` : il n'est pas revalidé. Les
|
|
99
|
+
chaînes sur plusieurs lignes (``\"\"\"…\"\"\"``) ne sont pas rendues. Les lignes
|
|
100
|
+
sont comptées sur ``\\n`` seulement, comme ``Location``.
|
|
101
|
+
"""
|
|
102
|
+
scanner = _Scanner(text)
|
|
103
|
+
try:
|
|
104
|
+
scanner.document()
|
|
105
|
+
except (IndexError, ValueError) as err:
|
|
106
|
+
# Ne devrait pas arriver sur un document valide : on garde ce qui a été trouvé.
|
|
107
|
+
logger.debug("TOML parcouru en partie seulement : %s", err)
|
|
108
|
+
return scanner.spans
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def decode_basic_string(raw: str) -> str:
|
|
112
|
+
"""Décode le contenu d'une chaîne TOML entre guillemets doubles (``\\"``, ``\\u00e9``…)."""
|
|
113
|
+
chars = []
|
|
114
|
+
index = 0
|
|
115
|
+
while index < len(raw):
|
|
116
|
+
char = raw[index]
|
|
117
|
+
if char != "\\":
|
|
118
|
+
chars.append(char)
|
|
119
|
+
index += 1
|
|
120
|
+
continue
|
|
121
|
+
kind = raw[index + 1]
|
|
122
|
+
if kind in _ESCAPE_LENGTHS:
|
|
123
|
+
length = _ESCAPE_LENGTHS[kind]
|
|
124
|
+
chars.append(chr(int(raw[index + 2 : index + length], 16)))
|
|
125
|
+
index += length
|
|
126
|
+
else:
|
|
127
|
+
chars.append(_SIMPLE_ESCAPES.get(kind, kind))
|
|
128
|
+
index += 2
|
|
129
|
+
return "".join(chars)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Scanner:
|
|
133
|
+
"""Parcourt un document TOML valide et note la position des chaînes."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, text: str) -> None:
|
|
136
|
+
self.text = text
|
|
137
|
+
self.pos = 0
|
|
138
|
+
self.spans: dict[TomlPath, StringSpan] = {}
|
|
139
|
+
self._line_starts = [0] + [i + 1 for i, char in enumerate(text) if char == "\n"]
|
|
140
|
+
self._array_tables: dict[TomlPath, int] = {}
|
|
141
|
+
"""Dernier indice de chaque tableau de tables (``[[package]]``)."""
|
|
142
|
+
|
|
143
|
+
def document(self) -> None:
|
|
144
|
+
table: TomlPath = ()
|
|
145
|
+
while True:
|
|
146
|
+
self._skip_blank()
|
|
147
|
+
if self.pos >= len(self.text):
|
|
148
|
+
return
|
|
149
|
+
if self.text[self.pos] == "[":
|
|
150
|
+
table = self._header()
|
|
151
|
+
else:
|
|
152
|
+
self._key_value(table)
|
|
153
|
+
|
|
154
|
+
def _header(self) -> TomlPath:
|
|
155
|
+
"""Lit ``[a.b]`` ou ``[[a.b]]`` ; rend le chemin de la table ainsi ouverte."""
|
|
156
|
+
self.pos += 1
|
|
157
|
+
array = self.text[self.pos] == "["
|
|
158
|
+
if array:
|
|
159
|
+
self.pos += 1
|
|
160
|
+
keys = self._key()
|
|
161
|
+
self._skip_spaces()
|
|
162
|
+
self.pos += 2 if array else 1
|
|
163
|
+
path: TomlPath = ()
|
|
164
|
+
for i, key in enumerate(keys):
|
|
165
|
+
path = (*path, key)
|
|
166
|
+
if array and i == len(keys) - 1:
|
|
167
|
+
index = self._array_tables.get(path, -1) + 1
|
|
168
|
+
self._array_tables[path] = index
|
|
169
|
+
return (*path, index)
|
|
170
|
+
if path in self._array_tables:
|
|
171
|
+
path = (*path, self._array_tables[path])
|
|
172
|
+
return path
|
|
173
|
+
|
|
174
|
+
def _key_value(self, table: TomlPath) -> None:
|
|
175
|
+
keys = self._key()
|
|
176
|
+
self._skip_spaces()
|
|
177
|
+
self.pos += 1 # le signe =
|
|
178
|
+
self._skip_spaces()
|
|
179
|
+
self._value((*table, *keys))
|
|
180
|
+
|
|
181
|
+
def _key(self) -> list[str]:
|
|
182
|
+
"""Lit une clé, pointée ou non (``a."b c".d``)."""
|
|
183
|
+
keys = []
|
|
184
|
+
while True:
|
|
185
|
+
self._skip_spaces()
|
|
186
|
+
char = self.text[self.pos]
|
|
187
|
+
if char == '"':
|
|
188
|
+
start = self.pos + 1
|
|
189
|
+
self._skip_basic_string()
|
|
190
|
+
keys.append(decode_basic_string(self.text[start : self.pos - 1]))
|
|
191
|
+
elif char == "'":
|
|
192
|
+
start = self.pos + 1
|
|
193
|
+
self.pos = self.text.index("'", start) + 1
|
|
194
|
+
keys.append(self.text[start : self.pos - 1])
|
|
195
|
+
else:
|
|
196
|
+
start = self.pos
|
|
197
|
+
while self.text[self.pos] in _BARE_KEY_CHARS:
|
|
198
|
+
self.pos += 1
|
|
199
|
+
if self.pos == start:
|
|
200
|
+
raise ValueError(f"clé attendue à la position {start}")
|
|
201
|
+
keys.append(self.text[start : self.pos])
|
|
202
|
+
self._skip_spaces()
|
|
203
|
+
if self.text[self.pos] != ".":
|
|
204
|
+
return keys
|
|
205
|
+
self.pos += 1
|
|
206
|
+
|
|
207
|
+
def _value(self, path: TomlPath) -> None:
|
|
208
|
+
char = self.text[self.pos]
|
|
209
|
+
if self.text.startswith(('"""', "'''"), self.pos):
|
|
210
|
+
self._skip_multiline_string()
|
|
211
|
+
elif char == '"':
|
|
212
|
+
start = self.pos + 1
|
|
213
|
+
self._skip_basic_string()
|
|
214
|
+
self._record(path, start, self.pos - 1, literal=False)
|
|
215
|
+
elif char == "'":
|
|
216
|
+
start = self.pos + 1
|
|
217
|
+
self.pos = self.text.index("'", start) + 1
|
|
218
|
+
self._record(path, start, self.pos - 1, literal=True)
|
|
219
|
+
elif char == "[":
|
|
220
|
+
self._array(path)
|
|
221
|
+
elif char == "{":
|
|
222
|
+
self._inline_table(path)
|
|
223
|
+
else:
|
|
224
|
+
# Nombre, booléen, date : on passe jusqu'au prochain séparateur.
|
|
225
|
+
while self.pos < len(self.text) and self.text[self.pos] not in ",]}#\r\n":
|
|
226
|
+
self.pos += 1
|
|
227
|
+
|
|
228
|
+
def _array(self, path: TomlPath) -> None:
|
|
229
|
+
self.pos += 1
|
|
230
|
+
index = 0
|
|
231
|
+
while True:
|
|
232
|
+
self._skip_blank()
|
|
233
|
+
if self.text[self.pos] == "]":
|
|
234
|
+
self.pos += 1
|
|
235
|
+
return
|
|
236
|
+
self._value((*path, index))
|
|
237
|
+
index += 1
|
|
238
|
+
self._skip_blank()
|
|
239
|
+
if self.text[self.pos] == ",":
|
|
240
|
+
self.pos += 1
|
|
241
|
+
|
|
242
|
+
def _inline_table(self, path: TomlPath) -> None:
|
|
243
|
+
self.pos += 1
|
|
244
|
+
while True:
|
|
245
|
+
self._skip_blank()
|
|
246
|
+
if self.text[self.pos] == "}":
|
|
247
|
+
self.pos += 1
|
|
248
|
+
return
|
|
249
|
+
self._key_value(path)
|
|
250
|
+
self._skip_blank()
|
|
251
|
+
if self.text[self.pos] == ",":
|
|
252
|
+
self.pos += 1
|
|
253
|
+
|
|
254
|
+
def _skip_basic_string(self) -> None:
|
|
255
|
+
"""Avance après une chaîne ``"…"`` ; ``pos`` est sur le guillemet ouvrant."""
|
|
256
|
+
index = self.pos + 1
|
|
257
|
+
while self.text[index] != '"':
|
|
258
|
+
index += 2 if self.text[index] == "\\" else 1
|
|
259
|
+
self.pos = index + 1
|
|
260
|
+
|
|
261
|
+
def _skip_multiline_string(self) -> None:
|
|
262
|
+
quote = self.text[self.pos : self.pos + 3]
|
|
263
|
+
index = self.pos + 3
|
|
264
|
+
while not self.text.startswith(quote, index):
|
|
265
|
+
index += 2 if quote == '"""' and self.text[index] == "\\" else 1
|
|
266
|
+
index += 3
|
|
267
|
+
# Jusqu'à deux guillemets de plus collés à la fin font partie du contenu.
|
|
268
|
+
extra = 0
|
|
269
|
+
while extra < 2 and index < len(self.text) and self.text[index] == quote[0]:
|
|
270
|
+
index += 1
|
|
271
|
+
extra += 1
|
|
272
|
+
self.pos = index
|
|
273
|
+
|
|
274
|
+
def _record(self, path: TomlPath, start: int, end: int, *, literal: bool) -> None:
|
|
275
|
+
line = bisect.bisect_right(self._line_starts, start)
|
|
276
|
+
self.spans[path] = StringSpan(
|
|
277
|
+
line=line,
|
|
278
|
+
column=start - self._line_starts[line - 1],
|
|
279
|
+
raw=self.text[start:end],
|
|
280
|
+
literal=literal,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _skip_spaces(self) -> None:
|
|
284
|
+
while self.pos < len(self.text) and self.text[self.pos] in " \t":
|
|
285
|
+
self.pos += 1
|
|
286
|
+
|
|
287
|
+
def _skip_blank(self) -> None:
|
|
288
|
+
"""Passe les espaces, les fins de ligne et les commentaires."""
|
|
289
|
+
while self.pos < len(self.text):
|
|
290
|
+
char = self.text[self.pos]
|
|
291
|
+
if char in " \t\r\n":
|
|
292
|
+
self.pos += 1
|
|
293
|
+
elif char == "#":
|
|
294
|
+
end = self.text.find("\n", self.pos)
|
|
295
|
+
self.pos = len(self.text) if end == -1 else end
|
|
296
|
+
else:
|
|
297
|
+
return
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Les registres : ce que chacun dit d'un paquet (versions, dernière stable, dépréciation).
|
|
2
|
+
|
|
3
|
+
Un client par registre, derrière l'interface de ``base.py``. Pour une session :
|
|
4
|
+
|
|
5
|
+
async with HttpClient() as http:
|
|
6
|
+
registry = for_ecosystem(Ecosystem.NPM, http, Cache.default())
|
|
7
|
+
info = await registry.package("lodash") # PackageInfo
|
|
8
|
+
info.latest # "4.17.21"
|
|
9
|
+
|
|
10
|
+
PyPI et npm pour l'instant ; les autres registres arrivent avec leur étape (E21-E26).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from forceupdate.models import Ecosystem
|
|
14
|
+
from forceupdate.net.cache import Cache
|
|
15
|
+
from forceupdate.net.http import HttpClient
|
|
16
|
+
from forceupdate.registries.base import (
|
|
17
|
+
PackageInfo,
|
|
18
|
+
PackageNotFound,
|
|
19
|
+
PackageVersion,
|
|
20
|
+
Registry,
|
|
21
|
+
RegistryError,
|
|
22
|
+
)
|
|
23
|
+
from forceupdate.registries.npm import Npm
|
|
24
|
+
from forceupdate.registries.pypi import PyPI
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PackageInfo",
|
|
28
|
+
"PackageNotFound",
|
|
29
|
+
"PackageVersion",
|
|
30
|
+
"Registry",
|
|
31
|
+
"RegistryError",
|
|
32
|
+
"for_ecosystem",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_REGISTRIES: dict[Ecosystem, type[Registry]] = {
|
|
36
|
+
Ecosystem.PYPI: PyPI,
|
|
37
|
+
Ecosystem.NPM: Npm,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def for_ecosystem(ecosystem: Ecosystem, http: HttpClient, cache: Cache) -> Registry:
|
|
42
|
+
"""Un client du registre de ``ecosystem``, pour la session ; ``LookupError`` s'il n'est pas
|
|
43
|
+
encore géré.
|
|
44
|
+
|
|
45
|
+
Garder l'instance pour toute la session : c'est elle qui fait qu'un paquet n'est demandé
|
|
46
|
+
qu'une fois.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
registry = _REGISTRIES[ecosystem]
|
|
50
|
+
except KeyError:
|
|
51
|
+
raise LookupError(f"registre {ecosystem} pas encore géré par forceupdate.") from None
|
|
52
|
+
return registry(http, cache)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Ce que les registres disent d'un paquet : ses versions, la dernière stable, sa dépréciation.
|
|
2
|
+
|
|
3
|
+
Un client par registre (PyPI et npm, puis ceux de la phase F), derrière une même
|
|
4
|
+
interface. Pour un paquet, trois issues :
|
|
5
|
+
|
|
6
|
+
- ``PackageInfo`` : le registre le connaît ;
|
|
7
|
+
- ``PackageNotFound`` : il ne le connaît pas (paquet privé, faute de frappe). Le constat
|
|
8
|
+
est « non vérifiable », ce n'est pas une erreur fatale ;
|
|
9
|
+
- ``RegistryError`` : pas de réponse exploitable (réseau, hors ligne sans cache, réponse
|
|
10
|
+
illisible). Le constat est « non concluant », jamais faux.
|
|
11
|
+
|
|
12
|
+
Les versions restent des chaînes, telles que le registre les donne. Elles s'interprètent
|
|
13
|
+
avec ``forceupdate.versions`` ; une version que ses règles ne savent pas lire (un vieux
|
|
14
|
+
paquet PyPI hors PEP 440) est gardée, mais n'est jamais candidate.
|
|
15
|
+
|
|
16
|
+
Une réponse est réduite à ce qui sert, puis rangée dans le cache disque (``CacheKind.
|
|
17
|
+
VERSIONS``, 1 h), un « introuvable » aussi. Une erreur ne l'est pas.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import functools
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from collections.abc import Iterable, Mapping
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from typing import Any, ClassVar
|
|
27
|
+
|
|
28
|
+
from forceupdate import versions
|
|
29
|
+
from forceupdate.models import Ecosystem
|
|
30
|
+
from forceupdate.net.cache import Cache, CacheEntry, CacheKind
|
|
31
|
+
from forceupdate.net.http import HttpClient, NetworkError, Response
|
|
32
|
+
from forceupdate.versions import InvalidVersion
|
|
33
|
+
|
|
34
|
+
_FORMAT = 1
|
|
35
|
+
"""Version du format des entrées du cache : une autre est ignorée, et le registre réinterrogé."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PackageNotFound(LookupError):
|
|
39
|
+
"""Le registre ne connaît pas le paquet : paquet privé, ou faute de frappe.
|
|
40
|
+
|
|
41
|
+
Le message, en français, est fait pour être montré tel quel dans le rapport.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RegistryError(Exception):
|
|
46
|
+
"""Le registre n'a pas donné de réponse exploitable : réseau, mode hors ligne sans
|
|
47
|
+
cache, statut inattendu, réponse illisible.
|
|
48
|
+
|
|
49
|
+
Le message, en français, est fait pour être montré tel quel dans le rapport.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, kw_only=True)
|
|
54
|
+
class PackageVersion:
|
|
55
|
+
"""Une version publiée d'un paquet."""
|
|
56
|
+
|
|
57
|
+
version: str
|
|
58
|
+
"""Telle que le registre l'écrit."""
|
|
59
|
+
published: datetime | None = None
|
|
60
|
+
"""Date de publication (UTC) ; ``None`` si le registre ne la donne pas."""
|
|
61
|
+
yanked: bool = False
|
|
62
|
+
"""Retirée : on ne la propose plus, et l'utiliser est un constat. Sur PyPI, quand tous
|
|
63
|
+
ses fichiers sont « yanked ». npm n'a pas de retrait : une version dépubliée disparaît."""
|
|
64
|
+
yanked_reason: str | None = None
|
|
65
|
+
"""La raison du retrait donnée par le registre, s'il y en a une."""
|
|
66
|
+
deprecated: str | None = None
|
|
67
|
+
"""npm : le message de dépréciation de cette version ; ``None`` si elle ne l'est pas."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, kw_only=True)
|
|
71
|
+
class PackageInfo:
|
|
72
|
+
"""Ce qu'un registre dit d'un paquet."""
|
|
73
|
+
|
|
74
|
+
ecosystem: Ecosystem
|
|
75
|
+
name: str
|
|
76
|
+
"""Le nom tel que le registre le connaît (normalisé selon PEP 503 pour PyPI)."""
|
|
77
|
+
versions: tuple[PackageVersion, ...]
|
|
78
|
+
"""Toutes les versions publiées, de la plus ancienne à la plus récente ; les versions
|
|
79
|
+
illisibles à la fin."""
|
|
80
|
+
latest: str | None
|
|
81
|
+
"""La dernière stable, celle qu'on propose (voir chaque registre pour sa règle) ; ``None``
|
|
82
|
+
s'il n'y en a pas (que des pré-versions, ou des versions retirées)."""
|
|
83
|
+
deprecated: str | None = None
|
|
84
|
+
"""Le paquet entier est déprécié, abandonné ou retiré : le message à montrer (celui du
|
|
85
|
+
registre quand il en donne un) ; ``None`` sinon."""
|
|
86
|
+
tags: Mapping[str, str] = field(default_factory=dict)
|
|
87
|
+
"""npm : les étiquettes (``dist-tags``) et leur version, ``{"latest": "4.17.21", "next":
|
|
88
|
+
…}``. Une dépendance écrite avec une étiquette suit celle-ci. Vide pour PyPI."""
|
|
89
|
+
url: str | None = None
|
|
90
|
+
"""La page du paquet, pour en savoir plus."""
|
|
91
|
+
fetched_at: datetime
|
|
92
|
+
"""Quand le registre a répondu (UTC) : plus ancien si la réponse vient du cache."""
|
|
93
|
+
|
|
94
|
+
def get(self, version: str) -> PackageVersion | None:
|
|
95
|
+
"""La version publiée ``version`` : même texte, sinon même version selon les règles de
|
|
96
|
+
l'écosystème (``1.8c1`` et ``1.8rc1`` sur PyPI). ``None`` si elle n'est pas publiée."""
|
|
97
|
+
found = self._by_text.get(version)
|
|
98
|
+
if found is not None:
|
|
99
|
+
return found
|
|
100
|
+
try:
|
|
101
|
+
return self._by_parsed.get(versions.for_ecosystem(self.ecosystem).parse(version))
|
|
102
|
+
except InvalidVersion:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def available(self) -> list[str]:
|
|
106
|
+
"""Les versions qu'on peut proposer, de la plus ancienne à la plus récente : toutes sauf
|
|
107
|
+
les retirées. C'est la liste à donner à ``versions`` (``latest``, ``max_satisfying``)."""
|
|
108
|
+
return [v.version for v in self.versions if not v.yanked]
|
|
109
|
+
|
|
110
|
+
@functools.cached_property
|
|
111
|
+
def _by_text(self) -> dict[str, PackageVersion]:
|
|
112
|
+
return {v.version: v for v in self.versions}
|
|
113
|
+
|
|
114
|
+
@functools.cached_property
|
|
115
|
+
def _by_parsed(self) -> dict[Any, PackageVersion]:
|
|
116
|
+
scheme = versions.for_ecosystem(self.ecosystem)
|
|
117
|
+
found = {}
|
|
118
|
+
for v in self.versions:
|
|
119
|
+
try:
|
|
120
|
+
found.setdefault(scheme.parse(v.version), v)
|
|
121
|
+
except InvalidVersion:
|
|
122
|
+
continue
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Registry(ABC):
|
|
127
|
+
"""Le client d'un registre : ``await registry.package(nom)``.
|
|
128
|
+
|
|
129
|
+
Une instance par session, avec le client HTTP et le cache de la session : un paquet
|
|
130
|
+
n'y est demandé qu'une fois, même si plusieurs projets le demandent en même temps.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
ecosystem: Ecosystem
|
|
134
|
+
label: str
|
|
135
|
+
"""Le nom du registre dans les messages (``PyPI``, ``npm``)."""
|
|
136
|
+
base_url: str
|
|
137
|
+
"""L'adresse du registre public, sans ``/`` final."""
|
|
138
|
+
headers: ClassVar[Mapping[str, str]] = {}
|
|
139
|
+
"""En-têtes des requêtes (le format de réponse voulu)."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, http: HttpClient, cache: Cache, *, base_url: str | None = None) -> None:
|
|
142
|
+
self._http = http
|
|
143
|
+
self._cache = cache
|
|
144
|
+
if base_url is not None:
|
|
145
|
+
self.base_url = base_url.rstrip("/")
|
|
146
|
+
self._lookups: dict[str, asyncio.Future[PackageInfo]] = {}
|
|
147
|
+
|
|
148
|
+
# -- à écrire pour chaque registre ------------------------------------------
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def normalize(self, name: str) -> str:
|
|
152
|
+
"""Le nom tel que le registre le connaît (``Flask`` → ``flask`` pour PyPI)."""
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
def url_for(self, name: str) -> str:
|
|
156
|
+
"""L'URL à interroger pour le paquet ``name``, déjà normalisé."""
|
|
157
|
+
|
|
158
|
+
@abstractmethod
|
|
159
|
+
def page_url(self, name: str) -> str | None:
|
|
160
|
+
"""La page web du paquet ; ``None`` si on ne la connaît pas (registre privé)."""
|
|
161
|
+
|
|
162
|
+
@abstractmethod
|
|
163
|
+
def parse(self, name: str, response: Response, fetched_at: datetime) -> PackageInfo:
|
|
164
|
+
"""Lit la réponse (statut 200) ; lève ``ValueError`` si elle est illisible."""
|
|
165
|
+
|
|
166
|
+
# -- commun --------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
async def package(self, name: str) -> PackageInfo:
|
|
169
|
+
"""Ce que le registre dit du paquet ``name``.
|
|
170
|
+
|
|
171
|
+
Lève ``PackageNotFound`` s'il ne le connaît pas, ``RegistryError`` sans réponse
|
|
172
|
+
exploitable. Une seule requête par paquet dans la session : les appels suivants, ou
|
|
173
|
+
simultanés, rendent la même réponse (ou la même erreur).
|
|
174
|
+
"""
|
|
175
|
+
key = self.normalize(name)
|
|
176
|
+
lookup = self._lookups.get(key)
|
|
177
|
+
if lookup is None:
|
|
178
|
+
lookup = self._lookups[key] = asyncio.ensure_future(self._lookup(key))
|
|
179
|
+
# shield : un appelant annulé n'annule pas la requête que d'autres attendent.
|
|
180
|
+
return await asyncio.shield(lookup)
|
|
181
|
+
|
|
182
|
+
async def _lookup(self, name: str) -> PackageInfo:
|
|
183
|
+
url = self.url_for(name)
|
|
184
|
+
entry = self._cache.get(CacheKind.VERSIONS, url, stale_ok=self._http.offline)
|
|
185
|
+
if entry is not None:
|
|
186
|
+
cached = self._from_cache(name, entry)
|
|
187
|
+
if cached is not None:
|
|
188
|
+
return cached
|
|
189
|
+
if self._http.offline:
|
|
190
|
+
raise RegistryError(
|
|
191
|
+
f"{self.label} : mode hors ligne, et {name} n'est pas dans le cache."
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
response = await self._http.get(url, headers=self.headers)
|
|
195
|
+
except NetworkError as error:
|
|
196
|
+
raise RegistryError(f"{self.label} : {error}") from error
|
|
197
|
+
if response.status_code == 404:
|
|
198
|
+
self._cache.set(CacheKind.VERSIONS, url, {"format": _FORMAT, "not_found": True})
|
|
199
|
+
raise PackageNotFound(self._not_found(name))
|
|
200
|
+
if response.status_code != 200:
|
|
201
|
+
raise RegistryError(
|
|
202
|
+
f"{self.label} : réponse inattendue pour {name} (HTTP {response.status_code})."
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
info = self.parse(name, response, datetime.now(UTC))
|
|
206
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
207
|
+
# Une réponse d'un format inconnu n'arrête pas l'analyse : elle est non concluante.
|
|
208
|
+
raise RegistryError(
|
|
209
|
+
f"{self.label} : réponse illisible pour {name} ({error})."
|
|
210
|
+
) from error
|
|
211
|
+
self._cache.set(CacheKind.VERSIONS, url, _info_to_cache(info))
|
|
212
|
+
return info
|
|
213
|
+
|
|
214
|
+
def _not_found(self, name: str) -> str:
|
|
215
|
+
return f"{name} est introuvable sur {self.label} : paquet privé, ou nom mal écrit ?"
|
|
216
|
+
|
|
217
|
+
def _from_cache(self, name: str, entry: CacheEntry) -> PackageInfo | None:
|
|
218
|
+
"""L'entrée du cache relue ; lève ``PackageNotFound`` pour un « introuvable » rangé,
|
|
219
|
+
``None`` pour une entrée d'un autre format."""
|
|
220
|
+
value = entry.value
|
|
221
|
+
if not isinstance(value, dict) or value.get("format") != _FORMAT:
|
|
222
|
+
return None
|
|
223
|
+
if value.get("not_found"):
|
|
224
|
+
raise PackageNotFound(self._not_found(name))
|
|
225
|
+
try:
|
|
226
|
+
return _info_from_cache(self.ecosystem, value, entry.stored_at)
|
|
227
|
+
except (ValueError, KeyError, TypeError):
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def sorted_versions(
|
|
232
|
+
ecosystem: Ecosystem, found: Iterable[PackageVersion]
|
|
233
|
+
) -> tuple[PackageVersion, ...]:
|
|
234
|
+
"""Les versions de la plus ancienne à la plus récente, les illisibles à la fin."""
|
|
235
|
+
key = versions.for_ecosystem(ecosystem).sort_key
|
|
236
|
+
return tuple(sorted(found, key=lambda v: key(v.version)))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_time(value: object) -> datetime | None:
|
|
240
|
+
"""Une date ISO 8601 du registre (``2021-05-21T15:53:08.128824Z``), en UTC ; ``None`` si
|
|
241
|
+
elle manque ou est illisible."""
|
|
242
|
+
if not isinstance(value, str):
|
|
243
|
+
return None
|
|
244
|
+
try:
|
|
245
|
+
when = datetime.fromisoformat(value)
|
|
246
|
+
except ValueError:
|
|
247
|
+
return None
|
|
248
|
+
return when.replace(tzinfo=UTC) if when.tzinfo is None else when.astimezone(UTC)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _info_to_cache(info: PackageInfo) -> dict[str, Any]:
|
|
252
|
+
return {
|
|
253
|
+
"format": _FORMAT,
|
|
254
|
+
"name": info.name,
|
|
255
|
+
"versions": [
|
|
256
|
+
[
|
|
257
|
+
v.version,
|
|
258
|
+
None if v.published is None else v.published.isoformat(),
|
|
259
|
+
v.yanked,
|
|
260
|
+
v.yanked_reason,
|
|
261
|
+
v.deprecated,
|
|
262
|
+
]
|
|
263
|
+
for v in info.versions
|
|
264
|
+
],
|
|
265
|
+
"latest": info.latest,
|
|
266
|
+
"deprecated": info.deprecated,
|
|
267
|
+
"tags": dict(info.tags),
|
|
268
|
+
"url": info.url,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _info_from_cache(
|
|
273
|
+
ecosystem: Ecosystem, value: dict[str, Any], stored_at: datetime
|
|
274
|
+
) -> PackageInfo:
|
|
275
|
+
found = tuple(
|
|
276
|
+
PackageVersion(
|
|
277
|
+
version=version,
|
|
278
|
+
published=parse_time(published),
|
|
279
|
+
yanked=yanked,
|
|
280
|
+
yanked_reason=reason,
|
|
281
|
+
deprecated=deprecated,
|
|
282
|
+
)
|
|
283
|
+
for version, published, yanked, reason, deprecated in value["versions"]
|
|
284
|
+
)
|
|
285
|
+
return PackageInfo(
|
|
286
|
+
ecosystem=ecosystem,
|
|
287
|
+
name=value["name"],
|
|
288
|
+
versions=found,
|
|
289
|
+
latest=value["latest"],
|
|
290
|
+
deprecated=value["deprecated"],
|
|
291
|
+
tags=dict(value["tags"]),
|
|
292
|
+
url=value["url"],
|
|
293
|
+
fetched_at=stored_at,
|
|
294
|
+
)
|