codesnake 1.2.1__tar.gz → 1.3.0__tar.gz
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.
- {codesnake-1.2.1/src/codesnake.egg-info → codesnake-1.3.0}/PKG-INFO +12 -3
- {codesnake-1.2.1 → codesnake-1.3.0}/README.md +11 -2
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake/_version.py +1 -1
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake/checker.py +107 -16
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake/cli.py +7 -0
- {codesnake-1.2.1 → codesnake-1.3.0/src/codesnake.egg-info}/PKG-INFO +12 -3
- {codesnake-1.2.1 → codesnake-1.3.0}/test/test_codesnake.py +171 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/LICENSE +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/pyproject.toml +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/setup.cfg +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake/__init__.py +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake/__main__.py +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake/banner.py +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake.egg-info/SOURCES.txt +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake.egg-info/dependency_links.txt +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake.egg-info/entry_points.txt +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake.egg-info/requires.txt +0 -0
- {codesnake-1.2.1 → codesnake-1.3.0}/src/codesnake.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codesnake
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Semantic code checker for Python 3
|
|
5
5
|
Author: CodeSnake Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,6 +30,11 @@ Provides-Extra: dev
|
|
|
30
30
|
Requires-Dist: codesnake[tools]; extra == "dev"
|
|
31
31
|
Dynamic: license-file
|
|
32
32
|
|
|
33
|
+
<p align="center">
|
|
34
|
+
<img src="https://raw.githubusercontent.com/bitWarrior/codesnake/main/images/codesnake-logo.jpg"
|
|
35
|
+
alt="CodeSnake — semantic analysis for Python" width="640">
|
|
36
|
+
</p>
|
|
37
|
+
|
|
33
38
|
# CodeSnake
|
|
34
39
|
|
|
35
40
|
[](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml)
|
|
@@ -97,10 +102,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna
|
|
|
97
102
|
## Usage
|
|
98
103
|
|
|
99
104
|
```bash
|
|
100
|
-
# Files or directories (walks *.py
|
|
105
|
+
# Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
|
|
101
106
|
codesnake check src/codesnake/checker.py test/example_bad_code.py
|
|
102
107
|
codesnake check src/
|
|
103
108
|
|
|
109
|
+
# Also analyze untracked files that .gitignore hides, e.g. generated output
|
|
110
|
+
codesnake check --no-ignore src/
|
|
111
|
+
|
|
104
112
|
# Same thing without the subcommand
|
|
105
113
|
codesnake src/
|
|
106
114
|
|
|
@@ -137,11 +145,12 @@ codesnake config -o .codesnake.json
|
|
|
137
145
|
| `--no-color` | Disable ANSI color (`NO_COLOR` also works) |
|
|
138
146
|
| `--bandit` | Merge Bandit results when the `bandit` executable is installed |
|
|
139
147
|
| `--staged` | Check `git diff --cached` Python files only |
|
|
148
|
+
| `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. |
|
|
140
149
|
| `--baseline FILE` | Hide issues whose fingerprint is already in the baseline |
|
|
141
150
|
| `--update-baseline FILE` | Write the current finding set as a baseline |
|
|
142
151
|
| `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) |
|
|
143
152
|
|
|
144
|
-
`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
|
|
153
|
+
`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
|
|
145
154
|
|
|
146
155
|
### Output formats
|
|
147
156
|
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/bitWarrior/codesnake/main/images/codesnake-logo.jpg"
|
|
3
|
+
alt="CodeSnake — semantic analysis for Python" width="640">
|
|
4
|
+
</p>
|
|
5
|
+
|
|
1
6
|
# CodeSnake
|
|
2
7
|
|
|
3
8
|
[](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml)
|
|
@@ -65,10 +70,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna
|
|
|
65
70
|
## Usage
|
|
66
71
|
|
|
67
72
|
```bash
|
|
68
|
-
# Files or directories (walks *.py
|
|
73
|
+
# Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
|
|
69
74
|
codesnake check src/codesnake/checker.py test/example_bad_code.py
|
|
70
75
|
codesnake check src/
|
|
71
76
|
|
|
77
|
+
# Also analyze untracked files that .gitignore hides, e.g. generated output
|
|
78
|
+
codesnake check --no-ignore src/
|
|
79
|
+
|
|
72
80
|
# Same thing without the subcommand
|
|
73
81
|
codesnake src/
|
|
74
82
|
|
|
@@ -105,11 +113,12 @@ codesnake config -o .codesnake.json
|
|
|
105
113
|
| `--no-color` | Disable ANSI color (`NO_COLOR` also works) |
|
|
106
114
|
| `--bandit` | Merge Bandit results when the `bandit` executable is installed |
|
|
107
115
|
| `--staged` | Check `git diff --cached` Python files only |
|
|
116
|
+
| `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. |
|
|
108
117
|
| `--baseline FILE` | Hide issues whose fingerprint is already in the baseline |
|
|
109
118
|
| `--update-baseline FILE` | Write the current finding set as a baseline |
|
|
110
119
|
| `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) |
|
|
111
120
|
|
|
112
|
-
`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
|
|
121
|
+
`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
|
|
113
122
|
|
|
114
123
|
### Output formats
|
|
115
124
|
|
|
@@ -360,6 +360,34 @@ def find_repo_root(start: Path) -> Optional[Path]:
|
|
|
360
360
|
return None
|
|
361
361
|
|
|
362
362
|
|
|
363
|
+
def git_tracked_files(repo_root: Path) -> Set[Path]:
|
|
364
|
+
"""Resolved paths of every file git tracks in ``repo_root``, or an empty set.
|
|
365
|
+
|
|
366
|
+
git's own rule is that ``.gitignore`` governs *untracked* files only -- a
|
|
367
|
+
file that is committed is not affected by an ignore rule matching it. A
|
|
368
|
+
scanner that filters tracked files by ``.gitignore`` therefore diverges
|
|
369
|
+
from git, and can be made to skip a committed file with ``git add -f``.
|
|
370
|
+
Failure here (no git, not a repository, a timeout) yields an empty set,
|
|
371
|
+
which restores the previous behavior rather than hiding files.
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
completed = _subprocess.run(
|
|
375
|
+
['git', 'ls-files', '-z', '--full-name'],
|
|
376
|
+
capture_output=True,
|
|
377
|
+
timeout=30,
|
|
378
|
+
cwd=str(repo_root),
|
|
379
|
+
)
|
|
380
|
+
except (OSError, _subprocess.TimeoutExpired):
|
|
381
|
+
return set()
|
|
382
|
+
if completed.returncode != 0:
|
|
383
|
+
return set()
|
|
384
|
+
tracked: Set[Path] = set()
|
|
385
|
+
for name in completed.stdout.decode('utf-8', errors='replace').split('\0'):
|
|
386
|
+
if name:
|
|
387
|
+
tracked.add(repo_root / name)
|
|
388
|
+
return tracked
|
|
389
|
+
|
|
390
|
+
|
|
363
391
|
def detect_source_encoding(data: bytes) -> str:
|
|
364
392
|
"""PEP 263 encoding cookie, else UTF-8 (with BOM)."""
|
|
365
393
|
if data.startswith(b'\xef\xbb\xbf'):
|
|
@@ -404,12 +432,15 @@ def issue_ignored_by_pragma(code: str, line: int, source_lines: Sequence[str]) -
|
|
|
404
432
|
return code.upper() in codes
|
|
405
433
|
|
|
406
434
|
|
|
407
|
-
def
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
435
|
+
def _load_ignore_state(
|
|
436
|
+
root_resolved: Path,
|
|
437
|
+
) -> Tuple['_IgnoreStack', Set[Path], Set[Path]]:
|
|
438
|
+
"""Ignore rules for a walk, plus the tracked paths exempt from them.
|
|
439
|
+
|
|
440
|
+
Returns the loaded ``.gitignore`` stack, the set of files git tracks, and
|
|
441
|
+
the directories those files live in. Tracked entries are exempt because
|
|
442
|
+
git applies ignore rules to untracked files only.
|
|
443
|
+
"""
|
|
413
444
|
ignore = _IgnoreStack()
|
|
414
445
|
# .gitignore files between the repository root and the target apply to
|
|
415
446
|
# everything below them, so load them first (top-most first).
|
|
@@ -425,23 +456,68 @@ def iter_python_files(root: Path) -> Iterable[Path]:
|
|
|
425
456
|
if gi_path.is_file():
|
|
426
457
|
ignore.add_gitignore(gi_path)
|
|
427
458
|
|
|
459
|
+
if repo_root is None:
|
|
460
|
+
return ignore, set(), set()
|
|
461
|
+
try:
|
|
462
|
+
tracked = git_tracked_files(repo_root.resolve())
|
|
463
|
+
except OSError:
|
|
464
|
+
return ignore, set(), set()
|
|
465
|
+
# An ignored *directory* holding a committed file must still be walked, or
|
|
466
|
+
# the file-level exemption is never reached.
|
|
467
|
+
tracked_dirs: Set[Path] = set()
|
|
468
|
+
for path in tracked:
|
|
469
|
+
tracked_dirs.update(path.parents)
|
|
470
|
+
return ignore, tracked, tracked_dirs
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def iter_python_files(root: Path, *, respect_gitignore: bool = True) -> Iterable[Path]:
|
|
474
|
+
"""Yield .py files under root, skipping venvs, caches, and (by default) .gitignore matches.
|
|
475
|
+
|
|
476
|
+
``respect_gitignore=False`` still skips ``SKIP_DIR_NAMES`` (venvs, caches,
|
|
477
|
+
``.git``, ...) so a CI gate does not have to crawl ``site-packages``. It
|
|
478
|
+
does not skip a committed file that ``.gitignore`` would hide — pass
|
|
479
|
+
``--no-ignore`` for that, or name the file explicitly.
|
|
480
|
+
"""
|
|
481
|
+
try:
|
|
482
|
+
root_resolved = root.resolve()
|
|
483
|
+
except OSError:
|
|
484
|
+
root_resolved = root
|
|
485
|
+
ignore, tracked, tracked_dirs = (
|
|
486
|
+
_load_ignore_state(root_resolved) if respect_gitignore
|
|
487
|
+
else (_IgnoreStack(), set(), set())
|
|
488
|
+
)
|
|
489
|
+
|
|
428
490
|
for dirpath, dirnames, filenames in os.walk(root_resolved):
|
|
429
491
|
current = Path(dirpath)
|
|
430
|
-
|
|
431
|
-
if
|
|
432
|
-
|
|
433
|
-
|
|
492
|
+
views: _DirectoryView = []
|
|
493
|
+
if respect_gitignore:
|
|
494
|
+
nested_gi = current / '.gitignore'
|
|
495
|
+
if nested_gi.is_file():
|
|
496
|
+
ignore.add_gitignore(nested_gi)
|
|
497
|
+
views = ignore.view(current)
|
|
434
498
|
|
|
435
499
|
dirnames[:] = [
|
|
436
500
|
name for name in dirnames
|
|
437
501
|
if name not in SKIP_DIR_NAMES
|
|
438
502
|
and not name.endswith('.egg-info')
|
|
439
|
-
and not
|
|
503
|
+
and not (
|
|
504
|
+
respect_gitignore
|
|
505
|
+
and (current / name) not in tracked_dirs
|
|
506
|
+
and _IgnoreStack.ignored(views, name, is_dir=True)
|
|
507
|
+
)
|
|
440
508
|
]
|
|
441
509
|
|
|
442
510
|
for name in filenames:
|
|
443
|
-
if name.endswith('.py')
|
|
444
|
-
|
|
511
|
+
if not name.endswith('.py'):
|
|
512
|
+
continue
|
|
513
|
+
candidate = current / name
|
|
514
|
+
if (
|
|
515
|
+
respect_gitignore
|
|
516
|
+
and candidate not in tracked
|
|
517
|
+
and _IgnoreStack.ignored(views, name, is_dir=False)
|
|
518
|
+
):
|
|
519
|
+
continue
|
|
520
|
+
yield candidate
|
|
445
521
|
|
|
446
522
|
|
|
447
523
|
@dataclass
|
|
@@ -2139,7 +2215,11 @@ def git_staged_python_files() -> Tuple[List[str], Optional[str]]:
|
|
|
2139
2215
|
return files, None
|
|
2140
2216
|
|
|
2141
2217
|
|
|
2142
|
-
def expand_python_targets(
|
|
2218
|
+
def expand_python_targets(
|
|
2219
|
+
paths: Sequence[str],
|
|
2220
|
+
*,
|
|
2221
|
+
respect_gitignore: bool = True,
|
|
2222
|
+
) -> Tuple[List[str], List[Issue]]:
|
|
2143
2223
|
"""Expand directories to .py files. Explicit files are kept as given."""
|
|
2144
2224
|
targets: List[str] = []
|
|
2145
2225
|
extras: List[Issue] = []
|
|
@@ -2160,7 +2240,12 @@ def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]
|
|
|
2160
2240
|
for raw in paths:
|
|
2161
2241
|
path = Path(raw)
|
|
2162
2242
|
if path.is_dir():
|
|
2163
|
-
found = [
|
|
2243
|
+
found = [
|
|
2244
|
+
str(candidate)
|
|
2245
|
+
for candidate in iter_python_files(
|
|
2246
|
+
path, respect_gitignore=respect_gitignore,
|
|
2247
|
+
)
|
|
2248
|
+
]
|
|
2164
2249
|
if not found:
|
|
2165
2250
|
extras.append(_io_issue(raw, f"No Python files found in '{raw}'"))
|
|
2166
2251
|
continue
|
|
@@ -2609,10 +2694,14 @@ def run_check(
|
|
|
2609
2694
|
baseline_path: Optional[str] = None,
|
|
2610
2695
|
update_baseline: Optional[str] = None,
|
|
2611
2696
|
jobs: Optional[int] = None,
|
|
2697
|
+
no_ignore: bool = False,
|
|
2612
2698
|
) -> int:
|
|
2613
2699
|
"""Analyze one or more files. Returns 1 if any error-severity issue exists.
|
|
2614
2700
|
|
|
2615
2701
|
``jobs``: worker processes (``None``/``0`` = auto, ``1`` = sequential).
|
|
2702
|
+
``no_ignore``: walk directories without applying ``.gitignore`` (venvs,
|
|
2703
|
+
caches, and ``.git`` are still skipped). Explicit file arguments are
|
|
2704
|
+
never gitignore-filtered.
|
|
2616
2705
|
"""
|
|
2617
2706
|
out = stream if stream is not None else sys.stdout
|
|
2618
2707
|
|
|
@@ -2643,7 +2732,9 @@ def run_check(
|
|
|
2643
2732
|
if show_banner and output_format == 'text':
|
|
2644
2733
|
print_snake_banner(use_color=use_color)
|
|
2645
2734
|
|
|
2646
|
-
targets, extra_issues = expand_python_targets(
|
|
2735
|
+
targets, extra_issues = expand_python_targets(
|
|
2736
|
+
file_list, respect_gitignore=not no_ignore,
|
|
2737
|
+
)
|
|
2647
2738
|
|
|
2648
2739
|
file_reports: List[Tuple[str, List[Issue], Optional[str]]] = []
|
|
2649
2740
|
for extra in extra_issues:
|
|
@@ -54,6 +54,12 @@ def add_check_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
|
|
|
54
54
|
action='store_true',
|
|
55
55
|
help='Check only Python files staged in git',
|
|
56
56
|
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
'--no-ignore',
|
|
59
|
+
action='store_true',
|
|
60
|
+
help='Do not skip .gitignore matches when walking directories '
|
|
61
|
+
'(venvs, caches, and .git are still skipped)',
|
|
62
|
+
)
|
|
57
63
|
parser.add_argument(
|
|
58
64
|
'--baseline',
|
|
59
65
|
metavar='FILE',
|
|
@@ -141,6 +147,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
141
147
|
baseline_path=args.baseline,
|
|
142
148
|
update_baseline=args.update_baseline,
|
|
143
149
|
jobs=args.jobs,
|
|
150
|
+
no_ignore=args.no_ignore,
|
|
144
151
|
)
|
|
145
152
|
|
|
146
153
|
if args.command == 'config':
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codesnake
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Semantic code checker for Python 3
|
|
5
5
|
Author: CodeSnake Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,6 +30,11 @@ Provides-Extra: dev
|
|
|
30
30
|
Requires-Dist: codesnake[tools]; extra == "dev"
|
|
31
31
|
Dynamic: license-file
|
|
32
32
|
|
|
33
|
+
<p align="center">
|
|
34
|
+
<img src="https://raw.githubusercontent.com/bitWarrior/codesnake/main/images/codesnake-logo.jpg"
|
|
35
|
+
alt="CodeSnake — semantic analysis for Python" width="640">
|
|
36
|
+
</p>
|
|
37
|
+
|
|
33
38
|
# CodeSnake
|
|
34
39
|
|
|
35
40
|
[](https://github.com/bitWarrior/codesnake/actions/workflows/ci.yml)
|
|
@@ -97,10 +102,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna
|
|
|
97
102
|
## Usage
|
|
98
103
|
|
|
99
104
|
```bash
|
|
100
|
-
# Files or directories (walks *.py
|
|
105
|
+
# Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
|
|
101
106
|
codesnake check src/codesnake/checker.py test/example_bad_code.py
|
|
102
107
|
codesnake check src/
|
|
103
108
|
|
|
109
|
+
# Also analyze untracked files that .gitignore hides, e.g. generated output
|
|
110
|
+
codesnake check --no-ignore src/
|
|
111
|
+
|
|
104
112
|
# Same thing without the subcommand
|
|
105
113
|
codesnake src/
|
|
106
114
|
|
|
@@ -137,11 +145,12 @@ codesnake config -o .codesnake.json
|
|
|
137
145
|
| `--no-color` | Disable ANSI color (`NO_COLOR` also works) |
|
|
138
146
|
| `--bandit` | Merge Bandit results when the `bandit` executable is installed |
|
|
139
147
|
| `--staged` | Check `git diff --cached` Python files only |
|
|
148
|
+
| `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. |
|
|
140
149
|
| `--baseline FILE` | Hide issues whose fingerprint is already in the baseline |
|
|
141
150
|
| `--update-baseline FILE` | Write the current finding set as a baseline |
|
|
142
151
|
| `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) |
|
|
143
152
|
|
|
144
|
-
`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
|
|
153
|
+
`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
|
|
145
154
|
|
|
146
155
|
### Output formats
|
|
147
156
|
|
|
@@ -864,6 +864,138 @@ class TestDirectoryWalkAndEncoding(unittest.TestCase):
|
|
|
864
864
|
names = {Path(path).name for path in targets}
|
|
865
865
|
self.assertEqual(names, {'kept.py'})
|
|
866
866
|
|
|
867
|
+
def _git_repo(self, tmp):
|
|
868
|
+
"""A real git repo; skip if git is unavailable."""
|
|
869
|
+
import subprocess
|
|
870
|
+
root = Path(tmp).resolve()
|
|
871
|
+
try:
|
|
872
|
+
for cmd in (['git', 'init', '-q', '.'],
|
|
873
|
+
['git', 'config', 'user.email', 't@e.st'],
|
|
874
|
+
['git', 'config', 'user.name', 'test']):
|
|
875
|
+
subprocess.run(cmd, cwd=root, check=True, capture_output=True, timeout=30)
|
|
876
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
877
|
+
self.skipTest('git not available')
|
|
878
|
+
return root
|
|
879
|
+
|
|
880
|
+
def _git(self, root, *args):
|
|
881
|
+
import subprocess
|
|
882
|
+
subprocess.run(['git', *args], cwd=root, check=True, capture_output=True, timeout=30)
|
|
883
|
+
|
|
884
|
+
def test_committed_file_is_not_hidden_by_gitignore(self):
|
|
885
|
+
"""git ignores .gitignore for tracked files; so must the walk.
|
|
886
|
+
|
|
887
|
+
Otherwise a PR can add a file, gitignore it, `git add -f` it, and the
|
|
888
|
+
default directory walk never sees it.
|
|
889
|
+
"""
|
|
890
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
891
|
+
root = self._git_repo(tmp)
|
|
892
|
+
(root / 'src').mkdir()
|
|
893
|
+
(root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8')
|
|
894
|
+
(root / 'src' / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
|
|
895
|
+
(root / '.gitignore').write_text('src/evil.py\n', encoding='utf-8')
|
|
896
|
+
self._git(root, 'add', '.gitignore', 'src/good.py')
|
|
897
|
+
self._git(root, 'add', '-f', 'src/evil.py')
|
|
898
|
+
self._git(root, 'commit', '-q', '-m', 'x')
|
|
899
|
+
|
|
900
|
+
targets, _ = expand_python_targets([str(root / 'src')])
|
|
901
|
+
self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'})
|
|
902
|
+
|
|
903
|
+
def test_committed_file_inside_ignored_directory_is_walked(self):
|
|
904
|
+
"""An ignored directory holding a tracked file must not be pruned."""
|
|
905
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
906
|
+
root = self._git_repo(tmp)
|
|
907
|
+
(root / 'src' / 'hidden').mkdir(parents=True)
|
|
908
|
+
(root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8')
|
|
909
|
+
(root / 'src' / 'hidden' / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
|
|
910
|
+
(root / '.gitignore').write_text('src/hidden/\n', encoding='utf-8')
|
|
911
|
+
self._git(root, 'add', '.gitignore', 'src/good.py')
|
|
912
|
+
self._git(root, 'add', '-f', 'src/hidden/evil.py')
|
|
913
|
+
self._git(root, 'commit', '-q', '-m', 'x')
|
|
914
|
+
|
|
915
|
+
targets, _ = expand_python_targets([str(root / 'src')])
|
|
916
|
+
self.assertEqual({Path(t).name for t in targets}, {'good.py', 'evil.py'})
|
|
917
|
+
|
|
918
|
+
def test_untracked_gitignored_files_are_still_skipped(self):
|
|
919
|
+
"""The exemption is for tracked files only — build output stays ignored."""
|
|
920
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
921
|
+
root = self._git_repo(tmp)
|
|
922
|
+
(root / 'src' / 'build').mkdir(parents=True)
|
|
923
|
+
(root / 'src' / 'good.py').write_text('x = 1\n', encoding='utf-8')
|
|
924
|
+
(root / 'src' / 'generated.py').write_text('eval(input())\n', encoding='utf-8')
|
|
925
|
+
(root / 'src' / 'build' / 'gen.py').write_text('eval(input())\n', encoding='utf-8')
|
|
926
|
+
(root / '.gitignore').write_text('src/generated.py\nsrc/build/\n', encoding='utf-8')
|
|
927
|
+
self._git(root, 'add', '.gitignore', 'src/good.py')
|
|
928
|
+
self._git(root, 'commit', '-q', '-m', 'x')
|
|
929
|
+
|
|
930
|
+
targets, _ = expand_python_targets([str(root / 'src')])
|
|
931
|
+
self.assertEqual({Path(t).name for t in targets}, {'good.py'})
|
|
932
|
+
|
|
933
|
+
def test_no_ignore_includes_gitignored_files(self):
|
|
934
|
+
"""A committed-but-ignored file must be visible to a CI directory walk."""
|
|
935
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
936
|
+
root = Path(tmp)
|
|
937
|
+
(root / '.gitignore').write_text('ignored.py\nsecret/\n', encoding='utf-8')
|
|
938
|
+
(root / 'kept.py').write_text('x = 1\n', encoding='utf-8')
|
|
939
|
+
(root / 'ignored.py').write_text('eval(1)\n', encoding='utf-8')
|
|
940
|
+
(root / 'secret').mkdir()
|
|
941
|
+
(root / 'secret' / 'bad.py').write_text('eval(1)\n', encoding='utf-8')
|
|
942
|
+
targets, extras = expand_python_targets(
|
|
943
|
+
[str(root)], respect_gitignore=False,
|
|
944
|
+
)
|
|
945
|
+
self.assertEqual(extras, [])
|
|
946
|
+
names = {Path(path).name for path in targets}
|
|
947
|
+
self.assertEqual(names, {'kept.py', 'ignored.py', 'bad.py'})
|
|
948
|
+
|
|
949
|
+
def test_no_ignore_still_skips_venvs_and_caches(self):
|
|
950
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
951
|
+
root = Path(tmp)
|
|
952
|
+
(root / 'kept.py').write_text('x = 1\n', encoding='utf-8')
|
|
953
|
+
(root / '__pycache__').mkdir()
|
|
954
|
+
(root / '__pycache__' / 'skip.py').write_text('eval(1)\n', encoding='utf-8')
|
|
955
|
+
(root / '.venv').mkdir()
|
|
956
|
+
(root / '.venv' / 'lib.py').write_text('eval(1)\n', encoding='utf-8')
|
|
957
|
+
targets, extras = expand_python_targets(
|
|
958
|
+
[str(root)], respect_gitignore=False,
|
|
959
|
+
)
|
|
960
|
+
self.assertEqual(extras, [])
|
|
961
|
+
self.assertEqual([Path(path).name for path in targets], ['kept.py'])
|
|
962
|
+
|
|
963
|
+
def test_run_check_no_ignore_reports_gitignored_error(self):
|
|
964
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
965
|
+
root = Path(tmp)
|
|
966
|
+
(root / '.gitignore').write_text('evil.py\n', encoding='utf-8')
|
|
967
|
+
(root / 'ok.py').write_text('x = 1\n', encoding='utf-8')
|
|
968
|
+
(root / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
|
|
969
|
+
skipped = StringIO()
|
|
970
|
+
self.assertEqual(
|
|
971
|
+
run_check(
|
|
972
|
+
[str(root)],
|
|
973
|
+
config=CheckerConfig(),
|
|
974
|
+
output_format='json',
|
|
975
|
+
show_banner=False,
|
|
976
|
+
color=False,
|
|
977
|
+
stream=skipped,
|
|
978
|
+
),
|
|
979
|
+
0,
|
|
980
|
+
)
|
|
981
|
+
found = StringIO()
|
|
982
|
+
rc = run_check(
|
|
983
|
+
[str(root)],
|
|
984
|
+
config=CheckerConfig(),
|
|
985
|
+
output_format='json',
|
|
986
|
+
show_banner=False,
|
|
987
|
+
color=False,
|
|
988
|
+
stream=found,
|
|
989
|
+
no_ignore=True,
|
|
990
|
+
)
|
|
991
|
+
self.assertEqual(rc, 1)
|
|
992
|
+
codes = [
|
|
993
|
+
issue['code']
|
|
994
|
+
for file_report in json.loads(found.getvalue())['files']
|
|
995
|
+
for issue in file_report['issues']
|
|
996
|
+
]
|
|
997
|
+
self.assertIn('SEC001', codes)
|
|
998
|
+
|
|
867
999
|
def test_run_check_on_directory(self):
|
|
868
1000
|
with tempfile.TemporaryDirectory() as tmp:
|
|
869
1001
|
root = Path(tmp)
|
|
@@ -2116,6 +2248,27 @@ class TestPackageAndCli(unittest.TestCase):
|
|
|
2116
2248
|
self.assertEqual(main(['check']), 2)
|
|
2117
2249
|
self.assertIn('provide files', err.getvalue())
|
|
2118
2250
|
|
|
2251
|
+
def test_cli_no_ignore_checks_gitignored_file(self):
|
|
2252
|
+
from unittest.mock import patch
|
|
2253
|
+
from codesnake.cli import main
|
|
2254
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2255
|
+
root = Path(tmp)
|
|
2256
|
+
(root / '.gitignore').write_text('evil.py\n', encoding='utf-8')
|
|
2257
|
+
(root / 'ok.py').write_text('x = 1\n', encoding='utf-8')
|
|
2258
|
+
(root / 'evil.py').write_text('eval(input())\n', encoding='utf-8')
|
|
2259
|
+
with patch('sys.stdout', StringIO()):
|
|
2260
|
+
self.assertEqual(
|
|
2261
|
+
main(['check', str(root), '--format', 'json', '--no-color']),
|
|
2262
|
+
0,
|
|
2263
|
+
)
|
|
2264
|
+
with patch('sys.stdout', StringIO()) as out:
|
|
2265
|
+
rc = main([
|
|
2266
|
+
'check', str(root), '--format', 'json', '--no-color',
|
|
2267
|
+
'--no-ignore',
|
|
2268
|
+
])
|
|
2269
|
+
self.assertEqual(rc, 1)
|
|
2270
|
+
self.assertIn('SEC001', out.getvalue())
|
|
2271
|
+
|
|
2119
2272
|
def test_deeply_nested_file_is_contained_not_fatal(self):
|
|
2120
2273
|
"""One unanalyzable file must not sink the whole run."""
|
|
2121
2274
|
from codesnake import check_file, run_check
|
|
@@ -2350,6 +2503,24 @@ class TestGitignoreFromRepoRoot(unittest.TestCase):
|
|
|
2350
2503
|
targets, _ = expand_python_targets([str(outer / 'proj')])
|
|
2351
2504
|
self.assertEqual({Path(t).name for t in targets}, {'a.py'})
|
|
2352
2505
|
|
|
2506
|
+
def test_no_ignore_includes_root_gitignored_files(self):
|
|
2507
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2508
|
+
root = Path(tmp).resolve()
|
|
2509
|
+
(root / '.git').mkdir()
|
|
2510
|
+
(root / '.gitignore').write_text('generated/\n*_pb2.py\n', encoding='utf-8')
|
|
2511
|
+
src = root / 'src'
|
|
2512
|
+
(src / 'generated').mkdir(parents=True)
|
|
2513
|
+
(src / 'a.py').write_text('x = 1\n', encoding='utf-8')
|
|
2514
|
+
(src / 'proto_pb2.py').write_text('x = 1\n', encoding='utf-8')
|
|
2515
|
+
(src / 'generated' / 'g.py').write_text('x = 1\n', encoding='utf-8')
|
|
2516
|
+
targets, _ = expand_python_targets(
|
|
2517
|
+
[str(src)], respect_gitignore=False,
|
|
2518
|
+
)
|
|
2519
|
+
self.assertEqual(
|
|
2520
|
+
{Path(t).name for t in targets},
|
|
2521
|
+
{'a.py', 'proto_pb2.py', 'g.py'},
|
|
2522
|
+
)
|
|
2523
|
+
|
|
2353
2524
|
|
|
2354
2525
|
class TestBaselineFingerprints(unittest.TestCase):
|
|
2355
2526
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|