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,478 @@
|
|
|
1
|
+
"""Trouver les fichiers de dépendances d'une source, et les regrouper en projets.
|
|
2
|
+
|
|
3
|
+
Un fichier de dépendances est un manifeste (``package.json``, ``pyproject.toml``…),
|
|
4
|
+
où les dépendances sont déclarées, ou un lockfile (``package-lock.json``,
|
|
5
|
+
``uv.lock``…), où leurs versions sont figées.
|
|
6
|
+
|
|
7
|
+
Un dossier qui en contient au moins un est un projet ; un monorepo en donne
|
|
8
|
+
plusieurs. Les lockfiles d'un projet sont dans son dossier, ou à la racine du
|
|
9
|
+
workspace dont il fait partie (npm, Yarn, pnpm, uv, Cargo).
|
|
10
|
+
|
|
11
|
+
Tout se fait sur des chemins POSIX relatifs à la racine de la source, sans lire
|
|
12
|
+
les fichiers : ça marche pareil en local et sur une forge (E17).
|
|
13
|
+
|
|
14
|
+
Repris de ``reference/argos_sca_parsers.py`` (L51-185, L221-381, L1388-1401), puis
|
|
15
|
+
adapté : des motifs au lieu de noms exacts, les lockfiles séparés des manifestes,
|
|
16
|
+
des chemins relatifs au lieu de ``Path``, ``logging`` au lieu de ``loguru``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import fnmatch
|
|
20
|
+
import logging
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from pathlib import Path, PurePath
|
|
24
|
+
from typing import NamedTuple
|
|
25
|
+
|
|
26
|
+
from forceupdate.models import Ecosystem, Project
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
DEFAULT_MAX_FILES = 2000
|
|
31
|
+
"""Plafond de fichiers de dépendances par analyse, contre l'explosion mémoire sur un
|
|
32
|
+
monorepo géant."""
|
|
33
|
+
|
|
34
|
+
DEFAULT_MAX_FILE_SIZE = 2_000_000
|
|
35
|
+
"""Taille maximale d'un fichier lu (2 Mo), sauf les lockfiles."""
|
|
36
|
+
|
|
37
|
+
DEFAULT_MAX_LOCKFILE_SIZE = 50_000_000
|
|
38
|
+
"""Taille maximale d'un lockfile lu (50 Mo) : un ``package-lock.json`` ou un ``uv.lock``
|
|
39
|
+
de gros projet dépasse souvent 2 Mo."""
|
|
40
|
+
|
|
41
|
+
IGNORED_DIRS: frozenset[str] = frozenset(
|
|
42
|
+
{
|
|
43
|
+
# Gestion de versions
|
|
44
|
+
".git",
|
|
45
|
+
".svn",
|
|
46
|
+
".hg",
|
|
47
|
+
# Caches d'outils
|
|
48
|
+
".tox",
|
|
49
|
+
".nox",
|
|
50
|
+
".mypy_cache",
|
|
51
|
+
".pytest_cache",
|
|
52
|
+
".ruff_cache",
|
|
53
|
+
".hypothesis",
|
|
54
|
+
".cache",
|
|
55
|
+
".parcel-cache",
|
|
56
|
+
".turbo",
|
|
57
|
+
".angular",
|
|
58
|
+
".gradle",
|
|
59
|
+
".dart_tool",
|
|
60
|
+
".terraform",
|
|
61
|
+
"__pycache__",
|
|
62
|
+
# Environnements et dépendances installées
|
|
63
|
+
".venv",
|
|
64
|
+
"venv",
|
|
65
|
+
"env",
|
|
66
|
+
".eggs",
|
|
67
|
+
"site-packages",
|
|
68
|
+
"node_modules",
|
|
69
|
+
"bower_components",
|
|
70
|
+
"jspm_packages",
|
|
71
|
+
".pnpm-store",
|
|
72
|
+
".yarn",
|
|
73
|
+
"Pods",
|
|
74
|
+
"vendor",
|
|
75
|
+
"third_party",
|
|
76
|
+
"third-party",
|
|
77
|
+
# Sorties de build
|
|
78
|
+
".next",
|
|
79
|
+
".nuxt",
|
|
80
|
+
".output",
|
|
81
|
+
".svelte-kit",
|
|
82
|
+
"dist",
|
|
83
|
+
"build",
|
|
84
|
+
"target",
|
|
85
|
+
"coverage",
|
|
86
|
+
"htmlcov",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
"""Dossiers jamais parcourus : gestion de versions, caches, environnements virtuels,
|
|
90
|
+
dépendances installées, sorties de build. Leurs manifestes sont ceux des
|
|
91
|
+
dépendances, pas ceux du projet. Les dossiers ``*.egg-info`` aussi (voir
|
|
92
|
+
``is_ignored_dir``)."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_ignored_dir(name: str) -> bool:
|
|
96
|
+
"""Vrai si un dossier de ce nom n'est jamais parcouru."""
|
|
97
|
+
return name in IGNORED_DIRS or name.endswith(".egg-info")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_ignored_path(path: str) -> bool:
|
|
101
|
+
"""Vrai si l'un des dossiers du chemin POSIX ``path`` n'est jamais parcouru."""
|
|
102
|
+
return any(is_ignored_dir(part) for part in path.split("/")[:-1])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_TEST_DIRS: frozenset[str] = frozenset(
|
|
106
|
+
{
|
|
107
|
+
"tests",
|
|
108
|
+
"test",
|
|
109
|
+
"__tests__",
|
|
110
|
+
"spec",
|
|
111
|
+
"specs",
|
|
112
|
+
"fixtures",
|
|
113
|
+
"fixture",
|
|
114
|
+
"examples",
|
|
115
|
+
"example",
|
|
116
|
+
"demo",
|
|
117
|
+
"demos",
|
|
118
|
+
"mocks",
|
|
119
|
+
"mock",
|
|
120
|
+
"stubs",
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
_TEST_FILE_SUFFIXES: tuple[str, ...] = (
|
|
125
|
+
"_test.py",
|
|
126
|
+
"_test.js",
|
|
127
|
+
"_test.ts",
|
|
128
|
+
"_test.tsx",
|
|
129
|
+
"_test.go",
|
|
130
|
+
"_test.rb",
|
|
131
|
+
"_test.java",
|
|
132
|
+
"_test.cs",
|
|
133
|
+
"_test.php",
|
|
134
|
+
".test.js",
|
|
135
|
+
".test.ts",
|
|
136
|
+
".test.jsx",
|
|
137
|
+
".test.tsx",
|
|
138
|
+
".spec.js",
|
|
139
|
+
".spec.ts",
|
|
140
|
+
".spec.jsx",
|
|
141
|
+
".spec.tsx",
|
|
142
|
+
".spec.rb",
|
|
143
|
+
)
|
|
144
|
+
"""Fins de noms de fichiers de tests, comparées en minuscules."""
|
|
145
|
+
|
|
146
|
+
_TEST_CLASS_SUFFIXES: tuple[str, ...] = ("Test.java", "Tests.java", "Test.cs", "Tests.cs")
|
|
147
|
+
"""Fins de noms de classes de tests, comparées avec la casse : ``Contest.java`` n'en est pas."""
|
|
148
|
+
|
|
149
|
+
_TEST_FILE_PREFIXES: tuple[str, ...] = ("test_", "tests_", "conftest")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_test_path(path: str | PurePath) -> bool:
|
|
153
|
+
"""Vrai pour un fichier de tests, ou rangé dans un dossier de tests, de fixtures ou d'exemples.
|
|
154
|
+
|
|
155
|
+
``path`` est relatif à la racine de la source : si on choisit d'analyser
|
|
156
|
+
``tests/`` lui-même, ce qu'il contient n'est plus « dans un dossier de tests ».
|
|
157
|
+
Les séparateurs ``\\`` sont acceptés.
|
|
158
|
+
"""
|
|
159
|
+
parts = str(path).replace("\\", "/").split("/")
|
|
160
|
+
if any(part.lower() in _TEST_DIRS for part in parts[:-1]):
|
|
161
|
+
return True
|
|
162
|
+
name = parts[-1]
|
|
163
|
+
lowered = name.lower()
|
|
164
|
+
return (
|
|
165
|
+
lowered.endswith(_TEST_FILE_SUFFIXES)
|
|
166
|
+
or name.endswith(_TEST_CLASS_SUFFIXES)
|
|
167
|
+
or lowered.startswith(_TEST_FILE_PREFIXES)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def safe_read_text(path: Path, *, max_size: int = DEFAULT_MAX_FILE_SIZE) -> str | None:
|
|
172
|
+
"""Lit un fichier en UTF-8, sans échouer : ``None`` s'il est trop gros ou illisible.
|
|
173
|
+
|
|
174
|
+
Les octets qui ne sont pas de l'UTF-8 valide deviennent ``\\ufffd``.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
if path.stat().st_size > max_size:
|
|
178
|
+
logger.debug("fichier de plus de %d octets, ignoré : %s", max_size, path)
|
|
179
|
+
return None
|
|
180
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
181
|
+
except OSError as err:
|
|
182
|
+
logger.debug("lecture impossible de %s : %s", path, err)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
DEPENDENCY_MANIFESTS: dict[str, Ecosystem] = {
|
|
187
|
+
# Python
|
|
188
|
+
"requirements*.txt": Ecosystem.PYPI,
|
|
189
|
+
"requirements*.in": Ecosystem.PYPI,
|
|
190
|
+
"requirements/*.txt": Ecosystem.PYPI,
|
|
191
|
+
"requirements/*.in": Ecosystem.PYPI,
|
|
192
|
+
"pyproject.toml": Ecosystem.PYPI,
|
|
193
|
+
"Pipfile": Ecosystem.PYPI,
|
|
194
|
+
"setup.py": Ecosystem.PYPI,
|
|
195
|
+
"setup.cfg": Ecosystem.PYPI,
|
|
196
|
+
# JavaScript / TypeScript
|
|
197
|
+
"package.json": Ecosystem.NPM,
|
|
198
|
+
# Ruby
|
|
199
|
+
"Gemfile": Ecosystem.RUBYGEMS,
|
|
200
|
+
# Go
|
|
201
|
+
"go.mod": Ecosystem.GO,
|
|
202
|
+
# Rust
|
|
203
|
+
"Cargo.toml": Ecosystem.CRATES_IO,
|
|
204
|
+
# PHP
|
|
205
|
+
"composer.json": Ecosystem.PACKAGIST,
|
|
206
|
+
# Java : Maven, Gradle
|
|
207
|
+
"pom.xml": Ecosystem.MAVEN,
|
|
208
|
+
"build.gradle": Ecosystem.MAVEN,
|
|
209
|
+
"build.gradle.kts": Ecosystem.MAVEN,
|
|
210
|
+
"gradle/libs.versions.toml": Ecosystem.MAVEN,
|
|
211
|
+
# .NET
|
|
212
|
+
"*.csproj": Ecosystem.NUGET,
|
|
213
|
+
"*.fsproj": Ecosystem.NUGET,
|
|
214
|
+
"*.vbproj": Ecosystem.NUGET,
|
|
215
|
+
"Directory.Packages.props": Ecosystem.NUGET,
|
|
216
|
+
"packages.config": Ecosystem.NUGET,
|
|
217
|
+
}
|
|
218
|
+
"""Motifs des manifestes, et leur écosystème. Comparés sans tenir compte de la casse.
|
|
219
|
+
Un motif avec un dossier (``requirements/*.txt``) porte sur les derniers éléments du
|
|
220
|
+
chemin, et le projet est le dossier au-dessus (``requirements/dev.txt`` est un
|
|
221
|
+
manifeste du projet ``.``)."""
|
|
222
|
+
|
|
223
|
+
LOCKFILES: dict[str, Ecosystem] = {
|
|
224
|
+
"Pipfile.lock": Ecosystem.PYPI,
|
|
225
|
+
"poetry.lock": Ecosystem.PYPI,
|
|
226
|
+
"uv.lock": Ecosystem.PYPI,
|
|
227
|
+
"pdm.lock": Ecosystem.PYPI,
|
|
228
|
+
"package-lock.json": Ecosystem.NPM,
|
|
229
|
+
"npm-shrinkwrap.json": Ecosystem.NPM,
|
|
230
|
+
"yarn.lock": Ecosystem.NPM,
|
|
231
|
+
"pnpm-lock.yaml": Ecosystem.NPM,
|
|
232
|
+
"Gemfile.lock": Ecosystem.RUBYGEMS,
|
|
233
|
+
"go.sum": Ecosystem.GO,
|
|
234
|
+
"Cargo.lock": Ecosystem.CRATES_IO,
|
|
235
|
+
"composer.lock": Ecosystem.PACKAGIST,
|
|
236
|
+
"packages.lock.json": Ecosystem.NUGET,
|
|
237
|
+
}
|
|
238
|
+
"""Noms des lockfiles, et leur écosystème. Comparés sans tenir compte de la casse."""
|
|
239
|
+
|
|
240
|
+
MANIFEST_LOCKFILES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
241
|
+
(
|
|
242
|
+
"package.json",
|
|
243
|
+
("package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"),
|
|
244
|
+
),
|
|
245
|
+
("pyproject.toml", ("uv.lock", "poetry.lock", "pdm.lock")),
|
|
246
|
+
("Pipfile", ("Pipfile.lock",)),
|
|
247
|
+
("Gemfile", ("Gemfile.lock",)),
|
|
248
|
+
("go.mod", ("go.sum",)),
|
|
249
|
+
("Cargo.toml", ("Cargo.lock",)),
|
|
250
|
+
("composer.json", ("composer.lock",)),
|
|
251
|
+
("*.csproj", ("packages.lock.json",)),
|
|
252
|
+
("*.fsproj", ("packages.lock.json",)),
|
|
253
|
+
("*.vbproj", ("packages.lock.json",)),
|
|
254
|
+
)
|
|
255
|
+
"""Motif de manifeste → lockfiles qui peuvent l'accompagner dans son dossier."""
|
|
256
|
+
|
|
257
|
+
WORKSPACE_LOCKFILES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
258
|
+
(
|
|
259
|
+
"package.json",
|
|
260
|
+
("package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"),
|
|
261
|
+
),
|
|
262
|
+
("pyproject.toml", ("uv.lock",)),
|
|
263
|
+
("Cargo.toml", ("Cargo.lock",)),
|
|
264
|
+
)
|
|
265
|
+
"""Manifestes dont le lockfile peut être à la racine d'un workspace, plus haut :
|
|
266
|
+
workspaces npm, Yarn et pnpm, uv, Cargo. Poetry, Pipenv, Bundler ou Composer n'en ont
|
|
267
|
+
pas."""
|
|
268
|
+
|
|
269
|
+
PIP_TOOLS_SOURCES = ("requirements*.in", "requirements/*.in")
|
|
270
|
+
"""Motifs des fichiers sources de pip-tools. ``pip-compile requirements.in`` écrit
|
|
271
|
+
``requirements.txt`` : ce ``.txt`` compilé, posé à côté de son ``.in``, est le
|
|
272
|
+
lockfile du ``.in`` (voir ``compiled_requirements``)."""
|
|
273
|
+
|
|
274
|
+
_PIP_TOOLS_OUTPUTS = ("requirements*.txt", "requirements/*.txt")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class DependencyFile(NamedTuple):
|
|
278
|
+
"""Un fichier de dépendances reconnu dans une source."""
|
|
279
|
+
|
|
280
|
+
path: str
|
|
281
|
+
"""Son chemin POSIX, relatif à la racine de la source."""
|
|
282
|
+
project: str
|
|
283
|
+
"""Le dossier du projet auquel il appartient (``.`` pour la racine)."""
|
|
284
|
+
pattern: str
|
|
285
|
+
"""Le motif reconnu (``requirements*.txt``, ``package.json``…)."""
|
|
286
|
+
ecosystem: Ecosystem
|
|
287
|
+
lockfile: bool
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
_PATTERNS: tuple[tuple[str, Ecosystem, bool], ...] = tuple(
|
|
291
|
+
sorted(
|
|
292
|
+
[(pattern, eco, False) for pattern, eco in DEPENDENCY_MANIFESTS.items()]
|
|
293
|
+
+ [(name, eco, True) for name, eco in LOCKFILES.items()],
|
|
294
|
+
# Les motifs avec un dossier d'abord : « requirements/requirements.txt » appartient
|
|
295
|
+
# au projet du dessus, pas au dossier « requirements ».
|
|
296
|
+
key=lambda entry: -entry[0].count("/"),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def match_dependency_file(path: str) -> DependencyFile | None:
|
|
302
|
+
"""Reconnaît un manifeste ou un lockfile d'après son chemin POSIX ; ``None`` sinon."""
|
|
303
|
+
parts = path.split("/")
|
|
304
|
+
for pattern, ecosystem, lockfile in _PATTERNS:
|
|
305
|
+
depth = pattern.count("/") + 1
|
|
306
|
+
if len(parts) < depth:
|
|
307
|
+
continue
|
|
308
|
+
tail = "/".join(parts[-depth:]).lower()
|
|
309
|
+
if fnmatch.fnmatchcase(tail, pattern.lower()):
|
|
310
|
+
project = "/".join(parts[:-depth]) or "."
|
|
311
|
+
return DependencyFile(path, project, pattern, ecosystem, lockfile)
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def ecosystem_for_manifest(path: str | PurePath) -> Ecosystem | None:
|
|
316
|
+
"""L'écosystème d'un manifeste ou d'un lockfile ; ``None`` si ce n'en est pas un."""
|
|
317
|
+
match = match_dependency_file(PurePath(path).as_posix())
|
|
318
|
+
return match.ecosystem if match else None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def lockfile_candidates(manifest: str) -> list[str]:
|
|
322
|
+
"""Les lockfiles qui peuvent accompagner ``manifest`` dans son dossier (chemins POSIX).
|
|
323
|
+
|
|
324
|
+
Pour un ``.in`` de pip-tools, c'est le ``.txt`` du même nom.
|
|
325
|
+
"""
|
|
326
|
+
match = match_dependency_file(manifest)
|
|
327
|
+
if match is None or match.lockfile:
|
|
328
|
+
return []
|
|
329
|
+
if match.pattern in PIP_TOOLS_SOURCES:
|
|
330
|
+
return [manifest[: -len(".in")] + ".txt"]
|
|
331
|
+
names = dict(MANIFEST_LOCKFILES).get(match.pattern, ())
|
|
332
|
+
return [_join(match.project, name) for name in names]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def compiled_requirements(paths: Iterable[str]) -> set[str]:
|
|
336
|
+
"""Parmi ``paths``, les ``requirements*.txt`` compilés par pip-tools.
|
|
337
|
+
|
|
338
|
+
Un ``.txt`` est compilé quand un ``.in`` du même nom est à côté de lui
|
|
339
|
+
(``requirements-dev.in`` → ``requirements-dev.txt``). C'est alors un lockfile :
|
|
340
|
+
les dépendances directes sont celles du ``.in``, les versions celles du ``.txt``.
|
|
341
|
+
"""
|
|
342
|
+
matches = [match for path in paths if (match := match_dependency_file(path)) is not None]
|
|
343
|
+
sources = {m.path[: -len(".in")].lower() for m in matches if m.pattern in PIP_TOOLS_SOURCES}
|
|
344
|
+
return {
|
|
345
|
+
m.path
|
|
346
|
+
for m in matches
|
|
347
|
+
if m.pattern in _PIP_TOOLS_OUTPUTS and m.path[: -len(".txt")].lower() in sources
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def max_read_size(path: str) -> int:
|
|
352
|
+
"""Taille maximale à lire pour ``path`` : plus grande pour un lockfile."""
|
|
353
|
+
match = match_dependency_file(path)
|
|
354
|
+
if match is not None and match.lockfile:
|
|
355
|
+
return DEFAULT_MAX_LOCKFILE_SIZE
|
|
356
|
+
return DEFAULT_MAX_FILE_SIZE
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def discover_dependency_manifests(
|
|
360
|
+
paths: Iterable[str],
|
|
361
|
+
*,
|
|
362
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
363
|
+
include_test_paths: bool = True,
|
|
364
|
+
) -> list[str]:
|
|
365
|
+
"""Les manifestes et lockfiles parmi ``paths``, des chemins POSIX relatifs, triés.
|
|
366
|
+
|
|
367
|
+
Les dossiers de ``IGNORED_DIRS`` sont écartés ; ceux de tests aussi quand
|
|
368
|
+
``include_test_paths`` est faux (voir ``is_test_path``). Au-delà de
|
|
369
|
+
``max_files`` fichiers trouvés, la liste est coupée.
|
|
370
|
+
"""
|
|
371
|
+
collected: list[str] = []
|
|
372
|
+
for path in paths:
|
|
373
|
+
if is_ignored_path(path) or match_dependency_file(path) is None:
|
|
374
|
+
continue
|
|
375
|
+
if not include_test_paths and is_test_path(path):
|
|
376
|
+
continue
|
|
377
|
+
if len(collected) >= max_files:
|
|
378
|
+
logger.warning("limite de %d fichiers de dépendances atteinte", max_files)
|
|
379
|
+
break
|
|
380
|
+
collected.append(path)
|
|
381
|
+
return sorted(collected)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def group_projects(paths: Iterable[str]) -> list[Project]:
|
|
385
|
+
"""Regroupe des manifestes et lockfiles en projets : un projet par dossier.
|
|
386
|
+
|
|
387
|
+
Un projet sans lockfile dans son dossier prend celui de la racine du workspace :
|
|
388
|
+
le plus proche dossier parent qui a le même manifeste et un lockfile qui va
|
|
389
|
+
avec (voir ``WORKSPACE_LOCKFILES``). Que le projet soit bien membre du
|
|
390
|
+
workspace, seul le contenu des fichiers le dit : ce sont les parseurs qui le
|
|
391
|
+
vérifient (E03, E04).
|
|
392
|
+
|
|
393
|
+
Un ``requirements*.txt`` compilé par pip-tools (un ``.in`` du même nom est à
|
|
394
|
+
côté) compte comme un lockfile, pas comme un manifeste.
|
|
395
|
+
"""
|
|
396
|
+
by_dir: dict[str, list[DependencyFile]] = defaultdict(list)
|
|
397
|
+
for path in paths:
|
|
398
|
+
match = match_dependency_file(path)
|
|
399
|
+
if match is not None:
|
|
400
|
+
by_dir[match.project].append(match)
|
|
401
|
+
|
|
402
|
+
projects = []
|
|
403
|
+
for directory in sorted(by_dir, key=_dir_sort_key):
|
|
404
|
+
files = by_dir[directory]
|
|
405
|
+
compiled = compiled_requirements(f.path for f in files)
|
|
406
|
+
manifests = {f.path for f in files if not f.lockfile} - compiled
|
|
407
|
+
lockfiles = {f.path for f in files if f.lockfile} | compiled
|
|
408
|
+
lockfiles.update(_workspace_lockfiles(directory, files, by_dir))
|
|
409
|
+
ecosystems = {f.ecosystem for f in files}
|
|
410
|
+
projects.append(
|
|
411
|
+
Project(
|
|
412
|
+
path=directory,
|
|
413
|
+
ecosystems=[e for e in Ecosystem if e in ecosystems],
|
|
414
|
+
manifests=sorted(manifests),
|
|
415
|
+
lockfiles=sorted(lockfiles),
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
return projects
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def find_projects(
|
|
422
|
+
paths: Iterable[str],
|
|
423
|
+
*,
|
|
424
|
+
include_tests: bool = False,
|
|
425
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
426
|
+
) -> tuple[list[Project], list[str]]:
|
|
427
|
+
"""Les projets d'une source, et les fichiers de dépendances écartés.
|
|
428
|
+
|
|
429
|
+
Par défaut, un manifeste rangé dans un dossier de tests, de fixtures ou
|
|
430
|
+
d'exemples ne fait pas un projet : ce sont des mini-projets de démonstration,
|
|
431
|
+
souvent figés exprès sur de vieilles versions. Ils sont rendus à part.
|
|
432
|
+
"""
|
|
433
|
+
found = discover_dependency_manifests(paths, max_files=max_files)
|
|
434
|
+
ignored = [] if include_tests else [path for path in found if is_test_path(path)]
|
|
435
|
+
skipped = set(ignored)
|
|
436
|
+
return group_projects(path for path in found if path not in skipped), ignored
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _workspace_lockfiles(
|
|
440
|
+
directory: str,
|
|
441
|
+
files: list[DependencyFile],
|
|
442
|
+
by_dir: dict[str, list[DependencyFile]],
|
|
443
|
+
) -> set[str]:
|
|
444
|
+
"""Les lockfiles de workspace des manifestes de ``directory`` qui n'en ont pas sur place."""
|
|
445
|
+
names_here = {f.path.rsplit("/", 1)[-1].lower() for f in files if f.lockfile}
|
|
446
|
+
found: set[str] = set()
|
|
447
|
+
for pattern, candidates in WORKSPACE_LOCKFILES:
|
|
448
|
+
wanted = {name.lower() for name in candidates}
|
|
449
|
+
if not any(f.pattern == pattern for f in files) or names_here & wanted:
|
|
450
|
+
continue
|
|
451
|
+
for ancestor in _ancestors(directory):
|
|
452
|
+
above = by_dir.get(ancestor, [])
|
|
453
|
+
if not any(f.pattern == pattern for f in above):
|
|
454
|
+
continue
|
|
455
|
+
lockfiles = {
|
|
456
|
+
f.path for f in above if f.lockfile and f.path.rsplit("/", 1)[-1].lower() in wanted
|
|
457
|
+
}
|
|
458
|
+
if lockfiles:
|
|
459
|
+
found |= lockfiles
|
|
460
|
+
break
|
|
461
|
+
return found
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _ancestors(directory: str) -> list[str]:
|
|
465
|
+
"""Les dossiers parents de ``directory``, du plus proche à la racine ``.``."""
|
|
466
|
+
if directory == ".":
|
|
467
|
+
return []
|
|
468
|
+
parts = directory.split("/")
|
|
469
|
+
return ["/".join(parts[:i]) for i in range(len(parts) - 1, 0, -1)] + ["."]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _dir_sort_key(directory: str) -> tuple[str, ...]:
|
|
473
|
+
"""Trie les dossiers comme une arborescence : ``.`` d'abord, puis par chemin."""
|
|
474
|
+
return () if directory == "." else tuple(directory.split("/"))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _join(directory: str, name: str) -> str:
|
|
478
|
+
return name if directory == "." else f"{directory}/{name}"
|