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,531 @@
1
+ """Le planificateur : pour chaque paquet à mettre à jour, les versions possibles, ce qu'il faut
2
+ toucher, et ce qui est pré-coché. Il n'écrit rien : la réécriture des manifestes est E10, celle
3
+ des lockfiles E11.
4
+
5
+ **Les versions proposées** (``UpdateOption``), pour un paquet qui a un constat :
6
+
7
+ - le **dernier correctif** et la **dernière mineure** de la version utilisée, sans dépasser la
8
+ dernière version du constat « plus récente » ; en 0.x, ``gap`` (E05) classe déjà en majeure
9
+ ce qui sort de ``^`` (``0.2.3`` → ``0.3.0``) ;
10
+ - la **dernière majeure** : la cible du constat « plus récente » quand elle est majeure. Pas
11
+ pour une indirecte, qui suit le paquet qui la tire ;
12
+ - la **correction des failles** : la plus petite version publiée qui les corrige toutes (au
13
+ moins la plus haute des versions corrigées, et qu'aucun des avis ne concerne plus : Flask
14
+ 2.3.0 est au-dessus de 2.2.5 mais concernée), ni retirée ni dépréciée. lodash 4.17.15 :
15
+ 4.18.0 corrige tout, mais est dépréciée (« Bad release ») ; c'est 4.18.1. Une faille sans
16
+ version corrigée (paquet malveillant, ``last_affected`` seul) n'est corrigée par aucune ;
17
+ - le **remplacement** d'une version retirée ou dépréciée (``checks.deprecated``), parfois en
18
+ dessous de la version utilisée.
19
+
20
+ Comme ``npm-pick-manifest`` (E07), les versions dépréciées ne sont proposées que faute
21
+ d'autre ; les retirées jamais ; les pré-versions seulement de la même version qu'une version
22
+ utilisée en pré-version. Une même version proposée pour plusieurs raisons ne fait qu'une
23
+ option.
24
+
25
+ **Ce qu'il faut toucher** :
26
+
27
+ - la version visée sort de la contrainte : la contrainte est réécrite dans son style
28
+ (``update.constraints``), puis le lockfile régénéré ;
29
+ - elle y est déjà : le lockfile seul. Sans lockfile, la version utilisée est la borne basse
30
+ (« approximative ») : on relève la borne basse ;
31
+ - une indirecte : le lockfile seul (``uv lock --upgrade-package x``, ``npm update x``), sauf
32
+ si une version forcée (``overrides``, ``resolutions``, ``pnpm.overrides``) la fige et
33
+ n'accepte pas la version visée : c'est elle qu'on réécrit ;
34
+ - une dépendance pair (npm) : sa plage s'élargit au lieu d'être remplacée.
35
+
36
+ Pour une faille, la nouvelle contrainte n'accepte plus de version concernée (``floor``).
37
+
38
+ **La sélection par défaut** (brief, décision n°12) : correctifs, mineures et corrections de
39
+ failles pré-cochés ; majeures proposées, jamais cochées d'office (« Majeure : peut demander
40
+ d'adapter le code. »). Une faille corrigée seulement par une majeure : proposée, pas cochée,
41
+ avec les deux mentions. Pour un paquet, l'option pré-cochée est, parmi celles qui ne sont pas
42
+ majeures, celle qui corrige le plus de failles, puis la plus récente.
43
+
44
+ **La sélection est une donnée** (``Selection``) : l'identifiant de chaque ``Update`` choisie et
45
+ sa version. ``select`` la calcule selon des règles (``Rules`` : les options de ``forceupdate
46
+ update``) ; l'interface la modifiera case par case.
47
+ """
48
+
49
+ import dataclasses
50
+ from collections.abc import Iterable, Mapping, Sequence
51
+ from dataclasses import dataclass
52
+ from fnmatch import fnmatchcase
53
+
54
+ from forceupdate import versions
55
+ from forceupdate.advisories import Advisory, Query, Vulnerabilities
56
+ from forceupdate.checks.base import Current, current_version
57
+ from forceupdate.models import (
58
+ Dependency,
59
+ Ecosystem,
60
+ Edit,
61
+ EditKind,
62
+ Finding,
63
+ FindingKind,
64
+ Gap,
65
+ Override,
66
+ Project,
67
+ Report,
68
+ Scope,
69
+ Update,
70
+ UpdateOption,
71
+ UpdateReason,
72
+ )
73
+ from forceupdate.parsers.lockfiles import dirname
74
+ from forceupdate.registries import PackageInfo
75
+ from forceupdate.update.constraints import rewrite
76
+ from forceupdate.versions import InvalidConstraint, InvalidVersion, VersionScheme
77
+
78
+ MAJOR_WARNING = "Majeure : peut demander d'adapter le code."
79
+ DOWNGRADE_WARNING = "Revient à une version plus ancienne."
80
+ INDIRECT_MAJOR_WARNING = (
81
+ "Dépendance indirecte : le paquet qui la tire devra peut-être monter aussi."
82
+ )
83
+
84
+ Selection = dict[str, str]
85
+ """Les mises à jour choisies : l'identifiant d'une ``Update``, et la version choisie parmi ses
86
+ options. Un paquet absent n'est pas mis à jour."""
87
+
88
+ _Operations = tuple[list[Edit], str | None, str | None]
89
+ """Ce qu'une option touche : les contraintes à réécrire, le lockfile à régénérer, et pourquoi
90
+ il faut le faire à la main s'il le faut."""
91
+
92
+ _ACTIONABLE = frozenset(
93
+ {FindingKind.VULNERABLE, FindingKind.OUTDATED, FindingKind.YANKED, FindingKind.DEPRECATED}
94
+ )
95
+ """Les constats qui peuvent donner une mise à jour."""
96
+
97
+
98
+ @dataclass(frozen=True, kw_only=True)
99
+ class Rules:
100
+ """Ce que garde une sélection : les options de ``forceupdate update``."""
101
+
102
+ level: Gap = Gap.MINOR
103
+ """L'écart le plus grand accepté (``--level``) : ``minor`` par défaut, la décision n°12."""
104
+ security_only: bool = False
105
+ """Seulement les corrections de failles (``--security-only``) : pour chaque paquet, la plus
106
+ petite version qui corrige le plus de failles."""
107
+ include: tuple[str, ...] = ()
108
+ """Motifs de noms de paquets à garder (``--include``, ``*`` et ``?`` permis) ; tous si
109
+ vide."""
110
+ exclude: tuple[str, ...] = ()
111
+ """Motifs de noms de paquets à écarter (``--exclude``)."""
112
+ ecosystems: frozenset[Ecosystem] = frozenset()
113
+ """Les écosystèmes à garder (``--ecosystem``) ; tous si vide."""
114
+
115
+
116
+ DEFAULT_RULES = Rules()
117
+
118
+
119
+ def plan(
120
+ report: Report,
121
+ infos: Mapping[tuple[Ecosystem, str], PackageInfo],
122
+ vulnerabilities: Mapping[Query, Vulnerabilities],
123
+ ) -> None:
124
+ """Remplit ``updates`` de chaque projet de ``report``, d'après ses constats (déjà triés),
125
+ ce que les registres disent des paquets (``infos``, par écosystème et nom de dépendance) et
126
+ les failles qu'OSV connaît de chaque version (``vulnerabilities``)."""
127
+ overrides = {project.path: project.overrides for project in report.projects}
128
+ for project in report.projects:
129
+ project.updates = _plan_project(project, infos, vulnerabilities, overrides)
130
+
131
+
132
+ def select(report: Report, rules: Rules = DEFAULT_RULES) -> Selection:
133
+ """La sélection selon ``rules`` : pour chaque mise à jour possible, la version à cocher, s'il
134
+ y en a une. Avec les règles par défaut, ce sont les versions pré-cochées
135
+ (``Update.selected``)."""
136
+ selection = {}
137
+ for project in report.projects:
138
+ for update in project.updates:
139
+ version = choose(update, rules)
140
+ if version is not None:
141
+ selection[update.id] = version
142
+ return selection
143
+
144
+
145
+ def chosen(report: Report, selection: Selection) -> list[tuple[Project, Update, UpdateOption]]:
146
+ """Les options choisies par ``selection``, projet par projet. Lève ``ValueError`` pour un
147
+ identifiant ou une version qui n'existent pas dans le rapport."""
148
+ found = []
149
+ known = set()
150
+ for project in report.projects:
151
+ for update in project.updates:
152
+ known.add(update.id)
153
+ version = selection.get(update.id)
154
+ if version is None:
155
+ continue
156
+ option = update.option(version)
157
+ if option is None:
158
+ raise ValueError(f"{update.name} : la version {version} n'est pas proposée.")
159
+ found.append((project, update, option))
160
+ unknown = sorted(set(selection) - known)
161
+ if unknown:
162
+ raise ValueError(f"mise à jour inconnue : {unknown[0]}")
163
+ return found
164
+
165
+
166
+ def choose(update: Update, rules: Rules = DEFAULT_RULES) -> str | None:
167
+ """La version que ``rules`` cochent pour ``update`` ; ``None`` pour aucune."""
168
+ if not _kept(update, rules):
169
+ return None
170
+ scheme = versions.for_ecosystem(update.ecosystem)
171
+ eligible = [
172
+ option
173
+ for option in update.options
174
+ if option.gap is not None and option.gap.rank <= rules.level.rank and option.manual is None
175
+ ]
176
+ if rules.security_only:
177
+ eligible = [option for option in eligible if option.fixes]
178
+ if not eligible:
179
+ return None
180
+ most = max(len(option.fixes) for option in eligible)
181
+ best = [option for option in eligible if len(option.fixes) == most]
182
+ return min(best, key=lambda option: scheme.sort_key(option.version)).version
183
+ if not eligible:
184
+ return None
185
+ return max(eligible, key=lambda o: (len(o.fixes), scheme.sort_key(o.version))).version
186
+
187
+
188
+ def _kept(update: Update, rules: Rules) -> bool:
189
+ if rules.ecosystems and update.ecosystem not in rules.ecosystems:
190
+ return False
191
+ name = update.name.lower()
192
+ if rules.include and not any(fnmatchcase(name, p.lower()) for p in rules.include):
193
+ return False
194
+ return not any(fnmatchcase(name, p.lower()) for p in rules.exclude)
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Un projet
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ def _plan_project(
203
+ project: Project,
204
+ infos: Mapping[tuple[Ecosystem, str], PackageInfo],
205
+ vulnerabilities: Mapping[Query, Vulnerabilities],
206
+ overrides: Mapping[str, list[Override]],
207
+ ) -> list[Update]:
208
+ groups: dict[tuple[str, ...], list[Dependency]] = {}
209
+ for dependency in project.dependencies:
210
+ if dependency.verifiable:
211
+ groups.setdefault(_group_key(dependency), []).append(dependency)
212
+ planned: dict[tuple[str, ...], Update | None] = {}
213
+ for finding in project.findings: # du plus grave au moins grave
214
+ dependency = finding.dependency
215
+ if dependency is None or finding.kind not in _ACTIONABLE:
216
+ continue
217
+ key = _group_key(dependency)
218
+ if key in planned or key not in groups:
219
+ continue
220
+ members = groups[key]
221
+ planned[key] = _Group(
222
+ project=project,
223
+ dependencies=members,
224
+ findings=[f for f in project.findings if f.dependency in members],
225
+ info=infos.get((dependency.ecosystem, dependency.name)),
226
+ vulnerabilities=vulnerabilities,
227
+ overrides=overrides.get(dirname(dependency.file), []) if not dependency.direct else [],
228
+ ).plan()
229
+ return [update for update in planned.values() if update is not None]
230
+
231
+
232
+ def _group_key(dependency: Dependency) -> tuple[str, ...]:
233
+ """Une directe : toutes ses déclarations ensemble. Une indirecte : une version installée."""
234
+ key = (dependency.ecosystem.value, dependency.name)
235
+ return key if dependency.direct else (*key, dependency.installed or "")
236
+
237
+
238
+ class _Group:
239
+ """Un paquet d'un projet, avec ses déclarations et ses constats."""
240
+
241
+ def __init__(
242
+ self,
243
+ *,
244
+ project: Project,
245
+ dependencies: list[Dependency],
246
+ findings: list[Finding],
247
+ info: PackageInfo | None,
248
+ vulnerabilities: Mapping[Query, Vulnerabilities],
249
+ overrides: Sequence[Override],
250
+ ) -> None:
251
+ self.project = project
252
+ self.dependencies = dependencies
253
+ self.lead = dependencies[0]
254
+ self.ecosystem = self.lead.ecosystem
255
+ self.scheme: VersionScheme = versions.for_ecosystem(self.ecosystem)
256
+ self.findings = findings
257
+ self.info = info
258
+ self.overrides = overrides
259
+ self.currents = [current_version(d) for d in dependencies]
260
+ self.vulns = [f for f in findings if f.kind is FindingKind.VULNERABLE]
261
+ self.fixable = [f for f in self.vulns if f.target_version is not None]
262
+ self.advisories = _advisories(dependencies, self.currents, vulnerabilities)
263
+
264
+ def plan(self) -> Update | None:
265
+ current = self._current()
266
+ if current is None:
267
+ return None
268
+ assert current.version is not None
269
+ update_id = "|".join((self.project.path, *_group_key(self.lead)))
270
+ base = {
271
+ "id": update_id,
272
+ "ecosystem": self.ecosystem,
273
+ "name": self.lead.name,
274
+ "direct": self.lead.direct,
275
+ "dependencies": tuple(self.dependencies),
276
+ "current_version": current.version,
277
+ "current_is_approximate": current.approximate,
278
+ }
279
+ if self.info is None:
280
+ if not self.vulns:
281
+ return None
282
+ note = "Versions publiées inconnues du registre : mise à jour non calculée."
283
+ return Update(**base, note=note)
284
+ reasons = self._versions(current.version)
285
+ options = []
286
+ for version in sorted(reasons, key=self.scheme.sort_key):
287
+ option = self._option(current.version, version, reasons[version])
288
+ if option is not None:
289
+ options.append(option)
290
+ note = self._note(reasons)
291
+ if not options and note is None:
292
+ return None
293
+ update = Update(**base, options=tuple(options), note=note)
294
+ return dataclasses.replace(update, selected=choose(update))
295
+
296
+ def _current(self) -> Current | None:
297
+ """La version utilisée du paquet : la plus basse de ses déclarations (elles ont la même
298
+ version installée ; sans lockfile, leurs bornes basses peuvent différer)."""
299
+ known = [c for c in self.currents if c.version is not None]
300
+ if not known:
301
+ return None
302
+ try:
303
+ return min(known, key=lambda c: self.scheme.parse(c.version or ""))
304
+ except InvalidVersion: # outdated l'a déjà dit
305
+ return None
306
+
307
+ # -- les versions --------------------------------------------------------
308
+
309
+ def _versions(self, current: str) -> dict[str, list[UpdateReason]]:
310
+ """Les versions à proposer, avec leurs raisons."""
311
+ assert self.info is not None
312
+ found: dict[str, list[UpdateReason]] = {}
313
+
314
+ def add(version: str | None, reason: UpdateReason) -> None:
315
+ if version is not None:
316
+ found.setdefault(version, []).append(reason)
317
+
318
+ fresh = [v.version for v in self.info.versions if not v.yanked and v.deprecated is None]
319
+ pool = sorted(self.scheme.candidates(fresh, current), key=self.scheme.sort_key)
320
+ above = [v for v in pool if self.scheme.compare(v, current) > 0]
321
+ outdated = next((f for f in self.findings if f.kind is FindingKind.OUTDATED), None)
322
+ ceiling = None if outdated is None else outdated.target_version
323
+ if ceiling is None and not self.lead.direct and self.vulns:
324
+ # Une indirecte en faille, vérifiée sans --transitive : son dernier correctif et sa
325
+ # dernière mineure, s'ils corrigent au moins une faille sans changer de majeure.
326
+ ceiling = self.info.latest
327
+ if ceiling is not None:
328
+ line = [
329
+ v
330
+ for v in above
331
+ if self.scheme.compare(v, ceiling) <= 0
332
+ and self.scheme.gap(current, v) is not Gap.MAJOR
333
+ ]
334
+ if outdated is None:
335
+ line = [v for v in line if any(not self._affected(f, v) for f in self.fixable)]
336
+ patches = [v for v in line if self.scheme.gap(current, v) is Gap.PATCH]
337
+ minors = [v for v in line if self.scheme.gap(current, v) is Gap.MINOR]
338
+ add(patches[-1] if patches else None, UpdateReason.LATEST_PATCH)
339
+ add(minors[-1] if minors else None, UpdateReason.LATEST_MINOR)
340
+ if self.lead.direct and outdated is not None and outdated.gap is Gap.MAJOR:
341
+ add(ceiling, UpdateReason.LATEST_MAJOR)
342
+ add(self._security_fix(current, pool), UpdateReason.SECURITY_FIX)
343
+ for finding in self.findings:
344
+ if finding.kind in (FindingKind.YANKED, FindingKind.DEPRECATED):
345
+ add(finding.target_version, UpdateReason.REPLACEMENT)
346
+ return found
347
+
348
+ def _security_fix(self, current: str, pool: list[str]) -> str | None:
349
+ """La plus petite version qui corrige toutes les failles qui peuvent l'être : au moins
350
+ la plus haute des versions corrigées, qu'aucun avis ne concerne plus ; non dépréciée
351
+ s'il y en a une, sinon dépréciée ; jamais retirée."""
352
+ if not self.fixable:
353
+ return None
354
+ assert self.info is not None
355
+ floor = max((f.target_version or "" for f in self.fixable), key=self.scheme.sort_key)
356
+ available = sorted(
357
+ self.scheme.candidates(self.info.available(), current), key=self.scheme.sort_key
358
+ )
359
+ for choices in (pool, available):
360
+ for version in choices:
361
+ if self.scheme.compare(version, floor) >= 0 and not any(
362
+ self._affected(f, version) for f in self.fixable
363
+ ):
364
+ return version
365
+ return None
366
+
367
+ def _affected(self, finding: Finding, version: str) -> bool:
368
+ """Vrai si la faille ``finding`` concerne encore ``version``, d'après ses avis ; faute
369
+ d'avis lisible, d'après sa version corrigée."""
370
+ mine = [a for a in self._advisories_of(finding) if a.unavailable is None]
371
+ if mine:
372
+ return any(a.affects(self.ecosystem, self.lead.name, version) for a in mine)
373
+ fix = finding.target_version
374
+ return fix is None or self.scheme.compare(version, fix) < 0
375
+
376
+ def _advisories_of(self, finding: Finding) -> list[Advisory]:
377
+ return [a for a in self.advisories if a.id in finding.identifiers]
378
+
379
+ def _note(self, reasons: Mapping[str, list[UpdateReason]]) -> str | None:
380
+ unfixable = [f for f in self.vulns if f.target_version is None]
381
+ if unfixable:
382
+ ids = ", ".join(_main_id(f) for f in unfixable)
383
+ malicious = any(a.malicious for f in unfixable for a in self._advisories_of(f))
384
+ if malicious:
385
+ return f"Code malveillant ({ids}) : retirer ce paquet, aucune version sûre connue."
386
+ return f"Aucune version corrigée connue pour {ids}."
387
+ if self.fixable and not any(UpdateReason.SECURITY_FIX in r for r in reasons.values()):
388
+ return "Aucune version publiée ne corrige toutes ses failles."
389
+ return None
390
+
391
+ # -- une option -----------------------------------------------------------
392
+
393
+ def _option(
394
+ self, current: str, version: str, reasons: list[UpdateReason]
395
+ ) -> UpdateOption | None:
396
+ assert self.info is not None
397
+ gap = self.scheme.gap(current, version)
398
+ if gap is None and self.scheme.compare(version, current) == 0:
399
+ return None
400
+ fixes = tuple(_main_id(f) for f in self.fixable if not self._affected(f, version))
401
+ unfixed = tuple(_main_id(f) for f in self.vulns if _main_id(f) not in fixes)
402
+ if self.lead.direct:
403
+ edits, lockfile, manual = self._direct_edits(version, floor=bool(fixes))
404
+ else:
405
+ edits, lockfile, manual = self._indirect_edits(version, floor=bool(fixes))
406
+ if not edits and lockfile is None and manual is None:
407
+ return None # sans lockfile, la contrainte accepte déjà cette version : rien à faire
408
+ warnings = []
409
+ if gap is Gap.MAJOR:
410
+ warnings.append(MAJOR_WARNING)
411
+ if not self.lead.direct:
412
+ warnings.append(INDIRECT_MAJOR_WARNING)
413
+ elif gap is None:
414
+ warnings.append(DOWNGRADE_WARNING)
415
+ published = self.info.get(version)
416
+ if published is not None and published.deprecated:
417
+ warnings.append(f"Version dépréciée : {published.deprecated}")
418
+ return UpdateOption(
419
+ version=version,
420
+ gap=gap,
421
+ reasons=tuple(dict.fromkeys(reasons)),
422
+ fixes=fixes,
423
+ unfixed=unfixed,
424
+ edits=tuple(edits),
425
+ lockfile=lockfile,
426
+ manual=manual,
427
+ warnings=tuple(warnings),
428
+ )
429
+
430
+ def _direct_edits(self, version: str, *, floor: bool) -> _Operations:
431
+ """Les contraintes à réécrire pour monter à ``version`` : celles qui ne l'acceptent pas,
432
+ et, sans lockfile, celles dont la borne basse est à relever."""
433
+ edits = []
434
+ manual = None
435
+ for dependency, current in zip(self.dependencies, self.currents, strict=True):
436
+ text = dependency.effective_constraint
437
+ try:
438
+ inside = self.scheme.satisfies(text, version)
439
+ except (InvalidConstraint, InvalidVersion):
440
+ manual = f"Contrainte « {text} » illisible : à réécrire à la main."
441
+ continue
442
+ if inside and not current.approximate:
443
+ continue
444
+ new = rewrite(
445
+ self.ecosystem,
446
+ text,
447
+ version,
448
+ floor=floor,
449
+ widen=dependency.scope is Scope.PEER,
450
+ )
451
+ if new is None:
452
+ manual = f"Contrainte « {text} » : style non géré, à réécrire à la main."
453
+ continue
454
+ if new == text:
455
+ continue
456
+ catalog = dependency.resolved_constraint is not None
457
+ if dependency.location is None and not catalog:
458
+ manual = (
459
+ f"Contrainte « {text} » de {dependency.file} : emplacement inconnu, à "
460
+ f"réécrire à la main en « {new} »."
461
+ )
462
+ continue
463
+ edits.append(
464
+ Edit(
465
+ kind=EditKind.CATALOG if catalog else EditKind.DEPENDENCY,
466
+ file=dependency.file,
467
+ location=dependency.location,
468
+ old=text,
469
+ new=new,
470
+ )
471
+ )
472
+ lockfile = next((d.lockfile for d in self.dependencies if d.lockfile), None)
473
+ return edits, lockfile, manual
474
+
475
+ def _indirect_edits(self, version: str, *, floor: bool) -> _Operations:
476
+ """Une indirecte monte par son lockfile ; une version forcée qui ne l'accepte pas est
477
+ réécrite."""
478
+ lockfile = self.lead.lockfile or self.lead.file
479
+ override = _override(self.lead.name, self.overrides)
480
+ if override is None:
481
+ return [], lockfile, None
482
+ described = f"Version forcée « {override.key} » ({override.section}, {override.file})"
483
+ try:
484
+ inside = self.scheme.satisfies(override.constraint, version)
485
+ except (InvalidConstraint, InvalidVersion):
486
+ return [], lockfile, f"{described} : « {override.constraint} », à réécrire à la main."
487
+ if inside:
488
+ return [], lockfile, None
489
+ new = rewrite(self.ecosystem, override.constraint, version, floor=floor)
490
+ if new is None or override.location is None:
491
+ return [], lockfile, f"{described} : « {override.constraint} », à réécrire à la main."
492
+ edit = Edit(
493
+ kind=EditKind.OVERRIDE,
494
+ file=override.file,
495
+ location=override.location,
496
+ old=override.constraint,
497
+ new=new,
498
+ override=override,
499
+ )
500
+ return [edit], lockfile, None
501
+
502
+
503
+ def _advisories(
504
+ dependencies: Iterable[Dependency],
505
+ currents: Iterable[Current],
506
+ vulnerabilities: Mapping[Query, Vulnerabilities],
507
+ ) -> list[Advisory]:
508
+ """Les avis d'OSV pour les versions utilisées du paquet, sans doublon."""
509
+ found: dict[str, Advisory] = {}
510
+ for dependency, current in zip(dependencies, currents, strict=True):
511
+ if current.version is None:
512
+ continue
513
+ query = (dependency.ecosystem, dependency.name, current.version)
514
+ result = vulnerabilities.get(query)
515
+ if result is not None:
516
+ for advisory in result.advisories:
517
+ found.setdefault(advisory.id, advisory)
518
+ return list(found.values())
519
+
520
+
521
+ def _override(name: str, overrides: Iterable[Override]) -> Override | None:
522
+ """La version forcée de ``name`` : celle qui vaut pour tout l'arbre de préférence, sinon
523
+ celle d'un paquet parent."""
524
+ mine = [o for o in overrides if o.name == name]
525
+ whole_tree = [o for o in mine if o.key in (name, f"**/{name}") or o.key.startswith(f"{name}@")]
526
+ return next(iter(whole_tree or mine), None)
527
+
528
+
529
+ def _main_id(finding: Finding) -> str:
530
+ """Le premier identifiant d'une faille : une CVE s'il y en a une."""
531
+ return finding.identifiers[0] if finding.identifiers else finding.message
@@ -0,0 +1,40 @@
1
+ """Les versions : comparer, classer l'écart, tester une contrainte, un module par écosystème.
2
+
3
+ Les modèles gardent versions et contraintes en chaînes, telles qu'écrites. Pour les
4
+ interpréter, prendre les règles de l'écosystème avec ``for_ecosystem`` :
5
+
6
+ scheme = for_ecosystem(Ecosystem.NPM)
7
+ scheme.max_satisfying("^4.17.0", ["4.17.21", "5.0.0"]) # "4.17.21"
8
+ scheme.gap("4.17.15", "5.0.0") # Gap.MAJOR
9
+
10
+ PyPI et npm pour l'instant ; les autres écosystèmes arrivent avec leur étape (E21-E26),
11
+ chacun avec sa ``VersionScheme``.
12
+ """
13
+
14
+ from typing import Any
15
+
16
+ from forceupdate.models import Ecosystem
17
+ from forceupdate.versions.base import Constraint, InvalidConstraint, InvalidVersion, VersionScheme
18
+ from forceupdate.versions.npm import NPM
19
+ from forceupdate.versions.pypi import PYPI
20
+
21
+ __all__ = [
22
+ "Constraint",
23
+ "InvalidConstraint",
24
+ "InvalidVersion",
25
+ "VersionScheme",
26
+ "for_ecosystem",
27
+ ]
28
+
29
+ _SCHEMES: dict[Ecosystem, VersionScheme[Any]] = {
30
+ Ecosystem.PYPI: PYPI,
31
+ Ecosystem.NPM: NPM,
32
+ }
33
+
34
+
35
+ def for_ecosystem(ecosystem: Ecosystem) -> VersionScheme[Any]:
36
+ """Les règles de version de ``ecosystem`` ; ``LookupError`` s'il n'est pas encore géré."""
37
+ try:
38
+ return _SCHEMES[ecosystem]
39
+ except KeyError:
40
+ raise LookupError(f"versions {ecosystem} pas encore gérées par forceupdate.") from None