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,229 @@
1
+ """L'interface commune des versions : lire, comparer, classer l'écart, tester une contrainte.
2
+
3
+ Chaque écosystème a ses règles (PEP 440 pour PyPI, semver pour npm…), et comparer
4
+ deux versions comme des chaînes donne ``"1.10" < "1.9"``. Les modèles gardent les
5
+ versions et les contraintes en chaînes, telles qu'écrites ; une ``VersionScheme``
6
+ les interprète. Toute comparaison de versions du code passe par elle.
7
+
8
+ Ce qui est commun à tous les écosystèmes est écrit ici une fois :
9
+
10
+ - l'écart entre deux versions (``gap``), avec la règle du 0.x ;
11
+ - les candidates : les pré-versions sont écartées, sauf celles de la version actuelle
12
+ quand c'en est une ;
13
+ - la plus haute version qui respecte une contrainte (``max_satisfying``).
14
+
15
+ Un écosystème n'écrit que la lecture d'une version et d'une contrainte.
16
+ """
17
+
18
+ from abc import ABC, abstractmethod
19
+ from collections.abc import Iterable
20
+ from typing import Any, Generic, Protocol, TypeVar
21
+
22
+ from forceupdate.models import Ecosystem, Gap
23
+
24
+
25
+ class InvalidVersion(ValueError):
26
+ """Une version illisible selon les règles de son écosystème."""
27
+
28
+
29
+ class InvalidConstraint(ValueError):
30
+ """Une contrainte illisible, ou qu'on ne sait pas interpréter seule (``catalog:``)."""
31
+
32
+
33
+ class _Comparable(Protocol):
34
+ def __lt__(self, other: Any, /) -> bool: ...
35
+
36
+
37
+ V = TypeVar("V", bound=_Comparable)
38
+ """Une version lue : un objet comparable (``packaging.version.Version``, ``SemVer``…)."""
39
+
40
+
41
+ class Constraint(ABC, Generic[V]):
42
+ """Une contrainte lue : l'ensemble des versions qu'elle accepte."""
43
+
44
+ @abstractmethod
45
+ def contains(self, version: V) -> bool:
46
+ """Vrai si ``version`` respecte la contrainte."""
47
+
48
+ @abstractmethod
49
+ def lower_bound(self) -> str | None:
50
+ """La plus petite version que la contrainte accepte ; ``None`` si elle n'a pas de
51
+ borne basse (``*``, ``<2``, une étiquette npm)."""
52
+
53
+ def exact(self) -> str | None:
54
+ """La version qu'impose une contrainte exacte (``==1.2.3``, ``1.2.3`` pour npm) ;
55
+ ``None`` si elle en accepte plusieurs."""
56
+ return None
57
+
58
+
59
+ class VersionScheme(ABC, Generic[V]):
60
+ """Les règles de version d'un écosystème.
61
+
62
+ Les versions et les contraintes entrent et sortent en chaînes, comme dans les
63
+ modèles. Une version illisible lève ``InvalidVersion``, une contrainte illisible
64
+ ``InvalidConstraint`` ; dans une liste de versions publiées, une version illisible
65
+ est sautée (un registre en garde parfois de très anciennes, hors normes).
66
+ """
67
+
68
+ ecosystem: Ecosystem
69
+
70
+ # -- à écrire pour chaque écosystème ------------------------------------
71
+
72
+ @abstractmethod
73
+ def parse(self, version: str) -> V:
74
+ """La version lue, comparable avec ``<`` ; lève ``InvalidVersion``."""
75
+
76
+ @abstractmethod
77
+ def parse_constraint(self, constraint: str) -> Constraint[V]:
78
+ """La contrainte lue, telle qu'écrite dans le manifeste ; lève ``InvalidConstraint``."""
79
+
80
+ @abstractmethod
81
+ def _release(self, version: V) -> tuple[int, int, int]:
82
+ """Les trois premiers nombres de la version (majeure, mineure, correctif)."""
83
+
84
+ @abstractmethod
85
+ def _is_pre(self, version: V) -> bool:
86
+ """Vrai pour une pré-version (``1.0.0-beta``, ``2.0.0rc1``)."""
87
+
88
+ def _same_release(self, a: V, b: V) -> bool:
89
+ """Vrai si ``a`` et ``b`` sont deux états de la même version (``2.0.0rc1`` et
90
+ ``2.0.0rc2``, ``2.0.0-beta.1`` et ``2.0.0``)."""
91
+ return self._release(a) == self._release(b)
92
+
93
+ # -- commun --------------------------------------------------------------
94
+
95
+ def compare(self, a: str, b: str) -> int:
96
+ """``-1`` si ``a`` est plus ancienne que ``b``, ``0`` si égales, ``1`` si plus récente."""
97
+ first, second = self.parse(a), self.parse(b)
98
+ return -1 if first < second else 1 if second < first else 0
99
+
100
+ def is_prerelease(self, version: str) -> bool:
101
+ """Vrai pour une pré-version : ``1.0.0-beta`` (npm), ``2.0.0rc1`` ou ``1.0.dev1`` (PyPI)."""
102
+ return self._is_pre(self.parse(version))
103
+
104
+ def sort_key(self, version: str) -> tuple[int, Any]:
105
+ """Une clé de tri qui ne lève jamais : les versions lisibles dans leur ordre, puis
106
+ les autres (une référence git…) dans l'ordre du texte."""
107
+ try:
108
+ return (0, self.parse(version))
109
+ except InvalidVersion:
110
+ return (1, version)
111
+
112
+ def gap(self, current: str, target: str) -> Gap | None:
113
+ """L'écart pour passer de ``current`` à ``target`` ; ``None`` si ``target`` n'est
114
+ pas plus récente.
115
+
116
+ C'est le premier nombre qui change : de ``2.3.1``, ``2.3.4`` est un correctif,
117
+ ``2.9.0`` une mineure, ``3.0.0`` une majeure. Deux règles s'y ajoutent :
118
+
119
+ - **0.x** : une majeure est ce qui sort de ``^current``. En ``0.y.z``, ``y`` joue
120
+ le rôle de la majeure (``0.2.3`` → ``0.3.0`` est majeure, ``0.2.3`` →
121
+ ``0.2.4`` un correctif) ; en ``0.0.z``, tout changement est majeur ;
122
+ - **pré-version** : ``X.Y.Z-rc.1`` est juste sous ``X.Y.Z``. Vers sa version
123
+ finale, l'écart est celui de ``X.Y.Z`` : ``1.0.0-rc.1`` → ``1.0.0`` est majeure,
124
+ ``1.2.0-rc.1`` → ``1.2.0`` mineure, ``1.2.3-rc.1`` → ``1.2.3`` un correctif.
125
+ Une pré-version de ``X.0.0`` vient d'avant ``X.0.0`` : tout ce qui suit est
126
+ majeur (``1.0.0-rc.1`` → ``1.1.1``). C'est la règle de ``semver.diff``.
127
+ """
128
+ a, b = self.parse(current), self.parse(target)
129
+ if not a < b:
130
+ return None
131
+ return self._gap(a, b)
132
+
133
+ def _gap(self, a: V, b: V) -> Gap:
134
+ major, minor, patch = self._release(a)
135
+ target = self._release(b)
136
+ level: Gap
137
+ if self._is_pre(a) and ((minor, patch) == (0, 0) or self._same_release(a, b)):
138
+ if (minor, patch) == (0, 0):
139
+ level = Gap.MAJOR
140
+ else:
141
+ level = Gap.MINOR if patch == 0 else Gap.PATCH
142
+ elif target[0] != major:
143
+ level = Gap.MAJOR
144
+ elif target[1] != minor:
145
+ level = Gap.MINOR
146
+ else:
147
+ level = Gap.PATCH
148
+ if major == 0 and (minor == 0 or level is Gap.MINOR):
149
+ return Gap.MAJOR
150
+ return level
151
+
152
+ def satisfies(self, constraint: str, version: str) -> bool:
153
+ """Vrai si ``version`` respecte ``constraint``."""
154
+ return self.parse_constraint(constraint).contains(self.parse(version))
155
+
156
+ def lower_bound(self, constraint: str) -> str | None:
157
+ """La plus petite version que ``constraint`` accepte (``^1.2`` → ``1.2.0`` pour npm,
158
+ ``1.2`` pour PyPI) ; ``None`` sans borne basse (``*``, ``<2``).
159
+
160
+ C'est la version actuelle approximative d'une dépendance sans lockfile (E07).
161
+ """
162
+ return self.parse_constraint(constraint).lower_bound()
163
+
164
+ def exact(self, constraint: str) -> str | None:
165
+ """La version qu'impose ``constraint`` si elle est exacte (``==1.2.3``) ; ``None``
166
+ sinon. Sans lockfile, c'est une version actuelle qui n'a rien d'approximatif."""
167
+ return self.parse_constraint(constraint).exact()
168
+
169
+ def candidates(self, versions: Iterable[str], current: str | None = None) -> list[str]:
170
+ """Les versions qu'on peut proposer, dans leur ordre : les versions lisibles, sans
171
+ les pré-versions.
172
+
173
+ Si ``current`` est une pré-version, celles de la même version restent :
174
+ de ``2.0.0-rc.1``, on propose ``2.0.0-rc.2``, mais pas ``2.1.0-beta.1``.
175
+ """
176
+ return [text for text, _ in self._candidates(versions, current)]
177
+
178
+ def _candidates(self, versions: Iterable[str], current: str | None) -> list[tuple[str, V]]:
179
+ line = None
180
+ if current is not None:
181
+ parsed = self.parse(current)
182
+ line = parsed if self._is_pre(parsed) else None
183
+ kept = []
184
+ for text in versions:
185
+ try:
186
+ version = self.parse(text)
187
+ except InvalidVersion:
188
+ continue
189
+ if self._is_pre(version) and (line is None or not self._same_release(version, line)):
190
+ continue
191
+ kept.append((text, version))
192
+ return kept
193
+
194
+ def latest(self, versions: Iterable[str], current: str | None = None) -> str | None:
195
+ """La plus haute des candidates (voir ``candidates``) ; ``None`` s'il n'y en a pas."""
196
+ return _highest(self._candidates(versions, current))
197
+
198
+ def max_satisfying(
199
+ self, constraint: str, versions: Iterable[str], current: str | None = None
200
+ ) -> str | None:
201
+ """La plus haute des candidates (voir ``candidates``) qui respecte ``constraint`` :
202
+ celle-là se met à jour sans toucher au manifeste. ``None`` s'il n'y en a pas."""
203
+ parsed = self.parse_constraint(constraint)
204
+ return _highest(
205
+ (text, version)
206
+ for text, version in self._candidates(versions, current)
207
+ if parsed.contains(version)
208
+ )
209
+
210
+ def highest(self, versions: Iterable[str]) -> str | None:
211
+ """La plus haute des versions lisibles, pré-versions comprises ; ``None`` s'il n'y
212
+ en a pas."""
213
+ readable = []
214
+ for text in versions:
215
+ try:
216
+ readable.append((text, self.parse(text)))
217
+ except InvalidVersion:
218
+ continue
219
+ return _highest(readable)
220
+
221
+
222
+ def _highest(pairs: Iterable[tuple[str, V]]) -> str | None:
223
+ """Le texte de la plus haute version ; la première rencontrée en cas d'égalité
224
+ (``1.0`` et ``1.0.0`` pour PyPI)."""
225
+ best: tuple[str, V] | None = None
226
+ for pair in pairs:
227
+ if best is None or best[1] < pair[1]:
228
+ best = pair
229
+ return None if best is None else best[0]
@@ -0,0 +1,432 @@
1
+ """Les versions npm : semver 2.0 et les plages de npm, écrits à la main (comme node-semver 7).
2
+
3
+ Les versions publiées sont du semver strict (``1.2.3``, ``1.0.0-beta.2``, ``v`` toléré
4
+ devant). Les plages suivent les règles de node-semver, en mode « lâche » comme npm :
5
+
6
+ - ``^``, ``~`` (et ``~>``), les jokers ``x``, ``X`` et ``*``, ``a - b``, les
7
+ comparateurs (``>=1.2 <2``), ``||`` ; ``v`` et ``=`` tolérés devant une version, et
8
+ des versions partielles (``1.2``) ;
9
+ - les règles du 0.x : ``^0.2.3`` veut dire ``>=0.2.3 <0.3.0-0``, ``^0.0.3``
10
+ ``>=0.0.3 <0.0.4-0`` ;
11
+ - une pré-version ne respecte une plage que si un comparateur de la plage porte sur
12
+ le même ``major.minor.patch`` avec une pré-version : ``^1.2.3-beta.2`` accepte
13
+ ``1.2.3-beta.4``, mais pas ``1.2.4-beta.1``.
14
+
15
+ Ce qu'un ``package.json`` peut écrire d'autre (E04) :
16
+
17
+ - ``""`` veut dire ``*`` ;
18
+ - une étiquette (``latest``, ``next``) suit le registre : elle accepte toutes les
19
+ versions et n'a pas de borne basse ;
20
+ - ``catalog:`` (pnpm) renvoie à ``pnpm-workspace.yaml`` : les parseurs y lisent la plage
21
+ (``Dependency.resolved_constraint``). Seule, elle lève ``InvalidConstraint``.
22
+
23
+ ``str()`` d'une plage donne sa forme développée, celle de ``semver.validRange``.
24
+ """
25
+
26
+ import functools
27
+ import re
28
+ from dataclasses import dataclass
29
+ from typing import Any, NamedTuple
30
+
31
+ from forceupdate.models import Ecosystem
32
+ from forceupdate.versions.base import Constraint, InvalidConstraint, InvalidVersion, VersionScheme
33
+
34
+ _NUMBER = r"0|[1-9]\d*"
35
+ _PRE_ID = r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)"
36
+ _LOOSE_ID = r"[0-9A-Za-z-]+"
37
+
38
+ _VERSION = re.compile(
39
+ rf"v?(?P<major>{_NUMBER})\.(?P<minor>{_NUMBER})\.(?P<patch>{_NUMBER})"
40
+ rf"(?:-(?P<pre>{_PRE_ID}(?:\.{_PRE_ID})*))?"
41
+ rf"(?:\+(?P<build>{_LOOSE_ID}(?:\.{_LOOSE_ID})*))?",
42
+ re.ASCII,
43
+ )
44
+ """Une version publiée : semver strict."""
45
+
46
+ _X = r"\d+|[xX*]"
47
+ _PARTIAL = re.compile(
48
+ rf"[v=\s]*(?P<major>{_X})(?:\.(?P<minor>{_X})(?:\.(?P<patch>{_X})"
49
+ rf"(?:-?(?P<pre>{_LOOSE_ID}(?:\.{_LOOSE_ID})*))?"
50
+ rf"(?:\+{_LOOSE_ID}(?:\.{_LOOSE_ID})*)?)?)?",
51
+ re.ASCII,
52
+ )
53
+ """Une version dans une plage : lâche (``v1``, ``=1.2``, ``01.2.3``, ``1.2.3beta``)
54
+ ou partielle (``1``, ``1.2.x``)."""
55
+
56
+ _HYPHEN = re.compile(r"(\S+)\s+-\s+(\S+)")
57
+ _LONE_OPERATOR = re.compile(r"[<>]=?|=|~>?|\^")
58
+ _COMPARATOR = re.compile(r"(?P<op>[<>]?=?)(?P<version>.*)")
59
+ _TAG = re.compile(r"[A-Za-z][0-9A-Za-z._-]*")
60
+ """Une étiquette du registre (``latest``, ``next``, ``v4-lts``) : ce qui n'est pas une plage."""
61
+
62
+
63
+ @functools.total_ordering
64
+ @dataclass(frozen=True, eq=False)
65
+ class SemVer:
66
+ """Une version semver 2.0. Les métadonnées de build (``+sha.1``) ne comptent pas dans
67
+ l'ordre."""
68
+
69
+ major: int
70
+ minor: int
71
+ patch: int
72
+ prerelease: tuple[int | str, ...] = ()
73
+ build: tuple[str, ...] = ()
74
+
75
+ def __str__(self) -> str:
76
+ text = f"{self.major}.{self.minor}.{self.patch}"
77
+ if self.prerelease:
78
+ text += "-" + ".".join(str(part) for part in self.prerelease)
79
+ return text
80
+
81
+ @property
82
+ def release(self) -> tuple[int, int, int]:
83
+ return self.major, self.minor, self.patch
84
+
85
+ def _key(self) -> tuple[int, int, int, tuple[Any, ...]]:
86
+ # Sans pré-version, une version passe après toutes ses pré-versions. Entre deux
87
+ # identifiants : les nombres avant le texte, les nombres par valeur, le texte en
88
+ # ASCII ; à égalité, la liste la plus courte d'abord.
89
+ if not self.prerelease:
90
+ return self.major, self.minor, self.patch, (1,)
91
+ parts = tuple((0, p, "") if isinstance(p, int) else (1, 0, p) for p in self.prerelease)
92
+ return self.major, self.minor, self.patch, (0, *parts)
93
+
94
+ def __eq__(self, other: object) -> bool:
95
+ return isinstance(other, SemVer) and self._key() == other._key()
96
+
97
+ def __lt__(self, other: "SemVer") -> bool:
98
+ return self._key() < other._key()
99
+
100
+ def __hash__(self) -> int:
101
+ return hash(self._key())
102
+
103
+
104
+ def _identifiers(text: str | None) -> tuple[int | str, ...]:
105
+ if not text:
106
+ return ()
107
+ return tuple(int(part) if part.isdigit() else part for part in text.split("."))
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class _Comparator:
112
+ """Un comparateur d'une plage développée : ``>=1.2.3``, ``<2.0.0-0``, ``1.2.3``."""
113
+
114
+ operator: str
115
+ """``>=``, ``>``, ``<=``, ``<`` ou ``""`` (égal)."""
116
+ version: SemVer
117
+
118
+ def __str__(self) -> str:
119
+ return f"{self.operator}{self.version}"
120
+
121
+ def test(self, version: SemVer) -> bool:
122
+ if self.operator == ">=":
123
+ return not version < self.version
124
+ if self.operator == ">":
125
+ return self.version < version
126
+ if self.operator == "<=":
127
+ return not self.version < version
128
+ if self.operator == "<":
129
+ return version < self.version
130
+ return version == self.version
131
+
132
+
133
+ _NULL = _Comparator("<", SemVer(0, 0, 0, (0,)))
134
+ """Le comparateur qu'aucune version ne respecte (``>*`` donne ça)."""
135
+ _ZERO = SemVer(0, 0, 0)
136
+
137
+ _Set = tuple[_Comparator, ...]
138
+ """Un ensemble de comparateurs, tous à respecter ; vide, il accepte tout."""
139
+
140
+
141
+ class NpmRange(Constraint[SemVer]):
142
+ """Une plage npm développée : une union d'ensembles de comparateurs, ou une étiquette."""
143
+
144
+ def __init__(self, sets: tuple[_Set, ...], tag: str | None = None) -> None:
145
+ self.sets = sets
146
+ self.tag = tag
147
+ """L'étiquette (``latest``, ``next``) ; le registre dit vers quelle version elle pointe."""
148
+
149
+ def __str__(self) -> str:
150
+ if self.tag is not None:
151
+ return self.tag
152
+ return "||".join(" ".join(str(c) for c in comparators) for comparators in self.sets) or "*"
153
+
154
+ def __repr__(self) -> str:
155
+ return f"NpmRange({str(self)!r})"
156
+
157
+ def contains(self, version: SemVer) -> bool:
158
+ if self.tag is not None:
159
+ return True
160
+ return any(_test_set(comparators, version) for comparators in self.sets)
161
+
162
+ def lower_bound(self) -> str | None:
163
+ """Comme ``semver.minVersion``, sauf qu'une plage dont un ensemble n'a pas de borne
164
+ basse (``*``, ``<2``, ``^0``) rend ``None`` au lieu de ``0.0.0``."""
165
+ if self.tag is not None:
166
+ return None
167
+ lowest: SemVer | None = None
168
+ for comparators in self.sets:
169
+ bounds = [_minimum(c) for c in comparators if c.operator in ("", ">=", ">")]
170
+ if not bounds:
171
+ return None
172
+ bound = max(bounds)
173
+ if lowest is None or bound < lowest:
174
+ lowest = bound
175
+ if lowest is None or not self.contains(lowest):
176
+ return None
177
+ return str(lowest)
178
+
179
+ def exact(self) -> str | None:
180
+ """Une version complète seule (``1.2.3``, ``=1.2.3``, ``v1.2.3``) : elle-même. ``1.2``
181
+ est une plage (``>=1.2.0 <1.3.0-0``)."""
182
+ if self.tag is not None or len(self.sets) != 1 or len(self.sets[0]) != 1:
183
+ return None
184
+ (comparator,) = self.sets[0]
185
+ return str(comparator.version) if comparator.operator == "" else None
186
+
187
+
188
+ def _minimum(comparator: _Comparator) -> SemVer:
189
+ """La plus petite version qui respecte un comparateur ``>=``, ``>`` ou ``=`` :
190
+ ``>1.2.3`` → ``1.2.4``, ``>1.2.3-beta`` → ``1.2.3-beta.0``."""
191
+ version = comparator.version
192
+ if comparator.operator != ">":
193
+ return version
194
+ if version.prerelease:
195
+ return SemVer(version.major, version.minor, version.patch, (*version.prerelease, 0))
196
+ return SemVer(version.major, version.minor, version.patch + 1)
197
+
198
+
199
+ def _test_set(comparators: _Set, version: SemVer) -> bool:
200
+ if not all(comparator.test(version) for comparator in comparators):
201
+ return False
202
+ if not version.prerelease:
203
+ return True
204
+ # Une pré-version n'entre que par un comparateur qui en porte une sur la même version.
205
+ return any(c.version.prerelease and c.version.release == version.release for c in comparators)
206
+
207
+
208
+ class NpmVersions(VersionScheme[SemVer]):
209
+ """semver 2.0, et les plages de npm."""
210
+
211
+ ecosystem = Ecosystem.NPM
212
+
213
+ def parse(self, version: str) -> SemVer:
214
+ return _parse(version)
215
+
216
+ def parse_constraint(self, constraint: str) -> NpmRange:
217
+ return _parse_range(constraint)
218
+
219
+ def _release(self, version: SemVer) -> tuple[int, int, int]:
220
+ return version.release
221
+
222
+ def _is_pre(self, version: SemVer) -> bool:
223
+ return bool(version.prerelease)
224
+
225
+
226
+ NPM = NpmVersions()
227
+
228
+
229
+ @functools.lru_cache(maxsize=4096)
230
+ def _parse(version: str) -> SemVer:
231
+ match = _VERSION.fullmatch(version.strip())
232
+ if match is None:
233
+ raise InvalidVersion(f"version semver illisible : {version!r}")
234
+ build = match.group("build")
235
+ return SemVer(
236
+ int(match.group("major")),
237
+ int(match.group("minor")),
238
+ int(match.group("patch")),
239
+ _identifiers(match.group("pre")),
240
+ tuple(build.split(".")) if build else (),
241
+ )
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Plages
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ class _RangeError(ValueError):
250
+ pass
251
+
252
+
253
+ class _Partial(NamedTuple):
254
+ """Une version de plage : ``None`` pour un nombre absent ou joker (``1.x``)."""
255
+
256
+ major: int | None
257
+ minor: int | None
258
+ patch: int | None
259
+ prerelease: tuple[int | str, ...]
260
+
261
+
262
+ @functools.lru_cache(maxsize=1024)
263
+ def _parse_range(constraint: str) -> NpmRange:
264
+ text = " ".join(constraint.split())
265
+ if text.lower().startswith("catalog:"):
266
+ raise InvalidConstraint(
267
+ f"{constraint!r} renvoie à un catalogue pnpm : la plage est dans pnpm-workspace.yaml"
268
+ )
269
+ try:
270
+ return NpmRange(_simplify(tuple(_parse_set(part.strip()) for part in text.split("||"))))
271
+ except _RangeError:
272
+ if _TAG.fullmatch(text):
273
+ return NpmRange((), tag=text)
274
+ raise InvalidConstraint(f"plage npm illisible : {constraint!r}") from None
275
+
276
+
277
+ def _simplify(sets: tuple[_Set, ...]) -> tuple[_Set, ...]:
278
+ """Comme node-semver : les ensembles vides de sens (``<0.0.0-0``) partent s'il en reste
279
+ d'autres, et un ensemble qui accepte tout absorbe l'union."""
280
+ if len(sets) == 1:
281
+ return sets
282
+ kept = tuple(s for s in sets if s != (_NULL,)) or sets[:1]
283
+ if () in kept:
284
+ return ((),)
285
+ return kept
286
+
287
+
288
+ def _parse_set(text: str) -> _Set:
289
+ """Un membre de l'union : ``1.2.3 - 2.3.4``, ou des comparateurs séparés par des espaces."""
290
+ if not text:
291
+ return ()
292
+ hyphen = _HYPHEN.fullmatch(text)
293
+ if hyphen is not None and (first := _partial_or_none(hyphen[1])) is not None:
294
+ comparators = _hyphen(first, _partial(hyphen[2]))
295
+ else:
296
+ comparators = []
297
+ operator = ""
298
+ for token in text.split(" "):
299
+ if _LONE_OPERATOR.fullmatch(token):
300
+ # « >= 1.2.3 », « ^ 1.2 » : l'opérateur va avec ce qui suit.
301
+ if operator:
302
+ raise _RangeError(token)
303
+ operator = token
304
+ continue
305
+ comparators.extend(_comparators(operator + token))
306
+ operator = ""
307
+ if operator:
308
+ raise _RangeError(operator)
309
+ # >=0.0.0 n'est pas une borne : c'est « toutes les versions ».
310
+ kept = [c for c in comparators if not (c.operator == ">=" and c.version == _ZERO)]
311
+ if _NULL in kept:
312
+ return (_NULL,)
313
+ return tuple(dict.fromkeys(kept))
314
+
315
+
316
+ def _partial(text: str) -> _Partial:
317
+ partial = _partial_or_none(text)
318
+ if partial is None:
319
+ raise _RangeError(text)
320
+ return partial
321
+
322
+
323
+ def _partial_or_none(text: str) -> _Partial | None:
324
+ match = _PARTIAL.fullmatch(text)
325
+ if match is None:
326
+ return None
327
+
328
+ def number(group: str) -> int | None:
329
+ value = match.group(group)
330
+ return None if value is None or value in ("x", "X", "*") else int(value)
331
+
332
+ return _Partial(
333
+ number("major"), number("minor"), number("patch"), _identifiers(match.group("pre"))
334
+ )
335
+
336
+
337
+ def _comparators(token: str) -> list[_Comparator]:
338
+ """Un comparateur tel qu'écrit, développé : ``^1.2.3`` → ``>=1.2.3 <2.0.0-0``."""
339
+ if token.startswith("^"):
340
+ return _caret(_partial(token[1:]))
341
+ if token.startswith("~"):
342
+ return _tilde(_partial(token[2:] if token.startswith("~>") else token[1:]))
343
+ match = _COMPARATOR.fullmatch(token)
344
+ if match is None: # pragma: no cover - l'expression accepte tout
345
+ raise _RangeError(token)
346
+ return _xrange(match.group("op"), _partial(match.group("version")))
347
+
348
+
349
+ def _ge(major: int, minor: int, patch: int, pre: tuple[int | str, ...] = ()) -> _Comparator:
350
+ return _Comparator(">=", SemVer(major, minor, patch, pre))
351
+
352
+
353
+ def _lt(major: int, minor: int, patch: int) -> _Comparator:
354
+ """``<X.Y.Z-0`` : sous ``X.Y.Z`` et toutes ses pré-versions."""
355
+ return _Comparator("<", SemVer(major, minor, patch, (0,)))
356
+
357
+
358
+ def _caret(p: _Partial) -> list[_Comparator]:
359
+ """``^`` : ne change pas le premier nombre non nul (règles du 0.x)."""
360
+ major, minor, patch = p.major, p.minor, p.patch
361
+ if major is None:
362
+ return []
363
+ if minor is None:
364
+ return [_ge(major, 0, 0), _lt(major + 1, 0, 0)]
365
+ if patch is None:
366
+ upper = _lt(0, minor + 1, 0) if major == 0 else _lt(major + 1, 0, 0)
367
+ return [_ge(major, minor, 0), upper]
368
+ lower = _ge(major, minor, patch, p.prerelease)
369
+ if major > 0:
370
+ return [lower, _lt(major + 1, 0, 0)]
371
+ if minor > 0:
372
+ return [lower, _lt(0, minor + 1, 0)]
373
+ return [lower, _lt(0, 0, patch + 1)]
374
+
375
+
376
+ def _tilde(p: _Partial) -> list[_Comparator]:
377
+ """``~`` : les correctifs si la mineure est donnée, sinon les mineures."""
378
+ major, minor, patch = p.major, p.minor, p.patch
379
+ if major is None:
380
+ return []
381
+ if minor is None:
382
+ return [_ge(major, 0, 0), _lt(major + 1, 0, 0)]
383
+ if patch is None:
384
+ return [_ge(major, minor, 0), _lt(major, minor + 1, 0)]
385
+ return [_ge(major, minor, patch, p.prerelease), _lt(major, minor + 1, 0)]
386
+
387
+
388
+ def _xrange(operator: str, p: _Partial) -> list[_Comparator]:
389
+ """Un comparateur, avec ou sans joker : ``1.2.x``, ``>1.2``, ``<=1``, ``*``, ``1.2.3``."""
390
+ major, minor, patch = p.major, p.minor, p.patch
391
+ if operator == "=":
392
+ operator = ""
393
+ if major is None:
394
+ return [_NULL] if operator in (">", "<") else []
395
+ if minor is None or patch is None:
396
+ if not operator:
397
+ if minor is None:
398
+ return [_ge(major, 0, 0), _lt(major + 1, 0, 0)]
399
+ return [_ge(major, minor, 0), _lt(major, minor + 1, 0)]
400
+ # Comparateur sur une version partielle : on le ramène à une version complète.
401
+ bump_major = minor is None
402
+ minor = minor or 0
403
+ if operator == ">": # >1 → >=2.0.0 ; >1.2 → >=1.3.0
404
+ operator = ">="
405
+ major, minor = (major + 1, 0) if bump_major else (major, minor + 1)
406
+ elif operator == "<=": # <=1.2 → <1.3.0-0
407
+ operator = "<"
408
+ major, minor = (major + 1, 0) if bump_major else (major, minor + 1)
409
+ if operator == "<":
410
+ return [_lt(major, minor, 0)]
411
+ return [_Comparator(operator, SemVer(major, minor, 0))]
412
+ return [_Comparator(operator, SemVer(major, minor, patch, p.prerelease))]
413
+
414
+
415
+ def _hyphen(first: _Partial, last: _Partial) -> list[_Comparator]:
416
+ """``1.2 - 2.3.4`` → ``>=1.2.0 <=2.3.4`` ; ``1.2.3 - 2.3`` → ``>=1.2.3 <2.4.0-0``."""
417
+ comparators = []
418
+ if first.major is not None:
419
+ comparators.append(
420
+ _ge(first.major, first.minor or 0, first.patch or 0, first.prerelease)
421
+ if first.minor is not None and first.patch is not None
422
+ else _ge(first.major, first.minor or 0, 0)
423
+ )
424
+ if last.major is not None:
425
+ if last.minor is None:
426
+ comparators.append(_lt(last.major + 1, 0, 0))
427
+ elif last.patch is None:
428
+ comparators.append(_lt(last.major, last.minor + 1, 0))
429
+ else:
430
+ version = SemVer(last.major, last.minor, last.patch, last.prerelease)
431
+ comparators.append(_Comparator("<=", version))
432
+ return comparators