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,317 @@
1
+ """Réécrire une contrainte pour une nouvelle version, en gardant son style.
2
+
3
+ ``^4.17.0`` devient ``^4.21.2``, pas ``4.21.2`` (brief, « Garder le style de contrainte ») :
4
+ seuls les numéros de version changent. Les opérateurs, les espaces, l'ordre, les préfixes
5
+ (``v``, ``=``) et le reste du texte sont gardés.
6
+
7
+ La règle : la nouvelle contrainte accepte la version visée, et sa borne basse monte jusqu'à
8
+ elle (``>=2.25,<3`` vers 3.1 : ``>=3.1,<4``). Par style :
9
+
10
+ - **version exacte** (``==1.2.3``, ``1.2.3`` npm, version nue Poetry) et **borne basse**
11
+ (``>=``, ``>``) : la version visée ; ``>`` devient ``>=``, sinon elle s'exclurait ;
12
+ - ``^`` et ``~`` (npm, Poetry) : la version visée derrière le même opérateur ;
13
+ - **borne haute** (``<``, ``<=``) : déplacée seulement si elle exclut la version visée, au même
14
+ niveau que celui qu'elle fixait (``<3`` vers 3.1 : ``<4`` ; ``<1.5`` vers 1.7.2 : ``<1.8``) ;
15
+ - ``!=`` est gardé tel quel ;
16
+ - **union** (``^1 || ^2``) : remplacée par le membre qui accepte la version visée, ou par le
17
+ dernier, réécrit ;
18
+ - **dépendance pair** (``widen``) : la plage dit avec quelles versions le paquet fonctionne ;
19
+ la remplacer retirerait les anciennes à ses utilisateurs. Un membre est ajouté à la fin
20
+ (``^18.0.0`` vers 19.1.0 : ``^18.0.0 || ^19.1.0`` ; ``^1 || ^2`` : ``^1 || ^2 || ^3.1.0``).
21
+
22
+ **Précision.** Le nombre de nombres écrits est gardé quand la version visée s'y écrit sans
23
+ rien perdre (``^1`` vers 2.0.0 : ``^2`` ; ``==1.2`` vers 1.3.0 : ``==1.3``). Sinon la version
24
+ visée est écrite en entier, sauf là où le nombre de nombres change le sens de la contrainte :
25
+
26
+ - ``~=1.4`` veut dire ``>=1.4, ==1.*``, mais ``~=1.4.2`` veut dire ``==1.4.*`` : ``~=`` garde
27
+ toujours sa précision (``~=1.4`` vers 1.9.2 : ``~=1.9``) ;
28
+ - les jokers (``1.2.*``, ``1.x``, la version partielle ``1.2`` de npm) aussi : ``2.3.*`` ;
29
+ - ``~1`` va jusqu'à 2, ``~1.3.4`` seulement jusqu'à 1.4 ; ``^0`` jusqu'à 1, ``^0.3.1`` seulement
30
+ jusqu'à 0.4 : ``~`` à un seul nombre, et ``^`` dont le premier nombre non nul de la version
31
+ visée tomberait hors de la précision écrite, gardent leur précision.
32
+
33
+ **Plancher** (``floor``) : pour une faille, la borne basse doit atteindre la version visée,
34
+ sinon la nouvelle contrainte accepterait encore des versions concernées. Quand le style ne le
35
+ permet pas (``~=1.9`` pour 1.9.2, ``2.3.*``), une borne ``>=`` est ajoutée :
36
+ ``~=1.9,>=1.9.2``, ``2.x >=2.3.1``.
37
+
38
+ Le résultat est relu : il doit accepter la version visée (et respecter le plancher). Sinon, ou
39
+ pour un style qu'on ne sait pas réécrire (intervalle npm ``1.2.3 - 2.3.4`` dépassé, contrainte
40
+ sans version comme ``*`` ou une étiquette), ``rewrite`` rend ``None`` : à réécrire à la main.
41
+ """
42
+
43
+ import re
44
+ from dataclasses import dataclass
45
+
46
+ from forceupdate import versions
47
+ from forceupdate.models import Ecosystem
48
+ from forceupdate.versions import InvalidConstraint, InvalidVersion, VersionScheme
49
+
50
+ _NPM_OPERATORS = r"~>|<=|>=|[<>=~^]"
51
+ _PYPI_OPERATORS = r"===|==|!=|<>|<=|>=|~=|[<>=~^]"
52
+ _VERSION_TEXT = r"[vV]?(?:\d|[xX*])[0-9A-Za-z.!+*_-]*"
53
+ """Une version telle qu'écrite dans une contrainte : complète, partielle, avec joker,
54
+ pré-version ou version locale. Le découpage fin est fait par ``_NUMBERS``."""
55
+
56
+ _NUMBERS = re.compile(
57
+ r"(?P<prefix>[vV=]*)(?P<epoch>\d+!)?(?P<numbers>(?:\d+|[xX*])(?:\.(?:\d+|[xX*]))*)"
58
+ r"(?P<rest>.*)",
59
+ re.ASCII,
60
+ )
61
+ _PLAIN = re.compile(r"(?P<epoch>\d+!)?(?P<numbers>\d+(?:\.\d+)*)(?P<rest>.*)", re.ASCII)
62
+ _HYPHEN = re.compile(r"(?P<first>\S+)(?P<middle>\s+-\s+)(?P<last>\S+)")
63
+ _UNION = re.compile(r"\s*\|\|?\s*")
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class _Comparator:
68
+ """Un comparateur tel qu'écrit dans un membre de contrainte."""
69
+
70
+ operator: str
71
+ operator_span: tuple[int, int]
72
+ version: str
73
+ version_span: tuple[int, int]
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _Target:
78
+ """La version visée, découpée : ses nombres, et ce qui les suit (pré-version, ``.post0``…)."""
79
+
80
+ text: str
81
+ epoch: str
82
+ numbers: tuple[int, ...]
83
+ rest: str
84
+
85
+ def written(self, count: int) -> str:
86
+ """Ses ``count`` premiers nombres (complétés par des zéros), sans ce qui les suit."""
87
+ numbers = (*self.numbers, *([0] * count))[:count]
88
+ return self.epoch + ".".join(str(n) for n in numbers)
89
+
90
+ def fits(self, count: int) -> bool:
91
+ """Vrai si ``count`` nombres l'écrivent sans rien perdre : ``2.0.0`` en deux (``2.0``),
92
+ pas ``2.0.1`` ni ``2.0.0rc1``."""
93
+ return not self.rest and all(n == 0 for n in self.numbers[count:])
94
+
95
+
96
+ def rewrite(
97
+ ecosystem: Ecosystem, constraint: str, target: str, *, floor: bool = False, widen: bool = False
98
+ ) -> str | None:
99
+ """La contrainte ``constraint`` réécrite pour ``target``, dans le même style (voir la
100
+ docstring du module) ; ``None`` si on ne sait pas la réécrire.
101
+
102
+ ``floor`` : la borne basse doit atteindre ``target`` (une faille). ``widen`` : la plage
103
+ s'élargit d'un membre au lieu d'être remplacée (une dépendance pair), et reste telle quelle
104
+ si elle accepte déjà ``target``.
105
+
106
+ La borne basse monte toujours jusqu'à ``target`` (ou ce que le style en garde) : quand la
107
+ contrainte l'accepte déjà et qu'un lockfile la fige, c'est au planificateur de ne pas
108
+ réécrire la contrainte (le lockfile seul suffit).
109
+ """
110
+ scheme = versions.for_ecosystem(ecosystem)
111
+ parsed_target = _split_target(target)
112
+ if parsed_target is None:
113
+ return None
114
+ members = _members(constraint)
115
+ try:
116
+ if len(members) > 1 or widen:
117
+ new = _rewrite_union(scheme, constraint, members, parsed_target, floor, widen)
118
+ else:
119
+ new = _rewrite_member(scheme, constraint.strip(), parsed_target, floor)
120
+ if new is None or not scheme.satisfies(new, target):
121
+ return None
122
+ if floor and not _reaches(scheme, new, target):
123
+ return None
124
+ except (InvalidConstraint, InvalidVersion):
125
+ return None
126
+ return new
127
+
128
+
129
+ def _split_target(target: str) -> _Target | None:
130
+ match = _PLAIN.fullmatch(target.strip().lstrip("vV="))
131
+ if match is None:
132
+ return None
133
+ numbers = tuple(int(n) for n in match.group("numbers").split("."))
134
+ return _Target(target, match.group("epoch") or "", numbers, match.group("rest"))
135
+
136
+
137
+ def _members(constraint: str) -> list[str]:
138
+ """Les membres d'une union (``||`` de npm et Poetry, ``|`` de Poetry)."""
139
+ return [part for part in _UNION.split(constraint.strip())]
140
+
141
+
142
+ def _rewrite_union(
143
+ scheme: VersionScheme,
144
+ constraint: str,
145
+ members: list[str],
146
+ target: _Target,
147
+ floor: bool,
148
+ widen: bool,
149
+ ) -> str | None:
150
+ containing = [m for m in members if m and scheme.satisfies(m, target.text)]
151
+ if widen:
152
+ if containing:
153
+ return constraint
154
+ added = _rewrite_member(scheme, members[-1], target, floor=False)
155
+ if added is None:
156
+ return None
157
+ separator = _UNION.search(constraint)
158
+ return constraint.rstrip() + (separator.group() if separator else " || ") + added
159
+ member = containing[0] if containing else members[-1]
160
+ return _rewrite_member(scheme, member, target, floor)
161
+
162
+
163
+ def _rewrite_member(scheme: VersionScheme, text: str, target: _Target, floor: bool) -> str | None:
164
+ npm = scheme.ecosystem is Ecosystem.NPM
165
+ hyphen = _HYPHEN.fullmatch(text) if npm else None
166
+ if hyphen is not None:
167
+ return _rewrite_hyphen(scheme, hyphen, target)
168
+ comparators = [(c, _kind(c, npm=npm)) for c in _comparators(text, npm=npm)]
169
+ if all(kind == "any" for _, kind in comparators):
170
+ return None # « * », « x », une étiquette : pas de version à réécrire
171
+ replacements: list[tuple[tuple[int, int], str]] = []
172
+ for comparator, kind in comparators:
173
+ if kind == "any":
174
+ continue
175
+ if kind == "upper":
176
+ if not scheme.satisfies(comparator.operator + comparator.version, target.text):
177
+ shifted = _shift_upper(comparator.version, target)
178
+ if shifted is None:
179
+ return None
180
+ replacements.append((comparator.version_span, shifted))
181
+ continue
182
+ if kind == "exclude":
183
+ continue
184
+ written = _written(comparator, kind, target)
185
+ if written is None:
186
+ return None
187
+ replacements.append((comparator.version_span, written))
188
+ if comparator.operator == ">":
189
+ replacements.append((comparator.operator_span, ">="))
190
+ new = _apply(text, replacements)
191
+ if floor and not _reaches(scheme, new, target.text):
192
+ new += f" >={target.text}" if npm else f",>={target.text}"
193
+ return new
194
+
195
+
196
+ def _comparators(text: str, *, npm: bool) -> list[_Comparator]:
197
+ operators = _NPM_OPERATORS if npm else _PYPI_OPERATORS
198
+ pattern = re.compile(rf"(?P<op>{operators})?\s*(?P<version>{_VERSION_TEXT})", re.ASCII)
199
+ found = []
200
+ for match in pattern.finditer(text):
201
+ operator = match.group("op") or ""
202
+ found.append(
203
+ _Comparator(
204
+ operator=operator,
205
+ operator_span=match.span("op") if operator else (match.start(), match.start()),
206
+ version=match.group("version"),
207
+ version_span=match.span("version"),
208
+ )
209
+ )
210
+ return found
211
+
212
+
213
+ def _kind(comparator: _Comparator, *, npm: bool) -> str:
214
+ """Le rôle d'un comparateur : ``exact``, ``lower``, ``upper``, ``caret``, ``tilde``,
215
+ ``compatible`` (``~=``), ``wildcard``, ``exclude`` (``!=``) ou ``any`` (``*``)."""
216
+ operator = comparator.operator
217
+ numbers = _NUMBERS.fullmatch(comparator.version)
218
+ count, wildcard = _count(numbers.group("numbers")) if numbers else (0, True)
219
+ if count == 0:
220
+ return "any"
221
+ if operator in ("<", "<="):
222
+ return "upper"
223
+ if operator in (">", ">="):
224
+ return "lower"
225
+ if operator == "^":
226
+ return "caret"
227
+ if operator in ("~", "~>"):
228
+ return "tilde"
229
+ if operator == "~=":
230
+ return "compatible"
231
+ if operator in ("!=", "<>"):
232
+ return "exclude"
233
+ if wildcard:
234
+ return "wildcard"
235
+ if npm and count < 3:
236
+ return "wildcard" # « 1.2 » veut dire « 1.2.x » pour npm
237
+ return "exact"
238
+
239
+
240
+ def _count(numbers: str) -> tuple[int, bool]:
241
+ """Le nombre de nombres écrits avant un éventuel joker, et s'il y a un joker."""
242
+ count = 0
243
+ for part in numbers.split("."):
244
+ if not part.isdigit():
245
+ return count, True
246
+ count += 1
247
+ return count, False
248
+
249
+
250
+ def _written(comparator: _Comparator, kind: str, target: _Target) -> str | None:
251
+ """Le nouveau texte de la version d'un comparateur qui porte la borne basse."""
252
+ numbers = _NUMBERS.fullmatch(comparator.version)
253
+ if numbers is None:
254
+ return None
255
+ prefix = numbers.group("prefix")
256
+ count, _ = _count(numbers.group("numbers"))
257
+ if kind == "wildcard":
258
+ return prefix + target.written(count) + _joker(numbers.group("numbers"), count)
259
+ if kind == "compatible":
260
+ return prefix + target.written(max(count, 2))
261
+ if target.fits(count):
262
+ return prefix + target.written(count)
263
+ if kind == "tilde" and count < 2:
264
+ return prefix + target.written(count)
265
+ if kind == "caret" and not any(n != 0 for n in target.numbers[:count]):
266
+ return prefix + target.written(count)
267
+ return prefix + target.text
268
+
269
+
270
+ def _joker(numbers: str, count: int) -> str:
271
+ """Ce qui suit les ``count`` premiers nombres : le joker (``.x``, ``.*``), ou rien."""
272
+ return numbers[len(".".join(numbers.split(".")[:count])) :]
273
+
274
+
275
+ def _shift_upper(upper: str, target: _Target) -> str | None:
276
+ """Une borne haute déplacée au-dessus de ``target``, au même niveau : le dernier nombre non
277
+ nul de la borne dit ce qu'elle fixait (``<3`` une majeure, ``<1.5`` une mineure)."""
278
+ match = _NUMBERS.fullmatch(upper)
279
+ if match is None:
280
+ return None
281
+ count, wildcard = _count(match.group("numbers"))
282
+ if wildcard or count == 0:
283
+ return None
284
+ written = [int(n) for n in match.group("numbers").split(".")]
285
+ level = max((i for i, n in enumerate(written) if n != 0), default=0)
286
+ numbers = [*target.numbers, *([0] * count)][: level + 1]
287
+ numbers[level] += 1
288
+ numbers += [0] * (count - level - 1)
289
+ return match.group("prefix") + ".".join(str(n) for n in numbers) + match.group("rest")
290
+
291
+
292
+ def _rewrite_hyphen(scheme: VersionScheme, hyphen: re.Match[str], target: _Target) -> str | None:
293
+ """``1.2.3 - 2.3.4`` (npm) : le début devient la version visée ; une fin partielle
294
+ (``- 2``, ``- 2.3``) suit la version visée à la même précision. Une fin complète dépassée
295
+ n'a pas de règle évidente : à réécrire à la main."""
296
+ last = hyphen.group("last")
297
+ if not scheme.satisfies(f"0.0.0 - {last}", target.text):
298
+ match = _NUMBERS.fullmatch(last)
299
+ if match is None:
300
+ return None
301
+ count, wildcard = _count(match.group("numbers"))
302
+ if count >= 3 and not wildcard:
303
+ return None
304
+ last = match.group("prefix") + target.written(count) + _joker(match.group("numbers"), count)
305
+ return f"{target.text}{hyphen.group('middle')}{last}"
306
+
307
+
308
+ def _apply(text: str, replacements: list[tuple[tuple[int, int], str]]) -> str:
309
+ for (start, end), new in sorted(replacements, reverse=True):
310
+ text = text[:start] + new + text[end:]
311
+ return text
312
+
313
+
314
+ def _reaches(scheme: VersionScheme, constraint: str, target: str) -> bool:
315
+ """Vrai si la borne basse de ``constraint`` atteint ``target``."""
316
+ lower = scheme.lower_bound(constraint)
317
+ return lower is not None and scheme.compare(lower, target) >= 0