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
forceupdate/models.py ADDED
@@ -0,0 +1,510 @@
1
+ """Les structures de données du moteur : dépendances, constats, projets, rapport.
2
+
3
+ Elles suivent le chemin de l'analyse (Source → Project → Dependency → Finding →
4
+ Report) et sortent en JSON avec ``to_dict`` : c'est ce que l'API renverra au front
5
+ et ce que ``scan --format json`` écrira.
6
+
7
+ Les chemins sont des chaînes au format POSIX (``backend/pyproject.toml``),
8
+ relatifs à la racine de la source analysée (le dossier choisi, ou le dépôt). Ils
9
+ ont la même forme sous Windows, sous Linux et pour un dépôt distant.
10
+
11
+ Les enums sont des ``StrEnum`` : leur valeur passe telle quelle en JSON. Pour trier
12
+ par gravité ou par écart, utiliser ``rank`` : comparer deux membres avec ``<``
13
+ compare des chaînes.
14
+ """
15
+
16
+ import dataclasses
17
+ import enum
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime
20
+ from typing import Any
21
+
22
+
23
+ class Ecosystem(enum.StrEnum):
24
+ """Un écosystème de paquets. Les valeurs sont les noms qu'utilise OSV."""
25
+
26
+ PYPI = "PyPI"
27
+ NPM = "npm"
28
+ CRATES_IO = "crates.io"
29
+ GO = "Go"
30
+ PACKAGIST = "Packagist"
31
+ RUBYGEMS = "RubyGems"
32
+ MAVEN = "Maven"
33
+ NUGET = "NuGet"
34
+
35
+
36
+ class Severity(enum.StrEnum):
37
+ """Gravité d'un constat, du plus grave au moins grave.
38
+
39
+ Remplace l'enum d'Argos (``ARGOS (1)`` dans ``reference/``), mêmes membres.
40
+ """
41
+
42
+ CRITICAL = "critical"
43
+ HIGH = "high"
44
+ MEDIUM = "medium"
45
+ LOW = "low"
46
+ INFO = "info"
47
+
48
+ @property
49
+ def rank(self) -> int:
50
+ """Rang pour trier : 4 pour ``CRITICAL``, 0 pour ``INFO``."""
51
+ return _SEVERITY_RANKS[self]
52
+
53
+
54
+ _SEVERITY_RANKS = {
55
+ Severity.CRITICAL: 4,
56
+ Severity.HIGH: 3,
57
+ Severity.MEDIUM: 2,
58
+ Severity.LOW: 1,
59
+ Severity.INFO: 0,
60
+ }
61
+
62
+
63
+ class FindingKind(enum.StrEnum):
64
+ """Le type d'un constat : les quatre types du brief, et ce qui n'a pas pu être vérifié."""
65
+
66
+ OUTDATED = "outdated"
67
+ """Type 1 : une version plus récente existe."""
68
+ VULNERABLE = "vulnerable"
69
+ """Type 2 : la version utilisée a une faille connue."""
70
+ EOL = "eol"
71
+ """Type 3 : le runtime ou le framework est en fin de vie, ou le sera bientôt."""
72
+ YANKED = "yanked"
73
+ """Type 4 : la version utilisée a été retirée du registre."""
74
+ DEPRECATED = "deprecated"
75
+ """Type 4 : le paquet est déprécié ou abandonné, ou la version utilisée est dépréciée (npm,
76
+ qui n'a pas de retrait : c'est sa façon de désavouer une version)."""
77
+ UNVERIFIABLE = "unverifiable"
78
+ """Impossible à vérifier : dépendance git ou locale, paquet introuvable sur le registre."""
79
+ INCONCLUSIVE = "inconclusive"
80
+ """On ne conclut rien : la vérification a échoué (réseau, hors ligne sans cache), ou il
81
+ manque de quoi conclure (version utilisée inconnue, inconnue du registre)."""
82
+
83
+
84
+ class Gap(enum.StrEnum):
85
+ """L'écart entre deux versions.
86
+
87
+ Depuis ``2.3.1`` : ``2.3.4`` est un correctif, ``2.9.0`` une mineure, ``3.0.0`` une majeure.
88
+ """
89
+
90
+ PATCH = "patch"
91
+ MINOR = "minor"
92
+ MAJOR = "major"
93
+
94
+ @property
95
+ def rank(self) -> int:
96
+ """Rang pour trier : 2 pour ``MAJOR``, 0 pour ``PATCH``."""
97
+ return _GAP_RANKS[self]
98
+
99
+
100
+ _GAP_RANKS = {Gap.MAJOR: 2, Gap.MINOR: 1, Gap.PATCH: 0}
101
+
102
+
103
+ class Scope(enum.StrEnum):
104
+ """À quoi sert une dépendance dans son projet."""
105
+
106
+ RUNTIME = "runtime"
107
+ """Nécessaire pour faire tourner le code."""
108
+ DEV = "dev"
109
+ """Développement et tests seulement (``devDependencies``, groupe ``dev``…)."""
110
+ OPTIONAL = "optional"
111
+ """Facultative (extras Python, ``optionalDependencies`` npm)."""
112
+ PEER = "peer"
113
+ """Fournie par le projet qui l'utilise (``peerDependencies`` npm)."""
114
+ BUILD = "build"
115
+ """Nécessaire à la construction seulement (``build-dependencies`` Cargo…)."""
116
+
117
+
118
+ @dataclass(frozen=True, kw_only=True)
119
+ class Location:
120
+ """L'endroit exact où une contrainte est écrite dans son fichier.
121
+
122
+ ``line`` commence à 1 ; les colonnes commencent à 0, comptent des caractères
123
+ (pas des octets), et ``end_column`` est exclue. C'est ce qui permet de
124
+ réécrire la contrainte sans toucher au reste du fichier (E10).
125
+ """
126
+
127
+ line: int
128
+ column: int
129
+ end_column: int
130
+
131
+ def __post_init__(self) -> None:
132
+ if self.line < 1 or self.column < 0 or self.end_column < self.column:
133
+ raise ValueError(f"emplacement invalide : {self!r}")
134
+
135
+ def extract(self, text: str) -> str:
136
+ """Le texte à cet emplacement dans ``text``, le contenu du fichier.
137
+
138
+ Les lignes sont coupées sur ``\\n`` seulement, comme le font les parseurs.
139
+ Chaîne vide si la ligne n'existe pas.
140
+ """
141
+ lines = text.split("\n")
142
+ if self.line > len(lines):
143
+ return ""
144
+ return lines[self.line - 1][self.column : self.end_column]
145
+
146
+
147
+ @dataclass(frozen=True, kw_only=True)
148
+ class Dependency:
149
+ """Une dépendance d'un projet.
150
+
151
+ Une directe vient du manifeste (``package.json``, ``pyproject.toml``…). Une
152
+ transitive vient seulement du lockfile : ``direct=False``, sans ``location``.
153
+ """
154
+
155
+ ecosystem: Ecosystem
156
+ name: str
157
+ """Nom du paquet, tel que le registre le connaît (normalisé selon PEP 503 pour PyPI)."""
158
+ constraint: str = ""
159
+ """Contrainte telle qu'écrite (``^4.18.0``, ``>=2.0,<3``) ; vide s'il n'y en a pas."""
160
+ resolved_constraint: str | None = None
161
+ """La plage que désigne ``constraint`` quand elle renvoie ailleurs : pour ``catalog:``
162
+ (pnpm), celle du catalogue dans ``pnpm-workspace.yaml``. ``None`` si ``constraint`` est
163
+ elle-même la plage, ou si la plage est introuvable."""
164
+ installed: str | None = None
165
+ """Version installée, lue dans le lockfile ; ``None`` sans lockfile."""
166
+ lockfile: str | None = None
167
+ """Le lockfile où ``installed`` a été lu, celui qu'une mise à jour régénère (E11) ;
168
+ ``None`` sans version installée. Pour une indirecte, c'est ``file``."""
169
+ direct: bool = True
170
+ scope: Scope = Scope.RUNTIME
171
+ file: str
172
+ """Fichier où la dépendance est déclarée (le lockfile pour une transitive)."""
173
+ location: Location | None = None
174
+ """Emplacement de ``constraint`` dans ``file`` ; ``None`` si on ne sait pas la réécrire."""
175
+ unverifiable_reason: str | None = None
176
+ """Pourquoi on ne peut pas la vérifier (dépendance git, ``file:``, ``workspace:``…)."""
177
+
178
+ @property
179
+ def verifiable(self) -> bool:
180
+ """Vrai si on peut interroger le registre et OSV pour cette dépendance."""
181
+ return self.unverifiable_reason is None
182
+
183
+ @property
184
+ def effective_constraint(self) -> str:
185
+ """La contrainte à interpréter (``forceupdate.versions``) : ``resolved_constraint``
186
+ s'il y en a une, sinon ``constraint``."""
187
+ return self.constraint if self.resolved_constraint is None else self.resolved_constraint
188
+
189
+
190
+ @dataclass(frozen=True, kw_only=True)
191
+ class Override:
192
+ """Une version forcée : ``overrides`` (npm), ``resolutions`` (Yarn), ``pnpm.overrides``.
193
+
194
+ Elle s'impose à toutes les copies du paquet dans l'arbre (ou à celles que tire un paquet
195
+ précis), quoi que demandent les paquets qui le tirent. Une indirecte forcée ne bouge pas
196
+ avec ``npm update x`` : c'est la version forcée qu'il faut réécrire.
197
+ """
198
+
199
+ ecosystem: Ecosystem
200
+ name: str
201
+ """Le paquet forcé."""
202
+ constraint: str
203
+ """La version forcée telle qu'écrite (``^3.4.13``, ``$dompurify``, ``npm:autre@1.0``)."""
204
+ key: str
205
+ """La clé telle qu'écrite : ``dompurify``, ``**/lodash`` (Yarn), ``foo@<2`` ou ``a>b``
206
+ (pnpm) ; pour npm, le chemin des clés imbriquées, séparées par ``>`` (``a>b``)."""
207
+ section: str
208
+ """``overrides``, ``resolutions`` ou ``pnpm.overrides``."""
209
+ file: str
210
+ location: Location | None = None
211
+ """Emplacement de ``constraint`` dans ``file`` ; ``None`` si on ne sait pas la réécrire."""
212
+
213
+
214
+ @dataclass(frozen=True, kw_only=True)
215
+ class Finding:
216
+ """Un constat : quelque chose à mettre à jour, ou qui n'a pas pu être vérifié.
217
+
218
+ Ce sont les contrôles (``checks/``) qui les produisent ; E07, E08 et E27
219
+ complètent ce que chaque type remplit.
220
+ """
221
+
222
+ kind: FindingKind
223
+ severity: Severity = Severity.INFO
224
+ """Pour une faille, sa gravité (CVSS) ; pour les autres types, fixée par le contrôle."""
225
+ message: str
226
+ """Le constat en une phrase, en français."""
227
+ dependency: Dependency | None = None
228
+ """La dépendance concernée ; ``None`` pour la fin de vie d'un runtime (Python 3.8…)."""
229
+ current_version: str | None = None
230
+ """Version actuelle : l'installée, sinon la borne basse de la contrainte."""
231
+ current_is_approximate: bool = False
232
+ """Vrai quand ``current_version`` vient de la contrainte, faute de lockfile."""
233
+ target_version: str | None = None
234
+ """Version visée : la dernière stable, la plus proche non retirée, ou la plus petite
235
+ version qui corrige la faille."""
236
+ wanted_version: str | None = None
237
+ """La plus haute version que la contrainte accepte déjà (``outdated``) : on y monte en
238
+ régénérant le lockfile, sans toucher au manifeste. ``None`` si on ne la connaît pas
239
+ (contrainte illisible, dépendance indirecte)."""
240
+ gap: Gap | None = None
241
+ """Écart entre ``current_version`` et ``target_version``."""
242
+ identifiers: tuple[str, ...] = ()
243
+ """Identifiants de la faille : les CVE, puis les GHSA, puis les autres (PYSEC, MAL…)."""
244
+ cvss_score: float | None = None
245
+ """Score CVSS 3 de la faille, de 0 à 10, s'il est connu."""
246
+ detail: str | None = None
247
+ """Ce que dit la source, tel quel : la raison d'un retrait, le message de dépréciation, le
248
+ titre de l'avis de sécurité."""
249
+ url: str | None = None
250
+ """Lien pour en savoir plus : avis de sécurité (osv.dev), page du paquet, endoflife.date."""
251
+
252
+
253
+ class UpdateReason(enum.StrEnum):
254
+ """Pourquoi une version est proposée. Une même version peut l'être pour plusieurs raisons
255
+ (la dernière mineure corrige souvent aussi les failles)."""
256
+
257
+ LATEST_PATCH = "latest_patch"
258
+ """Le dernier correctif de la version utilisée."""
259
+ LATEST_MINOR = "latest_minor"
260
+ """La dernière mineure : la plus haute version sans changer de majeure."""
261
+ LATEST_MAJOR = "latest_major"
262
+ """La dernière version, quand elle est majeure."""
263
+ SECURITY_FIX = "security_fix"
264
+ """La plus petite version publiée qui corrige toutes les failles qui peuvent l'être."""
265
+ REPLACEMENT = "replacement"
266
+ """Remplace une version retirée ou dépréciée."""
267
+
268
+
269
+ class EditKind(enum.StrEnum):
270
+ """Ce qu'une réécriture modifie."""
271
+
272
+ DEPENDENCY = "dependency"
273
+ """La contrainte d'une dépendance, dans son manifeste."""
274
+ CATALOG = "catalog"
275
+ """La plage d'un catalogue pnpm, dans ``pnpm-workspace.yaml`` (E10 l'y trouve)."""
276
+ OVERRIDE = "override"
277
+ """Une version forcée (``overrides``, ``resolutions``, ``pnpm.overrides``)."""
278
+
279
+
280
+ @dataclass(frozen=True, kw_only=True)
281
+ class Edit:
282
+ """Une contrainte à réécrire : à ``location`` dans ``file``, ``old`` devient ``new``.
283
+
284
+ C'est le planificateur (E09) qui le calcule ; la réécriture (E10) vérifie que le fichier
285
+ contient bien ``old`` à cet endroit avant d'y écrire ``new``. La déclaration réécrite est
286
+ celle de ``Update.dependencies`` qui a ce fichier et cet emplacement (pour un catalogue :
287
+ ce fichier et ``old`` pour ``resolved_constraint``).
288
+ """
289
+
290
+ kind: EditKind
291
+ file: str
292
+ """Le manifeste ; pour un catalogue pnpm, le ``package.json`` qui y renvoie."""
293
+ location: Location | None
294
+ """``None`` pour un catalogue pnpm (E10 cherche sa plage dans ``pnpm-workspace.yaml``)."""
295
+ old: str
296
+ new: str
297
+ override: Override | None = None
298
+ """La version forcée réécrite (``OVERRIDE``)."""
299
+
300
+
301
+ @dataclass(frozen=True, kw_only=True)
302
+ class UpdateOption:
303
+ """Une version vers laquelle monter un paquet : une case à cocher de l'interface."""
304
+
305
+ version: str
306
+ gap: Gap | None
307
+ """Écart depuis la version utilisée ; ``None`` pour un retour à une version plus ancienne
308
+ (la plus proche non retirée d'une version retirée peut être en dessous)."""
309
+ reasons: tuple[UpdateReason, ...]
310
+ fixes: tuple[str, ...] = ()
311
+ """Les failles qu'elle corrige : le premier identifiant de chacune (une CVE s'il y en a)."""
312
+ unfixed: tuple[str, ...] = ()
313
+ """Les failles qu'elle laisse."""
314
+ edits: tuple[Edit, ...] = ()
315
+ """Les contraintes à réécrire. Vide : la version est déjà dans la contrainte, on y monte en
316
+ régénérant le lockfile seul."""
317
+ lockfile: str | None = None
318
+ """Le lockfile à régénérer (E11) ; ``None`` sans lockfile."""
319
+ manual: str | None = None
320
+ """Pourquoi forceupdate ne sait pas l'appliquer seul (contrainte sans emplacement, style
321
+ non géré…) ; ``None`` s'il le sait. Elle n'est jamais pré-cochée."""
322
+ warnings: tuple[str, ...] = ()
323
+ """Ce qu'il faut savoir avant de la cocher (« Majeure : peut demander d'adapter le
324
+ code. »)."""
325
+
326
+
327
+ @dataclass(frozen=True, kw_only=True)
328
+ class Update:
329
+ """Les mises à jour possibles d'un paquet d'un projet. Au plus une est choisie : ses
330
+ options s'excluent l'une l'autre, comme des boutons radio qu'on peut aussi tous décocher.
331
+
332
+ Pour une directe, toutes ses déclarations (runtime et dev, deux extras) montent ensemble ;
333
+ une indirecte est une version installée précise (npm peut en installer plusieurs).
334
+ """
335
+
336
+ id: str
337
+ """Identifiant unique dans le rapport : c'est lui que la sélection désigne."""
338
+ ecosystem: Ecosystem
339
+ name: str
340
+ direct: bool
341
+ dependencies: tuple[Dependency, ...]
342
+ """Ses déclarations (directe), ou l'indirecte."""
343
+ current_version: str | None
344
+ current_is_approximate: bool = False
345
+ options: tuple[UpdateOption, ...] = ()
346
+ """De la plus ancienne à la plus récente version."""
347
+ selected: str | None = None
348
+ """La version pré-cochée (brief, décision n°12) ; ``None`` si aucune ne l'est."""
349
+ note: str | None = None
350
+ """Ce que les options ne disent pas : faille sans version corrigée, versions publiées
351
+ inconnues…"""
352
+
353
+ def option(self, version: str) -> UpdateOption | None:
354
+ """L'option de version ``version`` (le même texte) ; ``None`` s'il n'y en a pas."""
355
+ return next((option for option in self.options if option.version == version), None)
356
+
357
+
358
+ @dataclass(frozen=True, kw_only=True)
359
+ class Language:
360
+ """Un langage de la source, avec son poids en octets, comme GitHub Linguist."""
361
+
362
+ name: str
363
+ """Nom du langage, celui de Linguist (``Python``, ``TypeScript``, ``C++``)."""
364
+ bytes: int
365
+ """Octets de code dans ce langage, sans les fichiers vendorisés, générés ni la documentation."""
366
+ ecosystem: Ecosystem | None = None
367
+ """Son écosystème de paquets ; ``None`` si forceupdate n'en gère aucun (C, C++, Shell…)."""
368
+
369
+
370
+ @dataclass(frozen=True, kw_only=True)
371
+ class Notice:
372
+ """Une remarque sur un fichier d'un projet : non lu, illisible, mal formé…
373
+
374
+ Ce n'est pas un constat sur une dépendance : c'est ce que l'analyse n'a pas pu
375
+ faire, pour que le rapport ne passe rien sous silence.
376
+ """
377
+
378
+ file: str
379
+ """Le fichier concerné, relatif à la source."""
380
+ message: str
381
+ """La remarque en une phrase, en français."""
382
+
383
+
384
+ @dataclass(kw_only=True)
385
+ class Project:
386
+ """Un projet : un dossier qui contient au moins un manifeste ou un lockfile.
387
+
388
+ Un monorepo en donne plusieurs (``backend/`` en Python, ``frontend/`` en npm).
389
+ """
390
+
391
+ path: str
392
+ """Dossier du projet (``.`` pour la racine de la source)."""
393
+ ecosystems: list[Ecosystem] = field(default_factory=list)
394
+ """Écosystèmes de ses manifestes et lockfiles, dans l'ordre de ``Ecosystem``."""
395
+ manifests: list[str] = field(default_factory=list)
396
+ lockfiles: list[str] = field(default_factory=list)
397
+ """Ses lockfiles : ceux de son dossier, ou celui de la racine d'un workspace."""
398
+ dependencies: list[Dependency] = field(default_factory=list)
399
+ """Les directes d'abord, dans l'ordre des manifestes, puis les transitives."""
400
+ overrides: list[Override] = field(default_factory=list)
401
+ """Les versions forcées de son ``package.json`` (seules celles de la racine d'un workspace
402
+ comptent pour npm, Yarn et pnpm)."""
403
+ findings: list[Finding] = field(default_factory=list)
404
+ updates: list[Update] = field(default_factory=list)
405
+ """Les mises à jour possibles, une par paquet à mettre à jour, dans l'ordre des constats
406
+ (``update.planner``) ; vide si les dépendances n'ont pas été vérifiées."""
407
+ notices: list[Notice] = field(default_factory=list)
408
+ """Ce que l'analyse n'a pas pu lire (``setup.py``, fichier invalide…)."""
409
+
410
+
411
+ @dataclass(frozen=True, kw_only=True)
412
+ class ScanOptions:
413
+ """Ce que vérifie une analyse."""
414
+
415
+ include_tests: bool = False
416
+ """Garder aussi les projets rangés dans des dossiers de tests, de fixtures ou d'exemples."""
417
+ dev: bool = True
418
+ """Vérifier aussi les dépendances de développement (``Scope.DEV``)."""
419
+ transitive: bool = False
420
+ """Vérifier aussi les dépendances indirectes (celles du lockfile seul) : versions plus
421
+ récentes, retirées, dépréciées. Par défaut, ce n'est fait que pour les directes (brief,
422
+ décision n°3). Les failles, elles, sont toujours cherchées sur toutes les dépendances."""
423
+ offline: bool = False
424
+ """Hors ligne : seulement le cache, même périmé ; le reste est « non concluant »."""
425
+ cache: bool = True
426
+ """Lire et écrire le cache disque ; faux pour « sans cache »."""
427
+
428
+
429
+ @dataclass(kw_only=True)
430
+ class Report:
431
+ """Le résultat d'une analyse : une source, ses langages et ses projets."""
432
+
433
+ source: str
434
+ """Ce qui a été analysé : un chemin local, ou ``owner/repo`` sur une forge."""
435
+ languages: list[Language] = field(default_factory=list)
436
+ """Les langages, du plus lourd au plus léger (en octets, comme GitHub Linguist)."""
437
+ projects: list[Project] = field(default_factory=list)
438
+ ignored_manifests: list[str] = field(default_factory=list)
439
+ """Manifestes et lockfiles écartés : rangés dans un dossier de tests, de fixtures ou
440
+ d'exemples, ce ne sont pas de vrais projets (voir ``detect.manifests.is_test_path``)."""
441
+ options: ScanOptions | None = None
442
+ """Ce qui a été vérifié ; ``None`` si les dépendances n'ont pas été vérifiées auprès des
443
+ registres (``engine.inventory``)."""
444
+ scanned_at: datetime | None = None
445
+ """Quand l'analyse a été lancée (UTC)."""
446
+ oldest_data: datetime | None = None
447
+ """La date des plus anciennes données des registres et d'OSV utilisées (UTC). Hors ligne,
448
+ elles viennent du cache, et peuvent dater de plusieurs jours : le rapport le dit."""
449
+
450
+ @property
451
+ def findings(self) -> list[Finding]:
452
+ """Les constats de tous les projets."""
453
+ return [finding for project in self.projects for finding in project.findings]
454
+
455
+
456
+ class EventKind(enum.StrEnum):
457
+ """Les étapes d'une analyse, dans l'ordre où elles arrivent."""
458
+
459
+ READING = "reading"
460
+ """Lecture de la source : langages, projets, dépendances."""
461
+ PROJECTS_FOUND = "projects_found"
462
+ """Projets trouvés : ``total`` en donne le nombre."""
463
+ CHECKING = "checking"
464
+ """Vérification auprès des registres et d'OSV : ``total`` vérifications, une par paquet à
465
+ demander aux registres, plus une pour les failles."""
466
+ PACKAGE_CHECKED = "package_checked"
467
+ """Un paquet vérifié auprès de son registre (``ecosystem``, ``name``) : ``done`` sur
468
+ ``total``."""
469
+ VULNERABILITIES_CHECKED = "vulnerabilities_checked"
470
+ """Les failles cherchées auprès d'OSV, pour toutes les dépendances : ``done`` sur
471
+ ``total``."""
472
+ FINISHED = "finished"
473
+ """Le rapport est prêt."""
474
+
475
+
476
+ @dataclass(frozen=True, kw_only=True)
477
+ class Event:
478
+ """Un événement de progression d'une analyse : la ligne de commande en fait une barre de
479
+ progression, l'API (E12) l'envoie au navigateur."""
480
+
481
+ kind: EventKind
482
+ done: int = 0
483
+ total: int = 0
484
+ ecosystem: Ecosystem | None = None
485
+ name: str | None = None
486
+ """Le paquet vérifié (``PACKAGE_CHECKED``)."""
487
+
488
+
489
+ def to_dict(model: Any) -> dict[str, Any]:
490
+ """Convertit un modèle en dictionnaire de types simples, prêt pour ``json.dumps``.
491
+
492
+ Les enums donnent leur valeur, les tuples des listes, les dates leur forme ISO 8601.
493
+ """
494
+ if not dataclasses.is_dataclass(model) or isinstance(model, type):
495
+ raise TypeError(f"pas un modèle : {model!r}")
496
+ return _to_plain(model)
497
+
498
+
499
+ def _to_plain(value: Any) -> Any:
500
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
501
+ return {f.name: _to_plain(getattr(value, f.name)) for f in dataclasses.fields(value)}
502
+ if isinstance(value, enum.Enum):
503
+ return value.value
504
+ if isinstance(value, datetime):
505
+ return value.isoformat()
506
+ if isinstance(value, dict):
507
+ return {str(key): _to_plain(item) for key, item in value.items()}
508
+ if isinstance(value, list | tuple):
509
+ return [_to_plain(item) for item in value]
510
+ return value
@@ -0,0 +1,20 @@
1
+ """Le réseau : le client HTTP commun (``http.py``) et le cache disque (``cache.py``).
2
+
3
+ Tout ce qui interroge le réseau (registres, OSV, endoflife.date, forges) passe par
4
+ ``HttpClient``. Une erreur réseau sort en ``NetworkError`` : le constat sera « non
5
+ concluant », jamais faux.
6
+ """
7
+
8
+ from forceupdate.net.cache import Cache, CacheEntry, CacheKind, default_cache_path
9
+ from forceupdate.net.http import USER_AGENT, HttpClient, NetworkError, Response
10
+
11
+ __all__ = [
12
+ "USER_AGENT",
13
+ "Cache",
14
+ "CacheEntry",
15
+ "CacheKind",
16
+ "HttpClient",
17
+ "NetworkError",
18
+ "Response",
19
+ "default_cache_path",
20
+ ]