Skip to content

Add a generic is_module_member function in pylint.checkers.utils - #11294

Draft
Pierre-Sassoulas wants to merge 1 commit into
pylint-dev:mainfrom
Pierre-Sassoulas:is-module-member
Draft

Add a generic is_module_member function in pylint.checkers.utils#11294
Pierre-Sassoulas wants to merge 1 commit into
pylint-dev:mainfrom
Pierre-Sassoulas:is-module-member

Conversation

@Pierre-Sassoulas

@Pierre-Sassoulas Pierre-Sassoulas commented Aug 18, 2026

Copy link
Copy Markdown
Member

Type of Changes

Type
🔄 Refactor

Description

Groundwork split out of #11293, which needs the same question answered for sys
and reads much smaller once this lands.

I tried some design first (node, modname, *members: str) and (node, modname, members: tuple[str, ...]), but I think *qnames is the easier to understand.

Refs #11293

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.43%. Comparing base (43c7d8a) to head (5db503e).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main   #11294      +/-   ##
==========================================
+ Coverage   96.41%   96.43%   +0.02%     
==========================================
  Files         178      178              
  Lines       20068    20055      -13     
==========================================
- Hits        19348    19340       -8     
+ Misses        720      715       -5     
Files with missing lines Coverage Δ
pylint/checkers/utils.py 96.43% <100.00%> (+0.39%) ⬆️
pylint/checkers/variables.py 97.31% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

This comment has been minimized.

@Pierre-Sassoulas Pierre-Sassoulas added the Enhancement ✨ Improvement to a component label Aug 18, 2026
@Pierre-Sassoulas Pierre-Sassoulas added this to the 4.1.0 milestone Aug 18, 2026
@Pierre-Sassoulas Pierre-Sassoulas changed the title Ask once whether a node refers to a member of a module Add a generic is_module_member function in pylint.checkers.utils Aug 18, 2026
@Pierre-Sassoulas
Pierre-Sassoulas marked this pull request as draft August 18, 2026 08:48
@Pierre-Sassoulas
Pierre-Sassoulas force-pushed the is-module-member branch 4 times, most recently from 3018a36 to 9807f61 Compare August 18, 2026 09:50
@github-actions

This comment has been minimized.

Three helpers each answered that question their own way for the 'typing'
module, and 'is_sys_guard' answers a fourth version of it for 'sys' by
matching the spelling. is_module_member takes the module name and the members
as arguments, so the answer lives in one place:

- is_typing_member was that call with "typing" filled in, so it delegates
  rather than repeat the two lookups. It stays as it is public API.
- in_type_checking_block spelled out both branches by hand. Its 'TYPE_CHECKING
  = False' fallback is kept, still scoped to that one name so an unrelated flag
  inferring to False does not pass for a type-checking guard.
- uninferable_final_decorators spent 30 lines reaching an import node to ask
  whether a decorator is typing.final. It asks directly now, which drops the
  'import_node.modname' read that would raise an AttributeError on a
  nodes.Import, reachable through the '@Final' bound by 'import final'.

Resolving the module through 'lookup' rather than through inference is what
lets one helper serve all of them: inference comes up empty when the import
itself sits inside a guarded block, which the sys version guards need, and it
is also what tells a class named after a module apart from the module.

So all three gain an aliased 'from typing import TYPE_CHECKING as TC', an
'import typing' that sits inside a block, every binding of a name rather than
only the first, and a 'class typing' that no longer passes for the module.
None of that shows up in practice: across five primer packages, 158130 nodes
sitting directly inside an 'if' get the same in_type_checking_block verdict as
before.

'is_sys_guard' is left alone here; it is rewritten on top of this in its own
commit.
@Pierre-Sassoulas

Copy link
Copy Markdown
Member Author

is_module_member's signature — worth a maintainer opinion

This helper answers "does this node refer to <module>.<member>?" for every
spelling, and its third parameter has already been through three shapes in this
branch. Since pylint.checkers.utils is de facto public API for plugin authors
— removals from it get announced in the changelog, even though there is no
__all__ and doc/development_guide/technical_reference/checkers.rst is seven
lines long — I would rather fix the signature before release than after.

The branch currently ships option A. Happy to switch if anyone prefers B or C.

A — qualified names (shipped)

def is_module_member(node: nodes.NodeNG, *qnames: str) -> bool: ...

is_module_member(decorator, "typing.final")
is_module_member(value, "sys.version_info", "sys.hexversion")

For: matches by far the dominant convention in the codebase — 97 .qname() call
sites, and dotted constants everywhere, four of them in utils.py alone
(ABC_METHODS, TYPING_PROTOCOLS, SUBSCRIPTABLE_CLASSES_PEP585,
TERMINATING_FUNCS_QNAMES). Every trailing argument has the same role, which is
what made the earlier *members: str confusing. No trailing-comma tax on the
four of six call sites that name a single member.

Against: a dotless argument cannot be a type error, only a runtime one — hence
the explicit ValueError. And no other public helper in pylint/checkers/
uses *args, though _is_property_kind(node, *kinds: str) in utils.py is
already exactly this shape behind a private door.

B — module plus an iterable of members

def is_module_member(
    node: nodes.NodeNG, modname: str, members: Iterable[str]
) -> bool: ...

is_module_member(decorator, "typing", ("final",))
is_module_member(node, "typing", TYPING_NORETURN)   # a frozenset constant

For: this is precisely DeprecatedMixin.check_deprecated_class(self, node, mod_name: str, class_names: Iterable[str]) in pylint/checkers/deprecated.py,
same parameter order and roles, and it pairs with DEPRECATED_CLASSES in
stdlib.py, which really is a {module: {members}} mapping. Iterable[str] is
also the house type for "some names to match" (decorated_with,
is_module_ignored, truncated_dict_suggestion). It accepts existing frozenset
constants and generators; deprecated.py passes a generator today.

Against: str satisfies Iterable[str], so is_module_member(node, "sys", "version_info") type-checks and then matches individual characters.

C — module plus a tuple of members

Same as B with members: tuple[str, ...]. The only spelling where mypy rejects
the module/member confusion outright:

Argument 3 to "is_module_member" has incompatible type "str"; expected "tuple[str, ...]"

Against: ("final",) on four of six call sites, and it cannot take a frozenset
constant or a generator.

Ruled out on measured cost

A singular-member signature — is_module_member(node, mod, member) — would push
multi-member call sites into any(... for m in members), and resolving a module
is a scope lookup, so that repeats the lookup per member. Counting
nodes.Name.lookup calls on the real resolvers: 2 instead of 1 for a two-member
miss, 4 instead of 2 across three modules.

For the same reason option A groups by module before resolving, rather than
looping over qnames — ("sys.version_info", "sys.hexversion") costs one lookup,
not two — and the Attribute branch still tests the member name before touching
lookup, so a miss there costs none.

Two honest caveats about A

Dotted names need one entry per spelling variant (typing.X,
typing_extensions.X, and the .X local-name form used in TYPING_PROTOCOLS),
which the module/members split avoids. pylint/extensions/typing.py:76 even
derives bare member names back out of dotted keys, so the split is sometimes what
a caller actually wants.

And whichever we pick, both styles will keep coexisting inside utils.py: line
908 asks is_module_member, while 849, 1658 and 2258 compare dotted qnames
directly.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 According to the primer, this change has no effect on the checked open source code. 🤖🎉

This comment was generated for commit 5db503e

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement ✨ Improvement to a component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant