From 16ec0c1d464b627d0a8cd268da0b339eebda4ac0 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 5 Jan 2026 15:20:21 -0500 Subject: [PATCH] feat: add DALL1xx for __dir__ Signed-off-by: Henry Schreiner --- flake8_dunder_all/__init__.py | 22 ++++++++++++-- tests/common.py | 2 +- tests/test_dir_required.py | 53 +++++++++++++++++++++++++++++++++ tests/test_flake8_dunder_all.py | 16 +++++----- tests/test_subprocess.py | 3 +- 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 tests/test_dir_required.py diff --git a/flake8_dunder_all/__init__.py b/flake8_dunder_all/__init__.py index dc2dc89..8a8cd4b 100644 --- a/flake8_dunder_all/__init__.py +++ b/flake8_dunder_all/__init__.py @@ -69,6 +69,8 @@ DALL000 = "DALL000 Module lacks __all__." DALL001 = "DALL001 __all__ not sorted alphabetically" DALL002 = "DALL002 __all__ not a list or tuple of strings." +DALL100 = "DALL100 Top-level __dir__ function definition is required." +DALL101 = "DALL101 Top-level __dir__ function definition is required in __init__.py." class AlphabeticalOptions(Enum): @@ -106,6 +108,8 @@ class Visitor(ast.NodeVisitor): def __init__(self, use_endlineno: bool = False) -> None: self.found_all = False + self.found_lineno = -1 + self.found_dir = False self.members = set() self.last_import = 0 self.use_endlineno = use_endlineno @@ -177,6 +181,10 @@ def handle_def(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Clas if not node.name.startswith('_') and "overload" not in decorators: self.members.add(node.name) + if node.name == "__dir__": + self.found_dir = True + self.found_lineno = node.lineno + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: """ Visit ``def foo(): ...``. @@ -306,14 +314,16 @@ class Plugin: A Flake8 plugin which checks to ensure modules have defined ``__all__``. :param tree: The abstract syntax tree (AST) to check. + :param filename: The filename being checked. """ name: str = __name__ version: str = __version__ #: The plugin version dunder_all_alphabetical: AlphabeticalOptions = AlphabeticalOptions.NONE - def __init__(self, tree: ast.AST): + def __init__(self, tree: ast.AST, filename: str): self._tree = tree + self._filename = filename def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]: """ @@ -351,11 +361,19 @@ def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]: yield visitor.all_lineno, 0, f"{DALL001} (lowercase first).", type(self) elif not visitor.members: - return + pass else: yield 1, 0, DALL000, type(self) + # Require top-level __dir__ function + if not visitor.found_dir: + if self._filename.endswith("__init__.py"): + if visitor.members: + yield 1, 0, DALL101, type(self) + else: + yield 1, 0, DALL100, type(self) + @classmethod def add_options(cls, option_manager: OptionManager) -> None: # noqa: D102 # pragma: no cover diff --git a/tests/common.py b/tests/common.py index 7bc1a81..8585875 100644 --- a/tests/common.py +++ b/tests/common.py @@ -7,7 +7,7 @@ def results(s: str) -> Set[str]: - return {"{}:{}: {}".format(*r) for r in Plugin(ast.parse(s)).run()} + return {"{}:{}: {}".format(*r) for r in Plugin(ast.parse(s), "mod.py").run() if "DALL0" in r[2]} testing_source_a = ''' diff --git a/tests/test_dir_required.py b/tests/test_dir_required.py new file mode 100644 index 0000000..5177780 --- /dev/null +++ b/tests/test_dir_required.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +# stdlib +import ast +import inspect +from typing import Any + +# this package +from flake8_dunder_all import Plugin + + +def from_source(source: str, filename: str) -> list[tuple[int, int, str, type[Any]]]: + source_clean = inspect.cleandoc(source) + plugin = Plugin(ast.parse(source_clean), filename) + return list(plugin.run()) + + +def test_dir_required_non_init(): + source = """ + import foo + """ + results = from_source(source, "module.py") + assert any("DALL100" in r[2] for r in results) + + +def test_dir_required_non_init_with_dir(): + # __dir__ defined, should not yield DALL100 + source_with_dir = """ + def __dir__(): + return []\n""" + results = from_source(source_with_dir, "module.py") + assert not any("DALL100" in r[2] for r in results) + + +def test_dir_required_empty(): + source = """\nimport foo\n""" + # No __dir__ defined but no members present, should not yield DALL101 + results = from_source(source, "__init__.py") + assert not any("DALL101" in r[2] for r in results) + + +def test_dir_required_init(): + source = """\nimport foo\n\nclass Foo: ...\n""" + # No __dir__ defined, should yield DALL101 + results = from_source(source, "__init__.py") + assert any("DALL101" in r[2] for r in results) + + +def test_dir_required_init_with_dir(): + # __dir__ defined, should not yield DALL101 + source_with_dir = """\ndef __dir__():\n return []\n""" + results = from_source(source_with_dir, "__init__.py") + assert not any("DALL101" in r[2] for r in results) diff --git a/tests/test_flake8_dunder_all.py b/tests/test_flake8_dunder_all.py index 066366b..3d40ab5 100644 --- a/tests/test_flake8_dunder_all.py +++ b/tests/test_flake8_dunder_all.py @@ -135,9 +135,9 @@ def test_plugin(source: str, expects: Set[str]): ] ) def test_plugin_alphabetical(source: str, expects: Set[str], dunder_all_alphabetical: AlphabeticalOptions): - plugin = Plugin(ast.parse(source)) + plugin = Plugin(ast.parse(source), "mod.py") plugin.dunder_all_alphabetical = dunder_all_alphabetical - assert {"{}:{}: {}".format(*r) for r in plugin.run()} == expects + assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == expects @pytest.mark.parametrize( @@ -210,9 +210,9 @@ def test_plugin_alphabetical_ann_assign( expects: Set[str], dunder_all_alphabetical: AlphabeticalOptions, ): - plugin = Plugin(ast.parse(source)) + plugin = Plugin(ast.parse(source), "mod.py") plugin.dunder_all_alphabetical = dunder_all_alphabetical - assert {"{}:{}: {}".format(*r) for r in plugin.run()} == expects + assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == expects @pytest.mark.parametrize( @@ -229,16 +229,16 @@ def test_plugin_alphabetical_ann_assign( ] ) def test_plugin_alphabetical_not_list(source: str, dunder_all_alphabetical: AlphabeticalOptions): - plugin = Plugin(ast.parse(source)) + plugin = Plugin(ast.parse(source), "mod.py") plugin.dunder_all_alphabetical = dunder_all_alphabetical msg = "1:0: DALL002 __all__ not a list or tuple of strings." - assert {"{}:{}: {}".format(*r) for r in plugin.run()} == {msg} + assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == {msg} def test_plugin_alphabetical_tuple(): - plugin = Plugin(ast.parse("__all__ = ('bar',\n'foo')")) + plugin = Plugin(ast.parse("__all__ = ('bar',\n'foo')"), "mod.py") plugin.dunder_all_alphabetical = AlphabeticalOptions.IGNORE - assert {"{}:{}: {}".format(*r) for r in plugin.run()} == set() + assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == set() @pytest.mark.parametrize( diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py index b3624fa..0ea63da 100644 --- a/tests/test_subprocess.py +++ b/tests/test_subprocess.py @@ -24,6 +24,7 @@ def test_subprocess(tmp_pathplus: PathPlus, monkeypatch): assert result.stderr == b'' assert result.stdout == b"""\ demo.py:1:1: DALL000 Module lacks __all__. +demo.py:1:1: DALL100 Top-level __dir__ function definition is required. demo.py:2:1: W191 indentation contains tabs demo.py:2:1: W293 blank line contains whitespace demo.py:4:1: W191 indentation contains tabs @@ -84,7 +85,7 @@ def test_subprocess_noqa(tmp_pathplus: PathPlus, monkeypatch): monkeypatch.delenv("COV_CORE_DATAFILE", raising=False) monkeypatch.setenv("PYTHONWARNINGS", "ignore") - (tmp_pathplus / "demo.py").write_text("# noq" + "a: DALL000\n\n\t\ndef foo():\n\tpass\n\t") + (tmp_pathplus / "demo.py").write_text(" # noqa: DALL000,DALL100 \n\n\t\ndef foo():\n\tpass\n\t") with in_directory(tmp_pathplus): result = subprocess.run(