Skip to content

Commit 1395844

Browse files
feat: add DALL1xx for __dir__ (#92)
Co-authored-by: Dominic Davis-Foster <dominic@davis-foster.co.uk>
1 parent 3ce3cbf commit 1395844

7 files changed

Lines changed: 137 additions & 18 deletions

File tree

README.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,15 @@ To install with ``conda``:
153153
flake8 codes
154154
--------------
155155

156-
============== =======================================
156+
============== ===============================================================
157157
Code Description
158-
============== =======================================
158+
============== ===============================================================
159159
DALL000 Module lacks __all__.
160160
DALL001 __all__ not sorted alphabetically
161161
DALL002 __all__ not a list or tuple of strings.
162-
============== =======================================
162+
DALL100 Top-level __dir__ function definition is required.
163+
DALL101 Top-level __dir__ function definition is required in __init__.py.
164+
============== ===============================================================
163165

164166

165167
Use as a pre-commit hook

doc-source/usage.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Flake8 codes
1414
DALL000
1515
DALL001
1616
DALL002
17+
DALL100
18+
DALL101
1719

1820

1921
For the ``DALL001`` option there exists a configuration option (``dunder-all-alphabetical``)
@@ -27,6 +29,7 @@ The options are:
2729
If the ``dunder-all-alphabetical`` option is omitted the ``DALL001`` check is disabled.
2830

2931
.. versionchanged:: 0.5.0 Added the ``DALL001`` and ``DALL002`` checks.
32+
.. versionchanged:: 0.6.0 Added the ``DALL100`` and ``DALL101`` checks.
3033

3134
.. note::
3235

flake8_dunder_all/__init__.py

Lines changed: 22 additions & 5 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):
@@ -95,9 +97,12 @@ class Visitor(ast.NodeVisitor):
9597
.. versionchanged:: 0.5.0
9698
9799
Added the ``sorted_upper_first``, ``sorted_lower_first`` and ``all_lineno`` attributes.
100+
101+
.. versionchanged:: 0.6.0 Added the ``found_dir`` attribute.
98102
"""
99103

100104
found_all: bool #: Flag to indicate a ``__all__`` declaration has been found in the AST.
105+
found_dir: bool #: Flag to indicate a top-level ``__dir__`` function has been found in the AST.
101106
last_import: int #: The lineno of the last top-level or conditional import
102107
members: Set[str] #: List of functions and classed defined in the AST
103108
use_endlineno: bool
@@ -106,6 +111,7 @@ class Visitor(ast.NodeVisitor):
106111

107112
def __init__(self, use_endlineno: bool = False) -> None:
108113
self.found_all = False
114+
self.found_dir = False
109115
self.members = set()
110116
self.last_import = 0
111117
self.use_endlineno = use_endlineno
@@ -187,6 +193,9 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
187193
# Don't generic visit
188194
self.handle_def(node)
189195

196+
if node.name == "__dir__":
197+
self.found_dir = True
198+
190199
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
191200
"""
192201
Visit ``async def foo(): ...``.
@@ -306,14 +315,18 @@ class Plugin:
306315
A Flake8 plugin which checks to ensure modules have defined ``__all__``.
307316
308317
:param tree: The abstract syntax tree (AST) to check.
318+
:param filename: The filename being checked.
319+
320+
.. versionchanged:: 0.6.0 Added ``filename`` argument.
309321
"""
310322

311323
name: str = __name__
312324
version: str = __version__ #: The plugin version
313325
dunder_all_alphabetical: AlphabeticalOptions = AlphabeticalOptions.NONE
314326

315-
def __init__(self, tree: ast.AST):
327+
def __init__(self, tree: ast.AST, filename: str):
316328
self._tree = tree
329+
self._filename = filename
317330

318331
def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
319332
"""
@@ -350,12 +363,16 @@ def run(self) -> Generator[Tuple[int, int, str, Type[Any]], None, None]:
350363
if list(visitor.all_members) != sorted_alphabetical:
351364
yield visitor.all_lineno, 0, f"{DALL001} (lowercase first).", type(self)
352365

