Skip to content

Commit 8539a8c

Browse files
authored
feat(version-scanner): support globbing and subpath patterns in ignore file (googleapis#17539)
This PR upgrades the ignore engine of the version scanner to support wildcard patterns (like *.jpg and *.egg-info) and relative directory subpaths (like packages/pkg_a/.nox) in .scannerignore.
1 parent 4f94be8 commit 8539a8c

4 files changed

Lines changed: 154 additions & 9 deletions

File tree

scripts/version_scanner/.scannerignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,14 @@ repositories.bzl
2020
*.png
2121
*.gif
2222
*.ico
23+
*.pdf
24+
25+
# Ignore caches and temporary directories
26+
.ruff_cache
27+
.pytest_cache
28+
.mypy_cache
29+
.coverage
30+
.htmlcov
31+
32+
# Ignore data files
33+
*.csv

scripts/version_scanner/README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,32 @@ pip install -r scripts/version_scanner/requirements.txt
4545

4646
The scanner uses a YAML configuration file (`regex_pattern_config.yaml`) to define rules and regex patterns.
4747

48-
## Ignoring Directories
48+
## Matrix File Format
4949

50-
You can create a `.scannerignore` file in the directory you are scanning (usually the repo root) to list directories to skip, one per line.
50+
When using `--matrix-file`, you must provide a YAML file specifying dependencies and versions.
5151

52-
## Known Issues & Future Investigations
53-
- **Binary Ignores in `.scannerignore`**: Recursive wildcard ignores (e.g., `*.jpg`) currently do not effectively ignore deeply nested binary files. The scanner logic should be investigated to support robust globbing or full-path suffix matching.
52+
### Example
53+
```yaml
54+
python:
55+
- "3.10"
56+
- "3.11"
57+
protobuf: "4.25.8"
58+
```
59+
60+
> [!IMPORTANT]
61+
> **Versions must be specified as quoted strings** (e.g., `"3.10"`, not `3.10`). This prevents YAML parsers from converting them to floats (which would truncate `3.10` to `3.1`).
62+
63+
## Ignoring Directories and Files
64+
65+
In order to ignore files OR entire directories, you can add ignore patterns to the `.scannerignore` file located in the same directory as the script (`scripts/version_scanner/.scannerignore`). Ignore patterns should be added one per line.
66+
67+
### Features
68+
- **Case-insensitive**: All patterns are matched case-insensitively.
69+
- **Globbing**: Supports standard shell globbing patterns (e.g., `*.jpg`, `test_*`).
70+
- **Subpaths**: You can specify subpaths (e.g., `packages/pkg_a/.nox`).
71+
- **Root Anchoring**: Patterns starting with a slash `/` are anchored to the root of the scan (e.g., `/packages` ignores the `packages` directory at root, but not `some/other/packages`).
72+
73+
---
5474

5575
---
5676

scripts/version_scanner/tests/unit/test_version_scanner.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,50 @@ def test_scan_repository_ignores_version_scanner(tmp_path):
366366
assert len(results) == 0
367367

368368

369+
def test_scan_repository_wildcard_ignores(tmp_path):
370+
# Create files
371+
(tmp_path / "test.jpg").write_text("dummy version 3.7\n")
372+
(tmp_path / "test.py").write_text("python_requires = '>=3.7'\n")
373+
374+
rules = [
375+
{"name": "python_requires_check", "pattern": "python_requires\\s*=\\s*['\"]>=3\\.7['\"]"},
376+
{"name": "explicit_version_string", "pattern": "3\\.7"}
377+
]
378+
379+
from version_scanner import scan_repository
380+
# Without ignore
381+
results = scan_repository(str(tmp_path), rules)
382+
assert len(results) >= 2
383+
384+
# With wildcard ignore for *.jpg
385+
results_ignored = scan_repository(str(tmp_path), rules, ignore_dirs=["*.jpg"])
386+
# test.jpg should be ignored completely
387+
for match in results_ignored:
388+
assert not match["file_path"].endswith("test.jpg")
389+
390+
391+
DEFAULT_IGNORE_PATTERNS = [".git", "*.jpg", "packages/pkg_a/.nox", "*.egg-info"]
392+
393+
@pytest.mark.parametrize(
394+
"rel_path, name, ignore_patterns, expected",
395+
[
396+
pytest.param(".git", ".git", DEFAULT_IGNORE_PATTERNS, True, id="exact_match"),
397+
pytest.param(".GIT", ".GIT", DEFAULT_IGNORE_PATTERNS, True, id="case_insensitive_match"),
398+
pytest.param("some/path/image.jpg", "image.jpg", DEFAULT_IGNORE_PATTERNS, True, id="wildcard_subpath_match"),
399+
pytest.param("image.JPG", "image.JPG", DEFAULT_IGNORE_PATTERNS, True, id="wildcard_case_insensitive_match"),
400+
pytest.param("packages/pkg_a/.nox", ".nox", DEFAULT_IGNORE_PATTERNS, True, id="subpath_exact_match"),
401+
pytest.param("google_cloud_pubsub.egg-info", "google_cloud_pubsub.egg-info", DEFAULT_IGNORE_PATTERNS, True, id="wildcard_directory_match"),
402+
pytest.param("setup.py", "setup.py", DEFAULT_IGNORE_PATTERNS, False, id="no_match"),
403+
pytest.param("packages", "packages", ["/packages"], True, id="anchored_root_match"),
404+
pytest.param("some/other/packages", "packages", ["/packages"], False, id="anchored_root_nested_no_match"),
405+
]
406+
)
407+
def test__should_ignore(rel_path, name, ignore_patterns, expected):
408+
from version_scanner import _should_ignore, _preprocess_ignore_patterns
409+
preprocessed = _preprocess_ignore_patterns(ignore_patterns)
410+
assert _should_ignore(rel_path, name, preprocessed) is expected
411+
412+
369413
def test_load_ignore_file(tmp_path):
370414
from version_scanner import load_ignore_file
371415

scripts/version_scanner/version_scanner.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import argparse
2121
import csv
2222
import datetime
23+
import fnmatch
2324
import os
2425
import re
2526
import sys
@@ -498,6 +499,63 @@ def read_package_file(file_path: str) -> List[str]:
498499
return packages
499500

500501

502+
def _preprocess_ignore_patterns(ignore_patterns: List[str]) -> List[Tuple[str, str]]:
503+
"""Preprocesses ignore patterns into a classified list for faster matching.
504+
505+
Args:
506+
ignore_patterns: A list of raw ignore patterns from .scannerignore.
507+
508+
Returns:
509+
A list of tuples (type, pattern) where type is 'anchored', 'subpath', or 'filename'.
510+
"""
511+
if not ignore_patterns:
512+
return []
513+
514+
preprocessed = []
515+
for pattern in ignore_patterns:
516+
pattern_lower = pattern.lower()
517+
if '/' in pattern:
518+
if pattern_lower.startswith('/'):
519+
preprocessed.append(('anchored', pattern_lower[1:]))
520+
else:
521+
preprocessed.append(('subpath', pattern_lower))
522+
else:
523+
preprocessed.append(('filename', pattern_lower))
524+
return preprocessed
525+
526+
527+
def _should_ignore(rel_path: str, name: str, preprocessed_patterns: List[Tuple[str, str]]) -> bool:
528+
"""Check if a file or directory matches any of the ignore patterns.
529+
530+
Directories and files can be ignored by providing an ignore pattern in the
531+
.scannerignore file.
532+
533+
Args:
534+
rel_path: The relative path of the file or directory from the scan root.
535+
name: The name of the file or directory (basename).
536+
preprocessed_patterns: A list of preprocessed ignore patterns.
537+
538+
Returns:
539+
True if the file or directory should be ignored, False otherwise.
540+
"""
541+
if not preprocessed_patterns:
542+
return False
543+
name_lower = name.lower()
544+
rel_path_norm = rel_path.replace(os.sep, '/').lower()
545+
546+
for p_type, p_val in preprocessed_patterns:
547+
if p_type == 'anchored':
548+
if fnmatch.fnmatchcase(rel_path_norm, p_val):
549+
return True
550+
elif p_type == 'subpath':
551+
if fnmatch.fnmatchcase(rel_path_norm, p_val) or fnmatch.fnmatchcase(rel_path_norm, f"*/{p_val}"):
552+
return True
553+
elif p_type == 'filename':
554+
if fnmatch.fnmatchcase(name_lower, p_val):
555+
return True
556+
return False
557+
558+
501559
def scan_repository(
502560
root_path: str,
503561
rules: List[Dict[str, Any]],
@@ -528,7 +586,6 @@ def scan_repository(
528586
Returns:
529587
A list of dictionaries detailing each match.
530588
"""
531-
ignore_lower = {i.lower() for i in ignore_dirs} if ignore_dirs else set()
532589
results = []
533590

534591
filename_targets = []
@@ -552,18 +609,31 @@ def scan_repository(
552609
print(f"Error compiling regex for rule {rule['name']}: {e}", file=sys.stderr)
553610
continue
554611

612+
# Preprocess ignore patterns once
613+
preprocessed_ignores = _preprocess_ignore_patterns(ignore_dirs)
614+
555615
print(f"\nScanning repository: {root_path}")
556616
if target_packages:
557617
print(f"Filtering for packages: {target_packages}")
558618

559619
for root, dirs, files in os.walk(root_path):
620+
rel_root = os.path.relpath(root, root_path)
621+
622+
# Helper to construct relative path for ignore matching
623+
def get_rel_path(name):
624+
return name if rel_root == "." else os.path.join(rel_root, name)
625+
560626
# Prune ignore directories (case-insensitive)
561-
dirs[:] = [d for d in dirs if d.lower() not in ignore_lower]
627+
dirs[:] = [
628+
d for d in dirs
629+
if not _should_ignore(get_rel_path(d), d, preprocessed_ignores)
630+
]
562631

563632
# Filter ignore files (case-insensitive)
564-
files = [f for f in files if f.lower() not in ignore_lower]
565-
566-
rel_root = os.path.relpath(root, root_path)
633+
files = [
634+
f for f in files
635+
if not _should_ignore(get_rel_path(f), f, preprocessed_ignores)
636+
]
567637

568638
# Layout-agnostic generic subdirectory filtering
569639
if target_packages:

0 commit comments

Comments
 (0)