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.
Files changed (50) hide show
  1. forceupdate/__init__.py +9 -0
  2. forceupdate/__main__.py +5 -0
  3. forceupdate/advisories/__init__.py +30 -0
  4. forceupdate/advisories/cvss.py +95 -0
  5. forceupdate/advisories/osv.py +584 -0
  6. forceupdate/checks/__init__.py +49 -0
  7. forceupdate/checks/base.py +98 -0
  8. forceupdate/checks/deprecated.py +115 -0
  9. forceupdate/checks/outdated.py +183 -0
  10. forceupdate/checks/priority.py +82 -0
  11. forceupdate/checks/vulns.py +201 -0
  12. forceupdate/cli.py +309 -0
  13. forceupdate/detect/__init__.py +5 -0
  14. forceupdate/detect/languages.py +266 -0
  15. forceupdate/detect/manifests.py +478 -0
  16. forceupdate/engine.py +313 -0
  17. forceupdate/models.py +510 -0
  18. forceupdate/net/__init__.py +20 -0
  19. forceupdate/net/cache.py +217 -0
  20. forceupdate/net/http.py +270 -0
  21. forceupdate/parsers/__init__.py +43 -0
  22. forceupdate/parsers/base.py +22 -0
  23. forceupdate/parsers/json_spans.py +123 -0
  24. forceupdate/parsers/lockfiles.py +231 -0
  25. forceupdate/parsers/npm.py +1257 -0
  26. forceupdate/parsers/python.py +1421 -0
  27. forceupdate/parsers/toml_spans.py +297 -0
  28. forceupdate/registries/__init__.py +52 -0
  29. forceupdate/registries/base.py +294 -0
  30. forceupdate/registries/npm.py +153 -0
  31. forceupdate/registries/pypi.py +242 -0
  32. forceupdate/report/__init__.py +359 -0
  33. forceupdate/report/json.py +34 -0
  34. forceupdate/report/markdown.py +169 -0
  35. forceupdate/report/terminal.py +376 -0
  36. forceupdate/sources/__init__.py +1 -0
  37. forceupdate/sources/base.py +45 -0
  38. forceupdate/sources/local.py +183 -0
  39. forceupdate/update/__init__.py +8 -0
  40. forceupdate/update/constraints.py +317 -0
  41. forceupdate/update/planner.py +531 -0
  42. forceupdate/versions/__init__.py +40 -0
  43. forceupdate/versions/base.py +229 -0
  44. forceupdate/versions/npm.py +432 -0
  45. forceupdate/versions/pypi.py +220 -0
  46. forceupdate-0.1.0.dist-info/METADATA +79 -0
  47. forceupdate-0.1.0.dist-info/RECORD +50 -0
  48. forceupdate-0.1.0.dist-info/WHEEL +4 -0
  49. forceupdate-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
+ )