diff --git a/README.md b/README.md index 25ecfbb..1ea77db 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # ScanReq +[![PyPI - Version](https://img.shields.io/pypi/v/scanreq.svg)](https://pypi.org/project/scanreq) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/scanreq.svg)](https://pypi.org/project/scanreq) + **ScanReq** - Python tool to scan all unused packages in requirements.txt file for your project. ## Background @@ -22,10 +25,18 @@ So, this tool comes in handy for easily identifying which exact packages are act 10. **Code Better**: Keep your documentation and codebase cleaner for improved quality. +## Installation + + +```console +pip3 install scanreq +``` + + ## Usage -```bash -(env-myproject) ➜ myproject git:(development) ✗ python scan.py -r requirements.txt -p . +```console +(env-myproject) ➜ myproject git:(development) ✗ scanreq -r requirements.txt -p . [i] Please wait! It may take few minutes to complete... [i] Scanning unused packages: @@ -37,8 +48,8 @@ So, this tool comes in handy for easily identifying which exact packages are act Cool right? 😎 -```bash -(env-myproject) ➜ scan-unused-requirements git:(master) ✗ python scan.py --help +```console +(env-myproject) ➜ myproject git:(development) ✗ scanreq --help usage: scan.py [-h] [-r REQUIREMENTS] [-p PATH] Scan for unused Python packages. @@ -62,9 +73,9 @@ optional arguments: - [x] Requirement file to scan - [ ] 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 +- [ ] Support scan the `pyproject.toml` - [ ] Support multiple python versions -- [ ] Support CLI - make it as a command - [ ] Support multiple devices (Linux, Macbook, and Windows) -- [ ] Write some tests -- [ ] Publish to PyPi -- [ ] Support scan the `pyproject.toml` diff --git a/docs/build-publish.md b/docs/build-publish.md new file mode 100644 index 0000000..5777ba0 --- /dev/null +++ b/docs/build-publish.md @@ -0,0 +1,97 @@ +### 1. Installation + +Modern, extensible Python project management using Hatch: +https://hatch.pypa.io/latest/install/ + +```console +$ pip3 install hatch +``` + +> Ensure were working on environment. + +Just for knowledge how this project generated using `hatch new {project_name}`: + +``` +$ hatch new scanreq +``` + +### 2. Testing + +```console +$ hatch run test -vv +``` + + +### 3. Versioning + +https://hatch.pypa.io/latest/version/#updating + +**To check the current version:** + +```console +$ hatch version +0.0.1 +``` + +**To tag new release:** + +```console +$ hatch version "0.1.0" +Old: 0.0.1 +New: 0.1.0 +``` + +**Release micro version:** + +```console +$ hatch version micro +Old: 0.0.1 +New: 0.0.2 +``` + + +### 4. Building + +https://hatch.pypa.io/latest/build/ + +```console +$ hatch build +[sdist] +dist/hatch_demo-1rc0.tar.gz + +[wheel] +dist/hatch_demo-1rc0-py3-none-any.whl +``` + + +### 5. Publishing + +https://hatch.pypa.io/latest/publish/ + +Ensure we already setup the api token: +https://pypi.org/help/#apitoken + +To make it easy, you can save inside `~/.pypirc` file: + +```console +➜ ~ cat .pypirc +[pypi] + username = __token__ + password = pypi-XXXXX +``` + + +For the first time, `hatch` will require user to fill above PyPi token, +but it will be caching for the next publishments: + +```console +$ hatch publish + +Enter your username [__token__]: +Enter your credentials: +dist/scanreq-0.0.1.tar.gz ... success +dist/scanreq-0.0.1-py3-none-any.whl ... success + +[scanreq] +https://pypi.org/project/scanreq/0.0.1/ +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a5a2b3d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "scanreq" +dynamic = ["version"] +description = "Python tool to scan all unused packages in requirements.txt file for your project." +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = ["scan", "unused", "packages", "requirements"] +authors = [{ name = "agusmakmun", email = "summon.agus@gmail.com" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = ["importlib_metadata>=7.1.0"] +scripts.scanreq = "scanreq.__main__:main" + +[project.urls] +Changelog = "https://github.com/agusmakmun/scan-unused-requirements/releases" +Documentation = "https://github.com/agusmakmun/scanreq" +Issues = "https://github.com/agusmakmun/scanreq/issues" +Source = "https://github.com/agusmakmun/scanreq" + +[tool.hatch.version] +path = "src/scanreq/__about__.py" + +[tool.hatch.envs.default] +dependencies = ["coverage[toml]>=6.5", "pytest"] +[tool.hatch.envs.default.scripts] +test = "pytest {args:tests}" +test-cov = "coverage run -m pytest {args:tests}" +cov-report = ["- coverage combine", "coverage report"] +cov = ["test-cov", "cov-report"] + +[[tool.hatch.envs.all.matrix]] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.types] +dependencies = ["mypy>=1.0.0"] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:src/scanreq tests}" + +[tool.coverage.run] +source_pkgs = ["scanreq", "tests"] +branch = true +parallel = true +omit = ["src/scanreq/__about__.py"] + +[tool.coverage.paths] +scanreq = ["src/scanreq", "*/scanreq/src/scanreq"] +tests = ["tests", "*/scanreq/tests"] + +[tool.coverage.report] +exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] + +[tool.hatch.build.targets.sdist] +exclude = ["/.github", "/.vscode", "/docs"] + +[tool.hatch.build.targets.wheel] +packages = ["src/scanreq"] diff --git a/src/scanreq/__about__.py b/src/scanreq/__about__.py new file mode 100644 index 0000000..b1a19e3 --- /dev/null +++ b/src/scanreq/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.5" diff --git a/src/scanreq/__init__.py b/src/scanreq/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/scanreq/__main__.py b/src/scanreq/__main__.py new file mode 100644 index 0000000..2060c8c --- /dev/null +++ b/src/scanreq/__main__.py @@ -0,0 +1,56 @@ +import argparse +from typing import List + +from scanreq.scanner import ( + get_main_packages, + read_requirements, + search_string_in_python_files, +) + + +def main(): + parser = argparse.ArgumentParser(description="Scan for unused Python packages.") + parser.add_argument( + "-r", + "--requirements", + type=str, + default="requirements.txt", + help="Path to the requirements.txt file to read packages from.", + ) + parser.add_argument( + "-p", + "--path", + type=str, + default=".", + help="Project path to scan for unused packages (default: current directory).", + ) + args = parser.parse_args() + project_path: str = args.path + requirement_file: str = args.requirements + + print("\n[i] Please wait! It may take few minutes to complete...") + + main_packages: dict = get_main_packages() + package_names: List[str] = read_requirements(requirement_file) + + 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: + 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) + print( + f" {number}. Module: {module_name} ---> Package: {package_name}" + ) + number += 1 + + if len(unused_packages) < 1: + print("[i] Great! No unused packages found.") + return unused_packages + + +if __name__ == "__main__": + main() diff --git a/scan.py b/src/scanreq/scanner.py similarity index 72% rename from scan.py rename to src/scanreq/scanner.py index 4cd1541..25493e1 100644 --- a/scan.py +++ b/src/scanreq/scanner.py @@ -1,4 +1,3 @@ -import argparse import multiprocessing import os import re @@ -127,49 +126,3 @@ def read_requirements(file_path: str) -> List[str]: package_name: str = line.split("==")[0].strip().lower() package_names.append(package_name) return package_names - - -def main(project_path: str, requirement_file: str): - print("\n[i] Please wait! It may take few minutes to complete...") - - main_packages: dict = get_main_packages() - package_names: List[str] = read_requirements(requirement_file) - - 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: - 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) - print( - f" {number}. Module: {module_name} ---> Package: {package_name}" - ) - number += 1 - - if len(unused_packages) < 1: - print("[i] Great! No unused packages found.") - return unused_packages - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Scan for unused Python packages.") - parser.add_argument( - "-r", - "--requirements", - type=str, - default="requirements.txt", - help="Path to the requirements.txt file to read packages from.", - ) - parser.add_argument( - "-p", - "--path", - type=str, - default=".", - help="Project path to scan for unused packages (default: current directory).", - ) - - args = parser.parse_args() - main(project_path=args.path, requirement_file=args.requirements) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_scanner.py b/tests/test_scanner.py new file mode 100644 index 0000000..307afff --- /dev/null +++ b/tests/test_scanner.py @@ -0,0 +1,113 @@ +import os +from unittest import mock + +import pytest + +from src.scanreq.scanner import ( + get_main_packages, + read_requirements, + search_string_in_file, + search_string_in_python_files, +) + + +def test_get_main_packages(): + # Mock the importlib_metadata.packages_distributions to return known values + expected_packages: dict = { + "valid_package": ["folder-name"], + "valid2": ["folder-name2"], + "_private_package": ["should-not-include"], + "package-with-dash": ["should-not-include"], + "package/with-slash": ["should-not-include"], + "package__mypyc": ["should-not-include"], + } + + with mock.patch( + "importlib_metadata.packages_distributions", return_value=expected_packages + ): + result = get_main_packages() + assert result == {"valid_package": ["folder-name"], "valid2": ["folder-name2"]} + + +# Assuming the existence of a fixture that provides a temporary directory +@pytest.fixture +def temp_file(tmpdir): + file = tmpdir.join("test_file.py") + file.write("# This is a test file for search_string_in_file function\n") + return str(file) + + +def test_search_string_in_file_found(temp_file): + search_string = "search_string_in_file" + result = search_string_in_file(temp_file, search_string) + assert result == temp_file, "The search string should be found in the file" + + +def test_search_string_in_file_not_found(temp_file): + search_string = "non_existent_string" + result = search_string_in_file(temp_file, search_string) + assert result is None, "The search string should not be found in the file" + + +def test_search_string_in_file_unicode_error(temp_file): + # Write non-utf-8 content to the file to trigger UnicodeDecodeError + with open(temp_file, "wb") as file: + file.write(b"\x80abc") + result = search_string_in_file(temp_file, "abc") + assert result is None, "UnicodeDecodeError should be handled gracefully" + + +def test_search_string_in_file_general_exception(temp_file, capsys): + # Simulate an exception by providing an invalid file path + invalid_file_path = temp_file + "_invalid" + result = search_string_in_file(invalid_file_path, "test") + captured = capsys.readouterr() + assert ( + "Error occurred while reading" in captured.out + ), "General exceptions should be handled and logged" + assert result is None, "General exceptions should result in None" + + +@pytest.fixture +def create_test_files(tmp_path): + sub_dir = tmp_path / "test_dir" + sub_dir.mkdir() + (sub_dir / "test1.py").write_text("print('Hello, world!')") + (sub_dir / "test2.py").write_text("def foo():\n return 'bar'") + (sub_dir / "non_python.txt").write_text("This is not a Python file.") + return sub_dir + + +def test_search_string_in_python_files(create_test_files): + directory = str(create_test_files) + search_string = "foo" + expected_file = os.path.join(directory, "test2.py") + + found_files = search_string_in_python_files(directory, search_string) + assert expected_file in found_files + assert len(found_files) == 1 + + +def test_read_requirements_valid_file(tmp_path): + # Create a temporary requirements file + requirements_content = """ + Django==3.0.5 + requests==2.23.0 # A comment + numpy + """ + requirements_file = tmp_path / "requirements.txt" + requirements_file.write_text(requirements_content) + + # Call the function with the path to the created temporary file + result = read_requirements(str(requirements_file)) + + # Assert the function's output + expected_packages = ["django", "requests", "numpy"] + assert ( + result == expected_packages + ), "The read_requirements function did not return expected package names." + + +def test_read_requirements_file_not_found(): + with pytest.raises(FileNotFoundError): + read_requirements("non_existent_file.txt")