353-
elif not visitor.members:
354-
return
355-
356-
else:
366+
elif visitor.members:
357367
yield 1, 0, DALL000, type(self)
358368

369+
# Require a top-level __dir__, but only when the module defines public members
370+
if visitor.members and not visitor.found_dir:
371+
if self._filename.endswith("__init__.py"):
372+
yield 1, 0, DALL101, type(self)
373+
else:
374+
yield 1, 0, DALL100, type(self)
375+
359376
@classmethod
360377
def add_options(cls, option_manager: OptionManager) -> None: # noqa: D102 # pragma: no cover
361378

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 r[2].startswith("DALL0")}
1111

1212

1313
testing_source_a = '''

tests/test_dir_required.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
class Foo: ...
23+
"""
24+
results = from_source(source, "module.py")
25+
assert any("DALL100" in r[2] for r in results)
26+
27+
28+
def test_dir_required_non_init_with_dir():
29+
# __dir__ defined, should not yield DALL100
30+
source_with_dir = """
31+
class Foo: ...
32+
33+
def __dir__():
34+
return []
35+
"""
36+
results = from_source(source_with_dir, "module.py")
37+
assert not any("DALL100" in r[2] for r in results)
38+
39+
40+
def test_dir_required_empty():
41+
# No public members, so __dir__ is not required
42+
source = """\nimport foo\n"""
43+
results = from_source(source, "module.py")
44+
assert not any("DALL100" in r[2] for r in results)
45+
46+
47+
def test_dir_required_empty_init():
48+
# No public members, so __dir__ is not required in __init__.py either
49+
source = """\nimport foo\n"""
50+
results = from_source(source, "__init__.py")
51+
assert not any("DALL101" in r[2] for r in results)
52+
53+
54+
def test_dir_required_init():
55+
source = """\nimport foo\n\nclass Foo: ...\n"""
56+
# No __dir__ defined, should yield DALL101
57+
results = from_source(source, "__init__.py")
58+
assert any("DALL101" in r[2] for r in results)
59+
60+
61+
def test_dir_required_init_with_dir():
62+
# __dir__ defined, should not yield DALL101
63+
source_with_dir = """
64+
class Foo: ...
65+
66+
def __dir__():
67+
return []
68+
"""
69+
results = from_source(source_with_dir, "__init__.py")
70+
assert not any("DALL101" in r[2] for r in results)
71+
72+
73+
def test_dir_required_async_def_does_not_satisfy():
74+
# ``async def __dir__`` can't be used by ``dir(module)``, so DALL100 still applies
75+
source = """
76+
import foo
77+
78+
class Foo: ...
79+
80+
async def __dir__():
81+
return []
82+
"""
83+
results = from_source(source, "module.py")
84+
assert any("DALL100" in r[2] for r in results)
85+
86+
87+
def test_dir_required_class_does_not_satisfy():
88+
# ``class __dir__`` can't be used by ``dir(module)``, so DALL100 still applies
89+
source = """
90+
import foo
91+
92+
class Foo: ...
93+
94+
class __dir__: ...
95+
"""
96+
results = from_source(source, "module.py")
97+
assert any("DALL100" 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 r[2].startswith("DALL0")} == 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 r[2].startswith("DALL0")} == 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 r[2].startswith("DALL0")} == {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 r[2].startswith("DALL0")} == set()
242242

243243

244244
@pytest.mark.parametrize(

tests/test_subprocess.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_subprocess_noqa(tmp_pathplus: PathPlus, monkeypatch):
6666
monkeypatch.delenv("COV_CORE_DATAFILE", raising=False)
6767
monkeypatch.setenv("PYTHONWARNINGS", "ignore")
6868

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

7171
with in_directory(tmp_pathplus):
7272
result = subprocess.run(

0 commit comments

Comments
 (0)