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
forceupdate/net/cache.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Le cache disque : ce que le réseau a répondu, gardé un temps qui dépend du type de donnée.
|
|
2
|
+
|
|
3
|
+
Un même paquet revient dans plusieurs projets, plusieurs dépôts et plusieurs analyses : il
|
|
4
|
+
n'est demandé qu'une fois par durée de vie. On y range des valeurs JSON déjà réduites (les
|
|
5
|
+
versions d'un paquet, pas le document de 30 Mo du registre).
|
|
6
|
+
|
|
7
|
+
Le cache est une base SQLite, dans le dossier de cache de l'utilisateur (``platformdirs``) :
|
|
8
|
+
un seul fichier, des écritures atomiques, et l'interface et la ligne de commande peuvent
|
|
9
|
+
s'en servir en même temps.
|
|
10
|
+
|
|
11
|
+
- **Sans cache** (``Cache.disabled()``) : rien n'est lu ni écrit.
|
|
12
|
+
- **Hors ligne** : une entrée périmée sert quand même (``stale_ok``). Mieux vaut une donnée
|
|
13
|
+
datée, et dite datée (``CacheEntry.stored_at``), que rien.
|
|
14
|
+
- **Jetable** : un fichier illisible ou d'un autre format est vidé ; un disque où l'on ne
|
|
15
|
+
peut pas écrire désactive le cache, avec un avertissement dans les logs. Le cache ne
|
|
16
|
+
fait jamais échouer une analyse.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import enum
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import sqlite3
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import platformdirs
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_SCHEMA = 1
|
|
36
|
+
"""Version du format de la base, dans ``PRAGMA user_version`` : une autre la fait vider."""
|
|
37
|
+
_KEEP = 30 * 24 * 3600.0
|
|
38
|
+
"""Les entrées plus vieilles que 30 jours sont effacées à l'ouverture, même hors ligne."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CacheKind(enum.StrEnum):
|
|
42
|
+
"""Un type de donnée du cache, avec sa durée de vie (``ttl``)."""
|
|
43
|
+
|
|
44
|
+
VERSIONS = "versions"
|
|
45
|
+
"""Les versions d'un paquet et sa dépréciation : 1 h, une version peut sortir à tout moment."""
|
|
46
|
+
VULNERABILITIES = "vulnerabilities"
|
|
47
|
+
"""Les failles connues d'une version d'un paquet (leurs identifiants, chez OSV) : 1 h, un
|
|
48
|
+
avis peut être publié à tout moment."""
|
|
49
|
+
ADVISORIES = "advisories"
|
|
50
|
+
"""Un avis de sécurité complet (OSV) : 7 jours. Il ne change pas sans que sa date de
|
|
51
|
+
modification change, et on la vérifie à chaque fois."""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ttl(self) -> float:
|
|
55
|
+
"""Durée de vie d'une entrée, en secondes."""
|
|
56
|
+
return _TTLS[self]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_TTLS = {
|
|
60
|
+
CacheKind.VERSIONS: 3600.0,
|
|
61
|
+
CacheKind.VULNERABILITIES: 3600.0,
|
|
62
|
+
CacheKind.ADVISORIES: 7 * 24 * 3600.0,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, kw_only=True)
|
|
67
|
+
class CacheEntry:
|
|
68
|
+
"""Une valeur lue dans le cache."""
|
|
69
|
+
|
|
70
|
+
value: Any
|
|
71
|
+
stored_at: datetime
|
|
72
|
+
"""Quand elle a été rangée (UTC) : la date des données."""
|
|
73
|
+
fresh: bool
|
|
74
|
+
"""Faux pour une entrée périmée, rendue seulement parce que ``stale_ok`` le permettait."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def default_cache_path() -> Path:
|
|
78
|
+
"""Le fichier du cache, dans le dossier de cache de l'utilisateur :
|
|
79
|
+
``%LOCALAPPDATA%\\forceupdate\\Cache`` sous Windows, ``~/.cache/forceupdate`` sous Linux."""
|
|
80
|
+
return platformdirs.user_cache_path("forceupdate", appauthor=False) / "cache.sqlite3"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Cache:
|
|
84
|
+
"""Le cache disque, rangé dans le fichier ``path`` (créé au besoin) ; ``None`` le désactive.
|
|
85
|
+
|
|
86
|
+
Les clés sont des chaînes (l'URL interrogée, en général), les valeurs du JSON. Les
|
|
87
|
+
méthodes sont synchrones et rapides (une requête SQLite), et sûres entre threads.
|
|
88
|
+
``clock`` sert aux tests.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, path: Path | None, *, clock: Callable[[], float] = time.time) -> None:
|
|
92
|
+
self.path = path
|
|
93
|
+
self._clock = clock
|
|
94
|
+
self._lock = threading.Lock()
|
|
95
|
+
self._db: sqlite3.Connection | None = None
|
|
96
|
+
self._opened = path is None
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def default(cls) -> "Cache":
|
|
100
|
+
"""Le cache de l'utilisateur, à ``default_cache_path()``."""
|
|
101
|
+
return cls(default_cache_path())
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def disabled(cls) -> "Cache":
|
|
105
|
+
"""Un cache qui ne lit ni n'écrit rien : l'option « sans cache »."""
|
|
106
|
+
return cls(None)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def enabled(self) -> bool:
|
|
110
|
+
"""Faux si le cache est désactivé, au départ ou après une erreur du disque."""
|
|
111
|
+
with self._lock:
|
|
112
|
+
return self._connection() is not None
|
|
113
|
+
|
|
114
|
+
def get(self, kind: CacheKind, key: str, *, stale_ok: bool = False) -> CacheEntry | None:
|
|
115
|
+
"""L'entrée ``key`` de ``kind`` ; ``None`` si elle manque ou a dépassé sa durée de vie.
|
|
116
|
+
|
|
117
|
+
``stale_ok`` rend aussi une entrée périmée (mode hors ligne), avec ``fresh=False``.
|
|
118
|
+
"""
|
|
119
|
+
with self._lock:
|
|
120
|
+
row = self._execute(
|
|
121
|
+
"SELECT stored_at, value FROM entries WHERE kind = ? AND key = ?", (kind, key)
|
|
122
|
+
)
|
|
123
|
+
if row is None:
|
|
124
|
+
return None
|
|
125
|
+
stored_at, text = row
|
|
126
|
+
fresh = self._clock() - stored_at < kind.ttl
|
|
127
|
+
if not fresh and not stale_ok:
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
value = json.loads(text)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return None
|
|
133
|
+
return CacheEntry(
|
|
134
|
+
value=value, stored_at=datetime.fromtimestamp(stored_at, UTC), fresh=fresh
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def set(self, kind: CacheKind, key: str, value: Any) -> None:
|
|
138
|
+
"""Range ``value`` (du JSON) sous ``key``, à la place de l'ancienne entrée."""
|
|
139
|
+
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
140
|
+
with self._lock:
|
|
141
|
+
self._execute(
|
|
142
|
+
"INSERT OR REPLACE INTO entries (kind, key, stored_at, value) VALUES (?, ?, ?, ?)",
|
|
143
|
+
(kind, key, self._clock(), text),
|
|
144
|
+
commit=True,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def clear(self) -> None:
|
|
148
|
+
"""Vide le cache."""
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._execute("DELETE FROM entries", (), commit=True)
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
with self._lock:
|
|
154
|
+
if self._db is not None:
|
|
155
|
+
self._db.close()
|
|
156
|
+
self._db = None
|
|
157
|
+
|
|
158
|
+
# -- SQLite --------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _execute(self, sql: str, params: tuple[Any, ...], *, commit: bool = False) -> Any:
|
|
161
|
+
"""Lance ``sql`` et rend la première ligne ; ``None`` si le cache est désactivé. Une
|
|
162
|
+
erreur de SQLite désactive le cache pour la suite de la session."""
|
|
163
|
+
db = self._connection()
|
|
164
|
+
if db is None:
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
row = db.execute(sql, params).fetchone()
|
|
168
|
+
if commit:
|
|
169
|
+
db.commit()
|
|
170
|
+
except sqlite3.Error as error:
|
|
171
|
+
self._disable(error)
|
|
172
|
+
return None
|
|
173
|
+
return row
|
|
174
|
+
|
|
175
|
+
def _connection(self) -> sqlite3.Connection | None:
|
|
176
|
+
if not self._opened:
|
|
177
|
+
self._opened = True
|
|
178
|
+
assert self.path is not None
|
|
179
|
+
try:
|
|
180
|
+
self._db = self._open(self.path)
|
|
181
|
+
except sqlite3.DatabaseError:
|
|
182
|
+
# Fichier abîmé, ou qui n'est pas une base : le cache est jetable.
|
|
183
|
+
try:
|
|
184
|
+
self.path.unlink()
|
|
185
|
+
self._db = self._open(self.path)
|
|
186
|
+
except (OSError, sqlite3.Error) as error:
|
|
187
|
+
self._disable(error)
|
|
188
|
+
except (OSError, sqlite3.Error) as error:
|
|
189
|
+
self._disable(error)
|
|
190
|
+
return self._db
|
|
191
|
+
|
|
192
|
+
def _open(self, path: Path) -> sqlite3.Connection:
|
|
193
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
db = sqlite3.connect(path, timeout=5.0, check_same_thread=False)
|
|
195
|
+
try:
|
|
196
|
+
# WAL : les lectures d'un processus ne bloquent pas les écritures d'un autre.
|
|
197
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
198
|
+
(version,) = db.execute("PRAGMA user_version").fetchone()
|
|
199
|
+
if version != _SCHEMA:
|
|
200
|
+
db.execute("DROP TABLE IF EXISTS entries")
|
|
201
|
+
db.execute(
|
|
202
|
+
"CREATE TABLE entries (kind TEXT NOT NULL, key TEXT NOT NULL,"
|
|
203
|
+
" stored_at REAL NOT NULL, value TEXT NOT NULL, PRIMARY KEY (kind, key))"
|
|
204
|
+
)
|
|
205
|
+
db.execute(f"PRAGMA user_version = {_SCHEMA}")
|
|
206
|
+
db.execute("DELETE FROM entries WHERE stored_at < ?", (self._clock() - _KEEP,))
|
|
207
|
+
db.commit()
|
|
208
|
+
except BaseException:
|
|
209
|
+
db.close()
|
|
210
|
+
raise
|
|
211
|
+
return db
|
|
212
|
+
|
|
213
|
+
def _disable(self, error: BaseException) -> None:
|
|
214
|
+
logger.warning("cache désactivé (%s) : %s", self.path, error)
|
|
215
|
+
if self._db is not None:
|
|
216
|
+
self._db.close()
|
|
217
|
+
self._db = None
|
forceupdate/net/http.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Le client HTTP commun : un ``httpx.AsyncClient`` partagé, ses délais, ses reprises et ses
|
|
2
|
+
limites par hôte.
|
|
3
|
+
|
|
4
|
+
Tout ce qui parle au réseau passe par ``HttpClient`` :
|
|
5
|
+
|
|
6
|
+
- un User-Agent qui dit qui on est (crates.io l'exige, E21) ;
|
|
7
|
+
- des délais maximaux : 10 s pour se connecter, 30 s sans rien recevoir, 2 min pour un
|
|
8
|
+
essai entier (un serveur qui envoie un octet toutes les 29 s ne le fait pas durer) ;
|
|
9
|
+
- des reprises sur 429, 5xx, coupures et délais dépassés, en attendant ce que demande
|
|
10
|
+
``Retry-After`` ; s'il demande plus d'une minute, on abandonne ;
|
|
11
|
+
- au plus 8 requêtes à la fois par hôte ;
|
|
12
|
+
- un hôte resté injoignable après ses reprises (ou qui limite le débit) n'est plus
|
|
13
|
+
interrogé de la session : sans réseau, chaque paquet attendrait sinon ses reprises ;
|
|
14
|
+
- une réponse de plus de 128 Mo est refusée (le document npm de ``next`` en fait 32).
|
|
15
|
+
|
|
16
|
+
Une requête qui n'aboutit pas lève ``NetworkError``, avec un message en français fait pour
|
|
17
|
+
le rapport. Un statut final (200, 404, 403…) n'est pas une erreur du client : c'est à
|
|
18
|
+
l'appelant de dire ce qu'il veut dire.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import email.utils
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import random
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
from forceupdate import __version__
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
USER_AGENT = f"forceupdate/{__version__} (+https://github.com/Pauzzle-ozz/forceupdate)"
|
|
39
|
+
|
|
40
|
+
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
41
|
+
"""Les délais d'httpx : 10 s pour se connecter, 30 s pour le reste (entre deux octets…)."""
|
|
42
|
+
DEADLINE = 120.0
|
|
43
|
+
"""Durée maximale d'un essai, en secondes, réponse lue en entier."""
|
|
44
|
+
RETRIES = 3
|
|
45
|
+
"""Reprises après le premier essai, pour une requête qu'on peut rejouer."""
|
|
46
|
+
MAX_PER_HOST = 8
|
|
47
|
+
"""Requêtes simultanées par hôte."""
|
|
48
|
+
MAX_BODY = 128 * 1024 * 1024
|
|
49
|
+
"""Taille maximale d'une réponse, décompressée, en octets."""
|
|
50
|
+
MAX_RETRY_AFTER = 60.0
|
|
51
|
+
"""Attente maximale demandée par ``Retry-After``, en secondes : au-delà, on abandonne."""
|
|
52
|
+
|
|
53
|
+
_BACKOFF = 0.5
|
|
54
|
+
"""Première attente entre deux essais sans ``Retry-After`` ; elle double à chaque reprise."""
|
|
55
|
+
_IDEMPOTENT = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
56
|
+
_RETRYABLE = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError, TimeoutError)
|
|
57
|
+
"""Les échecs qui peuvent passer en réessayant : délai dépassé, connexion refusée ou coupée."""
|
|
58
|
+
_DIGITS = re.compile(r"[0-9]+")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NetworkError(Exception):
|
|
62
|
+
"""Une requête n'a pas abouti : réseau coupé, délai dépassé, serveur en erreur, mode hors
|
|
63
|
+
ligne… Le constat qui en dépend est « non concluant ».
|
|
64
|
+
|
|
65
|
+
Le message, en français, est fait pour être montré tel quel dans le rapport. Il cite
|
|
66
|
+
l'hôte, jamais l'URL (une URL peut contenir un token).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, kw_only=True)
|
|
71
|
+
class Response:
|
|
72
|
+
"""Une réponse HTTP, lue en entier."""
|
|
73
|
+
|
|
74
|
+
url: str
|
|
75
|
+
"""L'URL finale, après les redirections."""
|
|
76
|
+
status_code: int
|
|
77
|
+
headers: httpx.Headers
|
|
78
|
+
content: bytes
|
|
79
|
+
"""Le corps, décompressé."""
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def media_type(self) -> str:
|
|
83
|
+
"""Le type du corps sans ses paramètres, en minuscules (``application/json``)."""
|
|
84
|
+
return self.headers.get("content-type", "").partition(";")[0].strip().lower()
|
|
85
|
+
|
|
86
|
+
def json(self) -> Any:
|
|
87
|
+
"""Le corps lu en JSON ; lève ``ValueError`` s'il n'en est pas."""
|
|
88
|
+
return json.loads(self.content)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _TooLarge(Exception):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class HttpClient:
|
|
96
|
+
"""Le client HTTP d'une session : une analyse en ligne de commande, ou l'interface web.
|
|
97
|
+
|
|
98
|
+
Un seul par session, pour garder les connexions ouvertes et que les limites par hôte
|
|
99
|
+
vaillent pour tout le monde. À fermer après usage : ``async with HttpClient() as http``.
|
|
100
|
+
|
|
101
|
+
``offline`` : aucune requête ne part, chacune lève ``NetworkError`` ; les registres se
|
|
102
|
+
contentent alors du cache. ``sleep`` sert aux tests, pour ne pas attendre les reprises.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
offline: bool = False,
|
|
109
|
+
retries: int = RETRIES,
|
|
110
|
+
max_per_host: int = MAX_PER_HOST,
|
|
111
|
+
max_body: int = MAX_BODY,
|
|
112
|
+
deadline: float = DEADLINE,
|
|
113
|
+
sleep: Callable[[float], Awaitable[object]] = asyncio.sleep,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.offline = offline
|
|
116
|
+
self._retries = retries
|
|
117
|
+
self._max_per_host = max_per_host
|
|
118
|
+
self._max_body = max_body
|
|
119
|
+
self._deadline = deadline
|
|
120
|
+
self._sleep = sleep
|
|
121
|
+
self._slots: dict[str, asyncio.Semaphore] = {}
|
|
122
|
+
self._down: dict[str, str] = {}
|
|
123
|
+
"""Les hôtes abandonnés pour la session, avec le message de leur échec."""
|
|
124
|
+
self._client = httpx.AsyncClient(
|
|
125
|
+
headers={"User-Agent": USER_AGENT},
|
|
126
|
+
timeout=TIMEOUT,
|
|
127
|
+
follow_redirects=True,
|
|
128
|
+
limits=httpx.Limits(max_connections=64, max_keepalive_connections=32),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def __aenter__(self) -> "HttpClient":
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
async def __aexit__(self, *_: object) -> None:
|
|
135
|
+
await self.aclose()
|
|
136
|
+
|
|
137
|
+
async def aclose(self) -> None:
|
|
138
|
+
await self._client.aclose()
|
|
139
|
+
|
|
140
|
+
async def get(self, url: str, *, headers: Mapping[str, str] | None = None) -> Response:
|
|
141
|
+
"""``GET url`` ; voir ``request``."""
|
|
142
|
+
return await self.request("GET", url, headers=headers)
|
|
143
|
+
|
|
144
|
+
async def request(
|
|
145
|
+
self,
|
|
146
|
+
method: str,
|
|
147
|
+
url: str,
|
|
148
|
+
*,
|
|
149
|
+
headers: Mapping[str, str] | None = None,
|
|
150
|
+
json: Any = None,
|
|
151
|
+
retry: bool | None = None,
|
|
152
|
+
) -> Response:
|
|
153
|
+
"""Envoie la requête et rend la réponse, quel que soit son statut final.
|
|
154
|
+
|
|
155
|
+
Réessaie sur 429, 5xx, coupure ou délai dépassé, si la requête peut être rejouée :
|
|
156
|
+
par défaut ``GET``, ``HEAD`` et ``OPTIONS`` ; ``retry=True`` pour une autre qui ne
|
|
157
|
+
change rien (une recherche en ``POST``). ``json`` est envoyé en corps JSON.
|
|
158
|
+
|
|
159
|
+
Lève ``NetworkError`` quand la requête n'aboutit pas, ou que le statut reste 429 ou
|
|
160
|
+
5xx après les reprises.
|
|
161
|
+
"""
|
|
162
|
+
host = httpx.URL(url).host
|
|
163
|
+
if self.offline:
|
|
164
|
+
raise NetworkError(f"mode hors ligne : {host} n'est pas interrogé.")
|
|
165
|
+
if host in self._down:
|
|
166
|
+
raise NetworkError(self._down[host])
|
|
167
|
+
if retry is None:
|
|
168
|
+
retry = method.upper() in _IDEMPOTENT
|
|
169
|
+
attempts = 1 + (self._retries if retry else 0)
|
|
170
|
+
for attempt in range(attempts):
|
|
171
|
+
final = attempt == attempts - 1
|
|
172
|
+
try:
|
|
173
|
+
response = await self._send(host, method, url, headers, json)
|
|
174
|
+
except _TooLarge:
|
|
175
|
+
limit = self._max_body // 2**20
|
|
176
|
+
raise NetworkError(f"réponse de {host} trop grosse (plus de {limit} Mo).") from None
|
|
177
|
+
except _RETRYABLE as error:
|
|
178
|
+
logger.debug(
|
|
179
|
+
"%s %s : %r (essai %d sur %d)", method, url, error, attempt + 1, attempts
|
|
180
|
+
)
|
|
181
|
+
if final:
|
|
182
|
+
message = _failure(host, error)
|
|
183
|
+
self._down[host] = message
|
|
184
|
+
raise NetworkError(message) from error
|
|
185
|
+
await self._sleep(_backoff(attempt))
|
|
186
|
+
continue
|
|
187
|
+
except httpx.HTTPError as error:
|
|
188
|
+
raise NetworkError(
|
|
189
|
+
f"requête vers {host} impossible ({type(error).__name__})."
|
|
190
|
+
) from error
|
|
191
|
+
status = response.status_code
|
|
192
|
+
if status != 429 and status < 500:
|
|
193
|
+
return response
|
|
194
|
+
wait = _retry_after(response.headers)
|
|
195
|
+
logger.debug(
|
|
196
|
+
"%s %s : HTTP %d (essai %d sur %d)", method, url, status, attempt + 1, attempts
|
|
197
|
+
)
|
|
198
|
+
if final or (wait is not None and wait > MAX_RETRY_AFTER):
|
|
199
|
+
message = _status_failure(host, status, wait)
|
|
200
|
+
if status == 429:
|
|
201
|
+
self._down[host] = message
|
|
202
|
+
raise NetworkError(message)
|
|
203
|
+
await self._sleep(_backoff(attempt) if wait is None else wait)
|
|
204
|
+
raise AssertionError("inaccessible : le dernier essai rend ou lève")
|
|
205
|
+
|
|
206
|
+
async def _send(
|
|
207
|
+
self, host: str, method: str, url: str, headers: Mapping[str, str] | None, body: Any
|
|
208
|
+
) -> Response:
|
|
209
|
+
async with (
|
|
210
|
+
self._slot(host),
|
|
211
|
+
asyncio.timeout(self._deadline),
|
|
212
|
+
self._client.stream(method, url, headers=headers, json=body) as response,
|
|
213
|
+
):
|
|
214
|
+
declared = response.headers.get("content-length", "")
|
|
215
|
+
if _DIGITS.fullmatch(declared) and int(declared) > self._max_body:
|
|
216
|
+
raise _TooLarge
|
|
217
|
+
chunks = []
|
|
218
|
+
size = 0
|
|
219
|
+
async for chunk in response.aiter_bytes():
|
|
220
|
+
size += len(chunk)
|
|
221
|
+
if size > self._max_body:
|
|
222
|
+
raise _TooLarge
|
|
223
|
+
chunks.append(chunk)
|
|
224
|
+
return Response(
|
|
225
|
+
url=str(response.url),
|
|
226
|
+
status_code=response.status_code,
|
|
227
|
+
headers=response.headers,
|
|
228
|
+
content=b"".join(chunks),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _slot(self, host: str) -> asyncio.Semaphore:
|
|
232
|
+
slot = self._slots.get(host)
|
|
233
|
+
if slot is None:
|
|
234
|
+
slot = self._slots[host] = asyncio.Semaphore(self._max_per_host)
|
|
235
|
+
return slot
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _backoff(attempt: int) -> float:
|
|
239
|
+
"""L'attente avant la reprise n° ``attempt + 1`` : 0,5 s, 1 s, 2 s…, un peu au hasard
|
|
240
|
+
pour que les requêtes en échec ne reviennent pas toutes ensemble."""
|
|
241
|
+
return _BACKOFF * 2**attempt + random.uniform(0, _BACKOFF)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _retry_after(headers: httpx.Headers) -> float | None:
|
|
245
|
+
"""L'attente demandée par ``Retry-After``, en secondes (un nombre, ou une date HTTP) ;
|
|
246
|
+
``None`` sans en-tête lisible."""
|
|
247
|
+
value = headers.get("retry-after", "").strip()
|
|
248
|
+
if _DIGITS.fullmatch(value):
|
|
249
|
+
return float(value)
|
|
250
|
+
try:
|
|
251
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
252
|
+
except (TypeError, ValueError):
|
|
253
|
+
return None
|
|
254
|
+
if when.tzinfo is None:
|
|
255
|
+
when = when.replace(tzinfo=UTC)
|
|
256
|
+
return max(0.0, (when - datetime.now(UTC)).total_seconds())
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _failure(host: str, error: BaseException) -> str:
|
|
260
|
+
if isinstance(error, httpx.TimeoutException | TimeoutError):
|
|
261
|
+
return f"{host} ne répond pas (délai dépassé)."
|
|
262
|
+
return f"{host} injoignable ({type(error).__name__})."
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _status_failure(host: str, status: int, wait: float | None) -> str:
|
|
266
|
+
if status != 429:
|
|
267
|
+
return f"{host} en erreur (HTTP {status})."
|
|
268
|
+
if wait is not None and wait > MAX_RETRY_AFTER:
|
|
269
|
+
return f"{host} limite le débit (HTTP 429) : réessayer dans {round(wait / 60) or 1} min."
|
|
270
|
+
return f"{host} limite le débit (HTTP 429)."
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Les parseurs : lire les manifestes et lockfiles d'un projet, et en tirer ses dépendances.
|
|
2
|
+
|
|
3
|
+
Un module par écosystème : Python (E03) et npm (E04) pour l'instant, les autres en
|
|
4
|
+
phase F. Chacun relie les manifestes (dépendances directes, contraintes et leur
|
|
5
|
+
emplacement) aux lockfiles (versions installées, transitives), avec ce qui est
|
|
6
|
+
commun dans ``lockfiles.py``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
|
|
11
|
+
from forceupdate.detect.manifests import ecosystem_for_manifest
|
|
12
|
+
from forceupdate.models import Ecosystem, Notice, Project
|
|
13
|
+
from forceupdate.parsers import npm, python
|
|
14
|
+
from forceupdate.parsers.base import ParseError, ReadText
|
|
15
|
+
|
|
16
|
+
__all__ = ["ParseError", "ReadText", "parse_project"]
|
|
17
|
+
|
|
18
|
+
_PARSERS: dict[Ecosystem, Callable[[Project, ReadText, Sequence[str]], None]] = {
|
|
19
|
+
Ecosystem.PYPI: lambda project, read_text, _: python.parse_project(project, read_text),
|
|
20
|
+
Ecosystem.NPM: npm.parse_project,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_project(project: Project, read_text: ReadText, project_dirs: Sequence[str] = ()) -> None:
|
|
25
|
+
"""Remplit ``project.dependencies`` en lisant ses fichiers avec ``read_text``.
|
|
26
|
+
|
|
27
|
+
Ajoute à ``project.notices`` ce qui n'a pas pu être lu, et retire de
|
|
28
|
+
``project.lockfiles`` un lockfile de workspace dont le projet n'est pas membre.
|
|
29
|
+
Un écosystème que forceupdate ne lit pas encore donne une remarque, pour que le
|
|
30
|
+
rapport ne laisse pas croire que le projet n'a pas de dépendances.
|
|
31
|
+
|
|
32
|
+
``project_dirs`` sont les dossiers de tous les projets de la source : un
|
|
33
|
+
workspace Yarn 1 ne liste pas ses membres dans son lockfile.
|
|
34
|
+
"""
|
|
35
|
+
for ecosystem in project.ecosystems:
|
|
36
|
+
parser = _PARSERS.get(ecosystem)
|
|
37
|
+
if parser is not None:
|
|
38
|
+
parser(project, read_text, project_dirs)
|
|
39
|
+
continue
|
|
40
|
+
files = project.manifests + project.lockfiles
|
|
41
|
+
first = next(path for path in files if ecosystem_for_manifest(path) is ecosystem)
|
|
42
|
+
message = f"dépendances {ecosystem} pas encore lues par forceupdate."
|
|
43
|
+
project.notices.append(Notice(file=first, message=message))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Ce qui est commun aux parseurs de tous les écosystèmes."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
ReadText = Callable[[str], str | None]
|
|
6
|
+
"""Lit un fichier de la source par son chemin POSIX relatif : ``Source.read_text``.
|
|
7
|
+
Rend ``None`` si le fichier est illisible ou trop gros ; lève ``ValueError`` si le
|
|
8
|
+
chemin sort de la source."""
|
|
9
|
+
|
|
10
|
+
REASON_GIT = "dépendance git"
|
|
11
|
+
REASON_LOCAL = "chemin local"
|
|
12
|
+
REASON_URL = "téléchargée depuis une URL"
|
|
13
|
+
REASON_WORKSPACE = "membre du workspace"
|
|
14
|
+
"""Raisons courantes pour lesquelles une dépendance n'est pas vérifiable. Une raison ne
|
|
15
|
+
cite jamais l'URL ni le chemin : une URL peut contenir un token."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ParseError(ValueError):
|
|
19
|
+
"""Un fichier de dépendances ne peut pas être lu : TOML ou JSON invalide…
|
|
20
|
+
|
|
21
|
+
Le message, en français, est fait pour être montré tel quel dans le rapport.
|
|
22
|
+
"""
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Retrouver où sont écrites les chaînes d'un document JSON.
|
|
2
|
+
|
|
3
|
+
``json`` donne les valeurs d'un document, pas leur position. Comme ``toml_spans``
|
|
4
|
+
pour TOML, ce module parcourt le texte d'un document que ``json.loads`` a déjà
|
|
5
|
+
accepté, et rend, pour chaque chaîne, sa ligne et ses colonnes. C'est ce qui donne
|
|
6
|
+
l'emplacement exact d'une contrainte dans un ``package.json`` (et plus tard un
|
|
7
|
+
``composer.json``, E23), 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 ``json.loads`` : des clés et des indices de tableaux.
|
|
11
|
+
``{"dependencies": {"lodash": "^4.17.21"}}`` donne ``("dependencies", "lodash")``.
|
|
12
|
+
Seules les valeurs sont rendues, pas les clés. Une clé écrite deux fois dans un
|
|
13
|
+
objet garde sa dernière valeur, comme avec ``json.loads``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import bisect
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from forceupdate.parsers.toml_spans import StringSpan
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
JsonPath = tuple[str | int, ...]
|
|
25
|
+
"""Le chemin d'une valeur : clés et indices de tableaux, comme dans ``json.loads``."""
|
|
26
|
+
|
|
27
|
+
BOM = ""
|
|
28
|
+
"""L'indicateur d'ordre des octets qu'ajoutent certains éditeurs Windows en tête de
|
|
29
|
+
fichier. ``json.loads`` le refuse ; npm l'ignore."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def string_spans(text: str) -> dict[JsonPath, StringSpan]:
|
|
33
|
+
"""Les chaînes du document JSON ``text``, par chemin.
|
|
34
|
+
|
|
35
|
+
``text`` doit avoir été accepté par ``json.loads`` (après avoir retiré un
|
|
36
|
+
``BOM`` de tête, qui compte quand même pour les colonnes de la ligne 1) : il
|
|
37
|
+
n'est pas revalidé. Les lignes sont comptées sur ``\\n`` seulement, comme
|
|
38
|
+
``Location`` ; une chaîne JSON tient toujours sur une ligne.
|
|
39
|
+
"""
|
|
40
|
+
scanner = _Scanner(text)
|
|
41
|
+
try:
|
|
42
|
+
scanner.value(())
|
|
43
|
+
except (IndexError, ValueError) as err:
|
|
44
|
+
# Ne devrait pas arriver sur un document valide : on garde ce qui a été trouvé.
|
|
45
|
+
logger.debug("JSON parcouru en partie seulement : %s", err)
|
|
46
|
+
return scanner.spans
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Scanner:
|
|
50
|
+
"""Parcourt un document JSON valide et note la position des chaînes."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, text: str) -> None:
|
|
53
|
+
self.text = text
|
|
54
|
+
self.pos = 1 if text.startswith(BOM) else 0
|
|
55
|
+
self.spans: dict[JsonPath, StringSpan] = {}
|
|
56
|
+
self._line_starts = [0] + [i + 1 for i, char in enumerate(text) if char == "\n"]
|
|
57
|
+
|
|
58
|
+
def value(self, path: JsonPath) -> None:
|
|
59
|
+
self._skip_blank()
|
|
60
|
+
char = self.text[self.pos]
|
|
61
|
+
if char == '"':
|
|
62
|
+
start = self.pos + 1
|
|
63
|
+
self._skip_string()
|
|
64
|
+
self._record(path, start, self.pos - 1)
|
|
65
|
+
elif char == "{":
|
|
66
|
+
self._object(path)
|
|
67
|
+
elif char == "[":
|
|
68
|
+
self._array(path)
|
|
69
|
+
else:
|
|
70
|
+
# Nombre, true, false, null : on passe jusqu'au prochain séparateur.
|
|
71
|
+
while self.pos < len(self.text) and self.text[self.pos] not in ",]} \t\r\n":
|
|
72
|
+
self.pos += 1
|
|
73
|
+
|
|
74
|
+
def _object(self, path: JsonPath) -> None:
|
|
75
|
+
self.pos += 1
|
|
76
|
+
while True:
|
|
77
|
+
self._skip_blank()
|
|
78
|
+
if self.text[self.pos] == "}":
|
|
79
|
+
self.pos += 1
|
|
80
|
+
return
|
|
81
|
+
start = self.pos
|
|
82
|
+
self._skip_string()
|
|
83
|
+
key = json.loads(self.text[start : self.pos])
|
|
84
|
+
self._skip_blank()
|
|
85
|
+
self.pos += 1 # les deux-points
|
|
86
|
+
self.value((*path, key))
|
|
87
|
+
self._skip_blank()
|
|
88
|
+
if self.text[self.pos] == ",":
|
|
89
|
+
self.pos += 1
|
|
90
|
+
|
|
91
|
+
def _array(self, path: JsonPath) -> None:
|
|
92
|
+
self.pos += 1
|
|
93
|
+
index = 0
|
|
94
|
+
while True:
|
|
95
|
+
self._skip_blank()
|
|
96
|
+
if self.text[self.pos] == "]":
|
|
97
|
+
self.pos += 1
|
|
98
|
+
return
|
|
99
|
+
self.value((*path, index))
|
|
100
|
+
index += 1
|
|
101
|
+
self._skip_blank()
|
|
102
|
+
if self.text[self.pos] == ",":
|
|
103
|
+
self.pos += 1
|
|
104
|
+
|
|
105
|
+
def _skip_string(self) -> None:
|
|
106
|
+
"""Avance après une chaîne ``"…"`` ; ``pos`` est sur le guillemet ouvrant."""
|
|
107
|
+
index = self.pos + 1
|
|
108
|
+
while self.text[index] != '"':
|
|
109
|
+
index += 2 if self.text[index] == "\\" else 1
|
|
110
|
+
self.pos = index + 1
|
|
111
|
+
|
|
112
|
+
def _record(self, path: JsonPath, start: int, end: int) -> None:
|
|
113
|
+
line = bisect.bisect_right(self._line_starts, start)
|
|
114
|
+
self.spans[path] = StringSpan(
|
|
115
|
+
line=line,
|
|
116
|
+
column=start - self._line_starts[line - 1],
|
|
117
|
+
raw=self.text[start:end],
|
|
118
|
+
literal=False,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _skip_blank(self) -> None:
|
|
122
|
+
while self.pos < len(self.text) and self.text[self.pos] in " \t\r\n":
|
|
123
|
+
self.pos += 1
|