diff --git a/apps/unused_code/unused_code.py b/apps/unused_code/unused_code.py index 7710d3b..da989c7 100644 --- a/apps/unused_code/unused_code.py +++ b/apps/unused_code/unused_code.py @@ -131,6 +131,22 @@ def _build_fixture_param_pattern(function_name: str) -> str: return rf"\<{function_name}\>[[:space:]]*[,:]" +def _build_keyword_unpacking_pattern(function_name: str) -> str: + r"""Build a portable regex to find keyword unpacking usage (**function_name()). + + This pattern is designed to match patterns like **func_name() in function calls or dictionaries. + Uses word boundary semantics based on the detected grep engine. + - PCRE (-P): \*\*function_name\s*\( + - Basic (-G): \*\*function_name[[:space:]]*\( + """ + flag = _detect_supported_grep_flag() + if flag == "-P": + # Match ** followed by function name followed by optional whitespace and opening parenthesis + return rf"\*\*{function_name}\s*\(" + # For -G (basic regex), escape the asterisks and use POSIX classes + 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): @@ -334,7 +350,7 @@ def _is_documentation_pattern(line: str, function_name: str) -> bool: # Pattern 4: Check for common docstring patterns # Lines that start with common documentation patterns - doc_starters = ['"""', "'''", "# ", "## ", "### ", "*", "-", "•"] + doc_starters = ['"""', "'''", "# ", "## ", "### ", "-", "•"] if any(stripped_line.startswith(starter) for starter in doc_starters): if f"{function_name}(" in stripped_line: return True @@ -487,6 +503,30 @@ def process_file(py_file: str, func_ignore_prefix: list[str], file_ignore_list: used = True break + # If not found with general pattern, check for keyword unpacking usage (**func_name()) + if not used: + for entry in _git_grep( + pattern=_build_keyword_unpacking_pattern(function_name=func.name), file_path=py_file + ): + LOGGER.debug(f"Checking {entry} function: {func.name}") + parts = entry.split(":", 2) + if len(parts) != 3: + continue + _, _, _line = parts + + # Filter out documentation patterns that aren't actual function calls + if _is_documentation_pattern(line=_line, function_name=func.name): + LOGGER.debug(f"Skipping doc pattern {entry} function: {func.name}") + continue + + # Ignore commented lines (full line or inline) + if _line.strip().startswith("#"): + continue + + # If we find keyword unpacking usage, mark as used + 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]] = [ diff --git a/tests/unused_code/test_unused_code_keyword_unpacking.py b/tests/unused_code/test_unused_code_keyword_unpacking.py new file mode 100644 index 0000000..f908c1f --- /dev/null +++ b/tests/unused_code/test_unused_code_keyword_unpacking.py @@ -0,0 +1,155 @@ +import textwrap + +from simple_logger.logger import get_logger + +from apps.unused_code.unused_code import get_unused_functions, process_file +from tests.utils import get_cli_runner + +LOGGER = get_logger(name=__name__) + + +def test_keyword_unpacking_usage_is_detected(mocker, tmp_path): + """Test that functions used in **function_name() patterns are detected as used.""" + # Create a temporary python file with a function that would be unused without keyword unpacking + py_file = tmp_path / "tmp_keyword_unpacking.py" + py_file.write_text( + textwrap.dedent( + """ +def get_config(): + return {"key": "value"} +""" + ) + ) + + # Mock git grep to simulate the detection + def _mock_grep(pattern: str, **kwargs): + # Regular usage pattern - no matches found + if pattern == r"\bget_config\b": + return [] + # Keyword unpacking pattern - match found + elif pattern == r"\*\*get_config\s*\(": + return [f"{py_file.as_posix()}:10: result = some_function(**get_config())"] + 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 == "" + + +def test_keyword_unpacking_in_function_definition_is_detected(mocker, tmp_path): + """Test that functions used in keyword unpacking within function definitions are detected as used.""" + py_file = tmp_path / "tmp_keyword_unpacking_def.py" + py_file.write_text( + textwrap.dedent( + """ +def helper_function(): + return {"key": "value"} +""" + ) + ) + + def _mock_grep(pattern: str, **kwargs): + if pattern == r"\bhelper_function\b": + return [] + elif pattern == r"\*\*helper_function\s*\(": + # Return a function definition that USES helper_function - this should be detected + return [f"{py_file.as_posix()}:5:def target_function(**helper_function()):"] + 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 == "" + + +def test_keyword_unpacking_ignores_comments(mocker, tmp_path): + """Test that commented keyword unpacking usage is properly ignored.""" + py_file = tmp_path / "tmp_keyword_unpacking_comment.py" + py_file.write_text( + textwrap.dedent( + """ +def config_helper(): + return {"setting": "value"} +""" + ) + ) + + def _mock_grep(pattern: str, **kwargs): + if pattern == r"\bconfig_helper\b": + return [] + elif pattern == r"\*\*config_helper\s*\(": + # Return a commented usage which should be ignored + return [f"{py_file.as_posix()}:6: # result = setup(**config_helper())"] + 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 "Is not used anywhere in the code." in result + + +def test_keyword_unpacking_ignores_documentation_patterns(mocker, tmp_path): + """Test that documentation patterns with keyword unpacking are properly ignored.""" + py_file = tmp_path / "tmp_keyword_unpacking_doc.py" + py_file.write_text( + textwrap.dedent( + ''' +def doc_function(): + """ + Example: setup(**doc_function()) + This function returns configuration data. + """ + return {"config": "value"} +''' + ) + ) + + def _mock_grep(pattern: str, **kwargs): + if pattern == r"\bdoc_function\b": + return [] + elif pattern == r"\*\*doc_function\s*\(": + # Return documentation pattern which should be ignored + return [f"{py_file.as_posix()}:4: Example: setup(**doc_function())"] + 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 "Is not used anywhere in the code." in result + + +def test_keyword_unpacking_with_cli(tmp_path): + """Test keyword unpacking detection through the CLI interface.""" + py_file = tmp_path / "test_keyword_unpacking.py" + py_file.write_text( + textwrap.dedent( + """ +def get_config(): + return {"database_url": "localhost", "debug": True} + +def get_defaults(): + return {"timeout": 30, "retries": 3} + +def unused_function(): + return "This should be flagged as unused" + +def main(): + config = { + **get_config(), + "extra": "value" + } + result = setup(**get_defaults()) + return result +""" + ) + ) + + result = get_cli_runner().invoke( + get_unused_functions, + ["--file-path", str(py_file)], + ) + assert result.exit_code == 1 + assert "get_config" not in result.output + assert "get_defaults" not in result.output + assert "Is not used anywhere in the code." in result.output