From 7b0c6b21c31cc4a7384480e23f6a95674ae68b98 Mon Sep 17 00:00:00 2001 From: Joao Brandao Date: Wed, 24 Jun 2026 19:50:26 +0100 Subject: [PATCH] Count keyword argument names as uses (#411) Treat keyword-argument names in calls (e.g. `cls(bar=...)`) as uses of same-named variables/attributes, mirroring the existing visit_MatchClass handler. This fixes false-positive "unused variable" reports for dataclass/pydantic/attrs fields that are only consumed via keyword arguments in a factory classmethod or constructor call. Attributes assigned on an instance without a corresponding read (e.g. `inst.bar4 = ...`) remain reported, as connecting them to the class would require type information vulture does not track. Developed using maieutic-tools (https://github.com/jpab/maieutic-tools). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + tests/test_format_strings.py | 2 +- tests/test_scavenging.py | 59 ++++++++++++++++++++++++++++++++++++ vulture/core.py | 8 +++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81359b9..33ad1037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 2.17 (unreleased) +* Count keyword argument names (e.g. `cls(bar=...)`) as uses, fixing false positives for dataclass/pydantic fields consumed only via keyword arguments (#411). * Add support for Python 3.15 and drop 3.9 (Hugo van Kemenade, #416). # 2.16 (2026-03-25) diff --git a/tests/test_format_strings.py b/tests/test_format_strings.py index 3507f169..cbb1ee0f 100644 --- a/tests/test_format_strings.py +++ b/tests/test_format_strings.py @@ -65,4 +65,4 @@ def foobar(): "{} {a} {b}".format(1, a=used_var, b=locals()) """ ) - check(v.used_names, ["used_var", "locals", "format"]) + check(v.used_names, ["a", "b", "used_var", "locals", "format"]) diff --git a/tests/test_scavenging.py b/tests/test_scavenging.py index ec8e4565..09a081e6 100644 --- a/tests/test_scavenging.py +++ b/tests/test_scavenging.py @@ -156,6 +156,65 @@ def test_attribute1(v): check(v.unused_attrs, ["bar", "bar"]) +def test_keyword_argument_marks_variable_used(v): + v.scan( + """\ +class Foo: + bar: str + + @classmethod + def create(cls, value): + return cls(bar=value) +""" + ) + assert "bar" in v.used_names + check(v.unused_vars, []) + + +def test_keyword_argument_in_plain_call(v): + v.scan( + """\ +class Config: + timeout: int + + +Config(timeout=30) +""" + ) + assert "timeout" in v.used_names + check(v.unused_vars, []) + + +def test_keyword_argument_does_not_use_assigned_attribute(v): + """`inst.extra = ...` without a read is still reported (issue #411).""" + v.scan( + """\ +class Foo: + @classmethod + def create(cls): + inst = cls() + inst.extra = 1 + return inst +""" + ) + check(v.unused_attrs, ["extra"]) + + +def test_double_star_kwargs_add_no_name(v): + v.scan( + """\ +def call(**kwargs): + return func(alpha=1, **kwargs) +""" + ) + # `alpha` appears only as a keyword name, so its presence is + # attributable solely to the new kwarg loop. + assert "alpha" in v.used_names + # The `**kwargs` splat has `keyword.arg is None` and must be skipped; + # a broken guard would add `None` to used_names. + assert None not in v.used_names + + def test_ignored_attributes(v): v.scan( """\ diff --git a/vulture/core.py b/vulture/core.py index ff354d41..1803fb33 100644 --- a/vulture/core.py +++ b/vulture/core.py @@ -517,6 +517,14 @@ def visit_Call(self, node): ): self._handle_new_format_string(node.func.value.value) + # Count keyword argument names as usages of same-named attributes/ + # variables, e.g. `cls(bar=...)` marks `bar` as used. Mirrors + # visit_MatchClass; resolves false positives for dataclass/pydantic + # fields consumed only via keyword arguments (issue #411). + for keyword in node.keywords: + if keyword.arg is not None: # keyword.arg is None for **kwargs. + self.used_names.add(keyword.arg) + def _handle_new_format_string(self, s): def is_identifier(name): return bool(re.match(r"[a-zA-Z_][a-zA-Z0-9_]*", name))