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,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 :]