fluidattacks_core_filesystem 12.0.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.
@@ -0,0 +1,18 @@
1
+ from .detection import detect_languages_in_dir
2
+ from .language import Language
3
+ from .partitioning import build_part_tree, decide_part_tree
4
+ from .parts import Part, PartIdentity, PartTree
5
+ from .projects import detect_root_language, find_parent_project, find_projects
6
+
7
+ __all__ = [
8
+ "Language",
9
+ "Part",
10
+ "PartIdentity",
11
+ "PartTree",
12
+ "build_part_tree",
13
+ "decide_part_tree",
14
+ "detect_languages_in_dir",
15
+ "detect_root_language",
16
+ "find_parent_project",
17
+ "find_projects",
18
+ ]
@@ -0,0 +1,517 @@
1
+ from typing import TypedDict
2
+
3
+ from .language import Language
4
+
5
+ __all__ = [
6
+ "CONFIG_MARKERS",
7
+ "EXCLUDE_DIRS",
8
+ "EXCLUDE_FILES",
9
+ "EXTENDED_EXCLUDE_DIRS",
10
+ "MANIFEST_MARKERS",
11
+ "SKIP_DIRS",
12
+ "TEST_DATA_DIRS",
13
+ "TEST_FILES",
14
+ "TEST_GLOBS",
15
+ "VCS_DIRS",
16
+ "Language",
17
+ "MarkerSet",
18
+ ]
19
+
20
+
21
+ class MarkerSet(TypedDict):
22
+ names: set[str]
23
+ globs: list[str]
24
+
25
+
26
+ CONFIG_MARKERS: dict[Language, MarkerSet] = {
27
+ Language.Java: {
28
+ "names": {
29
+ "pom.xml",
30
+ "settings.gradle",
31
+ "settings.gradle.kts",
32
+ "build.gradle",
33
+ "build.gradle.kts",
34
+ "gradle.properties",
35
+ "gradlew",
36
+ "gradlew.bat",
37
+ "mvnw",
38
+ "mvnw.cmd",
39
+ "application.properties",
40
+ "application.yml",
41
+ "application.yaml",
42
+ "log4j.properties",
43
+ "log4j2.xml",
44
+ "logback.xml",
45
+ "web.xml",
46
+ "hibernate.cfg.xml",
47
+ "persistence.xml",
48
+ "MANIFEST.MF",
49
+ "sonar-project.properties",
50
+ "checkstyle.xml",
51
+ "spotbugs.xml",
52
+ "pmd.xml",
53
+ "findbugs.xml",
54
+ "junit-platform.properties",
55
+ "testng.xml",
56
+ "spring.factories",
57
+ "spring.profiles",
58
+ "bootstrap.properties",
59
+ "bootstrap.yml",
60
+ },
61
+ "globs": [
62
+ "*.gradle",
63
+ "*.gradle.kts",
64
+ "application-*.properties",
65
+ "application-*.yml",
66
+ "application-*.yaml",
67
+ "logback-*.xml",
68
+ "log4j2-*.xml",
69
+ ],
70
+ },
71
+ Language.Python: {
72
+ "names": {
73
+ "pyproject.toml",
74
+ "requirements.txt",
75
+ "requirements.in",
76
+ "requirements-dev.txt",
77
+ "requirements-test.txt",
78
+ "constraints.txt",
79
+ "setup.py",
80
+ "setup.cfg",
81
+ "Pipfile",
82
+ "Pipfile.lock",
83
+ "poetry.lock",
84
+ "poetry.toml",
85
+ "hatch.toml",
86
+ "uv.lock",
87
+ "uv.toml",
88
+ "tox.ini",
89
+ "pytest.ini",
90
+ "mypy.ini",
91
+ ".pylintrc",
92
+ "pylintrc",
93
+ ".flake8",
94
+ ".isort.cfg",
95
+ "ruff.toml",
96
+ ".ruff.toml",
97
+ ".coveragerc",
98
+ "coverage.ini",
99
+ "noxfile.py",
100
+ "pyrightconfig.json",
101
+ "MANIFEST.in",
102
+ "py.typed",
103
+ "tox.toml",
104
+ },
105
+ "globs": [
106
+ "requirements*.txt",
107
+ "requirements*.in",
108
+ "constraints*.txt",
109
+ "setup.cfg",
110
+ "*.ini",
111
+ ],
112
+ },
113
+ Language.JavaScript: {
114
+ "names": {
115
+ "package.json",
116
+ "package-lock.json",
117
+ "yarn.lock",
118
+ "pnpm-lock.yaml",
119
+ "bun.lockb",
120
+ "bunfig.toml",
121
+ "nx.json",
122
+ "lerna.json",
123
+ "turbo.json",
124
+ "angular.json",
125
+ "workspace.json",
126
+ "gulpfile.js",
127
+ "gulpfile.ts",
128
+ "gruntfile.js",
129
+ "gruntfile.coffee",
130
+ "karma.conf.js",
131
+ "karma.conf.coffee",
132
+ "protractor.conf.js",
133
+ "cypress.json",
134
+ "cypress.config.js",
135
+ "playwright.config.js",
136
+ "playwright.config.ts",
137
+ "vitest.config.js",
138
+ "vitest.config.ts",
139
+ "storybook",
140
+ ".storybook",
141
+ },
142
+ "globs": [
143
+ "vite.config.*",
144
+ "next.config.*",
145
+ "nuxt.config.*",
146
+ "svelte.config.*",
147
+ "astro.config.*",
148
+ "rollup.config.*",
149
+ "webpack.config.*",
150
+ "babel.config.*",
151
+ ".babelrc*",
152
+ "eslint.config.*",
153
+ ".eslintrc*",
154
+ ".prettierrc*",
155
+ "prettier.config.*",
156
+ "jest.config.*",
157
+ "vitest.config.*",
158
+ "cypress.config.*",
159
+ "playwright.config.*",
160
+ "karma.conf.*",
161
+ "protractor.conf.*",
162
+ "*.config.js",
163
+ "*.config.ts",
164
+ "*.config.mjs",
165
+ "*.config.cjs",
166
+ ],
167
+ },
168
+ Language.TypeScript: {
169
+ "names": {
170
+ "tsconfig.json",
171
+ "tsconfig.base.json",
172
+ "tsconfig.app.json",
173
+ "tsconfig.build.json",
174
+ "tsconfig.node.json",
175
+ "tsconfig.lib.json",
176
+ "tsconfig.spec.json",
177
+ "tsconfig.test.json",
178
+ "deno.json",
179
+ "deno.jsonc",
180
+ "tsconfig.eslint.json",
181
+ },
182
+ "globs": [
183
+ "tsconfig.*.json",
184
+ "tsconfig-*.json",
185
+ ],
186
+ },
187
+ Language.PHP: {
188
+ "names": {
189
+ "composer.json",
190
+ "composer.lock",
191
+ "php.ini",
192
+ "phpunit.xml",
193
+ "phpunit.xml.dist",
194
+ "phpcs.xml",
195
+ "phpcs.xml.dist",
196
+ "phpmd.xml",
197
+ "phpstan.neon",
198
+ "phpstan.neon.dist",
199
+ "psalm.xml",
200
+ "psalm.xml.dist",
201
+ ".php-cs-fixer.php",
202
+ ".php_cs",
203
+ ".php_cs.dist",
204
+ "codeception.yml",
205
+ "symfony.lock",
206
+ "artisan",
207
+ "wp-config.php",
208
+ "config.php",
209
+ },
210
+ "globs": [
211
+ "phpspec.yml*",
212
+ ],
213
+ },
214
+ Language.CSharp: {
215
+ "names": {
216
+ "app.config",
217
+ "web.config",
218
+ "packages.config",
219
+ "nuget.config",
220
+ "NuGet.Config",
221
+ "Directory.Build.props",
222
+ "Directory.Build.targets",
223
+ "Directory.Packages.props",
224
+ "global.json",
225
+ "appsettings.json",
226
+ },
227
+ "globs": [
228
+ "*.sln",
229
+ "*.csproj",
230
+ "*.fsproj",
231
+ "*.vbproj",
232
+ "appsettings.*.json",
233
+ ],
234
+ },
235
+ Language.Go: {
236
+ "names": {
237
+ "go.mod",
238
+ "go.sum",
239
+ "go.work",
240
+ "go.work.sum",
241
+ ".golangci.yml",
242
+ ".golangci.yaml",
243
+ "Gopkg.toml",
244
+ "Gopkg.lock",
245
+ "glide.yaml",
246
+ "glide.lock",
247
+ ".air.toml",
248
+ "air.toml",
249
+ "goreleaser.yml",
250
+ "goreleaser.yaml",
251
+ },
252
+ "globs": [
253
+ "*.mk",
254
+ ],
255
+ },
256
+ Language.Ruby: {
257
+ "names": {
258
+ "Gemfile",
259
+ "Gemfile.lock",
260
+ ".ruby-version",
261
+ ".rubocop.yml",
262
+ ".reek",
263
+ ".rspec",
264
+ ".overcommit.yml",
265
+ "Rakefile",
266
+ "Capfile",
267
+ "Guardfile",
268
+ "config.ru",
269
+ "database.yml",
270
+ "boot.rb",
271
+ },
272
+ "globs": [],
273
+ },
274
+ Language.Kotlin: {
275
+ "names": {
276
+ "build.gradle.kts",
277
+ "settings.gradle.kts",
278
+ },
279
+ "globs": [],
280
+ },
281
+ Language.Swift: {
282
+ "names": {
283
+ "Package.swift",
284
+ "Package.resolved",
285
+ "Config.xcconfig",
286
+ "Info.plist",
287
+ "project.pbxproj",
288
+ ".swiftlint.yml",
289
+ ".swiftformat",
290
+ "Cartfile",
291
+ "Cartfile.resolved",
292
+ "Podfile",
293
+ "Podfile.lock",
294
+ },
295
+ "globs": [],
296
+ },
297
+ Language.Scala: {
298
+ "names": {
299
+ "build.sbt",
300
+ "build.scala",
301
+ "project/build.properties",
302
+ "project/plugins.sbt",
303
+ "project/Dependencies.scala",
304
+ "project/Assembly.scala",
305
+ "mill",
306
+ "mill.bat",
307
+ "millw",
308
+ "millw.bat",
309
+ },
310
+ "globs": [
311
+ "*.sbt",
312
+ "project/*.scala",
313
+ "project/*.sbt",
314
+ ],
315
+ },
316
+ Language.Rust: {
317
+ "names": {
318
+ "Cargo.toml",
319
+ "Cargo.lock",
320
+ "rust-toolchain",
321
+ "rust-toolchain.toml",
322
+ "rustfmt.toml",
323
+ "clippy.toml",
324
+ "Cargo.toml.orig",
325
+ },
326
+ "globs": [],
327
+ },
328
+ Language.Dart: {
329
+ "names": {
330
+ "pubspec.yaml",
331
+ "pubspec.lock",
332
+ "analysis_options.yaml",
333
+ "dart_test.yaml",
334
+ "build.yaml",
335
+ "melos.yaml",
336
+ "melos.yml",
337
+ },
338
+ "globs": [
339
+ "pubspec*.yaml",
340
+ "pubspec*.yml",
341
+ ],
342
+ },
343
+ }
344
+
345
+ VCS_DIRS = [
346
+ ".git",
347
+ ".hg",
348
+ ".svn",
349
+ ]
350
+
351
+
352
+ SKIP_DIRS = {
353
+ *VCS_DIRS,
354
+ ".idea",
355
+ ".vscode",
356
+ "__pycache__",
357
+ ".mypy_cache",
358
+ ".pytest_cache",
359
+ "*cache*",
360
+ "node_modules",
361
+ ".venv",
362
+ "venv",
363
+ "dist",
364
+ "build",
365
+ "target",
366
+ "*test*",
367
+ "out",
368
+ "bin",
369
+ "obj",
370
+ }
371
+
372
+
373
+ TEST_GLOBS = {
374
+ Language.Python: [
375
+ "test*", # Python
376
+ "*test.py", # Python
377
+ ],
378
+ Language.JavaScript: [
379
+ "*.spec.js", # JavaScript
380
+ "*.test.js", # JavaScript
381
+ ],
382
+ Language.TypeScript: [
383
+ "*.spec.ts", # TypeScript
384
+ "*.test.ts", # TypeScript
385
+ ],
386
+ Language.Java: [
387
+ "*Test.java", # Java
388
+ ],
389
+ Language.Kotlin: [
390
+ "*Test.kt", # Kotlin
391
+ ],
392
+ Language.Go: [
393
+ "*_test.go", # Go
394
+ ],
395
+ Language.Ruby: [
396
+ "*_test.rb", # Ruby
397
+ ],
398
+ Language.CSharp: [
399
+ "*.Tests.cs", # C#
400
+ ],
401
+ Language.Swift: [
402
+ "*Test.swift", # Swift
403
+ ],
404
+ }
405
+
406
+
407
+ EXCLUDE_DIRS = [
408
+ "assets",
409
+ "target",
410
+ "build",
411
+ ".gradle",
412
+ ".mvn",
413
+ ".idea",
414
+ "out",
415
+ "tests",
416
+ "__tests__",
417
+ "spec",
418
+ ".venv",
419
+ "__pycache__",
420
+ "dist",
421
+ ".tox",
422
+ ".pytest_cache",
423
+ ".mypy_cache",
424
+ "node_modules",
425
+ ".cache",
426
+ "vendor",
427
+ ".composer",
428
+ "bin",
429
+ "obj",
430
+ ".vs",
431
+ "packages",
432
+ ".vscode",
433
+ "pkg",
434
+ ".bundle",
435
+ "tmp",
436
+ ".build",
437
+ "Carthage",
438
+ "Pods",
439
+ "DerivedData",
440
+ "cache",
441
+ ".ruff_cache",
442
+ ".metals",
443
+ ".bloop",
444
+ "wwwroot",
445
+ "plugin",
446
+ "plugins",
447
+ "libraries",
448
+ ".stack-work",
449
+ "dist-newstyle",
450
+ ".dart_tool",
451
+ "deps",
452
+ "_build",
453
+ "checkouts",
454
+ ]
455
+
456
+
457
+ EXCLUDE_FILES = [
458
+ "*.min.js",
459
+ "*.min.css",
460
+ "*.jar",
461
+ "*.war",
462
+ "*.ear",
463
+ "*.jmod",
464
+ "*.nupkg",
465
+ "*.gem",
466
+ "*.phar*",
467
+ "*.so",
468
+ "*.dll",
469
+ "*.dylib",
470
+ "*.a",
471
+ "*.lib",
472
+ "*.rlib",
473
+ "*.rs.bk",
474
+ "*mock*",
475
+ ]
476
+
477
+
478
+ TEST_FILES = [x for item in TEST_GLOBS.values() for x in item]
479
+
480
+
481
+ MANIFEST_MARKERS: dict[Language, frozenset[str]] = {
482
+ Language.Java: frozenset({"pom.xml", "build.gradle", "settings.gradle"}),
483
+ Language.Python: frozenset({"pyproject.toml", "setup.py", "Pipfile", "requirements.txt"}),
484
+ Language.JavaScript: frozenset({"package.json"}),
485
+ Language.TypeScript: frozenset({"tsconfig.json"}),
486
+ Language.PHP: frozenset({"composer.json"}),
487
+ Language.CSharp: frozenset({"Directory.Build.props", "Directory.Build.targets", "global.json"}),
488
+ Language.Go: frozenset({"go.mod", "Gopkg.toml", "glide.yaml"}),
489
+ Language.Ruby: frozenset({"Gemfile"}),
490
+ Language.Kotlin: frozenset({"build.gradle.kts", "settings.gradle.kts"}),
491
+ Language.Swift: frozenset({"Package.swift", "Podfile", "Cartfile", "project.pbxproj"}),
492
+ Language.Rust: frozenset({"Cargo.toml"}),
493
+ Language.Dart: frozenset({"pubspec.yaml"}),
494
+ Language.Scala: frozenset({"build.sbt"}),
495
+ }
496
+
497
+
498
+ TEST_DATA_DIRS = [
499
+ "test-data",
500
+ "testdata",
501
+ "test_data",
502
+ "fixtures",
503
+ "__fixtures__",
504
+ "fixture",
505
+ "mocks",
506
+ "__mocks__",
507
+ "*mock*",
508
+ "testfiles",
509
+ "test_files",
510
+ "sample_data",
511
+ "samples",
512
+ "examples",
513
+ "test",
514
+ ]
515
+
516
+
517
+ EXTENDED_EXCLUDE_DIRS = [*EXCLUDE_DIRS, *TEST_DATA_DIRS, *VCS_DIRS]
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from fnmatch import fnmatch
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .defaults import CONFIG_MARKERS, MANIFEST_MARKERS, MarkerSet
7
+ from .language import Language
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+
12
+ __all__ = ["detect_anchor_language", "detect_languages_in_dir", "is_config_marker"]
13
+
14
+ _FOLDED_MARKER_NAMES = frozenset(
15
+ name.casefold() for markers in CONFIG_MARKERS.values() for name in markers["names"]
16
+ )
17
+
18
+ _MARKER_GLOBS = tuple(pat for markers in CONFIG_MARKERS.values() for pat in markers["globs"])
19
+
20
+ _FOLDED_MANIFESTS = {
21
+ language: frozenset(name.casefold() for name in names)
22
+ for language, names in MANIFEST_MARKERS.items()
23
+ }
24
+
25
+ _LANGUAGE_PRIORITIES = {
26
+ Language.TypeScript: 10,
27
+ Language.JavaScript: 5,
28
+ Language.Kotlin: 8,
29
+ Language.Java: 6,
30
+ Language.Rust: 9,
31
+ Language.C: 4,
32
+ Language.Scala: 7,
33
+ Language.Dart: 8,
34
+ Language.Python: 7,
35
+ Language.Go: 8,
36
+ Language.PHP: 6,
37
+ Language.Ruby: 6,
38
+ Language.CSharp: 7,
39
+ Language.Swift: 8,
40
+ }
41
+
42
+ _LANGUAGE_CONFLICTS = {
43
+ Language.TypeScript: {Language.JavaScript},
44
+ Language.JavaScript: {Language.TypeScript, Language.Dart},
45
+ Language.Kotlin: {Language.Java, Language.Scala},
46
+ Language.Java: {Language.Kotlin, Language.Scala},
47
+ Language.Scala: {Language.Java, Language.Kotlin},
48
+ Language.Rust: {Language.C},
49
+ Language.C: {Language.Rust},
50
+ Language.Dart: {Language.JavaScript},
51
+ }
52
+
53
+
54
+ def _get_language_conflicts(lang: Language) -> set[Language]:
55
+ return _LANGUAGE_CONFLICTS.get(lang, set())
56
+
57
+
58
+ def _calculate_language_score(names: set[str], markers: MarkerSet) -> int:
59
+ exacts = {n.casefold() for n in markers["names"]}
60
+ patterns = markers["globs"]
61
+ score = len([n for n in exacts if n in names]) * 10
62
+ glob_matches = sum(1 for f in names if any(fnmatch(f, pat) for pat in patterns))
63
+ return score + glob_matches * 5
64
+
65
+
66
+ def _resolve_language_conflicts(
67
+ hits: list[Language],
68
+ language_scores: dict[Language, int],
69
+ ) -> list[Language]:
70
+ if len(hits) <= 1:
71
+ return hits
72
+ ordered = sorted(
73
+ hits,
74
+ key=lambda lang: (_LANGUAGE_PRIORITIES.get(lang, 0), language_scores.get(lang, 0)),
75
+ reverse=True,
76
+ )
77
+ resolved_hits: list[Language] = []
78
+ for lang in ordered:
79
+ conflicts = _get_language_conflicts(lang)
80
+ if not any(conflict in resolved_hits for conflict in conflicts):
81
+ resolved_hits.append(lang)
82
+ return resolved_hits
83
+
84
+
85
+ def detect_languages_in_dir(files: Iterable[str]) -> list[Language]:
86
+ names = {f.casefold() for f in files}
87
+ hits: list[Language] = []
88
+ language_scores: dict[Language, int] = {}
89
+
90
+ for lang, markers in CONFIG_MARKERS.items():
91
+ score = _calculate_language_score(names, markers)
92
+ if score > 0:
93
+ language_scores[lang] = score
94
+ hits.append(lang)
95
+
96
+ return _resolve_language_conflicts(hits, language_scores)
97
+
98
+
99
+ def is_config_marker(name: str) -> bool:
100
+ return name.casefold() in _FOLDED_MARKER_NAMES or any(
101
+ fnmatch(name, pat) for pat in _MARKER_GLOBS
102
+ )
103
+
104
+
105
+ def detect_anchor_language(files: Iterable[str]) -> Language | None:
106
+ names = tuple(files)
107
+ folded = {name.casefold() for name in names}
108
+ anchors = {language for language, manifests in _FOLDED_MANIFESTS.items() if manifests & folded}
109
+ if not anchors:
110
+ return None
111
+ ranked = tuple(lang for lang in detect_languages_in_dir(names) if lang in anchors) or tuple(
112
+ sorted(anchors, key=lambda language: language.value)
113
+ )
114
+ return ranked[0] if len(ranked) == 1 else Language.Unknown
@@ -0,0 +1,27 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Language(Enum):
5
+ Swift = "swift"
6
+ CSharp = "csharp"
7
+ Go = "golang"
8
+ Rust = "rust"
9
+ Dart = "dart"
10
+ FuzzyTestLang = "fuzzy_test_lang"
11
+ Java = "Java"
12
+ Python = "Python"
13
+ PHP = "PHP"
14
+ Ruby = "Ruby"
15
+ C = "C"
16
+ Kotlin = "Kotlin"
17
+ Ghidra = "Ghidra"
18
+ JavaScript = "JavaScript"
19
+ TypeScript = "TypeScript"
20
+ LLVM = "LLVM"
21
+ NewC = "newc"
22
+ Scala = "Scala"
23
+ Unknown = "unknown"
24
+
25
+ def __str__(self) -> str:
26
+ """Render as the bare value, as sifts and explores consumers expect."""
27
+ return str(self.value)
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ from collections import Counter
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from fnmatch import fnmatch
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .defaults import EXTENDED_EXCLUDE_DIRS
13
+ from .detection import detect_anchor_language, detect_languages_in_dir, is_config_marker
14
+ from .parts import Part, PartIdentity, PartTree
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Collection, Mapping
18
+
19
+ __all__ = ["DirListing", "build_part_tree", "decide_part_tree"]
20
+
21
+ DirListing = Sequence[tuple[Path, tuple[str, ...]]]
22
+
23
+ PotentialPart = tuple[Path, frozenset[Path], str | None]
24
+
25
+ _ROOT_PATH_MARKER = Path()
26
+
27
+
28
+ def _generate_part_name(path: Path, root_path: Path) -> str:
29
+ if path == root_path:
30
+ return "."
31
+ try:
32
+ relative = path.relative_to(root_path)
33
+ except ValueError:
34
+ return path.name
35
+ return "/".join(relative.parts)
36
+
37
+
38
+ def _relativize(path: Path, root_path: Path) -> Path:
39
+ try:
40
+ return path.relative_to(root_path)
41
+ except ValueError:
42
+ return path
43
+
44
+
45
+ def _find_config_files(dir_path: Path, files: Sequence[str]) -> frozenset[Path]:
46
+ return frozenset(dir_path / name for name in files if is_config_marker(name))
47
+
48
+
49
+ def _owning_part(dir_path: Path, part_paths: frozenset[Path], root_path: Path) -> Path | None:
50
+ current = dir_path
51
+ while True:
52
+ if current in part_paths:
53
+ return current
54
+ if current in (root_path, current.parent):
55
+ return None
56
+ current = current.parent
57
+
58
+
59
+ def _root_filenames(listing: DirListing, root_path: Path) -> tuple[str, ...]:
60
+ for dir_path, filenames in listing:
61
+ if dir_path == root_path:
62
+ return filenames
63
+ return ()
64
+
65
+
66
+ def _discover_potential_parts(listing: DirListing) -> tuple[PotentialPart, ...]:
67
+ return tuple(
68
+ (dir_path, _find_config_files(dir_path, filenames), language.value)
69
+ for dir_path, filenames in listing
70
+ if (language := detect_anchor_language(filenames)) is not None
71
+ )
72
+
73
+
74
+ def _build_hierarchy(
75
+ potential_parts: Sequence[PotentialPart],
76
+ root_path: Path,
77
+ ) -> Mapping[Path, tuple[Path, ...]]:
78
+ sorted_parts = sorted(potential_parts, key=lambda part: len(part[0].parts))
79
+ children: dict[Path, list[Path]] = {part[0]: [] for part in sorted_parts}
80
+
81
+ for part_path, _, _ in sorted_parts:
82
+ if part_path == root_path:
83
+ continue
84
+ current = part_path.parent
85
+ while True:
86
+ if current in children:
87
+ children[current].append(part_path)
88
+ break
89
+ if current == current.parent:
90
+ break
91
+ current = current.parent
92
+
93
+ return {path: tuple(child_paths) for path, child_paths in children.items()}
94
+
95
+
96
+ def _assign_files_to_parts(
97
+ part_paths: frozenset[Path],
98
+ listing: DirListing,
99
+ root_path: Path,
100
+ ) -> Mapping[Path, frozenset[Path]]:
101
+ owned: dict[Path, set[Path]] = {part_path: set() for part_path in part_paths}
102
+
103
+ for dir_path, filenames in listing:
104
+ owner = _owning_part(dir_path, part_paths, root_path)
105
+ if owner is None:
106
+ continue
107
+ owned[owner].update(_relativize(dir_path / name, root_path) for name in filenames)
108
+
109
+ return {part_path: frozenset(files) for part_path, files in owned.items()}
110
+
111
+
112
+ def _collect_files_for_all(listing: DirListing, root_path: Path) -> frozenset[Path]:
113
+ return frozenset(
114
+ _relativize(dir_path / name, root_path)
115
+ for dir_path, filenames in listing
116
+ for name in filenames
117
+ )
118
+
119
+
120
+ def _find_all_config_files(listing: DirListing) -> frozenset[Path]:
121
+ return frozenset(
122
+ path for dir_path, filenames in listing for path in _find_config_files(dir_path, filenames)
123
+ )
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class _BuildContext:
128
+ parent_map: Mapping[Path, tuple[Path, ...]]
129
+ parts_info: Mapping[Path, tuple[frozenset[Path], str | None]]
130
+ root_path: Path
131
+ files_by_part: Mapping[Path, frozenset[Path]]
132
+
133
+
134
+ def _build_flat_parts(
135
+ ctx: _BuildContext,
136
+ ) -> tuple[Mapping[str, Part], Mapping[str, tuple[str, ...]]]:
137
+ parts: dict[str, Part] = {}
138
+ children: dict[str, tuple[str, ...]] = {}
139
+
140
+ for part_path, child_paths in ctx.parent_map.items():
141
+ config_files, language = ctx.parts_info.get(part_path, (frozenset(), None))
142
+ name = _generate_part_name(part_path, ctx.root_path)
143
+ parts[name] = Part(
144
+ identity=PartIdentity(name=name, path=_relativize(part_path, ctx.root_path)),
145
+ files=ctx.files_by_part.get(part_path, frozenset()),
146
+ config_files=frozenset(_relativize(path, ctx.root_path) for path in config_files),
147
+ detected_language=language,
148
+ )
149
+ children[name] = tuple(
150
+ _generate_part_name(child, ctx.root_path) for child in sorted(child_paths)
151
+ )
152
+
153
+ return parts, children
154
+
155
+
156
+ def _validate_tree(tree: PartTree) -> None:
157
+ all_parts = tree.get_all_parts()
158
+
159
+ names = Counter(part.name for part in all_parts)
160
+ repeated = sorted(name for name, count in names.items() if count > 1)
161
+ if repeated:
162
+ msg = f"Duplicate part names found: {repeated}"
163
+ raise ValueError(msg)
164
+
165
+ files = Counter(path for part in all_parts for path in part.files)
166
+ shared = sorted(str(path) for path, count in files.items() if count > 1)
167
+ if shared:
168
+ msg = f"Files assigned to multiple parts: {shared[:5]}"
169
+ raise ValueError(msg)
170
+
171
+
172
+ def _build_single_root_tree(root_path: Path, listing: DirListing) -> PartTree:
173
+ config_files = _find_all_config_files(listing)
174
+ languages = detect_languages_in_dir(path.name for path in config_files)
175
+ root_part = Part(
176
+ identity=PartIdentity(name=".", path=_ROOT_PATH_MARKER),
177
+ files=_collect_files_for_all(listing, root_path),
178
+ config_files=frozenset(_relativize(path, root_path) for path in config_files),
179
+ detected_language=languages[0].value if languages else None,
180
+ )
181
+ return PartTree(
182
+ root_path=root_path,
183
+ parts={".": root_part},
184
+ children_map={".": ()},
185
+ root_name=".",
186
+ )
187
+
188
+
189
+ def decide_part_tree(root_path: Path, listing: DirListing) -> PartTree:
190
+ potential_parts = _discover_potential_parts(listing)
191
+ if not potential_parts:
192
+ return _build_single_root_tree(root_path, listing)
193
+
194
+ if all(path != root_path for path, _, _ in potential_parts):
195
+ root_files = _root_filenames(listing, root_path)
196
+ anchor = detect_anchor_language(root_files)
197
+ potential_parts = (
198
+ (
199
+ root_path,
200
+ _find_config_files(root_path, root_files),
201
+ anchor.value if anchor else None,
202
+ ),
203
+ *potential_parts,
204
+ )
205
+
206
+ parent_map = _build_hierarchy(potential_parts, root_path)
207
+ context = _BuildContext(
208
+ parent_map=parent_map,
209
+ parts_info={path: (configs, lang) for path, configs, lang in potential_parts},
210
+ root_path=root_path,
211
+ files_by_part=_assign_files_to_parts(frozenset(parent_map), listing, root_path),
212
+ )
213
+ parts, children_map = _build_flat_parts(context)
214
+
215
+ tree = PartTree(
216
+ root_path=root_path,
217
+ parts=parts,
218
+ children_map=children_map,
219
+ root_name=".",
220
+ )
221
+ _validate_tree(tree)
222
+ return tree
223
+
224
+
225
+ def _is_member_file(path: Path) -> bool:
226
+ try:
227
+ return stat.S_ISREG(path.lstat().st_mode)
228
+ except OSError:
229
+ return False
230
+
231
+
232
+ def _walk_listing(root_path: Path, exclude_dirs: Collection[str]) -> DirListing:
233
+ listing: list[tuple[Path, tuple[str, ...]]] = []
234
+ for current, dirs, filenames in os.walk(root_path):
235
+ dirs[:] = sorted(d for d in dirs if not any(fnmatch(d, pat) for pat in exclude_dirs))
236
+ directory = Path(current)
237
+ kept = (name for name in filenames if _is_member_file(directory / name))
238
+ listing.append((directory, tuple(sorted(kept))))
239
+ return tuple(listing)
240
+
241
+
242
+ def build_part_tree(root_path: Path, extra_excludes: Sequence[str] | None = None) -> PartTree:
243
+ resolved = Path(root_path).resolve()
244
+ if not resolved.is_dir():
245
+ msg = f"Root path must be a directory: {root_path}"
246
+ raise ValueError(msg)
247
+
248
+ exclusions = (*EXTENDED_EXCLUDE_DIRS, *(extra_excludes or ()))
249
+ listing = _walk_listing(resolved, exclusions)
250
+ if not listing:
251
+ msg = f"Root path could not be read: {root_path}"
252
+ raise OSError(msg)
253
+
254
+ return decide_part_tree(resolved, listing)
@@ -0,0 +1,178 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+
5
+ __all__ = ["Part", "PartIdentity", "PartTree"]
6
+
7
+ _SAMPLE_FILE_LIMIT = 10
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PartIdentity:
12
+ name: str
13
+ path: Path
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Part:
18
+ identity: PartIdentity
19
+ files: frozenset[Path] = field(default_factory=frozenset)
20
+ config_files: frozenset[Path] = field(default_factory=frozenset)
21
+ detected_language: str | None = None
22
+
23
+ @property
24
+ def name(self) -> str:
25
+ return self.identity.name
26
+
27
+ @property
28
+ def path(self) -> Path:
29
+ return self.identity.path
30
+
31
+ def file_count(self) -> int:
32
+ return len(self.files)
33
+
34
+ def all_files(self) -> frozenset[Path]:
35
+ return self.files
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class PartTree:
40
+ root_path: Path
41
+ parts: Mapping[str, Part] = field(default_factory=dict)
42
+ children_map: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
43
+ root_name: str = "."
44
+
45
+ @property
46
+ def root(self) -> Part | None:
47
+ return self.parts.get(self.root_name)
48
+
49
+ def __len__(self) -> int:
50
+ """Count every part in the tree, not just the root's children."""
51
+ return len(self.parts)
52
+
53
+ def get_part(self, name: str) -> Part | None:
54
+ return self.parts.get(name)
55
+
56
+ def get_all_parts(self) -> tuple[Part, ...]:
57
+ return self._collect_parts_dfs(self.root_name)
58
+
59
+ def get_part_by_path(self, path: Path) -> Part | None:
60
+ for part in self.parts.values():
61
+ if part.path == path:
62
+ return part
63
+ return None
64
+
65
+ def get_children(self, part_name: str) -> tuple[Part, ...]:
66
+ child_names = self.children_map.get(part_name, ())
67
+ return tuple(self.parts[name] for name in child_names if name in self.parts)
68
+
69
+ def get_parent(self, part_name: str) -> Part | None:
70
+ for parent_name, child_names in self.children_map.items():
71
+ if part_name in child_names:
72
+ return self.parts.get(parent_name)
73
+ return None
74
+
75
+ def recursive_file_count(self, part_name: str) -> int:
76
+ part = self.parts.get(part_name)
77
+ if part is None:
78
+ return 0
79
+ count = len(part.files)
80
+ for child_name in self.children_map.get(part_name, ()):
81
+ count += self.recursive_file_count(child_name)
82
+ return count
83
+
84
+ def recursive_all_files(self, part_name: str) -> frozenset[Path]:
85
+ part = self.parts.get(part_name)
86
+ if part is None:
87
+ return frozenset()
88
+ all_files: set[Path] = set(part.files)
89
+ for child_name in self.children_map.get(part_name, ()):
90
+ all_files.update(self.recursive_all_files(child_name))
91
+ return frozenset(all_files)
92
+
93
+ def get_part_context(self, part_name: str) -> str:
94
+ part = self.parts.get(part_name)
95
+ if part is None:
96
+ return ""
97
+ lines = [
98
+ f"# Part: {part.name}",
99
+ f"**Path**: `{part.path}`",
100
+ "",
101
+ ]
102
+ if part.detected_language:
103
+ lines.extend([f"**Language**: {part.detected_language}", ""])
104
+
105
+ if part.config_files:
106
+ lines.append("**Config files**:")
107
+ lines.extend(f"- `{config_file}`" for config_file in sorted(part.config_files))
108
+ lines.append("")
109
+
110
+ children = self.get_children(part_name)
111
+ total_file_count = self.recursive_file_count(part_name)
112
+
113
+ lines.extend(
114
+ [
115
+ f"**Direct files**: {len(part.files)}",
116
+ f"**Total files** (including children): {total_file_count}",
117
+ "",
118
+ ]
119
+ )
120
+
121
+ if children:
122
+ lines.append("**Child parts**:")
123
+ lines.extend(
124
+ f"- `{child.name}` ({self.recursive_file_count(child.name)} files)"
125
+ for child in children
126
+ )
127
+ lines.append("")
128
+
129
+ sample_files = sorted(part.files)[:_SAMPLE_FILE_LIMIT]
130
+ if sample_files:
131
+ lines.append("**Sample files**:")
132
+ lines.extend(f"- `{f}`" for f in sample_files)
133
+ if len(part.files) > _SAMPLE_FILE_LIMIT:
134
+ lines.append(f"- ... and {len(part.files) - _SAMPLE_FILE_LIMIT} more")
135
+
136
+ return "\n".join(lines)
137
+
138
+ def to_tree_string(self) -> str:
139
+ rendered = self._format_part_recursive(self.root_name, prefix="", is_last=True)
140
+ return "\n".join((f"{self.root_path}", *rendered))
141
+
142
+ def _collect_parts_dfs(self, part_name: str) -> tuple[Part, ...]:
143
+ part = self.parts.get(part_name)
144
+ if part is None:
145
+ return ()
146
+ return (
147
+ part,
148
+ *(
149
+ descendant
150
+ for child_name in self.children_map.get(part_name, ())
151
+ for descendant in self._collect_parts_dfs(child_name)
152
+ ),
153
+ )
154
+
155
+ def _format_part_recursive(
156
+ self, part_name: str, *, prefix: str, is_last: bool
157
+ ) -> tuple[str, ...]:
158
+ part = self.parts.get(part_name)
159
+ if part is None:
160
+ return ()
161
+ connector = "└── " if is_last else "├── "
162
+ lang_info = f" [{part.detected_language}]" if part.detected_language else ""
163
+ file_info = f" ({self.recursive_file_count(part_name)} files)"
164
+ child_prefix = prefix + (" " if is_last else "│ ")
165
+ child_names = self.children_map.get(part_name, ())
166
+
167
+ return (
168
+ f"{prefix}{connector}{part.name}{lang_info}{file_info}",
169
+ *(
170
+ line
171
+ for index, child_name in enumerate(child_names)
172
+ for line in self._format_part_recursive(
173
+ child_name,
174
+ prefix=child_prefix,
175
+ is_last=index == len(child_names) - 1,
176
+ )
177
+ ),
178
+ )
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from fnmatch import fnmatch
5
+ from pathlib import Path
6
+
7
+ from .defaults import (
8
+ EXCLUDE_DIRS,
9
+ EXCLUDE_FILES,
10
+ SKIP_DIRS,
11
+ TEST_FILES,
12
+ )
13
+ from .detection import detect_anchor_language, detect_languages_in_dir
14
+ from .language import Language
15
+
16
+ __all__ = ["detect_root_language", "find_parent_project", "find_projects"]
17
+
18
+
19
+ def _file_names(directory: Path) -> list[str]:
20
+ try:
21
+ entries = list(os.scandir(directory))
22
+ except OSError:
23
+ return []
24
+ return [e.name for e in entries if e.is_file(follow_symlinks=False)]
25
+
26
+
27
+ def _optimize_exclusions(exclusions: list[str]) -> list[str]:
28
+ if not exclusions:
29
+ return []
30
+
31
+ paths = sorted([Path(p) for p in set(exclusions)])
32
+
33
+ optimized: list[Path] = []
34
+ for p in paths:
35
+ if not any(p.is_relative_to(opt_p) for opt_p in optimized):
36
+ optimized.append(p)
37
+
38
+ return sorted([p.as_posix() for p in optimized])
39
+
40
+
41
+ def _collect_files(
42
+ entries: list[os.DirEntry[str]],
43
+ root_dir: Path,
44
+ excluded: set[str],
45
+ ) -> list[str]:
46
+ files: list[str] = []
47
+ for e in entries:
48
+ if not e.is_file(follow_symlinks=False):
49
+ continue
50
+ name = e.name
51
+ if any(fnmatch(name, pat) for pat in TEST_FILES) or any(
52
+ fnmatch(name, pat) for pat in EXCLUDE_FILES
53
+ ):
54
+ excluded.add(Path(e.path).relative_to(root_dir).as_posix())
55
+ continue
56
+ files.append(name)
57
+ return files
58
+
59
+
60
+ def _scan_dir(
61
+ dir_path: Path,
62
+ root_dir: Path,
63
+ excluded_by_skip: set[str],
64
+ ) -> list[tuple[Path, Language, list[str]]]:
65
+ try:
66
+ entries = list(os.scandir(dir_path))
67
+ except OSError:
68
+ return []
69
+
70
+ files = _collect_files(entries, root_dir, excluded_by_skip)
71
+ language = detect_anchor_language(files)
72
+ subprojects: list[tuple[Path, Language, list[str]]] = []
73
+
74
+ for sd in sorted(
75
+ (e for e in entries if e.is_dir(follow_symlinks=False)),
76
+ key=lambda e: e.name,
77
+ ):
78
+ rel_sd = Path(sd.path).relative_to(root_dir).as_posix()
79
+ skip_dir = (
80
+ sd.name.startswith(".tox")
81
+ or any(fnmatch(sd.name, pat) for pat in SKIP_DIRS)
82
+ or any(fnmatch(rel_sd, pat) for pat in EXCLUDE_DIRS)
83
+ )
84
+ if skip_dir:
85
+ excluded_by_skip.add(rel_sd)
86
+ continue
87
+ subprojects.extend(_scan_dir(Path(sd.path), root_dir, excluded_by_skip))
88
+
89
+ if language is not None:
90
+ exclusions = _optimize_exclusions(
91
+ [sp[0].relative_to(dir_path).as_posix() for sp in subprojects]
92
+ )
93
+ return [(dir_path, language, exclusions), *subprojects]
94
+
95
+ return subprojects
96
+
97
+
98
+ def _enrich(
99
+ projects: list[tuple[Path, Language, list[str]]],
100
+ root_dir: Path,
101
+ excluded_by_skip: set[str],
102
+ ) -> list[tuple[Path, str, list[str]]]:
103
+ enriched: list[tuple[Path, str, list[str]]] = []
104
+ for p_path, p_lang, p_excls in projects:
105
+ extra = [
106
+ (root_dir / rel).relative_to(p_path).as_posix()
107
+ for rel in excluded_by_skip
108
+ if (root_dir / rel).is_relative_to(p_path)
109
+ ]
110
+ final_excls = _optimize_exclusions([*p_excls, *extra]) if extra else p_excls
111
+ enriched.append((p_path, p_lang.value, final_excls))
112
+ return enriched
113
+
114
+
115
+ def find_projects(root_dir: str | Path) -> list[tuple[Path, str, list[str]]]:
116
+ root_dir = Path(root_dir).resolve()
117
+
118
+ excluded_by_skip: set[str] = set()
119
+ scanned = _scan_dir(root_dir, root_dir, excluded_by_skip)
120
+ contained = sorted((p for p in scanned if p[0].is_relative_to(root_dir)), key=lambda p: p[0])
121
+ enriched_projects = _enrich(contained, root_dir, excluded_by_skip)
122
+
123
+ root_exclusions = [p[0].relative_to(root_dir).as_posix() for p in enriched_projects]
124
+ root_exclusions.extend(excluded_by_skip)
125
+
126
+ return [
127
+ (root_dir, "root", _optimize_exclusions(root_exclusions)),
128
+ *enriched_projects,
129
+ ]
130
+
131
+
132
+ def detect_root_language(root_dir: str | Path) -> Language:
133
+ langs = detect_languages_in_dir(_file_names(Path(root_dir)))
134
+ return langs[0] if len(langs) == 1 else Language.Unknown
135
+
136
+
137
+ def find_parent_project(
138
+ file_path: str | Path,
139
+ base_dir: Path,
140
+ ) -> tuple[Path, str] | None:
141
+ base = Path(base_dir).resolve()
142
+ start = Path(file_path).resolve()
143
+ current = start if start.is_dir() else start.parent
144
+
145
+ if not current.is_relative_to(base):
146
+ return None
147
+
148
+ while True:
149
+ language = detect_anchor_language(_file_names(current))
150
+ if language is not None:
151
+ return (current, language.value)
152
+ if current in (base, current.parent):
153
+ return None
154
+ current = current.parent
File without changes
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: fluidattacks_core_filesystem
3
+ Version: 12.0.0
4
+ Summary: Fluid Attacks Core Filesystem Library
5
+ Author-email: Development <development@fluidattacks.com>
6
+ License: MPL-2.0
7
+ Classifier: Development Status :: 1 - Planning
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Requires-Python: >=3.11
@@ -0,0 +1,11 @@
1
+ fluidattacks_core/filesystem/__init__.py,sha256=g7DDWi0WEYVmNFPu2ZSqNy2--gQzELpvwPdZH2_hG14,498
2
+ fluidattacks_core/filesystem/defaults.py,sha256=vu9XB4La9bjVRYaRyckmUh731DojJml1udvRu5oa0nU,11740
3
+ fluidattacks_core/filesystem/detection.py,sha256=ave7Y67w2FYTLzCEM5UcFzT2sdaqQ1vc3jc4A84zcoY,3607
4
+ fluidattacks_core/filesystem/language.py,sha256=B_ESvmw56rjQw4C-e3Xk4j-iT_eBI-89FyAtZV7ZOeQ,592
5
+ fluidattacks_core/filesystem/partitioning.py,sha256=Q0rHNO1miDj1u3yYnbFZ5-FHxCso5XTxCmdiAlQsWBk,8468
6
+ fluidattacks_core/filesystem/parts.py,sha256=meyUBR9HdGLr3rGihRcF7J8KcBslC2cuvTi1-3kV7Lk,5838
7
+ fluidattacks_core/filesystem/projects.py,sha256=VTbSZQzE7889_j6_JFomB-nk4ecLWWCOoMlq89NTx6Q,4671
8
+ fluidattacks_core/filesystem/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ fluidattacks_core_filesystem-12.0.0.dist-info/METADATA,sha256=Q6FkoLFxZgt9cHFYXTSGL8OBSk3MCXGl1FqEluALdTU,539
10
+ fluidattacks_core_filesystem-12.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ fluidattacks_core_filesystem-12.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any