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,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}"