Skip to content

Commit 16ec0c1

Browse files
committed
feat: add DALL1xx for __dir__
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
1 parent 3427373 commit 16ec0c1

File tree

5 files changed

+84
-12
lines changed

5 files changed

+84
-12
lines changed

flake8_dunder_all/__init__.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@
6969
DALL000 = "DALL000 Module lacks __all__."
7070
DALL001 = "DALL001 __all__ not sorted alphabetically"
7171
DALL002 = "DALL002 __all__ not a list or tuple of strings."
72+
DALL100 = "DALL100 Top-level __dir__ function definition is required."
73+
DALL101 = "DALL101 Top-level __dir__ function definition is required in __init__.py."
7274

7375

7476
class AlphabeticalOptions(Enum):
@@ -106,6 +108,8 @@ class Visitor(ast.NodeVisitor):
106108

107109
def __init__(self, use_endlineno: bool = False) -> None:
108110
self.found_all = False
111+
self.found_lineno = -1
112+
self.found_dir = False
109113
self.members = set()
110114
self.last_import = 0
111115
self.use_endlineno = use_endlineno
@@ -177,6 +181,10 @@ def handle_def(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Clas
177181
if not node.name.startswith('_') and "overload" not in decorators:
178182
self.members.add(node.name)
179183

184+
if node.name == "__dir__":
185+
self.found_dir = True
186+
self.found_lineno = node.lineno
187+
180188
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
181189
"""
182190
Visit ``def foo(): ...``.
@@ -306,14 +314,16 @@ class Plugin:
306314
A Flake8 plugin which checks to ensure modules have defined ``__all__``.
307315
308316
:param tree: The abstract syntax tree (AST) to check.
317+
:param filename: The filename being checked.
309318
"""
310319

311320
name: str = __name__
312321
version: str = __version__ #: The plugin version
313322
dunder_all_alphabetical: AlphabeticalOptions = AlphabeticalOptions.NONE
314323

315-
def __init__(self, tree: ast.AST):
324+
def __init__(self, tree: ast.AST, filename: str):
316325
self._tree = tree
326+
self._filename = filename
317327

318328
def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
319329
"""
@@ -351,11 +361,19 @@ def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
351361
yield visitor.all_lineno, 0, f"{DALL001} (lowercase first).", type(self)
352362

353363
elif not visitor.members:
354-
return
364+
pass
355365

356366
else:
357367
yield 1, 0, DALL000, type(self)
358368

369+
# Require top-level __dir__ function
370+
if not visitor.found_dir:
371+
if self._filename.endswith("__init__.py"):
372+
if visitor.members:
373+
yield 1, 0, DALL101, type(self)
374+
else:
375+
yield 1, 0, DALL100, type(self)
376+
359377
@classmethod
360378
def add_options(cls, option_manager: OptionManager) -> None: # noqa: D102 # pragma: no cover
361379

tests/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88

99
def results(s: str) -> Set[str]:
10-
return {"{}:{}: {}".format(*r) for r in Plugin(ast.parse(s)).run()}
10+
return {"{}:{}: {}".format(*r) for r in Plugin(ast.parse(s), "mod.py").run() if "DALL0" in r[2]}
1111

1212

1313
testing_source_a = '''

tests/test_dir_required.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
# stdlib
4+
import ast
5+
import inspect
6+
from typing import Any
7+
8+
# this package
9+
from flake8_dunder_all import Plugin
10+
11+
12+
def from_source(source: str, filename: str) -> list[tuple[int, int, str, type[Any]]]:
13+
source_clean = inspect.cleandoc(source)
14+
plugin = Plugin(ast.parse(source_clean), filename)
15+
return list(plugin.run())
16+
17+
18+
def test_dir_required_non_init():
19+
source = """
20+
import foo
21+
"""
22+
results = from_source(source, "module.py")
23+
assert any("DALL100" in r[2] for r in results)
24+
25+
26+
def test_dir_required_non_init_with_dir():
27+
# __dir__ defined, should not yield DALL100
28+
source_with_dir = """
29+
def __dir__():
30+
return []\n"""
31+
results = from_source(source_with_dir, "module.py")
32+
assert not any("DALL100" in r[2] for r in results)
33+
34+
35+
def test_dir_required_empty():
36+
source = """\nimport foo\n"""
37+
# No __dir__ defined but no members present, should not yield DALL101
38+
results = from_source(source, "__init__.py")
39+
assert not any("DALL101" in r[2] for r in results)
40+
41+
42+
def test_dir_required_init():
43+
source = """\nimport foo\n\nclass Foo: ...\n"""
44+
# No __dir__ defined, should yield DALL101
45+
results = from_source(source, "__init__.py")
46+
assert any("DALL101" in r[2] for r in results)
47+
48+
49+
def test_dir_required_init_with_dir():
50+
# __dir__ defined, should not yield DALL101
51+
source_with_dir = """\ndef __dir__():\n return []\n"""
52+
results = from_source(source_with_dir, "__init__.py")
53+
assert not any("DALL101" in r[2] for r in results)

tests/test_flake8_dunder_all.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,9 @@ def test_plugin(source: str, expects: Set[str]):
135135
]
136136
)
137137
def test_plugin_alphabetical(source: str, expects: Set[str], dunder_all_alphabetical: AlphabeticalOptions):
138-
plugin = Plugin(ast.parse(source))
138+
plugin = Plugin(ast.parse(source), "mod.py")
139139
plugin.dunder_all_alphabetical = dunder_all_alphabetical
140-
assert {"{}:{}: {}".format(*r) for r in plugin.run()} == expects
140+
assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == expects
141141

142142

143143
@pytest.mark.parametrize(
@@ -210,9 +210,9 @@ def test_plugin_alphabetical_ann_assign(
210210
expects: Set[str],
211211
dunder_all_alphabetical: AlphabeticalOptions,
212212
):
213-
plugin = Plugin(ast.parse(source))
213+
plugin = Plugin(ast.parse(source), "mod.py")
214214
plugin.dunder_all_alphabetical = dunder_all_alphabetical
215-
assert {"{}:{}: {}".format(*r) for r in plugin.run()} == expects
215+
assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == expects
216216

217217

218218
@pytest.mark.parametrize(
@@ -229,16 +229,16 @@ def test_plugin_alphabetical_ann_assign(
229229
]
230230
)
231231
def test_plugin_alphabetical_not_list(source: str, dunder_all_alphabetical: AlphabeticalOptions):
232-
plugin = Plugin(ast.parse(source))
232+
plugin = Plugin(ast.parse(source), "mod.py")
233233
plugin.dunder_all_alphabetical = dunder_all_alphabetical
234234
msg = "1:0: DALL002 __all__ not a list or tuple of strings."
235-
assert {"{}:{}: {}".format(*r) for r in plugin.run()} == {msg}
235+
assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == {msg}
236236

237237

238238
def test_plugin_alphabetical_tuple():
239-
plugin = Plugin(ast.parse("__all__ = ('bar',\n'foo')"))
239+
plugin = Plugin(ast.parse("__all__ = ('bar',\n'foo')"), "mod.py")
240240
plugin.dunder_all_alphabetical = AlphabeticalOptions.IGNORE
241-
assert {"{}:{}: {}".format(*r) for r in plugin.run()} == set()
241+
assert {"{}:{}: {}".format(*r) for r in plugin.run() if "DALL0" in r[2]} == set()
242242

243243

244244
@pytest.mark.parametrize(

tests/test_subprocess.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def test_subprocess(tmp_pathplus: PathPlus, monkeypatch):
2424
assert result.stderr == b''
2525
assert result.stdout == b"""\
2626
demo.py:1:1: DALL000 Module lacks __all__.
27+
demo.py:1:1: DALL100 Top-level __dir__ function definition is required.
2728
demo.py:2:1: W191 indentation contains tabs
2829
demo.py:2:1: W293 blank line contains whitespace
2930
demo.py:4:1: W191 indentation contains tabs
@@ -84,7 +85,7 @@ def test_subprocess_noqa(tmp_pathplus: PathPlus, monkeypatch):
8485
monkeypatch.delenv("COV_CORE_DATAFILE", raising=False)
8586
monkeypatch.setenv("PYTHONWARNINGS", "ignore")
8687

87-
(tmp_pathplus / "demo.py").write_text("# noq" + "a: DALL000\n\n\t\ndef foo():\n\tpass\n\t")
88+
(tmp_pathplus / "demo.py").write_text(" # noqa: DALL000,DALL100 \n\n\t\ndef foo():\n\tpass\n\t")
8889

8990
with in_directory(tmp_pathplus):
9091
result = subprocess.run(

0 commit comments

Comments
 (0)