Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_format_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
59 changes: 59 additions & 0 deletions tests/test_scavenging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""\
Expand Down
8 changes: 8 additions & 0 deletions vulture/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading