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,231 @@
|
|
|
1
|
+
"""Le lien entre manifestes et lockfiles, commun à tous les écosystèmes.
|
|
2
|
+
|
|
3
|
+
Un manifeste donne les dépendances directes (``Declared``), un lockfile les paquets
|
|
4
|
+
figés (``LockedPackage``). Ce module relie les deux : la version installée de
|
|
5
|
+
chaque directe, et les transitives (``direct=False``), avec leur scope tiré du
|
|
6
|
+
graphe du lockfile quand il en a un. Il dit aussi quels dossiers sont membres d'un
|
|
7
|
+
workspace (``in_workspace``), dont le lockfile est à la racine.
|
|
8
|
+
|
|
9
|
+
Écrit pour Python (E03), sorti de ``parsers/python.py`` à E04 pour servir aussi à
|
|
10
|
+
npm, puis aux écosystèmes de la phase F. La façon de trouver la version installée
|
|
11
|
+
d'une directe reste propre à chaque écosystème : la plus haute selon PEP 440 pour
|
|
12
|
+
Python, la résolution de ``node_modules`` pour npm.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import posixpath
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass, field, replace
|
|
19
|
+
from fnmatch import fnmatchcase
|
|
20
|
+
|
|
21
|
+
from forceupdate.models import Dependency, Ecosystem, Location, Scope
|
|
22
|
+
|
|
23
|
+
Edge = tuple[str, frozenset[str]]
|
|
24
|
+
"""Ce qu'un paquet requiert : l'autre paquet (son ``LockedPackage.node``), et ses extras
|
|
25
|
+
(Python)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Declared:
|
|
30
|
+
"""Une dépendance directe, avec les extras demandés : ils comptent pour suivre le lockfile."""
|
|
31
|
+
|
|
32
|
+
dependency: Dependency
|
|
33
|
+
extras: frozenset[str] = frozenset()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, kw_only=True)
|
|
37
|
+
class LockedPackage:
|
|
38
|
+
"""Un paquet figé dans un lockfile."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
version: str = ""
|
|
42
|
+
dependencies: tuple[Edge, ...] = ()
|
|
43
|
+
"""Ce qu'il requiert, si le lockfile le dit (``uv.lock``, ``package-lock.json``…)."""
|
|
44
|
+
extras: Mapping[str, tuple[Edge, ...]] = field(default_factory=dict)
|
|
45
|
+
"""Ce que requiert chacun de ses extras."""
|
|
46
|
+
scope: Scope | None = None
|
|
47
|
+
"""Ce que le lockfile dit de son scope (``category``, ``dev: true``, section ``develop``)."""
|
|
48
|
+
local: bool = False
|
|
49
|
+
"""Le projet lui-même, un membre du workspace ou un lien local : pas une dépendance."""
|
|
50
|
+
unverifiable_reason: str | None = None
|
|
51
|
+
constraint: str = ""
|
|
52
|
+
"""La contrainte telle qu'écrite, pour un ``.txt`` compilé (``==2.0.1``)."""
|
|
53
|
+
location: Location | None = None
|
|
54
|
+
key: str = ""
|
|
55
|
+
"""Son identifiant dans le graphe, quand un même nom peut y être en plusieurs versions
|
|
56
|
+
(npm : ``node_modules/a/node_modules/x``, descripteur Yarn, clé pnpm). Vide : le nom."""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def node(self) -> str:
|
|
60
|
+
"""Ce qui le désigne dans le graphe : sa clé, sinon son nom."""
|
|
61
|
+
return self.key or self.name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, kw_only=True)
|
|
65
|
+
class Lockfile:
|
|
66
|
+
"""Un lockfile lu."""
|
|
67
|
+
|
|
68
|
+
path: str
|
|
69
|
+
ecosystem: Ecosystem
|
|
70
|
+
packages: tuple[LockedPackage, ...]
|
|
71
|
+
graph: bool = False
|
|
72
|
+
"""Vrai si les paquets disent ce qu'ils requièrent : on peut suivre le graphe."""
|
|
73
|
+
shared: bool = False
|
|
74
|
+
"""Vrai s'il contient les paquets d'autres projets (workspace) : seuls ceux que
|
|
75
|
+
tirent les dépendances du projet sont à lui."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
SCOPE_ORDER = (Scope.RUNTIME, Scope.PEER, Scope.OPTIONAL, Scope.DEV)
|
|
79
|
+
"""Le scope d'une transitive est le premier, dans cet ordre, des directes qui la tirent.
|
|
80
|
+
Les dépendances de build ne sont pas dans les lockfiles."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def with_installed(
|
|
84
|
+
declared: Iterable[Declared], installed: Callable[[Declared], tuple[str, str] | None]
|
|
85
|
+
) -> list[Dependency]:
|
|
86
|
+
"""Les dépendances directes, avec la version installée et le lockfile que leur donne
|
|
87
|
+
``installed`` (``(version, chemin du lockfile)``).
|
|
88
|
+
|
|
89
|
+
Une dépendance de build n'en reçoit jamais : elle n'est pas installée avec le
|
|
90
|
+
projet, et les lockfiles ne la figent pas.
|
|
91
|
+
"""
|
|
92
|
+
result = []
|
|
93
|
+
for item in declared:
|
|
94
|
+
dependency = item.dependency
|
|
95
|
+
if dependency.scope is not Scope.BUILD:
|
|
96
|
+
found = installed(item)
|
|
97
|
+
if found is not None:
|
|
98
|
+
version, lockfile = found
|
|
99
|
+
dependency = replace(dependency, installed=version, lockfile=lockfile)
|
|
100
|
+
result.append(dependency)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
Start = tuple[Scope, Edge]
|
|
105
|
+
"""Un point de départ dans le graphe d'un lockfile : une directe, avec son scope."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def graph_starts(declared: Iterable[Declared]) -> list[Start]:
|
|
109
|
+
"""Les directes comme points de départ du graphe : par nom, avec leurs extras."""
|
|
110
|
+
return [(d.dependency.scope, (d.dependency.name, d.extras)) for d in declared]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def transitives(
|
|
114
|
+
lock: Lockfile, starts: Sequence[Start], is_direct: Callable[[LockedPackage], bool]
|
|
115
|
+
) -> list[Dependency]:
|
|
116
|
+
"""Les paquets du lockfile qui ne sont pas des directes (selon ``is_direct``).
|
|
117
|
+
|
|
118
|
+
Avec un graphe, le scope de chacun est celui des directes (``starts``) qui le
|
|
119
|
+
tirent ; pour un lockfile partagé (workspace), seuls comptent les paquets
|
|
120
|
+
qu'elles tirent. Un même paquet à la même version n'est rendu qu'une fois.
|
|
121
|
+
"""
|
|
122
|
+
scopes = reachable_scopes(starts, lock) if lock.graph else {}
|
|
123
|
+
result = []
|
|
124
|
+
seen: set[tuple[str, str]] = set()
|
|
125
|
+
for package in lock.packages:
|
|
126
|
+
if package.local or is_direct(package):
|
|
127
|
+
continue
|
|
128
|
+
if lock.shared and package.node not in scopes:
|
|
129
|
+
continue
|
|
130
|
+
if (package.name, package.version) in seen:
|
|
131
|
+
continue
|
|
132
|
+
seen.add((package.name, package.version))
|
|
133
|
+
result.append(
|
|
134
|
+
Dependency(
|
|
135
|
+
ecosystem=lock.ecosystem,
|
|
136
|
+
name=package.name,
|
|
137
|
+
constraint=package.constraint,
|
|
138
|
+
installed=package.version or None,
|
|
139
|
+
lockfile=lock.path,
|
|
140
|
+
direct=False,
|
|
141
|
+
scope=scopes.get(package.node) or package.scope or Scope.RUNTIME,
|
|
142
|
+
file=lock.path,
|
|
143
|
+
location=package.location,
|
|
144
|
+
unverifiable_reason=package.unverifiable_reason,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def reachable_scopes(starts: Sequence[Start], lock: Lockfile) -> dict[str, Scope]:
|
|
151
|
+
"""Pour chaque nœud que tirent les directes, son scope (voir ``SCOPE_ORDER``)."""
|
|
152
|
+
by_node: dict[str, list[LockedPackage]] = defaultdict(list)
|
|
153
|
+
for package in lock.packages:
|
|
154
|
+
by_node[package.node].append(package)
|
|
155
|
+
scopes: dict[str, Scope] = {}
|
|
156
|
+
for scope in SCOPE_ORDER:
|
|
157
|
+
for node in reach([edge for s, edge in starts if s is scope], by_node):
|
|
158
|
+
scopes.setdefault(node, scope)
|
|
159
|
+
return scopes
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def reach(starts: Iterable[Edge], by_node: Mapping[str, list[LockedPackage]]) -> set[str]:
|
|
163
|
+
"""Les nœuds tirés, de proche en proche, depuis ``starts``.
|
|
164
|
+
|
|
165
|
+
Un paquet demandé avec des extras tire aussi ce qu'ils requièrent. Le graphe
|
|
166
|
+
peut avoir des cycles : chaque (nœud, extra) n'est suivi qu'une fois. Quand les
|
|
167
|
+
nœuds sont des noms (Python), les versions d'un même nom sont confondues.
|
|
168
|
+
"""
|
|
169
|
+
reached: set[str] = set()
|
|
170
|
+
followed: set[tuple[str, str | None]] = set()
|
|
171
|
+
stack = list(starts)
|
|
172
|
+
while stack:
|
|
173
|
+
node, extras = stack.pop()
|
|
174
|
+
todo = [item for item in (None, *extras) if (node, item) not in followed]
|
|
175
|
+
if not todo:
|
|
176
|
+
continue
|
|
177
|
+
followed.update((node, item) for item in todo)
|
|
178
|
+
reached.add(node)
|
|
179
|
+
for package in by_node.get(node, ()):
|
|
180
|
+
for item in todo:
|
|
181
|
+
stack.extend(package.dependencies if item is None else package.extras.get(item, ()))
|
|
182
|
+
return reached
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Workspaces et chemins
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def in_workspace(path: str, members: Iterable[str], exclude: Iterable[str] = ()) -> bool:
|
|
191
|
+
"""Vrai si le dossier ``path`` (relatif à la racine du workspace) est membre :
|
|
192
|
+
il correspond à un motif de ``members`` et à aucun de ``exclude``."""
|
|
193
|
+
return any(glob_match(path, pattern) for pattern in members) and not any(
|
|
194
|
+
glob_match(path, pattern) for pattern in exclude
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def glob_match(path: str, pattern: str) -> bool:
|
|
199
|
+
"""Vrai si le chemin POSIX ``path`` correspond au motif ``pattern`` (``packages/*``,
|
|
200
|
+
``libs/**``), élément par élément, comme les membres d'un workspace."""
|
|
201
|
+
pattern = pattern.replace("\\", "/")
|
|
202
|
+
parts = [part for part in pattern.split("/") if part not in ("", ".")]
|
|
203
|
+
return _match_parts(path.split("/") if path != "." else [], parts)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _match_parts(parts: Sequence[str], pattern: Sequence[str]) -> bool:
|
|
207
|
+
if not pattern:
|
|
208
|
+
return not parts
|
|
209
|
+
head, rest = pattern[0], pattern[1:]
|
|
210
|
+
if head == "**":
|
|
211
|
+
return any(_match_parts(parts[i:], rest) for i in range(len(parts) + 1))
|
|
212
|
+
return bool(parts) and fnmatchcase(parts[0], head) and _match_parts(parts[1:], rest)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def dirname(path: str) -> str:
|
|
216
|
+
"""Le dossier d'un chemin POSIX relatif ; ``.`` pour un fichier à la racine."""
|
|
217
|
+
return posixpath.dirname(path) or "."
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def join(directory: str, name: str) -> str:
|
|
221
|
+
"""``directory/name``, sans ``./`` devant quand ``directory`` est la racine."""
|
|
222
|
+
return name if directory == "." else f"{directory}/{name}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def relative_to(path: str, root: str) -> str:
|
|
226
|
+
"""Le dossier ``path`` vu depuis ``root``, qui le contient (``.`` si c'est le même)."""
|
|
227
|
+
if root == ".":
|
|
228
|
+
return path
|
|
229
|
+
if path == root:
|
|
230
|
+
return "."
|
|
231
|
+
return path[len(root) + 1 :]
|