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,153 @@
1
+ """Le client npm : le document complet d'un paquet (``GET /{nom}``).
2
+
3
+ Pourquoi le document complet, et pas l'abrégé que lit ``npm install`` (``Accept:
4
+ application/vnd.npm.install-v1+json``) : l'abrégé garde les ``dist-tags`` et le
5
+ ``deprecated`` de chaque version, mais perd les dates de publication (``time``). Et il
6
+ n'allège pas là où ça compte : compressés, les deux pèsent presque pareil (``@types/node`` :
7
+ 1,4 Mo contre 1,1), et pour ``next``, le plus gros, l'abrégé fait encore 25,7 Mo
8
+ décompressés contre 31,5, à lire aussi lentement. Mesuré le 2026-10-27. Du document, on ne
9
+ garde que ce qui sert : c'est ça qui va dans le cache.
10
+
11
+ Ce qu'on en tire :
12
+
13
+ - les versions (``versions``), datées par ``time``. npm n'a pas de retrait : une version
14
+ dépubliée disparaît, aucune n'est donc « retirée » ;
15
+ - le ``deprecated`` de chaque version (une chaîne vide veut dire « plus dépréciée ») ;
16
+ - les étiquettes (``dist-tags``), pour une dépendance écrite avec une étiquette ;
17
+ - **la dernière stable** : ``dist-tags.latest`` si c'est une version stable. C'est celle que
18
+ choisit ``npm install``, et celle que suivent Renovate et Dependabot. Une version plus
19
+ haute publiée sous une autre étiquette (une 5.0.0 sous ``next``) n'est pas proposée : le
20
+ mainteneur ne l'a pas encore désignée comme la version à installer. Si ``latest`` est une
21
+ pré-version, ou manque : la plus haute stable qui ne la dépasse pas ;
22
+ - **la dépréciation du paquet** : celle de sa version ``latest``, comme sur npmjs.com. Deux
23
+ cas s'y ajoutent : un paquet remplacé par l'équipe sécurité de npm (version
24
+ ``0.0.1-security``, « security holding package »), souvent pour du code malveillant, et un
25
+ paquet dont toutes les versions ont été dépubliées.
26
+
27
+ Un nom scopé s'encode : ``@types/node`` → ``@types%2Fnode``.
28
+ """
29
+
30
+ from collections.abc import Mapping
31
+ from datetime import datetime
32
+ from typing import Any, ClassVar
33
+ from urllib.parse import quote
34
+
35
+ from forceupdate.models import Ecosystem
36
+ from forceupdate.net.http import Response
37
+ from forceupdate.registries.base import (
38
+ PackageInfo,
39
+ PackageVersion,
40
+ Registry,
41
+ parse_time,
42
+ sorted_versions,
43
+ )
44
+ from forceupdate.versions import InvalidVersion
45
+ from forceupdate.versions.npm import NPM
46
+
47
+ _SECURITY_HOLDING = "security holding package"
48
+ """La description des paquets vides que publie l'équipe sécurité de npm à la place d'un paquet
49
+ retiré."""
50
+
51
+
52
+ class Npm(Registry):
53
+ ecosystem = Ecosystem.NPM
54
+ label = "npm"
55
+ base_url = "https://registry.npmjs.org"
56
+ headers: ClassVar[Mapping[str, str]] = {"Accept": "application/json"}
57
+
58
+ def normalize(self, name: str) -> str:
59
+ return name
60
+
61
+ def url_for(self, name: str) -> str:
62
+ return f"{self.base_url}/{quote(name, safe='@')}"
63
+
64
+ def page_url(self, name: str) -> str | None:
65
+ if self.base_url != Npm.base_url:
66
+ return None
67
+ return f"https://www.npmjs.com/package/{name}"
68
+
69
+ def parse(self, name: str, response: Response, fetched_at: datetime) -> PackageInfo:
70
+ data = _mapping(response.json())
71
+ manifests = {
72
+ version: _mapping(manifest)
73
+ for version, manifest in _mapping(data.get("versions")).items()
74
+ }
75
+ times = _mapping(data.get("time"))
76
+ tags = {
77
+ tag: version
78
+ for tag, version in _mapping(data.get("dist-tags")).items()
79
+ if isinstance(version, str)
80
+ }
81
+ found = sorted_versions(
82
+ self.ecosystem,
83
+ (
84
+ PackageVersion(
85
+ version=version,
86
+ published=parse_time(times.get(version)),
87
+ deprecated=_deprecation(manifest),
88
+ )
89
+ for version, manifest in manifests.items()
90
+ ),
91
+ )
92
+ latest = _latest([v.version for v in found], tags.get("latest"))
93
+ return PackageInfo(
94
+ ecosystem=self.ecosystem,
95
+ name=name,
96
+ versions=found,
97
+ latest=latest,
98
+ deprecated=_package_deprecation(manifests, times, tags.get("latest") or latest),
99
+ tags=tags,
100
+ url=self.page_url(name),
101
+ fetched_at=fetched_at,
102
+ )
103
+
104
+
105
+ def _mapping(value: Any) -> Mapping[str, Any]:
106
+ if value is None:
107
+ return {}
108
+ if not isinstance(value, dict):
109
+ raise ValueError(f"objet JSON attendu, pas {type(value).__name__}")
110
+ return value
111
+
112
+
113
+ def _deprecation(manifest: Mapping[str, Any]) -> str | None:
114
+ value = manifest.get("deprecated")
115
+ if isinstance(value, str):
116
+ return value.strip() or None
117
+ return "version dépréciée" if value is True else None
118
+
119
+
120
+ def _latest(published: list[str], tag: str | None) -> str | None:
121
+ """La dernière stable : la plus haute des candidates qui ne dépasse pas ``tag``
122
+ (``dist-tags.latest``), ou de toutes si ``tag`` manque ou est illisible."""
123
+ if tag is not None:
124
+ try:
125
+ ceiling = NPM.parse(tag)
126
+ except InvalidVersion:
127
+ ceiling = None
128
+ if ceiling is not None:
129
+ published = [v for v in published if _at_most(v, ceiling)]
130
+ return NPM.latest(published)
131
+
132
+
133
+ def _at_most(version: str, ceiling: Any) -> bool:
134
+ try:
135
+ return not ceiling < NPM.parse(version)
136
+ except InvalidVersion:
137
+ return False
138
+
139
+
140
+ def _package_deprecation(
141
+ manifests: Mapping[str, Mapping[str, Any]], times: Mapping[str, Any], latest: str | None
142
+ ) -> str | None:
143
+ if not manifests and "unpublished" in times:
144
+ return "toutes ses versions ont été dépubliées du registre npm."
145
+ manifest = manifests.get(latest) if latest is not None else None
146
+ if manifest is None:
147
+ return None
148
+ if manifest.get("description") == _SECURITY_HOLDING:
149
+ return (
150
+ "paquet retiré du registre par l'équipe sécurité de npm, souvent pour du code"
151
+ " malveillant : son nom est gardé par un paquet vide."
152
+ )
153
+ return _deprecation(manifest)
@@ -0,0 +1,242 @@
1
+ """Le client PyPI : l'API « Simple » en JSON (PEP 691), avec ``versions`` et ``upload-time``
2
+ (PEP 700) et le statut du projet (PEP 792).
3
+
4
+ Pourquoi pas l'API JSON de PyPI (``/pypi/{nom}/json``) : sa clé ``releases``, la seule qui
5
+ liste les versions, est dépréciée (la doc de PyPI renvoie vers l'API Simple) ; elle pèse un
6
+ tiers de plus ; et elle ne dit pas si le projet est archivé. L'API Simple est un standard,
7
+ que parlent aussi les miroirs (devpi, Artifactory…). Vérifié le 2026-10-27 : PyPI sert la
8
+ version 1.4 de l'API.
9
+
10
+ Ce qu'on en tire :
11
+
12
+ - les versions : la liste ``versions`` (PEP 700). Un miroir qui ne la donne pas (API 1.0) :
13
+ celles lues dans les noms de fichiers ;
14
+ - une version est **retirée** si tous ses fichiers sont « yanked » (PEP 592), avec la raison
15
+ donnée ; sa date est celle de son premier fichier ;
16
+ - **la dernière stable** : la plus haute des versions non retirées, pré-versions exclues ;
17
+ - **la dépréciation** : le statut du projet. ``archived`` (plus de nouvelles versions),
18
+ ``deprecated``, ou ``quarantined`` (soupçon de code malveillant : PyPI ne propose plus ses
19
+ fichiers, toutes ses versions sont donc retirées).
20
+
21
+ Les fichiers sont rangés par version d'après leur nom : ``Flask-2.0.1-py3-none-any.whl``,
22
+ ``flask-2.0.1.tar.gz``, mais aussi les vieux formats (``PyYAML-3.10.win32-py2.7.exe``,
23
+ ``pytz-2005k.tar.bz2``). Le nom peut écrire la version autrement que la liste (``1.8rc1``
24
+ pour ``1.8c1``, ``69.4`` pour ``69.4.0``) : on compare alors les versions lues. Un fichier
25
+ qu'on ne sait pas ranger est ignoré, et une version sans fichier rangé n'est ni datée ni
26
+ retirée : un nom de fichier mal lu ne doit pas donner un faux « retiré ».
27
+ """
28
+
29
+ from collections.abc import Iterable, Mapping
30
+ from datetime import datetime
31
+ from typing import Any, ClassVar
32
+ from urllib.parse import quote
33
+
34
+ from packaging.utils import (
35
+ InvalidSdistFilename,
36
+ InvalidWheelFilename,
37
+ canonicalize_name,
38
+ parse_sdist_filename,
39
+ parse_wheel_filename,
40
+ )
41
+
42
+ from forceupdate.models import Ecosystem
43
+ from forceupdate.net.http import Response
44
+ from forceupdate.registries.base import (
45
+ PackageInfo,
46
+ PackageVersion,
47
+ Registry,
48
+ parse_time,
49
+ sorted_versions,
50
+ )
51
+ from forceupdate.versions import InvalidVersion
52
+ from forceupdate.versions.pypi import PYPI
53
+
54
+ ACCEPT = "application/vnd.pypi.simple.v1+json"
55
+
56
+ _STATUS = {
57
+ "archived": "projet archivé sur PyPI : il ne recevra plus de nouvelles versions",
58
+ "deprecated": "projet déclaré déprécié sur PyPI",
59
+ "quarantined": (
60
+ "projet mis en quarantaine par PyPI (soupçon de code malveillant) : "
61
+ "ses fichiers ne sont plus proposés"
62
+ ),
63
+ }
64
+ """Les statuts de PEP 792 qui font un paquet déprécié ; ``active`` n'en est pas un."""
65
+
66
+
67
+ class PyPI(Registry):
68
+ ecosystem = Ecosystem.PYPI
69
+ label = "PyPI"
70
+ base_url = "https://pypi.org"
71
+ headers: ClassVar[Mapping[str, str]] = {"Accept": ACCEPT}
72
+
73
+ def normalize(self, name: str) -> str:
74
+ return canonicalize_name(name)
75
+
76
+ def url_for(self, name: str) -> str:
77
+ return f"{self.base_url}/simple/{quote(name)}/"
78
+
79
+ def page_url(self, name: str) -> str | None:
80
+ return f"https://pypi.org/project/{name}/" if self.base_url == PyPI.base_url else None
81
+
82
+ def parse(self, name: str, response: Response, fetched_at: datetime) -> PackageInfo:
83
+ if response.media_type != ACCEPT:
84
+ raise ValueError(f"pas le JSON de l'API Simple ({response.media_type or 'sans type'})")
85
+ data = _mapping(response.json())
86
+ api = str(_mapping(data.get("meta")).get("api-version", "1.0"))
87
+ if api.partition(".")[0] != "1":
88
+ raise ValueError(f"version {api} de l'API Simple inconnue")
89
+ files = [f for f in _list(data.get("files")) if isinstance(f, dict)]
90
+ listed = data.get("versions")
91
+ if isinstance(listed, list):
92
+ listed = list(dict.fromkeys(v for v in listed if isinstance(v, str)))
93
+ else:
94
+ listed = _versions_from_filenames(f.get("filename") for f in files)
95
+ status = _mapping(data.get("project-status"))
96
+ state = status.get("status")
97
+ quarantined = state == "quarantined"
98
+
99
+ groups = _group_files(name, listed, files)
100
+ found = []
101
+ for version in listed:
102
+ group = groups[version]
103
+ yanked = quarantined or (bool(group) and all(_is_yanked(f) for f in group))
104
+ reason = "projet en quarantaine sur PyPI" if quarantined else _yanked_reason(group)
105
+ dates = [d for d in (parse_time(f.get("upload-time")) for f in group) if d is not None]
106
+ found.append(
107
+ PackageVersion(
108
+ version=version,
109
+ published=min(dates, default=None),
110
+ yanked=yanked,
111
+ yanked_reason=reason if yanked else None,
112
+ )
113
+ )
114
+ found = sorted_versions(self.ecosystem, found)
115
+ return PackageInfo(
116
+ ecosystem=self.ecosystem,
117
+ name=name,
118
+ versions=found,
119
+ latest=PYPI.latest(v.version for v in found if not v.yanked),
120
+ deprecated=_deprecation(state, status.get("reason")),
121
+ url=self.page_url(name),
122
+ fetched_at=fetched_at,
123
+ )
124
+
125
+
126
+ def _mapping(value: Any) -> Mapping[str, Any]:
127
+ if value is None:
128
+ return {}
129
+ if not isinstance(value, dict):
130
+ raise ValueError(f"objet JSON attendu, pas {type(value).__name__}")
131
+ return value
132
+
133
+
134
+ def _list(value: Any) -> list[Any]:
135
+ if value is None:
136
+ return []
137
+ if not isinstance(value, list):
138
+ raise ValueError(f"liste JSON attendue, pas {type(value).__name__}")
139
+ return value
140
+
141
+
142
+ def _is_yanked(file: Mapping[str, Any]) -> bool:
143
+ # PEP 691 : faux, vrai, ou la raison du retrait (une chaîne).
144
+ value = file.get("yanked", False)
145
+ return value is True or isinstance(value, str)
146
+
147
+
148
+ def _yanked_reason(files: Iterable[Mapping[str, Any]]) -> str | None:
149
+ for file in files:
150
+ value = file.get("yanked")
151
+ if isinstance(value, str) and value.strip():
152
+ return value.strip()
153
+ return None
154
+
155
+
156
+ def _deprecation(state: object, reason: object) -> str | None:
157
+ message = _STATUS.get(state) if isinstance(state, str) else None
158
+ if message is None:
159
+ return None
160
+ if isinstance(reason, str) and reason.strip():
161
+ message += f" ({reason.strip()})"
162
+ return message + "."
163
+
164
+
165
+ def _versions_from_filenames(filenames: Iterable[object]) -> list[str]:
166
+ """Les versions lues dans les noms des wheels et des sdists, sans doublon : pour un miroir
167
+ qui ne donne pas la liste ``versions`` (API Simple 1.0)."""
168
+ found: dict[Any, str] = {}
169
+ for filename in filenames:
170
+ if not isinstance(filename, str):
171
+ continue
172
+ try:
173
+ if filename.endswith(".whl"):
174
+ version = parse_wheel_filename(filename)[1]
175
+ else:
176
+ version = parse_sdist_filename(filename)[1]
177
+ except (InvalidWheelFilename, InvalidSdistFilename, ValueError):
178
+ continue
179
+ found.setdefault(version, str(version))
180
+ return list(found.values())
181
+
182
+
183
+ def _group_files(
184
+ name: str, listed: list[str], files: list[Mapping[str, Any]]
185
+ ) -> dict[str, list[Mapping[str, Any]]]:
186
+ """Les fichiers de chaque version de ``listed``, d'après leur nom."""
187
+ canonical = canonicalize_name(name)
188
+ known = set(listed)
189
+ by_parsed: dict[Any, str] = {}
190
+ for text in listed:
191
+ try:
192
+ by_parsed.setdefault(PYPI.parse(text), text)
193
+ except InvalidVersion:
194
+ continue
195
+ groups: dict[str, list[Mapping[str, Any]]] = {text: [] for text in listed}
196
+ for file in files:
197
+ filename = file.get("filename")
198
+ if not isinstance(filename, str):
199
+ continue
200
+ version = _file_version(filename, canonical, known, by_parsed)
201
+ if version is not None:
202
+ groups[version].append(file)
203
+ return groups
204
+
205
+
206
+ def _file_version(
207
+ filename: str, canonical: str, known: set[str], by_parsed: Mapping[Any, str]
208
+ ) -> str | None:
209
+ """La version de la liste à laquelle appartient le fichier ; ``None`` si on ne sait pas.
210
+
211
+ Après le nom du projet et un ``-`` vient la version. Une wheel la termine par ``-`` ;
212
+ les autres formats y collent une extension ou une plateforme (``.tar.gz``,
213
+ ``.win32-py2.7.exe``, ``-py2.7.egg``). On essaie donc chaque coupure, de la plus longue
214
+ à la plus courte : d'abord le texte exact de la liste, puis la même version lue.
215
+ """
216
+ rest = _after_name(filename, canonical)
217
+ if rest is None:
218
+ return None
219
+ if filename.endswith(".whl"):
220
+ cuts = [rest.partition("-")[0]]
221
+ else:
222
+ cuts = [rest[:i] for i in range(len(rest) - 1, 0, -1) if rest[i] in ".-"]
223
+ for text in cuts:
224
+ if text in known:
225
+ return text
226
+ for text in cuts:
227
+ try:
228
+ version = PYPI.parse(text)
229
+ except InvalidVersion:
230
+ continue
231
+ if version in by_parsed:
232
+ return by_parsed[version]
233
+ return None
234
+
235
+
236
+ def _after_name(filename: str, canonical: str) -> str | None:
237
+ """Ce qui suit le nom du projet et son ``-`` dans ``filename`` ; ``None`` si le fichier ne
238
+ commence pas par le nom (écrit ``PyYAML``, ``python_dateutil``…)."""
239
+ for index, char in enumerate(filename):
240
+ if char == "-" and canonicalize_name(filename[:index]) == canonical:
241
+ return filename[index + 1 :]
242
+ return None