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.
- forceupdate/__init__.py +9 -0
- forceupdate/__main__.py +5 -0
- forceupdate/advisories/__init__.py +30 -0
- forceupdate/advisories/cvss.py +95 -0
- forceupdate/advisories/osv.py +584 -0
- forceupdate/checks/__init__.py +49 -0
- forceupdate/checks/base.py +98 -0
- forceupdate/checks/deprecated.py +115 -0
- forceupdate/checks/outdated.py +183 -0
- forceupdate/checks/priority.py +82 -0
- forceupdate/checks/vulns.py +201 -0
- forceupdate/cli.py +309 -0
- forceupdate/detect/__init__.py +5 -0
- forceupdate/detect/languages.py +266 -0
- forceupdate/detect/manifests.py +478 -0
- forceupdate/engine.py +313 -0
- forceupdate/models.py +510 -0
- forceupdate/net/__init__.py +20 -0
- forceupdate/net/cache.py +217 -0
- forceupdate/net/http.py +270 -0
- forceupdate/parsers/__init__.py +43 -0
- forceupdate/parsers/base.py +22 -0
- forceupdate/parsers/json_spans.py +123 -0
- forceupdate/parsers/lockfiles.py +231 -0
- forceupdate/parsers/npm.py +1257 -0
- forceupdate/parsers/python.py +1421 -0
- forceupdate/parsers/toml_spans.py +297 -0
- forceupdate/registries/__init__.py +52 -0
- forceupdate/registries/base.py +294 -0
- forceupdate/registries/npm.py +153 -0
- forceupdate/registries/pypi.py +242 -0
- forceupdate/report/__init__.py +359 -0
- forceupdate/report/json.py +34 -0
- forceupdate/report/markdown.py +169 -0
- forceupdate/report/terminal.py +376 -0
- forceupdate/sources/__init__.py +1 -0
- forceupdate/sources/base.py +45 -0
- forceupdate/sources/local.py +183 -0
- forceupdate/update/__init__.py +8 -0
- forceupdate/update/constraints.py +317 -0
- forceupdate/update/planner.py +531 -0
- forceupdate/versions/__init__.py +40 -0
- forceupdate/versions/base.py +229 -0
- forceupdate/versions/npm.py +432 -0
- forceupdate/versions/pypi.py +220 -0
- forceupdate-0.1.0.dist-info/METADATA +79 -0
- forceupdate-0.1.0.dist-info/RECORD +50 -0
- forceupdate-0.1.0.dist-info/WHEEL +4 -0
- forceupdate-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|