From 15442a3625b8eef2bb5d14877274ffb59b9086af Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 01:10:40 -0400 Subject: [PATCH 1/2] chore(corekit): spell the import-outside-toplevel pragma correctly (#1923) - `pcapkit/corekit/fields/collections.py` and `misc.py`: the pragmas said `import-outside-top-level`, which pylint rejects as `unknown-option-value` while still reporting `import-outside-toplevel`; spell the name correctly. Comments only, the AST is unchanged. - `tests/project/test_pylint_pragma_names_1923_unit.py`: every pylint pragma name in `pcapkit/` must be on a pinned list; where pylint is importable, each name is also checked against its message store with the Makefile's plugins loaded. Closes #1923 --- pcapkit/corekit/fields/collections.py | 2 +- pcapkit/corekit/fields/misc.py | 8 +- .../test_pylint_pragma_names_1923_unit.py | 188 ++++++++++++++++++ 3 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 tests/project/test_pylint_pragma_names_1923_unit.py diff --git a/pcapkit/corekit/fields/collections.py b/pcapkit/corekit/fields/collections.py index 9d12671f5..fa74f45ed 100644 --- a/pcapkit/corekit/fields/collections.py +++ b/pcapkit/corekit/fields/collections.py @@ -149,7 +149,7 @@ def pack(self, value: 'Optional[list[_TL]]', packet: 'dict[str, Any]') -> 'bytes return value from pcapkit.protocols.schema.schema import \ - Schema # pylint: disable=import-outside-top-level + Schema # pylint: disable=import-outside-toplevel temp = [] # type: list[bytes] for item in value: diff --git a/pcapkit/corekit/fields/misc.py b/pcapkit/corekit/fields/misc.py index 76bdc680f..f7dcbcef9 100644 --- a/pcapkit/corekit/fields/misc.py +++ b/pcapkit/corekit/fields/misc.py @@ -255,7 +255,7 @@ def optional(self) -> 'bool': def protocol(self) -> 'Type[_TP]': """Payload protocol.""" if self._protocol is None: - from pcapkit.protocols.misc.raw import Raw # type: ignore[unreachable] # pylint: disable=import-outside-top-level # isort:skip + from pcapkit.protocols.misc.raw import Raw # type: ignore[unreachable] # pylint: disable=import-outside-toplevel # isort:skip return Raw return self._protocol @@ -275,7 +275,7 @@ def protocol(self, protocol: 'Type[_TP] | str') -> 'None': """ if isinstance(protocol, str): - from pcapkit.protocols import __proto__ # pylint: disable=import-outside-top-level + from pcapkit.protocols import __proto__ # pylint: disable=import-outside-toplevel # NOTE: The registry is keyed on the upper-cased class name, both # when it is seeded (``pcapkit/protocols/__init__.py:76``) and when @@ -372,7 +372,7 @@ def pack(self, value: 'Optional[_TP | Schema | bytes]', packet: 'dict[str, Any]' value = cast('_TP', self._default) from pcapkit.protocols.schema.schema import \ - Schema # pylint: disable=import-outside-top-level + Schema # pylint: disable=import-outside-toplevel if isinstance(value, bytes): return value if isinstance(value, Schema): @@ -728,7 +728,7 @@ def pack(self, value: 'Optional[_TS | bytes]', packet: 'dict[str, Any]') -> 'byt # through ``from_dict`` hands that mapping back here; turn it into the # declared schema before packing it. from pcapkit.protocols.schema.schema import \ - Schema # pylint: disable=import-outside-top-level + Schema # pylint: disable=import-outside-toplevel if isinstance(value, Mapping) and not isinstance(value, Schema): value = self._schema.from_dict(value) diff --git a/tests/project/test_pylint_pragma_names_1923_unit.py b/tests/project/test_pylint_pragma_names_1923_unit.py new file mode 100644 index 000000000..eba5ff307 --- /dev/null +++ b/tests/project/test_pylint_pragma_names_1923_unit.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +"""Every ``# pylint:`` pragma in :mod:`pcapkit` names a real message (#1923). + +A pragma that misspells its message, as ``import-outside-top-level`` did for +``import-outside-toplevel``, suppresses nothing. pylint reports ``W0012 +unknown-option-value`` on it and still reports the message it was meant to +silence, but the lint job is advisory, so nothing noticed. + +:data:`KNOWN_MESSAGES` pins the names the package's pragmas use, and every +pragma name has to be on it, so that check runs on every leg with nothing +installed. Where pylint is importable, each name in the pragmas and on the list +is also looked up in pylint's own message store, with the plugins ``make +pylint`` loads, so the list cannot pin a misspelling. No test extra installs +pylint, so that lookup skips in CI. + +Pragmas are read from comment tokens with :mod:`tokenize`, so a docstring that +quotes one is not a pragma. This module is unit-tier: it imports nothing it +scans. + +""" +from __future__ import annotations + +import io +import re +import tokenize +import unittest +from typing import TYPE_CHECKING + +from tests._tiers import ROOT + +if TYPE_CHECKING: + from pathlib import Path + from typing import Iterator + +#: Package whose pragmas are checked. +PACKAGE = ROOT / 'pcapkit' + +#: The Makefile, whose ``PYLINT_FLAGS`` name the plugins ``make pylint`` loads. +MAKEFILE = ROOT / 'Makefile' + +#: A ``pylint:`` pragma in a comment, up to the next ``;`` or ``#``, as +#: :data:`pylint.utils.pragma_parser.OPTION_RGX` reads it. +PRAGMA = re.compile(r'\bpylint:\s*([^;#\n]+)') + +#: The pragma keywords that take a list of messages, from +#: :data:`pylint.utils.pragma_parser.MESSAGE_KEYWORDS`. +ASSIGNMENT = re.compile(r'^\s*(disable-next|disable-msg|enable-msg|disable|enable)\s*=\s*(.*)$') + +#: The message names the package's pragmas may use. Add a name here only once +#: ``pylint --list-msgs`` lists it. +KNOWN_MESSAGES = frozenset({ + 'abstract-method', + 'arguments-differ', + 'arguments-renamed', + 'attribute-defined-outside-init', + 'bad-super-call', + 'broad-except', + 'consider-using-f-string', + 'consider-using-with', + 'else-if-used', + 'exec-used', + 'fixme', + 'global-statement', + 'import-error', + 'import-outside-toplevel', + 'inconsistent-return-statements', + 'invalid-index-returned', + 'invalid-name', + 'line-too-long', + 'logging-fstring-interpolation', + 'multiple-statements', + 'no-else-raise', + 'no-member', + 'no-value-for-parameter', + 'non-parent-init-called', + 'not-callable', + 'protected-access', + 'raise-missing-from', + 'redefined-builtin', + 'redefined-outer-name', + 'signature-differs', + 'super-init-not-called', + 'too-many-arguments', + 'unidiomatic-typecheck', + 'unspecified-encoding', + 'unsubscriptable-object', + 'unused-argument', + 'unused-import', + 'unused-wildcard-import', + 'wildcard-import', + 'wrong-import-position', +}) + +try: + from pylint.exceptions import UnknownMessageError + from pylint.lint import PyLinter +except ImportError: + HAS_PYLINT = False +else: + HAS_PYLINT = True + + +def _names(comment: 'str') -> 'Iterator[str]': + """The message names the ``pylint:`` pragmas in ``comment`` list.""" + for pragma in PRAGMA.finditer(comment): + assignment = ASSIGNMENT.match(pragma.group(1)) + if assignment is None: + continue + for name in assignment.group(2).split(','): + name = name.strip() + if name: + yield name + + +def _pragmas(path: 'Path') -> 'Iterator[tuple[int, str]]': + """The line and message name of each pragma entry in the comments of ``path``.""" + with path.open('rb') as file: + for token in tokenize.tokenize(file.readline): + if token.type == tokenize.COMMENT: + for name in _names(token.string): + yield token.start[0], name + + +def _package_pragmas() -> 'list[tuple[str, int, str]]': + """Every pragma entry in :data:`PACKAGE`, as the file, line and message name.""" + return [(path.relative_to(ROOT).as_posix(), line, name) + for path in sorted(PACKAGE.rglob('*.py')) + for line, name in _pragmas(path)] + + +def _plugins() -> 'list[str]': + """The plugins ``PYLINT_FLAGS`` in the Makefile loads.""" + match = re.search(r'^PYLINT_FLAGS = .*?--load-plugins=(\S+)', MAKEFILE.read_text(encoding='utf-8'), re.MULTILINE) + if match is None: + raise AssertionError(f'{MAKEFILE} names no --load-plugins= in PYLINT_FLAGS') + return match.group(1).split(',') + + +class PragmaParserTests(unittest.TestCase): + """The scan reads pragma names the way pylint does.""" + + def test_reads_every_name_of_a_pragma(self) -> None: + comment = '# type: ignore[unreachable] # pylint: disable=import-outside-top-level,no-member # isort:skip' + self.assertEqual(list(_names(comment)), ['import-outside-top-level', 'no-member']) + + def test_reads_disable_next(self) -> None: + self.assertEqual(list(_names('# pylint: disable-next=unused-import')), ['unused-import']) + + def test_ignores_prose_about_pragmas(self) -> None: + self.assertEqual(list(_names('# ``# pylint: disable`` is *line*-scoped')), []) + + def test_ignores_pragmas_in_strings(self) -> None: + source = b'x = "# pylint: disable=not-a-message" # pylint: disable=invalid-name\n' + tokens = tokenize.tokenize(io.BytesIO(source).readline) + comments = [token.string for token in tokens if token.type == tokenize.COMMENT] + self.assertEqual([name for comment in comments for name in _names(comment)], ['invalid-name']) + + def test_package_has_pragmas(self) -> None: + self.assertTrue(_package_pragmas(), f'no pylint pragma found under {PACKAGE}; the scan is broken') + + +class PragmaNameTests(unittest.TestCase): + """Every pragma in :data:`PACKAGE` names a real pylint message.""" + + def test_pragma_names_are_known(self) -> None: + unknown = [f'{path}:{line}: {name}' for path, line, name in _package_pragmas() + if name not in KNOWN_MESSAGES] + self.assertEqual(unknown, [], 'pylint pragma names not in KNOWN_MESSAGES; check the spelling ' + 'against `pylint --list-msgs` before adding one') + + @unittest.skipUnless(HAS_PYLINT, 'pylint is not installed, so its message store cannot be read') + def test_names_are_pylint_messages(self) -> None: + linter = PyLinter() + linter.load_default_plugins() + linter.load_plugin_modules(_plugins()) + entries = [(f'{path}:{line}', name) for path, line, name in _package_pragmas()] + entries.extend(('KNOWN_MESSAGES', name) for name in sorted(KNOWN_MESSAGES)) + unknown = [] + for where, name in entries: + try: + linter.msgs_store.get_message_definitions(name) + except UnknownMessageError: # also raised for a deleted or unloaded message + unknown.append(f'{where}: {name}') + self.assertEqual(unknown, [], 'names pylint does not know') + + +if __name__ == '__main__': + unittest.main() From 0f1f4ea1cce14588154b8fbbafd9c707007c008b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Mon, 19 Oct 2026 02:22:41 -0400 Subject: [PATCH 2/2] test: declare the pylint gate in tests._dependency_gates (#1923) - `tests/project/test_pylint_pragma_names_1923_unit.py`: define `HAS_PYLINT` with `importlib.util.find_spec`, the shape the gate scan resolves, and import pylint inside the gated method. - `tests/_dependency_gates.py`: map `pylint` to its distribution and record why the `test` and `gate` jobs leave the gate dark: like mypy, pylint is a lint-tier tool that lint.yml already runs and no extra declares. Closes #1923 --- tests/_dependency_gates.py | 23 +++++++++++++++++++ .../test_pylint_pragma_names_1923_unit.py | 14 +++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/tests/_dependency_gates.py b/tests/_dependency_gates.py index f2f5f7308..6a03da651 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -199,6 +199,11 @@ # about 1.5s, against the ~238 MB and licence questions that keep ``NGAP`` # out of ``all``. 'isort': ('isort',), + # The linter ``HAS_PYLINT`` asks for, whose message store the #1923 pragma + # check reads. Like ``mypy`` above, no extra declares it: the Pipfile's + # ``[dev-packages]`` and lint.yml's direct install are what carry it, so the + # gate is dark on every pytest job and its exclusion says why. + 'pylint': ('pylint',), # ``beautifulsoup4`` ships ``bs4``; ``html5lib`` is an *extra of* that # distribution, so ``beautifulsoup4`` alone does not provide it. That # distinction is the whole reason ``HAS_CRAWLER_DEPS`` is satisfied in CI @@ -667,6 +672,24 @@ class Exclusion(NamedTuple): 'nothing and run on every leg.' ), ), + 'HAS_PYLINT': Exclusion( + dark={'test': ('pylint',), 'gate': ('pylint',)}, + reason=( + 'The method it gates (#1923) looks every pylint pragma name in pcapkit/ up in ' + "pylint's own message store, with the plugins `make pylint` loads, so it needs " + 'pylint, which no pytest job installs. pylint is a lint-tier tool, as mypy is: ' + 'a Pipfile [dev-packages] entry that `make pylint` resolves, and a direct ' + '"pip install -U vermin pylint mypy bandit" in lint.yml, which runs no pytest; ' + 'no pyproject.toml extra declares it. The argument that declines HAS_MYPY above ' + 'declines it here: lint.yml already runs pylint over the whole package, and an ' + 'unknown pragma name shows there as W0012 unknown-option-value, so a pytest leg ' + 'that installed pylint would pay for a second copy of that check. What runs on ' + 'every leg instead is the ungated check in the same module, which holds each ' + 'pragma name to the pinned KNOWN_MESSAGES list; the lookup keeps that list ' + 'honest wherever pylint is importable, a developer checkout included, and skips ' + 'visibly elsewhere.' + ), + ), 'HAS_DOCUTILS': Exclusion( dark={'test': ('docutils',), 'gate': ('docutils',)}, reason=( diff --git a/tests/project/test_pylint_pragma_names_1923_unit.py b/tests/project/test_pylint_pragma_names_1923_unit.py index eba5ff307..96646a584 100644 --- a/tests/project/test_pylint_pragma_names_1923_unit.py +++ b/tests/project/test_pylint_pragma_names_1923_unit.py @@ -20,6 +20,7 @@ """ from __future__ import annotations +import importlib.util import io import re import tokenize @@ -91,13 +92,9 @@ 'wrong-import-position', }) -try: - from pylint.exceptions import UnknownMessageError - from pylint.lint import PyLinter -except ImportError: - HAS_PYLINT = False -else: - HAS_PYLINT = True +#: Whether :mod:`pylint` is importable, for the message-store lookup. Its CI +#: coverage is recorded in :data:`tests._dependency_gates.DEPENDENCY_GATE_EXCLUSIONS`. +HAS_PYLINT = importlib.util.find_spec('pylint') is not None def _names(comment: 'str') -> 'Iterator[str]': @@ -170,6 +167,9 @@ def test_pragma_names_are_known(self) -> None: @unittest.skipUnless(HAS_PYLINT, 'pylint is not installed, so its message store cannot be read') def test_names_are_pylint_messages(self) -> None: + from pylint.exceptions import UnknownMessageError # pylint: disable=import-outside-toplevel + from pylint.lint import PyLinter # pylint: disable=import-outside-toplevel + linter = PyLinter() linter.load_default_plugins() linter.load_plugin_modules(_plugins())