Skip to content

Commit

Permalink
feat: support ignored packages
Browse files Browse the repository at this point in the history
  • Loading branch information
agusmakmun committed Apr 7, 2024
1 parent 97aa6cc commit e75f295
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 18 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pip3 install scanreq
> **Note:** Ensure you're working on python environment & already installed all your project requirements.txt
```console
scanreq -r requirements.txt -p . -o unused-requirements.txt
scanreq -r requirements.txt -p . -o unused-requirements.txt -i black,flake8
```

```
Expand Down Expand Up @@ -70,7 +70,7 @@ scanreq --help
```

```
usage: scan.py [-h] [-r REQUIREMENTS] [-p PATH] [-o OUTPUT]
usage: scanreq [-h] [-r REQUIREMENTS] [-p PATH] [-o OUTPUT] [-i IGNORED_PACKAGES]
Scan for unused Python packages.
Expand All @@ -81,6 +81,8 @@ optional arguments:
-p PATH, --path PATH Project path to scan for unused packages (default: current directory).
-o OUTPUT, --output OUTPUT
Path to the output file where unused packages will be saved.
-i IGNORED_PACKAGES, --ignored-packages IGNORED_PACKAGES
Comma separated list of package names to be ignored.
```

> **Note:** Don't forget to cross-check the unused packages after finding them,
Expand All @@ -94,8 +96,8 @@ optional arguments:
- [x] Directory to scan
- [x] Requirement file to scan
- [x] Option to write the output of unused packages
- [x] Option to exclude or ignore some packages
- [ ] Option to auto replace the package from requirements.txt file
- [ ] Option to exclude or ignore some packages
- [x] Support CLI - make it as a command
- [x] Write some tests
- [x] Publish to PyPi
Expand Down
7 changes: 0 additions & 7 deletions requirements-dev.txt

This file was deleted.

1 change: 0 additions & 1 deletion requirements.txt

This file was deleted.

2 changes: 1 addition & 1 deletion src/scanreq/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.7"
__version__ = "0.0.8"
10 changes: 10 additions & 0 deletions src/scanreq/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,22 @@ def main():
default=None,
help="Path to the output file where unused packages will be saved.",
)
parser.add_argument(
"-i",
"--ignored-packages",
type=str,
default=None,
help="Comma separated list of package names to be ignored.",
)
args = parser.parse_args()

scan(
requirement_file=args.requirements,
project_path=args.path,
output_path=args.output,
ignored_packages=(
args.ignored_packages.split(",") if args.ignored_packages else []
),
)


