diff --git a/.gitignore b/.gitignore index 6f5da41..5e608dc 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,11 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +# AI +.cursor/ +CLAUDE.md +.agent-os/ +.cursorrules +.claude/ +GEMINI.md diff --git a/18 b/18 deleted file mode 100644 index e69de29..0000000 diff --git a/apps/jira_utils/jira_information.py b/apps/jira_utils/jira_information.py index 0eb0902..d044d3d 100644 --- a/apps/jira_utils/jira_information.py +++ b/apps/jira_utils/jira_information.py @@ -151,6 +151,7 @@ def process_jira_command_line_config_file( config_dict = get_util_config(util_name="pyutils-jira", config_file_path=config_file_path) url = url or config_dict.get("url", "") token = token or config_dict.get("token", "") + if not (url and token): LOGGER.error("Jira url and token are required.") sys.exit(1) diff --git a/apps/unused_code/README.md b/apps/unused_code/README.md index 479b18f..69f27ef 100644 --- a/apps/unused_code/README.md +++ b/apps/unused_code/README.md @@ -4,11 +4,42 @@ Helper to identify unused code in a pytest repository. It should be run from ins ## Usage +### Basic Usage + ```bash +# Analyze all Python files in the current directory (must be a git repository) pyutils-unusedcode + +# Show help with all available options pyutils-unusedcode --help ``` +### Analyze Specific Files or Directories + +```bash +# Analyze a single Python file +pyutils-unusedcode --file-path /path/to/your/file.py +pyutils-unusedcode -f /path/to/your/file.py + +# Analyze all Python files in a specific directory recursively +pyutils-unusedcode --directory /path/to/your/project +pyutils-unusedcode -d /path/to/your/project +``` + +**Note:** When using `--file-path` or `--directory`, the tool will analyze files from any git repository, not just the current working directory. + +## Command-Line Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--file-path` | `-f` | Analyze a single Python file for unused functions. Must be an existing .py file. | +| `--directory` | `-d` | Analyze all Python files in a directory recursively for unused functions. Must be an existing directory. | +| `--exclude-files` | | Comma-separated list of files to exclude from analysis. | +| `--exclude-function-prefixes` | | Comma-separated list of function prefixes to exclude from analysis. | +| `--config-file-path` | | Path to custom config file (default: `~/.config/python-utility-scripts/config.yaml`). | +| `--verbose` | `-v` | Enable verbose logging for debugging. | +| `--help` | | Show help message with all available options. | + ## Config file To skip unused code check on specific files or functions of a repository, a config file with the list of names of such files and function prefixes should be added to diff --git a/apps/unused_code/unused_code.py b/apps/unused_code/unused_code.py index f975a45..7710d3b 100644 --- a/apps/unused_code/unused_code.py +++ b/apps/unused_code/unused_code.py @@ -3,10 +3,13 @@ import ast import logging import os +import re import subprocess import sys from concurrent.futures import Future, ThreadPoolExecutor, as_completed -from typing import Any, Iterable +from functools import lru_cache +from pathlib import Path +from typing import Any, Callable, Iterable import click from ast_comments import parse @@ -17,6 +20,42 @@ LOGGER = get_logger(name=__name__) +@lru_cache(maxsize=1) +def _detect_supported_grep_flag() -> str: + """Detect and cache a supported regex engine flag for git grep. + + Prefer PCRE ("-P") for proper \b handling; fall back to basic regex ("-G"). + Run a harmless grep to verify support and cache the first working flag. + Uses lru_cache for thread-safe caching that runs only once per process. + """ + candidate_flags = ["-P", "-G"] + for flag in candidate_flags: + try: + # Use a trivial pattern to minimize output. We only care that the flag is accepted. + # Discard stdout/stderr to prevent capturing large data in big repositories. + probe_cmd = [ + "git", + "grep", + "-n", + "--no-color", + "--untracked", + "-I", + flag, + "^$", # match empty lines; success (rc=0) or no matches (rc=1) are both fine + ] + result = subprocess.run(probe_cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode in (0, 1): + return flag + except Exception: + # Try next candidate + pass + + raise RuntimeError( + "git grep does not support '-P' (PCRE) or '-G' (basic regex) on this platform. " + "Please ensure a compatible git/grep is installed." + ) + + def is_fixture_autouse(func: ast.FunctionDef) -> bool: deco_list: list[Any] = func.decorator_list for deco in deco_list or []: @@ -31,6 +70,340 @@ def is_fixture_autouse(func: ast.FunctionDef) -> bool: return False +def is_pytest_fixture(func: ast.FunctionDef) -> bool: + """Return True if the function is decorated with @pytest.fixture. + + Detects any pytest fixture regardless of parameters (scope, autouse, etc.). + """ + decorators: list[Any] = func.decorator_list + for decorator in decorators or []: + # Case 1: @pytest.fixture(...) + if hasattr(decorator, "func"): + # e.g. @pytest.fixture(...) + if getattr(decorator.func, "attr", None) and getattr(decorator.func, "value", None): + if decorator.func.attr == "fixture" and getattr(decorator.func.value, "id", None) == "pytest": + return True + # e.g. from pytest import fixture; @fixture(...) + if isinstance(decorator.func, ast.Name) and decorator.func.id == "fixture": + return True + # Case 2: @pytest.fixture (no parentheses) + else: + # e.g. @pytest.fixture + if getattr(decorator, "attr", None) == "fixture" and getattr(decorator, "value", None): + if getattr(decorator.value, "id", None) == "pytest": + return True + # e.g. from pytest import fixture; @fixture + if isinstance(decorator, ast.Name) and decorator.id == "fixture": + return True + return False + + +def _build_usage_pattern(function_name: str) -> str: + r"""Build a portable regex to match function usages. + + Uses word boundary semantics based on the detected grep engine. + The pattern is designed to match function calls as well as references + (e.g., when passed as an argument). + - PCRE (-P): \bname\b + - Basic (-G): \ + """ + flag = _detect_supported_grep_flag() + if flag == "-P": + return rf"\b{function_name}\b" + # -G basic regex: use word-start and word-end tokens + return rf"\<{function_name}\>" + + +def _build_fixture_param_pattern(function_name: str) -> str: + r"""Build a portable regex to find a parameter named `function_name` in a function signature. + + This pattern is designed to match parameter names in function signatures, including + multiline function definitions where parameters may be on different lines from the 'def'. + It looks for the parameter name followed by typical parameter syntax (colon, comma, or closing paren). + """ + flag = _detect_supported_grep_flag() + if flag == "-P": + # Match parameter name followed by type annotation (:), comma, or closing paren + # This handles both single-line and multiline function definitions + return rf"\b{function_name}\b\s*[,:]" + # For -G (basic regex), use POSIX classes + # Match word boundary + name + optional spaces + (colon or comma or closing paren) + return rf"\<{function_name}\>[[:space:]]*[,:]" + + +def _is_pytest_mark_usefixtures_call(call_node: ast.Call) -> bool: + """Check if an AST Call node represents pytest.mark.usefixtures(...).""" + if isinstance(call_node.func, ast.Attribute): + # Handle pytest.mark.usefixtures + if ( + call_node.func.attr == "usefixtures" + and isinstance(call_node.func.value, ast.Attribute) + and call_node.func.value.attr == "mark" + and isinstance(call_node.func.value.value, ast.Name) + and call_node.func.value.value.id == "pytest" + ): + return True + return False + + +def _check_fixturenames_insert_pattern(fixture_name: str, file_path: str) -> bool: + """Check if a fixture is used in item.fixturenames.insert() pattern. + + This catches dynamic fixture injection patterns like: + item.fixturenames.insert(0, "fixture_name") + """ + try: + with open(file_path) as f: + content = f.read() + + tree = parse(source=content) + + # Look for method calls like item.fixturenames.insert(...) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + # Check for attribute calls like item.fixturenames.insert + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "insert" + and isinstance(node.func.value, ast.Attribute) + and node.func.value.attr == "fixturenames" + ): + # Check the arguments for our fixture name + for arg in node.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + if arg.value == fixture_name: + return True + + return False + except (FileNotFoundError, SyntaxError, ValueError): + return False + + +def _check_getfixturevalue_pattern(fixture_name: str, file_path: str) -> bool: + """Check if a fixture is used in request.getfixturevalue() pattern. + + This catches dynamic fixture access patterns like: + - request.getfixturevalue(argname="fixture_name") + - request.getfixturevalue("fixture_name") + """ + try: + with open(file_path) as f: + content = f.read() + + tree = parse(source=content) + + # Look for method calls like request.getfixturevalue(...) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + # Check for attribute calls like request.getfixturevalue + if isinstance(node.func, ast.Attribute) and node.func.attr == "getfixturevalue": + # Check positional arguments + for arg in node.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + if arg.value == fixture_name: + return True + + # Check keyword arguments (argname="fixture_name") + for keyword in node.keywords: + if keyword.arg == "argname": + if ( + isinstance(keyword.value, ast.Constant) + and isinstance(keyword.value.value, str) + and keyword.value.value == fixture_name + ): + return True + + return False + except (FileNotFoundError, SyntaxError, ValueError): + return False + + +def _is_usefixtures_context(file_path: str, line_number: str, fixture_name: str) -> bool: + """Check if a fixture usage is within a pytest.mark.usefixtures context. + + Uses AST parsing to efficiently check the context instead of reading lines. + """ + try: + with open(file_path) as f: + content = f.read() + + # Parse the AST of the file + tree = parse(source=content) + target_line = int(line_number) + + # Look for pytest.mark.usefixtures calls that span the target line + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + # Check if the value is a call to pytest.mark.usefixtures + if isinstance(node.value, ast.Call): + if _is_pytest_mark_usefixtures_call(node.value): + # Check if the target line is within this call's range + call_start = node.value.lineno + call_end = node.value.end_lineno or call_start + + if call_start <= target_line <= call_end: + # Check if any of the arguments contain our fixture name + for arg in node.value.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + if arg.value == fixture_name: + return True + + # Also check if the value is a list containing pytest.mark.usefixtures calls + elif isinstance(node.value, ast.List): + for element in node.value.elts: + if isinstance(element, ast.Call) and _is_pytest_mark_usefixtures_call(element): + # Check if the target line is within this call's range + call_start = element.lineno + call_end = element.end_lineno or call_start + + if call_start <= target_line <= call_end: + # Check if any of the arguments contain our fixture name + for arg in element.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + if arg.value == fixture_name: + return True + + # Also check decorators + elif isinstance(node, (ast.FunctionDef, ast.ClassDef)): + for decorator in node.decorator_list: + if isinstance(decorator, ast.Call) and _is_pytest_mark_usefixtures_call(decorator): + call_start = decorator.lineno + call_end = decorator.end_lineno or call_start + + if call_start <= target_line <= call_end: + for arg in decorator.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + if arg.value == fixture_name: + return True + + return False + except (FileNotFoundError, SyntaxError, ValueError): + return False + + +def _is_documentation_pattern(line: str, function_name: str) -> bool: + """Check if a line contains a documentation pattern rather than a function call. + + Filters out common documentation patterns that include function names with parentheses: + - Parameter descriptions: 'param_name (type): description' + - Type annotations in docstrings + - Inline documentation patterns + - Lines within triple-quoted strings that aren't actual code + + Args: + line: The line of code to check + function_name: The function name we're searching for + + Returns: + True if this appears to be documentation, False if it might be a function call + """ + stripped_line = line.strip() + + # First, exclude obvious code patterns that should never be considered documentation + # Skip control flow statements and common code patterns + code_prefixes = ["if ", "elif ", "while ", "for ", "with ", "assert ", "return ", "yield ", "raise "] + if any(stripped_line.startswith(prefix) for prefix in code_prefixes): + return False + + # Skip function definitions (these are already filtered elsewhere but be extra safe) + if stripped_line.startswith("def "): + return False + + # Pattern 1: Parameter description format "name (type): description" + # But be more specific - require either indentation or specific doc context + # This catches patterns like " namespace (str): The namespace of the pod." + if re.search(rf"^\s+{re.escape(function_name)}\s*\([^)]*\)\s*:", line): + return True + + # Pattern 2: Lines that look like type annotations in docstrings + # Must have descriptive text after the colon, not just code + type_annotation_pattern = rf"\b{re.escape(function_name)}\s*\([^)]*\)\s*:\s+[A-Z][a-z]" + if re.search(type_annotation_pattern, stripped_line): + return True + + # Pattern 3: Lines that contain common documentation keywords near the function name + doc_keywords = ["Args:", "Arguments:", "Parameters:", "Returns:", "Return:", "Raises:", "Note:", "Example:"] + for keyword in doc_keywords: + if keyword in stripped_line: + # If we find doc keywords and function name with parens, likely documentation + if f"{function_name}(" in stripped_line: + return True + # Also catch cases where the keyword line itself contains the function name + if keyword.rstrip(":").lower() == function_name.lower(): + return True + + # Pattern 4: Check for common docstring patterns + # Lines that start with common documentation patterns + doc_starters = ['"""', "'''", "# ", "## ", "### ", "*", "-", "•"] + if any(stripped_line.startswith(starter) for starter in doc_starters): + if f"{function_name}(" in stripped_line: + return True + + return False + + +def _find_git_root(file_path: str) -> str: + """Find the git repository root for a given file path. + + Args: + file_path: Path to a file within a git repository + + Returns: + The git repository root path + """ + path = Path(file_path).resolve().parent + + while path != path.parent: # Stop at filesystem root + if (path / ".git").exists(): + return str(path) + path = path.parent + + # Fallback to current directory (we know it's a git repo from the main check) + return os.getcwd() + + +def _git_grep(pattern: str, file_path: str | None = None) -> list[str]: + """Run git grep with a pattern and return matching lines. + + - Uses dynamically detected regex engine (prefers PCRE ``-P``, falls back to basic ``-G``). + - Includes untracked files so local changes are considered. + - Return an empty list when no matches are found (rc=1). + - Raise on other non-zero exit codes. + - If file_path is provided, runs git grep from the repository root of that file. + + Args: + pattern: The regex pattern to search for + file_path: Optional file path to determine the git repository root + """ + # Determine the working directory for git grep + if file_path: + cwd = _find_git_root(file_path) + else: + # Fall back to current directory (already verified as git repo) + cwd = os.getcwd() + + cmd = [ + "git", + "grep", + "-n", # include line numbers + "--no-color", + "--untracked", + "-I", # ignore binary files + _detect_supported_grep_flag(), + "-e", # safely handle patterns starting with dash + pattern, + ] + result = subprocess.run(cmd, check=False, capture_output=True, text=True, cwd=cwd) + if result.returncode == 0: + return [line for line in result.stdout.splitlines() if line] + # rc=1 means no matches were found + if result.returncode == 1: + return [] + + error_message = result.stderr.strip() or "Unknown git grep error" + raise RuntimeError(f"git grep failed (rc={result.returncode}) for pattern {pattern!r}: {error_message}") + + def _iter_functions(tree: ast.Module) -> Iterable[ast.FunctionDef]: """ Get all function from python file @@ -53,6 +426,14 @@ def is_ignore_function_list(ignore_prefix_list: list[str], function: ast.Functio return False +def _resolve_absolute_path(git_grep_path: str, reference_file: str) -> str: + """Convert git grep path to absolute path if needed.""" + if not os.path.isabs(git_grep_path): + git_root = _find_git_root(reference_file) + return os.path.join(git_root, git_grep_path) + return git_grep_path + + def process_file(py_file: str, func_ignore_prefix: list[str], file_ignore_list: list[str]) -> str: if os.path.basename(py_file) in file_ignore_list: LOGGER.debug(f"Skipping file: {py_file}") @@ -61,6 +442,8 @@ def process_file(py_file: str, func_ignore_prefix: list[str], file_ignore_list: with open(py_file) as fd: tree = parse(source=fd.read()) + unused_messages: list[str] = [] + for func in _iter_functions(tree=tree): if func_ignore_prefix and is_ignore_function_list(ignore_prefix_list=func_ignore_prefix, function=func): LOGGER.debug(f"Skipping function: {func.name}") @@ -75,25 +458,79 @@ def process_file(py_file: str, func_ignore_prefix: list[str], file_ignore_list: continue used = False - _func_grep_found = subprocess.check_output(["git", "grep", "-wE", f"{func.name}(.*)"], shell=False) - for entry in _func_grep_found.decode().splitlines(): - _, _line = entry.split(":", 1) + # Search for any occurrence of the function name as a whole word. + for entry in _git_grep(pattern=_build_usage_pattern(function_name=func.name), file_path=py_file): + # git grep -n output format: path:line-number:line-content + parts = entry.split(":", 2) + if len(parts) != 3: + continue + _, _, _line = parts + # ignore its own definition if f"def {func.name}" in _line: continue - if _line.strip().startswith("#"): + # Filter out documentation patterns that aren't actual function calls + if _is_documentation_pattern(_line, func.name): continue - if func.name in _line: - used = True - break + # Ignore commented lines (full line or inline) + code_part = _line.split("#", 1)[0] + if code_part.startswith("import") or code_part.startswith("from"): + continue + + if func.name not in code_part: + continue + + # If we are here, it's a valid usage. + used = True + break + + # If not found and it's a pytest fixture, check all fixture usage patterns + if not used and is_pytest_fixture(func=func): + patterns: list[tuple[str, Callable[..., bool] | None]] = [ + (_build_fixture_param_pattern(function_name=func.name), None), # Parameter usage + (rf'"{func.name}"', _is_usefixtures_context), # usefixtures usage + (rf'fixturenames\.insert.*"{func.name}"', _check_fixturenames_insert_pattern), # fixturenames.insert + (rf'getfixturevalue.*"{func.name}"', _check_getfixturevalue_pattern), # getfixturevalue + ] + + for pattern, validator_func in patterns: + for entry in _git_grep(pattern=pattern, file_path=py_file): + parts = entry.split(":", 2) + if len(parts) != 3: + continue + _path, _lineno, _line = parts + + # ignore commented lines + if _line.strip().startswith("#"): + continue + + absolute_path = _resolve_absolute_path(_path, py_file) + + # Apply validator function if provided, otherwise it's a match + if validator_func is None: + used = True + break + elif validator_func == _is_usefixtures_context: + if validator_func(absolute_path, _lineno, func.name): + used = True + break + else: # _check_fixturenames_insert_pattern or _check_getfixturevalue_pattern + if validator_func(func.name, absolute_path): + used = True + break + + if used: + break if not used: - return f"{os.path.relpath(py_file)}:{func.name}:{func.lineno}:{func.col_offset} Is not used anywhere in the code." + unused_messages.append( + f"{os.path.relpath(py_file)}:{func.name}:{func.lineno}:{func.col_offset} Is not used anywhere in the code." + ) - return "" + return "\n".join(unused_messages) @click.command() @@ -114,38 +551,90 @@ def process_file(py_file: str, func_ignore_prefix: list[str], file_ignore_list: type=ListParamType(), ) @click.option("--verbose", "-v", default=False, is_flag=True) +@click.option( + "--file-path", + "-f", + help="Analyze a single Python file for unused functions. Must be an existing .py file.", + type=click.Path(exists=True), +) +@click.option( + "--directory", + "-d", + help="Analyze all Python files in a directory recursively for unused functions. Must be an existing directory.", + type=click.Path(exists=True, dir_okay=True), +) def get_unused_functions( - config_file_path: str, exclude_files: list[str], exclude_function_prefixes: list[str], verbose: bool + config_file_path: str, + exclude_files: list[str], + exclude_function_prefixes: list[str], + verbose: bool, + file_path: click.Path, + directory: click.Path, ) -> None: LOGGER.setLevel(logging.DEBUG if verbose else logging.INFO) + if file_path and not os.path.isfile(str(file_path)): + LOGGER.error("File path must be a file, not a directory.") + sys.exit(1) + + if directory and not os.path.isdir(str(directory)): + LOGGER.error("Directory must be a directory, not a file.") + sys.exit(1) + unused_functions: list[str] = [] unused_code_config = get_util_config(util_name="pyutils-unusedcode", config_file_path=config_file_path) func_ignore_prefix = exclude_function_prefixes or unused_code_config.get("exclude_function_prefix", []) file_ignore_list = exclude_files or unused_code_config.get("exclude_files", []) - jobs: list[Future] = [] + jobs: dict[Future, str] = {} if not os.path.exists(".git"): LOGGER.error("Must be run from a git repository") sys.exit(1) - with ThreadPoolExecutor() as executor: - for py_file in all_python_files(): - jobs.append( - executor.submit( + # Pre-flight grep flag detection to fail fast with clear error if unsupported + try: + detected_flag = _detect_supported_grep_flag() + LOGGER.debug(f"Using git grep flag: {detected_flag}") + except RuntimeError as e: + LOGGER.error(str(e)) + sys.exit(1) + + if file_path: + _unused_functions = process_file( + py_file=str(file_path), + func_ignore_prefix=func_ignore_prefix, + file_ignore_list=file_ignore_list, + ) + if _unused_functions: + unused_functions.append(_unused_functions) + else: + with ThreadPoolExecutor() as executor: + for py_file in all_python_files(directory=directory): + future = executor.submit( process_file, py_file=py_file, func_ignore_prefix=func_ignore_prefix, file_ignore_list=file_ignore_list, ) - ) + jobs[future] = py_file + + processing_errors: list[str] = [] + for future in as_completed(jobs): + try: + if unused_func := future.result(): + unused_functions.append(unused_func) + except Exception as exc: + processing_errors.append(f"{jobs[future]}: {exc}") - for result in as_completed(jobs): - if unused_func := result.result(): - unused_functions.append(unused_func) + if processing_errors: + joined = "\n".join(processing_errors) + LOGGER.error(f"One or more files failed to process:\n{joined}") + sys.exit(2) if unused_functions: - click.echo("\n".join(unused_functions)) + # Sort output for deterministic CI logs + sorted_output = sorted(unused_functions) + click.echo("\n".join(sorted_output)) sys.exit(1) diff --git a/apps/utils.py b/apps/utils.py index e9ecbff..f3c1df6 100644 --- a/apps/utils.py +++ b/apps/utils.py @@ -73,12 +73,14 @@ def convert(self, cli_value: Any, param: click.Parameter | None, ctx: click.Cont ) -def all_python_files() -> Iterable[str]: +def all_python_files(directory: click.Path | None = None) -> Iterable[str]: """ Get all python files from current directory and subdirectories """ exclude_dirs = [".tox", "venv", ".pytest_cache", "site-packages", ".git"] - for root, _, files in os.walk(os.path.abspath(os.curdir)): + target = str(directory) if directory else os.path.abspath(os.curdir) + + for root, _, files in os.walk(target): if [_dir for _dir in exclude_dirs if _dir in root]: continue for filename in files: diff --git a/pyproject.toml b/pyproject.toml index 0c99318..55b1e21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ skip_empty = true [tool.coverage.html] directory = ".tests_coverage" -show_contexts = true +show_contexts = false [tool.uv] default-groups = [ "dev", "test" ] diff --git a/tests/unused_code/manifests/functions_as_args.py b/tests/unused_code/manifests/functions_as_args.py new file mode 100644 index 0000000..41ed7f3 --- /dev/null +++ b/tests/unused_code/manifests/functions_as_args.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +class TimeoutSampler: + def __init__(self, wait_timeout: int, sleep: int, func: callable, **func_kwargs) -> None: + self.wait_timeout = wait_timeout + self.sleep = sleep + self.func = func + self.func_kwargs = func_kwargs + + +def get_failed_cluster_operator(admin_client: str) -> bool: + return admin_client == "failed" + + +def main(admin_client: str) -> TimeoutSampler: + return TimeoutSampler( + wait_timeout=10, + sleep=1, + func=get_failed_cluster_operator, + admin_client=admin_client, + ) diff --git a/tests/unused_code/manifests/test_config.yaml b/tests/unused_code/manifests/test_config.yaml new file mode 100644 index 0000000..3727e8c --- /dev/null +++ b/tests/unused_code/manifests/test_config.yaml @@ -0,0 +1,4 @@ +pyutils-unusedcode: + exclude_files: + - "unused_code_file_for_test.py" + - "functions_as_args.py" diff --git a/tests/unused_code/manifests/unused_code_file_for_test.py b/tests/unused_code/manifests/unused_code_file_for_test.py new file mode 100644 index 0000000..55d30be --- /dev/null +++ b/tests/unused_code/manifests/unused_code_file_for_test.py @@ -0,0 +1,218 @@ +""" +Test file for unused code detection with various documentation patterns. + +This file contains functions that are referenced in documentation but not in actual code, +to test the false positive detection capabilities of unused_code.py. +""" + + +# Original functions for existing tests compatibility +def unused_code_check_fail() -> None: + """Original function for compatibility with existing tests.""" + pass + + +def unused_code_check_file() -> None: + """Original function for compatibility with existing tests.""" + pass + + +def unused_code_namespace(): + """Create a new namespace. + + Args: + unused_code_namespace (str): The namespace of the pod. + unused_code_namespace (str): Kubernetes namespace in which to create the Secret. + unused_code_namespace (Optional[str]): The namespace to use. + unused_code_namespace (Union[str, None]): Optional namespace parameter + unused_code_namespace (List[str]): List of namespaces + + Parameters: + unused_code_namespace (str): Namespace identifier + + Returns: + str: The created namespace name. + + Note: + * unused_code_namespace (string): Pod namespace + - unused_code_namespace (str, optional): Target namespace + unused_code_namespace (str, default='default'): The namespace name + """ + # unused_code_namespace (str): Comment documentation + ## unused_code_namespace (str): Markdown style documentation + return "default" + + +def unused_code_create_secret(): + """Create secrets with various documentation patterns. + + Args: + unused_code_create_secret (callable): Function to create secrets + unused_code_create_secret ( str ) : Description with extra spaces + unused_code_create_secret(str): Documentation without space before paren + unused_code_create_secret (Callable[[str], Secret]): Complex type annotation + unused_code_create_secret (Union[str, None]): Union type in docs + unused_code_create_secret (Dict[str, Any]): Dictionary type parameter + + Returns: + str: The created secret. + """ + return "secret-value" + + +def unused_code_create_namespace(): + """Create a new namespace. + + This function demonstrates documentation false positives. + + Args: + unused_code_create_namespace (str): The namespace to create. + + Returns: + str: The created namespace name. + """ + return "default" + + +def unused_code_get_pod_status(): + """Get the status of a pod. + + Args: + unused_code_get_pod_status (callable): Function to get status. + + Returns: + str: Pod status. + """ + return "Running" + + +def unused_code_check_pods(): + """Check pod status. + + This function actually calls get_pod_status(), so get_pod_status should NOT + be marked as unused. + + Args: + unused_code_get_pod_status (callable): Function to get status. + """ + # This is a real function call + return unused_code_get_pod_status() + + +def unused_code_validate_namespace(): + """Validate a Kubernetes namespace. + + Args: + unused_code_validate_namespace (str): The namespace to validate. + + Returns: + bool: True if namespace is valid. + """ + return True + + +def unused_code_deploy_app(): + """Deploy an application with comprehensive documentation. + + Standard Python docstring formats: + unused_code_deploy_app (str): Application name to deploy + unused_code_deploy_app (Optional[str]): Optional app name + unused_code_deploy_app (Union[str, None]): App name or None + unused_code_deploy_app (List[str]): List of app names + unused_code_deploy_app (Dict[str, Any]): App configuration + unused_code_deploy_app (Callable[[str], bool]): Deployment function + + Markdown documentation: + * unused_code_deploy_app (str): Application name parameter + - unused_code_deploy_app (string): The app to deploy + + Type hints in comments: + # unused_code_deploy_app (str): Type annotation comment + ## unused_code_deploy_app (str): Markdown header documentation + + In docstrings with quotes: + '''unused_code_deploy_app (str): Parameter description''' + + Returns: + bool: True if deployment successful. + """ + # unused_code_deploy_app (str): Type annotation comment + ## unused_code_deploy_app (str): Markdown header documentation + return True + + +def unused_code_some_other_function(): + """Some other function. + + This function contains documentation references to other functions + but doesn't actually call them. + + Args: + unused_code_create_namespace (str): The namespace to create. + unused_code_validate_namespace (str): The namespace to validate. + unused_code_deploy_app (str): Application name to deploy + unused_code_namespace (str): The namespace of the pod. + + Returns: + str: The created namespace name. + """ + pass + + +def unused_code_edge_case_function(): + """Function with edge case documentation patterns. + + Various edge cases for documentation pattern detection: + unused_code_namespace (str): The namespace of the pod. + unused_code_namespace (str): Kubernetes namespace description + unused_code_namespace (Optional[str]): Optional namespace + unused_code_create_secret (callable): Function to create secrets + unused_code_deploy_app (str): Application name to deploy + + Returns: + None + """ + pass + + +def unused_code_function_with_legitimate_calls(): + """Function that actually uses other functions. + + This function demonstrates real function calls that should be detected + as legitimate usage, not documentation patterns. + """ + # These should be detected as real usage + result = unused_code_namespace() + unused_code_create_secret() + + if unused_code_validate_namespace(): + deploy_result = unused_code_deploy_app() + return deploy_result + + return result + + +def unused_code_unused_function_no_docs(): + """This function has no documentation patterns referencing it. + + It should be detected as unused since there are no references to it + anywhere in the codebase. + """ + return "I am unused" + + +def unused_code_unused_function_with_docs(): + """This function is only referenced in documentation. + + Args: + unused_code_unused_function_with_docs (callable): This function itself + + It should be detected as unused since the only reference is in + its own documentation. + """ + return "I am also unused" + + +def unused_code_skip_with_comment() -> None: + """Function that should be skipped due to comment.""" + pass # skip-unused-code diff --git a/tests/unused_code/test_unused_code.py b/tests/unused_code/test_unused_code.py index d904a99..37b6388 100644 --- a/tests/unused_code/test_unused_code.py +++ b/tests/unused_code/test_unused_code.py @@ -1,6 +1,10 @@ +import os +import textwrap + +import pytest from simple_logger.logger import get_logger -from apps.unused_code.unused_code import get_unused_functions +from apps.unused_code.unused_code import _git_grep, get_unused_functions, process_file from tests.utils import get_cli_runner LOGGER = get_logger(name=__name__) @@ -14,14 +18,25 @@ def test_unused_code(): def test_unused_code_file_list(): - result = get_cli_runner().invoke(get_unused_functions, '--exclude-files "unused_code_file_for_test.py"') + result = get_cli_runner().invoke( + get_unused_functions, + '--exclude-files "unused_code_file_for_test.py"', + ) LOGGER.info(f"Result output: {result.output}, exit code: {result.exit_code}, exceptions: {result.exception}") assert result.exit_code == 0 assert "Is not used anywhere in the code" not in result.output def test_unused_code_function_list_exclude_all(): - result = get_cli_runner().invoke(get_unused_functions, '--exclude-function-prefixes "unused_code_"') + result = get_cli_runner().invoke( + get_unused_functions, + [ + "--exclude-function-prefixes", + "unused_code_", + "--exclude-files", + "manifests/unused_code_file_for_test.py", + ], + ) LOGGER.info(f"Result output: {result.output}, exit code: {result.exit_code}, exceptions: {result.exception}") assert result.exit_code == 0 assert "Is not used anywhere in the code" not in result.output @@ -39,3 +54,240 @@ def test_unused_code_check_skip_with_comment(): LOGGER.info(f"Result output: {result.output}, exit code: {result.exit_code}, exceptions: {result.exception}") assert result.exit_code == 1 assert "skip_with_comment" not in result.output + + +def test_unused_code_handles_pytest_fixture_parameter_usage(mocker, tmp_path): + # Create a temporary python file with a pytest fixture and a test using it as a parameter + py_file = tmp_path / "tmp_fixture_usage.py" + py_file.write_text( + textwrap.dedent( + """ +import pytest + +@pytest.fixture +def sample_fixture(): + return 1 + +def test_something(sample_fixture): + assert sample_fixture == 1 +""" + ) + ) + + # Mock grep to simulate: no direct call matches, but parameter usage is detected + def _mock_grep(pattern: str, **kwargs): + # The usage pattern will now match the fixture name in the function signature + if pattern == r"\bsample_fixture\b": + return [f"{py_file.as_posix()}:1:def test_something(sample_fixture): pass"] + return [] + + mocker.patch("apps.unused_code.unused_code._git_grep", side_effect=_mock_grep) + + result = process_file(py_file=str(py_file), func_ignore_prefix=[], file_ignore_list=[]) + assert result == "" # should not report fixture as unused + + +def test_unused_code_handles_no_matches_without_crashing(mocker, tmp_path): + # Create a temporary python file with a simple function + py_file = tmp_path / "tmp_simple.py" + py_file.write_text( + textwrap.dedent( + """ +def my_helper(): + return 42 +""" + ) + ) + + # Mock grep to simulate no matches anywhere + mocker.patch("apps.unused_code.unused_code._git_grep", return_value=[]) + + # Should return an "unused" message and not crash + result = process_file(py_file=str(py_file), func_ignore_prefix=[], file_ignore_list=[]) + assert "Is not used anywhere in the code." in result + + +def test_unused_code_skips_autouse_fixture(tmp_path): + py_file = tmp_path / "tmp_autouse_fixture.py" + py_file.write_text( + textwrap.dedent( + """ +import pytest + +@pytest.fixture(autouse=True) +def auto_fixture(): + return 1 +""" + ) + ) + + result = process_file(py_file=str(py_file), func_ignore_prefix=[], file_ignore_list=[]) + # should skip autouse fixture and not report unused + assert result == "" + + +def test_git_grep_raises_on_unexpected_error(mocker): + class FakeCompleted: + def __init__(self): + self.returncode = 2 + self.stdout = "" + self.stderr = "fatal: not a git repository" + + mocker.patch("apps.unused_code.unused_code.subprocess.run", return_value=FakeCompleted()) + with pytest.raises(RuntimeError): + _git_grep(pattern="anything") + + +def test_commented_usage_is_ignored(mocker, tmp_path): + # Create a temporary python file with a simple function + py_file = tmp_path / "tmp_commented_usage.py" + py_file.write_text( + textwrap.dedent( + """ +def only_here(): + return 0 +""" + ) + ) + + # Simulate git grep finding only a commented reference + mocker.patch( + "apps.unused_code.unused_code._git_grep", + return_value=["some/other/file.py:12:# only_here() is not really used"], + ) + + # Should still be reported as unused because usage is commented out + result = process_file(py_file=str(py_file), func_ignore_prefix=[], file_ignore_list=[]) + assert "Is not used anywhere in the code." in result + + +def test_git_grep_parsing_handles_windows_paths_with_colons(mocker, tmp_path): + # Create a temporary python file with a simple function + py_file = tmp_path / "tmp_windows_path.py" + py_file.write_text( + textwrap.dedent( + """ +def my_function(): + return 42 +""" + ) + ) + + # Simulate git grep output with Windows-style paths containing drive letters and colons + # Format: path:line-number:line-content + # Windows paths like C:\path\to\file.py:123:content would break with split(":", 2) + # but should work correctly with rsplit(":", 2) + mocker.patch( + "apps.unused_code.unused_code._git_grep", + return_value=[ + "C:\\Users\\test\\project\\file.py:25:result = my_function()", + "/some/unix/path/with:colon/file.py:30:my_function() # usage found", + "D:\\Another\\Windows\\Path\\test.py:15: my_function() # another usage", + ], + ) + + # Should detect the function as used (not report as unused) + result = process_file(py_file=str(py_file), func_ignore_prefix=[], file_ignore_list=[]) + assert result == "" # Empty string means function is used, not unused + + +def test_git_grep_parsing_handles_malformed_output_gracefully(mocker, tmp_path): + # Create a temporary python file with a simple function + py_file = tmp_path / "tmp_malformed.py" + py_file.write_text( + textwrap.dedent( + """ +def my_function(): + return 42 +""" + ) + ) + + # Simulate git grep output with malformed entries (missing parts) + # The parsing should skip malformed entries and continue processing + mocker.patch( + "apps.unused_code.unused_code._git_grep", + return_value=[ + "malformed_line_without_colons", # Should be skipped + "only:one:colon", # Should be skipped (only 2 parts after rsplit) + "C:\\valid\\path\\file.py:25:result = my_function()", # Valid - should be processed + ], + ) + + # Should detect the function as used despite malformed entries + result = process_file(py_file=str(py_file), func_ignore_prefix=[], file_ignore_list=[]) + assert result == "" # Empty string means function is used, not unused + + +def test_function_as_argument_is_used(): + result = process_file( + py_file="tests/unused_code/manifests/functions_as_args.py", + func_ignore_prefix=[], + file_ignore_list=[], + ) + assert result == "" + + +def test_unused_code_with_file_path_no_unused(): + result = get_cli_runner().invoke( + get_unused_functions, + ["--file-path", "tests/unused_code/manifests/functions_as_args.py"], + ) + assert result.exit_code == 0 + assert "Is not used anywhere in the code" not in result.output + + +def test_unused_code_with_file_path_with_unused(): + result = get_cli_runner().invoke( + get_unused_functions, + ["--file-path", "tests/unused_code/manifests/unused_code_file_for_test.py"], + ) + assert result.exit_code == 1 + assert "Is not used anywhere in the code" in result.output + + +def test_unused_code_with_directory(): + result = get_cli_runner().invoke(get_unused_functions, ["--directory", "tests/unused_code/manifests/"]) + assert result.exit_code == 1 + assert "Is not used anywhere in the code" in result.output + + +def test_unused_code_with_config_file(): + result = get_cli_runner().invoke( + get_unused_functions, + [ + "--config-file-path", + "tests/unused_code/manifests/test_config.yaml", + "--directory", + "tests/unused_code/manifests/", + ], + ) + assert result.exit_code == 0 + assert "Is not used anywhere in the code" not in result.output + + +def test_unused_code_with_file_path_as_dir(): + result = get_cli_runner().invoke(get_unused_functions, ["--file-path", "tests/unused_code/"]) + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + + +def test_unused_code_with_directory_as_file(): + result = get_cli_runner().invoke( + get_unused_functions, + ["--directory", "tests/unused_code/test_unused_code.py"], + ) + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + + +def test_unused_code_not_a_git_repo(tmp_path): + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + # tmp_path is not a git repo, so this should fail + result = get_cli_runner().invoke(get_unused_functions) + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + finally: + os.chdir(original_cwd) diff --git a/tests/unused_code/test_unused_code_coverage.py b/tests/unused_code/test_unused_code_coverage.py new file mode 100644 index 0000000..6587436 --- /dev/null +++ b/tests/unused_code/test_unused_code_coverage.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +import ast +import logging +import os +import subprocess +import textwrap + +import pytest +from ast_comments import parse + +import apps.unused_code.unused_code +from apps.unused_code.unused_code import ( + _check_fixturenames_insert_pattern, + _check_getfixturevalue_pattern, + _find_git_root, + _git_grep, + _is_documentation_pattern, + _is_pytest_mark_usefixtures_call, + _is_usefixtures_context, + _iter_functions, + _resolve_absolute_path, + get_unused_functions, + is_fixture_autouse, + is_ignore_function_list, + is_pytest_fixture, + process_file, +) +from tests.utils import get_cli_runner + + +@pytest.mark.parametrize( + ("code", "is_fixture"), + [ + ("@pytest.fixture\ndef my_fixture(): pass", True), + ("@pytest.fixture()\ndef my_fixture(): pass", True), + ("def my_function(): pass", False), + ( + textwrap.dedent( + """ + import pytest + + @pytest.fixture + def my_fixture(): + pass + """ + ), + True, + ), + ( + textwrap.dedent( + """ + from pytest import fixture + + @fixture + def my_fixture(): + pass + """ + ), + True, + ), + ( + textwrap.dedent( + """ + import pytest + + @pytest.fixture + def my_fixture(): + pass + """ + ), + True, + ), + ( + textwrap.dedent( + """ + from pytest import fixture + + @fixture() + def my_fixture(): + pass + """ + ), + True, + ), + ], +) +def test_is_pytest_fixture(code, is_fixture): + tree = parse(code) + func = tree.body[0] + if isinstance(func, ast.Import): + func = tree.body[1] + if isinstance(func, ast.ImportFrom): + func = tree.body[1] + + assert is_pytest_fixture(func) == is_fixture + + +@pytest.mark.parametrize( + ("code", "is_autouse"), + [ + ("@pytest.fixture(autouse=True)\ndef my_fixture(): pass", True), + ("@pytest.fixture\ndef my_fixture(): pass", False), + ], +) +def test_is_fixture_autouse(code, is_autouse): + tree = parse(code) + func = tree.body[0] + assert is_fixture_autouse(func) == is_autouse + + +def test_check_fixturenames_insert_pattern(tmp_path): + py_file = tmp_path / "tmp_fixture_insert.py" + py_file.write_text('item.fixturenames.insert(0, "my_fixture")') + assert _check_fixturenames_insert_pattern("my_fixture", str(py_file)) + assert not _check_fixturenames_insert_pattern("other_fixture", str(py_file)) + + +def test_check_getfixturevalue_pattern(tmp_path): + py_file = tmp_path / "tmp_getfixturevalue.py" + py_file.write_text('request.getfixturevalue("my_fixture")') + assert _check_getfixturevalue_pattern("my_fixture", str(py_file)) + assert not _check_getfixturevalue_pattern("other_fixture", str(py_file)) + + +def test_check_fixturenames_insert_pattern_error(tmp_path): + py_file = tmp_path / "tmp_fixture_insert_error.py" + py_file.write_text("invalid python code") + assert not _check_fixturenames_insert_pattern("my_fixture", str(py_file)) + + +def test_check_getfixturevalue_pattern_error(tmp_path): + py_file = tmp_path / "tmp_getfixturevalue_error.py" + py_file.write_text("invalid python code") + assert not _check_getfixturevalue_pattern("my_fixture", str(py_file)) + + +@pytest.mark.parametrize( + ("line", "is_doc"), + [ + (" my_function (str): description", True), + ("if my_function(): pass", False), + ("def my_function(): pass", False), + (" # my_function()", True), + ], +) +def test_is_documentation_pattern(line, is_doc): + assert _is_documentation_pattern(line, "my_function") == is_doc + + +def test_git_grep_error(mocker): + mocker.patch( + "apps.unused_code.unused_code.subprocess.run", + side_effect=Exception("git error"), + ) + with pytest.raises(Exception): + _git_grep("pattern") + + +@pytest.mark.parametrize( + ("prefixes", "func_name", "is_ignored"), + [ + (["_"], "_my_function", True), + (["test_"], "test_my_function", True), + ([], "my_function", False), + ], +) +def test_is_ignore_function_list(prefixes, func_name, is_ignored): + tree = parse(f"def {func_name}(): pass") + func = tree.body[0] + assert is_ignore_function_list(prefixes, func) == is_ignored + + +def test_is_usefixtures_context(tmp_path): + py_file = tmp_path / "tmp_usefixtures.py" + py_file.write_text( + textwrap.dedent( + """ + import pytest + + @pytest.mark.usefixtures("my_fixture") + def test_something(): + pass + """ + ) + ) + assert _is_usefixtures_context(str(py_file), "4", "my_fixture") + + +def test_is_usefixtures_context_in_list(tmp_path): + py_file = tmp_path / "tmp_usefixtures_list.py" + py_file.write_text( + textwrap.dedent( + """ + import pytest + + pytestmark = [pytest.mark.usefixtures("my_fixture")] + + def test_something(): + pass + """ + ) + ) + assert _is_usefixtures_context(str(py_file), "4", "my_fixture") + + +def test_is_usefixtures_context_in_assignment(tmp_path): + py_file = tmp_path / "tmp_usefixtures_assignment.py" + py_file.write_text( + textwrap.dedent( + """ + import pytest + + my_marks = pytest.mark.usefixtures("my_fixture") + + @my_marks + def test_something(): + pass + """ + ) + ) + assert _is_usefixtures_context(str(py_file), "4", "my_fixture") + + +def test_is_usefixtures_context_class(tmp_path): + py_file = tmp_path / "tmp_usefixtures_class.py" + py_file.write_text( + textwrap.dedent( + """ + import pytest + + @pytest.mark.usefixtures("my_fixture") + class TestSomething: + def test_one(self): + pass + """ + ) + ) + assert _is_usefixtures_context(str(py_file), "4", "my_fixture") + + +def test_detect_supported_grep_flag_fallback(mocker): + apps.unused_code.unused_code._detect_supported_grep_flag.cache_clear() + mocker.patch( + "apps.unused_code.unused_code.subprocess.run", + side_effect=[ + subprocess.CalledProcessError(1, "git"), + subprocess.CalledProcessError(1, "git"), + ], + ) + with pytest.raises(RuntimeError): + apps.unused_code.unused_code._detect_supported_grep_flag() + + +def test_process_file_usefixtures(tmp_path): + py_file = tmp_path / "tmp_usefixtures.py" + py_file.write_text( + textwrap.dedent( + """ + import pytest + + @pytest.fixture + def my_fixture(): + pass + + @pytest.mark.usefixtures("my_fixture") + def test_something(): + pass + """ + ) + ) + assert "my_fixture" not in process_file(str(py_file), [], []) + + +def test_process_file_fixturenames_insert(tmp_path): + py_file = tmp_path / "tmp_fixturenames_insert.py" + py_file.write_text( + textwrap.dedent( + """ + import pytest + + @pytest.fixture + def my_fixture(): + pass + + def pytest_runtest_setup(item): + item.fixturenames.insert(0, "my_fixture") + """ + ) + ) + assert "my_fixture" not in process_file(str(py_file), [], []) + + +def test_process_file_getfixturevalue(tmp_path): + py_file = tmp_path / "tmp_getfixturevalue.py" + py_file.write_text( + textwrap.dedent( + """ + import pytest + + @pytest.fixture + def my_fixture(): + pass + + def test_something(request): + request.getfixturevalue("my_fixture") + """ + ) + ) + assert "my_fixture" not in process_file(str(py_file), [], []) + + +def test_process_file_lambda(tmp_path): + py_file = tmp_path / "tmp_lambda.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + pass + + x = lambda: my_function() + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_list_comprehension(tmp_path): + py_file = tmp_path / "tmp_list_comprehension.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + pass + + x = [my_function() for _ in range(5)] + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_dict_comprehension(tmp_path): + py_file = tmp_path / "tmp_dict_comprehension.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + pass + + x = {i: my_function() for i in range(5)} + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_generator_expression(tmp_path): + py_file = tmp_path / "tmp_generator_expression.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + pass + + x = (my_function() for _ in range(5)) + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_yield_from(tmp_path): + py_file = tmp_path / "tmp_yield_from.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + yield from range(5) + + def my_generator(): + yield from my_function() + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_with_statement(tmp_path): + py_file = tmp_path / "tmp_with_statement.py" + py_file.write_text( + textwrap.dedent( + """ + from contextlib import contextmanager + + @contextmanager + def my_function(): + yield + + with my_function(): + pass + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_for_loop(tmp_path): + py_file = tmp_path / "tmp_for_loop.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + return range(5) + + for i in my_function(): + pass + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_while_loop(tmp_path): + py_file = tmp_path / "tmp_while_loop.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + return False + + while my_function(): + pass + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_process_file_if_statement(tmp_path): + py_file = tmp_path / "tmp_if_statement.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + return True + + if my_function(): + pass + """ + ) + ) + assert "my_function" not in process_file(str(py_file), [], []) + + +def test_find_git_root(tmp_path): + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + git_dir = tmp_path / ".git" + git_dir.mkdir() + assert str(tmp_path) == _find_git_root(str(tmp_path)) + finally: + os.chdir(original_cwd) + + +def test_resolve_absolute_path(tmp_path): + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + git_dir = tmp_path / ".git" + git_dir.mkdir() + file_path = tmp_path / "my_file.py" + file_path.touch() + assert str(file_path) == _resolve_absolute_path("my_file.py", str(tmp_path)) + finally: + os.chdir(original_cwd) + + +def test_is_pytest_mark_usefixtures_call(): + tree = parse( + textwrap.dedent( + """ + import pytest + + @pytest.mark.usefixtures("my_fixture") + def test_something(): + pass + """ + ) + ) + decorator = tree.body[1].decorator_list[0] + assert _is_pytest_mark_usefixtures_call(decorator) + + +def test_iter_functions(): + tree = parse( + textwrap.dedent( + """ + def my_function(): + pass + + def test_my_function(): + pass + """ + ) + ) + functions = list(_iter_functions(tree)) + assert len(functions) == 1 + assert functions[0].name == "my_function" + + +def test_process_file_skip_comment(tmp_path): + py_file = tmp_path / "tmp_skip_comment.py" + py_file.write_text( + textwrap.dedent( + """ + def my_function(): + # skip-unused-code + pass + """ + ) + ) + assert "" == process_file(str(py_file), [], []) + + +def test_process_file_ignore_file(tmp_path): + py_file = tmp_path / "tmp_ignore_file.py" + py_file.write_text("def my_function(): pass") + assert "" == process_file(str(py_file), [], ["tmp_ignore_file.py"]) + + +def test_get_unused_functions_processing_error(mocker): + mocker.patch( + "apps.unused_code.unused_code.process_file", + side_effect=Exception("processing error"), + ) + result = get_cli_runner().invoke(get_unused_functions) + assert result.exit_code == 2 + assert isinstance(result.exception, SystemExit) + + +def test_get_unused_functions_verbose(mocker): + mocker.patch("apps.unused_code.unused_code.LOGGER.setLevel") + get_cli_runner().invoke(get_unused_functions, ["--verbose"]) + apps.unused_code.unused_code.LOGGER.setLevel.assert_called_once_with(logging.DEBUG) diff --git a/tests/unused_code/unused_code_file_for_test.py b/tests/unused_code/unused_code_file_for_test.py deleted file mode 100644 index 714aad5..0000000 --- a/tests/unused_code/unused_code_file_for_test.py +++ /dev/null @@ -1,10 +0,0 @@ -def unused_code_check_fail(): - pass - - -def unused_code_check_file(): - pass - - -def skip_with_comment(): # skip-unused-code - pass