docsentinel 1.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.
- docsentinel/__init__.py +20 -0
- docsentinel/baseline.py +56 -0
- docsentinel/cli.py +237 -0
- docsentinel/code_discovery.py +84 -0
- docsentinel/config.py +604 -0
- docsentinel/deep_support.py +59 -0
- docsentinel/detectors/__init__.py +41 -0
- docsentinel/detectors/_triples.py +169 -0
- docsentinel/detectors/anchors.py +196 -0
- docsentinel/detectors/assets.py +419 -0
- docsentinel/detectors/comments.py +90 -0
- docsentinel/detectors/contradictions.py +134 -0
- docsentinel/detectors/deprecated_refs.py +916 -0
- docsentinel/detectors/document_ids.py +773 -0
- docsentinel/detectors/fence_languages.py +214 -0
- docsentinel/detectors/fences.py +172 -0
- docsentinel/detectors/headings.py +237 -0
- docsentinel/detectors/links.py +202 -0
- docsentinel/detectors/loader.py +29 -0
- docsentinel/detectors/naming.py +547 -0
- docsentinel/detectors/navigation.py +652 -0
- docsentinel/detectors/path_boundaries.py +499 -0
- docsentinel/detectors/paths.py +180 -0
- docsentinel/detectors/references.py +381 -0
- docsentinel/detectors/sections.py +533 -0
- docsentinel/detectors/standard/__init__.py +10 -0
- docsentinel/detectors/standard/complexity.py +117 -0
- docsentinel/detectors/standard/corpus.py +334 -0
- docsentinel/detectors/standard/duplication.py +95 -0
- docsentinel/detectors/standard/low_information.py +142 -0
- docsentinel/detectors/standard/signatures.py +175 -0
- docsentinel/detectors/tables.py +424 -0
- docsentinel/discovery.py +43 -0
- docsentinel/engine.py +229 -0
- docsentinel/errors.py +16 -0
- docsentinel/markdown_blocks.py +1442 -0
- docsentinel/models.py +36 -0
- docsentinel/py.typed +0 -0
- docsentinel/pytest_plugin.py +14 -0
- docsentinel/reporting.py +188 -0
- docsentinel/rules.py +97 -0
- docsentinel-1.0.0.dist-info/METADATA +200 -0
- docsentinel-1.0.0.dist-info/RECORD +46 -0
- docsentinel-1.0.0.dist-info/WHEEL +4 -0
- docsentinel-1.0.0.dist-info/entry_points.txt +2 -0
- docsentinel-1.0.0.dist-info/licenses/LICENSE +21 -0
docsentinel/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Public interface for the M0 documentation inventory."""
|
|
2
|
+
|
|
3
|
+
from docsentinel.baseline import filter_baselined, fingerprint, load_baseline, save_baseline
|
|
4
|
+
from docsentinel.config import Config, ConfigError, load_config
|
|
5
|
+
from docsentinel.engine import scan
|
|
6
|
+
from docsentinel.models import Document, Finding, ScanResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Config",
|
|
10
|
+
"ConfigError",
|
|
11
|
+
"Document",
|
|
12
|
+
"Finding",
|
|
13
|
+
"ScanResult",
|
|
14
|
+
"filter_baselined",
|
|
15
|
+
"fingerprint",
|
|
16
|
+
"load_baseline",
|
|
17
|
+
"load_config",
|
|
18
|
+
"save_baseline",
|
|
19
|
+
"scan",
|
|
20
|
+
]
|
docsentinel/baseline.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Finding fingerprints and baseline suppression mechanism."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from docsentinel.config import ConfigError
|
|
8
|
+
from docsentinel.models import Finding
|
|
9
|
+
|
|
10
|
+
__all__ = ["filter_baselined", "fingerprint", "load_baseline", "save_baseline"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fingerprint(finding: Finding) -> str:
|
|
14
|
+
"""Compute a stable fingerprint string for a finding."""
|
|
15
|
+
message_hash = hashlib.sha256(finding.message.encode("utf-8")).hexdigest()[:12]
|
|
16
|
+
return f"{finding.rule}:{finding.path}:{message_hash}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_baseline(path: Path) -> frozenset[str]:
|
|
20
|
+
"""Load suppressed finding fingerprints from a JSON baseline file.
|
|
21
|
+
|
|
22
|
+
Returns an empty frozenset if the file does not exist.
|
|
23
|
+
Raises ConfigError if the file cannot be read, contains invalid JSON,
|
|
24
|
+
or does not adhere strictly to the `{"fingerprints": [...]}` schema
|
|
25
|
+
with string list items.
|
|
26
|
+
"""
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return frozenset()
|
|
29
|
+
try:
|
|
30
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
31
|
+
data = json.load(stream)
|
|
32
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
33
|
+
raise ConfigError(f"Cannot read {path}: {exc}") from exc
|
|
34
|
+
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
raise ConfigError(f"Invalid baseline shape in {path}: expected a JSON object")
|
|
37
|
+
if set(data.keys()) != {"fingerprints"}:
|
|
38
|
+
raise ConfigError(f"Invalid baseline in {path}: expected exactly 'fingerprints' key")
|
|
39
|
+
fingerprints = data["fingerprints"]
|
|
40
|
+
if not isinstance(fingerprints, list) or not all(isinstance(item, str) for item in fingerprints):
|
|
41
|
+
raise ConfigError(f"Invalid baseline in {path}: 'fingerprints' must be a list of strings")
|
|
42
|
+
return frozenset(fingerprints)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_baseline(path: Path, findings: tuple[Finding, ...]) :
|
|
46
|
+
"""Save finding fingerprints to a JSON baseline file in deterministic sorted order."""
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
payload = {"fingerprints": sorted({fingerprint(f) for f in findings})}
|
|
49
|
+
content = json.dumps(payload, indent=2) + "\n"
|
|
50
|
+
with path.open("w", encoding="utf-8") as stream:
|
|
51
|
+
stream.write(content)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def filter_baselined(findings: tuple[Finding, ...], known: frozenset[str]) -> tuple[Finding, ...]:
|
|
55
|
+
"""Filter out findings whose fingerprints are present in the known baseline set."""
|
|
56
|
+
return tuple(finding for finding in findings if fingerprint(finding) not in known)
|
docsentinel/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Command-line access to the shared scan engine."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from docsentinel import deep_support
|
|
10
|
+
from docsentinel.baseline import save_baseline
|
|
11
|
+
from docsentinel.config import ConfigError, load_config
|
|
12
|
+
from docsentinel.detectors.loader import ensure_standard_loaded
|
|
13
|
+
from docsentinel.engine import scan
|
|
14
|
+
from docsentinel.errors import ExtraNotInstalledError
|
|
15
|
+
from docsentinel.models import ScanResult
|
|
16
|
+
from docsentinel.reporting import neutralize_text, render
|
|
17
|
+
from docsentinel.rules import Rule, rules_for_profile
|
|
18
|
+
|
|
19
|
+
TEMPLATE = (
|
|
20
|
+
'profile = "fast"\n'
|
|
21
|
+
'include = ["*.md", "**/*.md"]\n'
|
|
22
|
+
'exclude = [".git", ".venv", "node_modules"]\n'
|
|
23
|
+
"select = []\n"
|
|
24
|
+
"ignore = []\n"
|
|
25
|
+
"\n"
|
|
26
|
+
'# Standard profile thresholds (profile = "standard"); defaults shown.\n'
|
|
27
|
+
"# max_grade_level = 14.0 # DS201: flag prose above this readability grade\n"
|
|
28
|
+
"# duplicate_similarity = 0.85 # DS202: flag paragraph pairs at/above this cosine similarity\n"
|
|
29
|
+
"# min_information_score = 0.15 # DS203: flag paragraphs scoring below this information score\n"
|
|
30
|
+
'# docsig_paths = ["src"] # DS204: Python source roots checked against docstrings\n'
|
|
31
|
+
"#\n"
|
|
32
|
+
"# Statistical size limits (standard profile); defaults shown.\n"
|
|
33
|
+
"# min_document_words = 50 # DS201: skip documents with fewer prose words\n"
|
|
34
|
+
"# min_paragraph_words = 5 # DS202/DS203: skip paragraphs with fewer words\n"
|
|
35
|
+
"# min_paragraph_chars = 30 # DS202/DS203: skip paragraphs shorter than this\n"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _opt_in_suffix(rule: Rule) -> str:
|
|
40
|
+
"""Return the ``rules`` listing suffix marking an opt-in policy rule, else empty.
|
|
41
|
+
|
|
42
|
+
:param rule: A registered rule.
|
|
43
|
+
:returns: ``" [opt-in: set <option>]"`` for an opt-in policy rule, or ``""``
|
|
44
|
+
for an ordinary always-active rule.
|
|
45
|
+
"""
|
|
46
|
+
return f" [opt-in: set {rule.opt_in_option}]" if rule.opt_in_option else ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _standard_rule_lines() -> list[str]:
|
|
50
|
+
"""Return formatted rule lines for the standard profile, or install guidance if missing."""
|
|
51
|
+
try:
|
|
52
|
+
ensure_standard_loaded()
|
|
53
|
+
except ExtraNotInstalledError:
|
|
54
|
+
missing_msg = ["standard profile: install docsentinel[standard] to enable (DS2xx)"]
|
|
55
|
+
return missing_msg
|
|
56
|
+
registered = rules_for_profile("standard")
|
|
57
|
+
if registered:
|
|
58
|
+
return [f" {rule.code}: {rule.title}{_opt_in_suffix(rule)}" for rule in registered]
|
|
59
|
+
return [" (none registered)"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _deep_doctor_lines() -> list[str]:
|
|
63
|
+
"""Return formatted lines for doctor describing deep profile readiness."""
|
|
64
|
+
libs = deep_support.libraries_available()
|
|
65
|
+
model = deep_support.model_available()
|
|
66
|
+
ready = deep_support.deep_ready()
|
|
67
|
+
|
|
68
|
+
lines = []
|
|
69
|
+
if ready:
|
|
70
|
+
lines.append("deep profile: ready")
|
|
71
|
+
lines.append(" ✓ deep libraries installed (spacy, textacy)")
|
|
72
|
+
lines.append(f" ✓ deep model installed ({deep_support.MODEL_NAME})")
|
|
73
|
+
else:
|
|
74
|
+
lines.append("deep profile: not ready")
|
|
75
|
+
if libs:
|
|
76
|
+
lines.append(" ✓ deep libraries installed (spacy, textacy)")
|
|
77
|
+
else:
|
|
78
|
+
lines.append(f" ✗ deep libraries missing: {deep_support.DEEP_EXTRA_HINT}")
|
|
79
|
+
if model:
|
|
80
|
+
lines.append(f" ✓ deep model installed ({deep_support.MODEL_NAME})")
|
|
81
|
+
else:
|
|
82
|
+
lines.append(
|
|
83
|
+
f" ✗ deep model missing ({deep_support.MODEL_NAME}): python -m spacy download {deep_support.MODEL_NAME}"
|
|
84
|
+
)
|
|
85
|
+
return lines
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _exit_code(result: ScanResult, *, strict: bool = False) -> int:
|
|
89
|
+
"""Return the exit code for a scan result.
|
|
90
|
+
|
|
91
|
+
Deep profile findings are heuristic candidates and advisory-only: they never fail
|
|
92
|
+
the exit code (returning 0), even when strict is True. This profile-keyed exemption
|
|
93
|
+
ensures candidate contradictions inform review without breaking builds.
|
|
94
|
+
A future --fail-on-deep flag / deep_fail setting is deferred.
|
|
95
|
+
|
|
96
|
+
For fast and standard profiles, returns 1 if strict mode is enabled and there are any
|
|
97
|
+
findings, or if any finding has severity == "error". Otherwise, returns 0.
|
|
98
|
+
"""
|
|
99
|
+
if result.profile == "deep":
|
|
100
|
+
return 0
|
|
101
|
+
if strict:
|
|
102
|
+
return 1 if result.findings else 0
|
|
103
|
+
return 1 if any(finding.severity == "error" for finding in result.findings) else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
107
|
+
"""Run the DocSentinel command line interface.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
argv : Optional[list[str]]
|
|
112
|
+
Command-line arguments; defaults to ``sys.argv[1:]`` when omitted.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
int
|
|
117
|
+
Process exit code: 0 on success, 1 for blocking findings, 2 on failure.
|
|
118
|
+
"""
|
|
119
|
+
parser = argparse.ArgumentParser(prog="docsentinel")
|
|
120
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
121
|
+
scan_parser = commands.add_parser("scan", help="Inventory Markdown documents")
|
|
122
|
+
scan_parser.add_argument("root", nargs="?", default=".", help="Directory or file to check")
|
|
123
|
+
scan_parser.add_argument("--config", type=Path)
|
|
124
|
+
scan_parser.add_argument("--profile", choices=("fast", "standard", "deep"))
|
|
125
|
+
scan_parser.add_argument("--select", type=str, default=None)
|
|
126
|
+
scan_parser.add_argument("--ignore", type=str, default=None)
|
|
127
|
+
scan_parser.add_argument("--baseline", type=Path, default=None)
|
|
128
|
+
scan_parser.add_argument("--format", choices=("text", "json", "github"), default="text")
|
|
129
|
+
scan_parser.add_argument("--strict", action="store_true")
|
|
130
|
+
scan_parser.add_argument(
|
|
131
|
+
"--i-understand-deep-is-heuristic",
|
|
132
|
+
action="store_true",
|
|
133
|
+
dest="deep_confirm",
|
|
134
|
+
help="Explicitly confirm execution of heuristic deep profile detectors",
|
|
135
|
+
)
|
|
136
|
+
baseline_parser = commands.add_parser("baseline", help="Record current findings into a baseline file")
|
|
137
|
+
baseline_parser.add_argument("root", nargs="?", default=".", help="Directory or file to check")
|
|
138
|
+
baseline_parser.add_argument("--config", type=Path)
|
|
139
|
+
baseline_parser.add_argument("--output", type=Path)
|
|
140
|
+
baseline_parser.add_argument("--profile", choices=("fast", "standard", "deep"))
|
|
141
|
+
init_parser = commands.add_parser("init", help="Create a starter config")
|
|
142
|
+
init_parser.add_argument("root", nargs="?", default=".")
|
|
143
|
+
commands.add_parser("rules", help="List active detection rules")
|
|
144
|
+
commands.add_parser("doctor", help="Show M0 capabilities")
|
|
145
|
+
args = parser.parse_args(argv)
|
|
146
|
+
if args.command == "init":
|
|
147
|
+
target = Path(args.root) / "doc_sentinel.toml"
|
|
148
|
+
try:
|
|
149
|
+
with target.open("x", encoding="utf-8") as stream:
|
|
150
|
+
stream.write(TEMPLATE)
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
print(f"Cannot create {target}: {exc}", file=sys.stderr)
|
|
153
|
+
return 2
|
|
154
|
+
print(f"Created {target}")
|
|
155
|
+
return 0
|
|
156
|
+
if args.command == "rules":
|
|
157
|
+
for profile in ("fast", "standard", "deep"):
|
|
158
|
+
print(f"{profile}:")
|
|
159
|
+
if profile == "standard":
|
|
160
|
+
for line in _standard_rule_lines():
|
|
161
|
+
print(line)
|
|
162
|
+
else:
|
|
163
|
+
registered = rules_for_profile(profile)
|
|
164
|
+
if registered:
|
|
165
|
+
for rule in registered:
|
|
166
|
+
print(f" {rule.code}: {rule.title}{_opt_in_suffix(rule)}")
|
|
167
|
+
else:
|
|
168
|
+
print(" (none registered)")
|
|
169
|
+
return 0
|
|
170
|
+
if args.command == "doctor":
|
|
171
|
+
print("Inventory: available")
|
|
172
|
+
standard_lines = _standard_rule_lines()
|
|
173
|
+
if standard_lines == ["standard profile: install docsentinel[standard] to enable (DS2xx)"]:
|
|
174
|
+
print("standard profile: not installed (pip install docsentinel[standard])")
|
|
175
|
+
else:
|
|
176
|
+
print("standard profile: available")
|
|
177
|
+
for line in _deep_doctor_lines():
|
|
178
|
+
print(line)
|
|
179
|
+
for profile in ("fast", "standard", "deep"):
|
|
180
|
+
registered = rules_for_profile(profile)
|
|
181
|
+
opt_in = tuple(rule for rule in registered if rule.opt_in_option)
|
|
182
|
+
summary = f"{profile} rules: {len(registered)} registered"
|
|
183
|
+
if opt_in:
|
|
184
|
+
codes = ", ".join(rule.code for rule in opt_in)
|
|
185
|
+
summary += f" ({len(opt_in)} opt-in, off unless configured: {codes})"
|
|
186
|
+
print(summary)
|
|
187
|
+
return 0
|
|
188
|
+
if args.command == "baseline":
|
|
189
|
+
try:
|
|
190
|
+
target = Path(args.root).resolve()
|
|
191
|
+
directory = target.parent if target.is_file() else target
|
|
192
|
+
settings = load_config(directory, args.config)
|
|
193
|
+
if args.profile is not None:
|
|
194
|
+
settings = replace(settings, profile=args.profile)
|
|
195
|
+
if args.output is not None:
|
|
196
|
+
output_path = Path(args.output) if Path(args.output).is_absolute() else Path.cwd() / args.output
|
|
197
|
+
elif settings.baseline is not None:
|
|
198
|
+
output_path = (
|
|
199
|
+
Path(settings.baseline) if Path(settings.baseline).is_absolute() else directory / settings.baseline
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
output_path = directory / ".docsentinel-baseline.json"
|
|
203
|
+
|
|
204
|
+
unbaselined_config = replace(settings, baseline=None)
|
|
205
|
+
result = scan(args.root, config=unbaselined_config)
|
|
206
|
+
save_baseline(output_path, result.findings)
|
|
207
|
+
except (ConfigError, ValueError, OSError) as exc:
|
|
208
|
+
print(f"Baseline generation failed: {neutralize_text(str(exc))}", file=sys.stderr)
|
|
209
|
+
return 2
|
|
210
|
+
print(f"Saved baseline: {output_path}")
|
|
211
|
+
return 0
|
|
212
|
+
try:
|
|
213
|
+
target = Path(args.root).resolve()
|
|
214
|
+
settings = load_config(target.parent if target.is_file() else target, args.config)
|
|
215
|
+
changes = {}
|
|
216
|
+
if args.profile is not None:
|
|
217
|
+
changes["profile"] = args.profile
|
|
218
|
+
if args.select is not None:
|
|
219
|
+
changes["select"] = tuple(item.strip() for item in args.select.split(",") if item.strip())
|
|
220
|
+
if args.ignore is not None:
|
|
221
|
+
changes["ignore"] = tuple(item.strip() for item in args.ignore.split(",") if item.strip())
|
|
222
|
+
if args.baseline is not None:
|
|
223
|
+
changes["baseline"] = str(args.baseline)
|
|
224
|
+
if getattr(args, "deep_confirm", False):
|
|
225
|
+
changes["deep_confirm"] = True
|
|
226
|
+
if changes:
|
|
227
|
+
settings = replace(settings, **changes)
|
|
228
|
+
result = scan(args.root, config=settings)
|
|
229
|
+
except (ConfigError, ValueError, OSError) as exc:
|
|
230
|
+
print(f"Scan failed: {neutralize_text(str(exc))}", file=sys.stderr)
|
|
231
|
+
return 2
|
|
232
|
+
print(render(result, args.format))
|
|
233
|
+
return _exit_code(result, strict=args.strict)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Discover Python source files for code-vs-doc signature checks."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from docsentinel.config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def discover_python(root: Path, config: Config) -> tuple[Path, ...]:
|
|
11
|
+
"""Return a reproducible inventory of matching Python source files."""
|
|
12
|
+
if not root.is_dir():
|
|
13
|
+
raise ValueError(f"Not a directory: {root}")
|
|
14
|
+
root = root.resolve()
|
|
15
|
+
|
|
16
|
+
def raise_walk_error(error: OSError):
|
|
17
|
+
"""Re-raise directory walk errors encountered during os.walk."""
|
|
18
|
+
raise error
|
|
19
|
+
|
|
20
|
+
discovered: list[Path] = []
|
|
21
|
+
|
|
22
|
+
for entry in config.docsig_paths:
|
|
23
|
+
configured_target = root / entry
|
|
24
|
+
if configured_target.is_symlink():
|
|
25
|
+
continue
|
|
26
|
+
target = configured_target.resolve()
|
|
27
|
+
if not target.is_relative_to(root):
|
|
28
|
+
raise ValueError(f"docsig path must remain under scan root: {entry}")
|
|
29
|
+
if not target.exists():
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
if target.is_file():
|
|
33
|
+
if not target.name.endswith(".py"):
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
relative = target.relative_to(root).as_posix()
|
|
37
|
+
rel_parts = target.relative_to(root).parts[:-1]
|
|
38
|
+
except ValueError:
|
|
39
|
+
relative = target.as_posix()
|
|
40
|
+
rel_parts = target.parts[:-1]
|
|
41
|
+
if any(fnmatchcase(relative, pattern) for pattern in config.exclude):
|
|
42
|
+
continue
|
|
43
|
+
if any(fnmatchcase(part, pattern) for part in rel_parts for pattern in config.exclude):
|
|
44
|
+
continue
|
|
45
|
+
discovered.append(target)
|
|
46
|
+
elif target.is_dir():
|
|
47
|
+
try:
|
|
48
|
+
target_rel = target.relative_to(root).as_posix()
|
|
49
|
+
target_parts = target.relative_to(root).parts
|
|
50
|
+
except ValueError:
|
|
51
|
+
target_rel = target.as_posix()
|
|
52
|
+
target_parts = target.parts
|
|
53
|
+
if any(fnmatchcase(target_rel, pattern) for pattern in config.exclude):
|
|
54
|
+
continue
|
|
55
|
+
if any(fnmatchcase(part, pattern) for part in target_parts for pattern in config.exclude):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
for current, directories, filenames in os.walk(target, topdown=True, onerror=raise_walk_error):
|
|
59
|
+
current_path = Path(current)
|
|
60
|
+
directories[:] = [
|
|
61
|
+
name
|
|
62
|
+
for name in directories
|
|
63
|
+
if not (current_path / name).is_symlink()
|
|
64
|
+
and not any(fnmatchcase(name, pattern) for pattern in config.exclude)
|
|
65
|
+
]
|
|
66
|
+
for filename in filenames:
|
|
67
|
+
path = current_path / filename
|
|
68
|
+
if not path.is_file() or path.is_symlink():
|
|
69
|
+
continue
|
|
70
|
+
if not filename.endswith(".py"):
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
relative = path.relative_to(root).as_posix()
|
|
74
|
+
rel_parts = path.relative_to(root).parts[:-1]
|
|
75
|
+
except ValueError:
|
|
76
|
+
relative = path.as_posix()
|
|
77
|
+
rel_parts = path.parts[:-1]
|
|
78
|
+
if any(fnmatchcase(relative, pattern) for pattern in config.exclude):
|
|
79
|
+
continue
|
|
80
|
+
if any(fnmatchcase(part, pattern) for part in rel_parts for pattern in config.exclude):
|
|
81
|
+
continue
|
|
82
|
+
discovered.append(path)
|
|
83
|
+
|
|
84
|
+
return tuple(sorted(set(discovered)))
|