Expand Down
77 changes: 71 additions & 6 deletions src/scanreq/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"""

# allowed extensions to scan
ALLOWED_EXTENSIONS: Tuple[str] = (
DEFAULT_ALLOWED_EXTENSIONS: Tuple[str] = (
".py",
".conf",
".cfg",
Expand All @@ -26,6 +26,29 @@
"Dockerfile",
)

# default ignored packages to scan
DEFAULT_IGNORED_PACKAGES: List[str] = [
"scanreq",
"ipdb",
"mypy",
"isort",
"black",
"flake8",
"twine",
"codespell",
"django-coverage-plugin",
"pytest-sugar",
"pytest-cov",
"pytest-asyncio",
"pytest-mock",
"pytest-subtests",
"pytest-xdist",
"pylint-django",
"pylint-celery",
"pytest-django",
"pre-commit",
]


def get_main_packages() -> dict:
"""
Expand Down Expand Up @@ -92,7 +115,7 @@ def search_string_in_python_files(directory: str, search_string: str) -> List[st
pool = multiprocessing.Pool()
for root, _, files in os.walk(directory):
for file_name in files:
if file_name.endswith(ALLOWED_EXTENSIONS):
if file_name.endswith(DEFAULT_ALLOWED_EXTENSIONS):
file_path = os.path.join(root, file_name)
found_file = pool.apply_async(
search_string_in_file, (file_path, search_string)
Expand All @@ -103,6 +126,32 @@ def search_string_in_python_files(directory: str, search_string: str) -> List[st
return [result.get() for result in found_files if result.get()]


def clean_package_name(package_name: str) -> str:
"""
Clean the package name by removing any version number and extra spaces, and converting to lowercase.
For example:
- "django==3.2" -> "django", "Django==3.2" -> "django"
- "Flask>=1.0" -> "flask", "Flask<=1.0" -> "flask"
- "django-cookie-cutter<2.0" -> "django-cookiecutter"
- "django-cookie-cutter>2.0" -> "django-cookiecutter"
- "NumPy" -> "numpy"
Args:
package_name (str): The name of the package.
Returns:
str: The cleaned package name.
"""
# Remove the version number and any relational operators
cleaned_name = re.sub(r"[<=>!]=?\d+(\.\d+)*", "", package_name)
# Replace any hyphens followed by non-alphanumeric characters (like hyphen version separator)
cleaned_name = re.sub(r"-[^a-zA-Z0-9]", "", cleaned_name)
# Remove any remaining non-alphanumeric characters except hyphens and underscores
cleaned_name = re.sub(r"[^a-zA-Z0-9-_]", "", cleaned_name)
# Convert to lowercase and strip extra spaces
return cleaned_name.strip().lower()


def read_requirements(file_path: str) -> List[str]:
"""
Reads the requirements from the specified file and returns a list of package names.
Expand All @@ -124,19 +173,25 @@ def read_requirements(file_path: str) -> List[str]:
line = re.sub(r"#.*", "", line).strip()
if line:
# Split the line to get the package name
package_name: str = line.split("==")[0].strip().lower()
package_name: str = clean_package_name(line)
package_names.append(package_name)
return package_names


def scan(requirement_file: str, project_path: str, output_path: str = None) -> None:
def scan(
requirement_file: str,
project_path: str,
output_path: str = None,
ignored_packages: List[str] = [],
) -> None:
"""
A function that scans for unused packages in a project based on a given requirements file.
Parameters:
- requirement_file (str): the path to the requirements file to be scanned.
- project_path (str): the path to the project to be scanned.
- output_path (str, optional): the path to the output file where unused packages will be saved. Defaults to None.
- ignored_packages (List[str], optional): a list of package names to be ignored. Defaults to [].
Returns:
- None
Expand All @@ -146,12 +201,22 @@ def scan(requirement_file: str, project_path: str, output_path: str = None) -> N
package_names: List[str] = read_requirements(requirement_file)
main_packages: dict = get_main_packages()

# ignored packages to scan
ignored_packages = (
DEFAULT_IGNORED_PACKAGES + [clean_package_name(pkg) for pkg in ignored_packages]
if ignored_packages
else DEFAULT_IGNORED_PACKAGES
)

print("[i] Scanning unused packages:")
unused_packages: List[str] = []
number: int = 1
for package_name in package_names:
for module_name, package_names in main_packages.items():
if package_name in package_names:
for module_name, main_package_names in main_packages.items():
if (
package_name in main_package_names
and package_name not in ignored_packages
):
results: list = search_string_in_python_files(project_path, module_name)
if not results and (module_name not in unused_packages):
unused_packages.append(package_name)
Expand Down
17 changes: 17 additions & 0 deletions tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from src.scanreq.scanner import (
clean_package_name,
get_main_packages,
read_requirements,
search_string_in_file,
Expand Down Expand Up @@ -88,6 +89,22 @@ def test_search_string_in_python_files(create_test_files):
assert len(found_files) == 1


@pytest.mark.parametrize(
"package_name, expected",
[
("django==3.2", "django"),
(" Django==3.2 ", "django"),
("Flask>=1.0", "flask"),
("requests", "requests"),
(" NumPy ", "numpy"),
("django-cookie-cutter>2.0", "django-cookie-cutter"),
("django-cookie-cutter<2.0", "django-cookie-cutter"),
],
)
def test_clean_package_name(package_name, expected):
assert clean_package_name(package_name) == expected


def test_read_requirements_valid_file(tmp_path):
# Create a temporary requirements file
requirements_content = """
Expand Down

0 comments on commit e75f295

Please sign in to comment.