Skip to content

Commit 2c6eadc

Browse files
matusvaloDanielNoordjacobtylerwallsPierre-Sassoulas
authored
Lint all files in a directory by expanding arguments (#5682)
* Added --recursive=y/n option and a mention in FAQ and user guide Co-authored-by: Daniël van Noord <[email protected]> Co-authored-by: Jacob Walls <[email protected]> Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent d7013f6 commit 2c6eadc

File tree

12 files changed

+138
-9
lines changed

12 files changed

+138
-9
lines changed

ChangeLog

+6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Release date: TBA
99
..
1010
Put new features here and also in 'doc/whatsnew/2.13.rst'
1111

12+
* Add ``--recursive`` option to allow recursive discovery of all modules and packages in subtree. Running pylint with
13+
``--recursive=y`` option will check all discovered ``.py`` files and packages found inside subtree of directory provided
14+
as parameter to pylint.
15+
16+
Closes #352
17+
1218
* Add ``modified-iterating-list``, ``modified-iterating-dict`` and ``modified-iterating-set``,
1319
emitted when items are added to or removed from respectively a list, dictionary or
1420
set being iterated through.

doc/faq.rst

+21
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,27 @@ For example::
116116

117117
Much probably. Read :ref:`ide-integration`
118118

119+
3.5 I need to run pylint over all modules and packages in my project directory.
120+
-------------------------------------------------------------------------------
121+
122+
By default the ``pylint`` command only accepts a list of python modules and packages. Using a
123+
directory which is not a package results in an error::
124+
125+
pylint mydir
126+
************* Module mydir
127+
mydir/__init__.py:1:0: F0010: error while code parsing: Unable to load file mydir/__init__.py:
128+
[Errno 2] No such file or directory: 'mydir/__init__.py' (parse-error)
129+
130+
To execute pylint over all modules and packages under the directory, the ``--recursive=y`` option must
131+
be provided. This option makes ``pylint`` attempt to discover all modules (files ending with ``.py`` extension)
132+
and all packages (all directories containing a ``__init__.py`` file).
133+
Those modules and packages are then analyzed::
134+
135+
pylint --recursive=y mydir
136+
137+
When ``--recursive=y`` option is used, modules and packages are also accepted as parameters::
138+
139+
pylint --recursive=y mydir mymodule mypackage
119140

120141
4. Message Control
121142
==================

doc/user_guide/run.rst

+4
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ will work if ``directory`` is a python package (i.e. has an __init__.py
3333
file or it is an implicit namespace package) or if "directory" is in the
3434
python path.
3535

36+
By default, pylint will exit with an error when one of the arguments is a directory which is not
37+
a python package. In order to run pylint over all modules and packages within the provided
38+
subtree of a directory, the ``--recursive=y`` option must be provided.
39+
3640
For more details on this see the :ref:`faq`.
3741

3842
It is also possible to call Pylint from another Python program,

doc/whatsnew/2.13.rst

+6
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ Extensions
9292
Other Changes
9393
=============
9494

95+
* Add ``--recursive`` option to allow recursive discovery of all modules and packages in subtree. Running pylint with
96+
``--recursive=y`` option will check all discovered ``.py`` files and packages found inside subtree of directory provided
97+
as parameter to pylint.
98+
99+
Closes #352
100+
95101
* Fix false-negative for ``assignment-from-none`` checker with list.sort() method.
96102

97103
Closes #5722

pylint/lint/pylinter.py

+38
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,15 @@ def make_options() -> Tuple[Tuple[str, OptionDict], ...]:
515515
),
516516
},
517517
),
518+
(
519+
"recursive",
520+
{
521+
"type": "yn",
522+
"metavar": "<yn>",
523+
"default": False,
524+
"help": "Discover python modules and packages in the file system subtree.",
525+
},
526+
),
518527
(
519528
"py-version",
520529
{
@@ -1005,6 +1014,33 @@ def initialize(self):
10051014
if not msg.may_be_emitted():
10061015
self._msgs_state[msg.msgid] = False
10071016

1017+
@staticmethod
1018+
def _discover_files(files_or_modules: Sequence[str]) -> Iterator[str]:
1019+
"""Discover python modules and packages in subdirectory.
1020+
1021+
Returns iterator of paths to discovered modules and packages.
1022+
"""
1023+
for something in files_or_modules:
1024+
if os.path.isdir(something) and not os.path.isfile(
1025+
os.path.join(something, "__init__.py")
1026+
):
1027+
skip_subtrees: List[str] = []
1028+
for root, _, files in os.walk(something):
1029+
if any(root.startswith(s) for s in skip_subtrees):
1030+
# Skip subtree of already discovered package.
1031+
continue
1032+
if "__init__.py" in files:
1033+
skip_subtrees.append(root)
1034+
yield root
1035+
else:
1036+
yield from (
1037+
os.path.join(root, file)
1038+
for file in files
1039+
if file.endswith(".py")
1040+
)
1041+
else:
1042+
yield something
1043+
10081044
def check(self, files_or_modules: Union[Sequence[str], str]) -> None:
10091045
"""main checking entry: check a list of files or modules from their name.
10101046
@@ -1019,6 +1055,8 @@ def check(self, files_or_modules: Union[Sequence[str], str]) -> None:
10191055
DeprecationWarning,
10201056
)
10211057
files_or_modules = (files_or_modules,) # type: ignore[assignment]
1058+
if self.config.recursive:
1059+
files_or_modules = tuple(self._discover_files(files_or_modules))
10221060
if self.config.from_stdin:
10231061
if len(files_or_modules) != 1:
10241062
raise exceptions.InvalidArgsError(

tests/regrtest_data/directory/package/__init__.py

Whitespace-only changes.

tests/regrtest_data/directory/package/module.py

Whitespace-only changes.

tests/regrtest_data/directory/package/subpackage/__init__.py

Whitespace-only changes.

tests/regrtest_data/directory/package/subpackage/module.py

Whitespace-only changes.

tests/regrtest_data/directory/subdirectory/module.py

Whitespace-only changes.

tests/regrtest_data/directory/subdirectory/subsubdirectory/module.py

Whitespace-only changes.

tests/test_self.py

+63-9
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ def _configure_lc_ctype(lc_ctype: str) -> Iterator:
100100
os.environ[lc_ctype_env] = original_lctype
101101

102102

103+
@contextlib.contextmanager
104+
def _test_sys_path() -> Generator[None, None, None]:
105+
original_path = sys.path
106+
try:
107+
yield
108+
finally:
109+
sys.path = original_path
110+
111+
112+
@contextlib.contextmanager
113+
def _test_cwd() -> Generator[None, None, None]:
114+
original_dir = os.getcwd()
115+
try:
116+
yield
117+
finally:
118+
os.chdir(original_dir)
119+
120+
103121
class MultiReporter(BaseReporter):
104122
def __init__(self, reporters: List[BaseReporter]) -> None:
105123
# pylint: disable=super-init-not-called
@@ -810,14 +828,6 @@ def test_fail_on_edge_case(self, opts, out):
810828

811829
@staticmethod
812830
def test_modify_sys_path() -> None:
813-
@contextlib.contextmanager
814-
def test_sys_path() -> Generator[None, None, None]:
815-
original_path = sys.path
816-
try:
817-
yield
818-
finally:
819-
sys.path = original_path
820-
821831
@contextlib.contextmanager
822832
def test_environ_pythonpath(
823833
new_pythonpath: Optional[str],
@@ -837,7 +847,7 @@ def test_environ_pythonpath(
837847
# Only delete PYTHONPATH if new_pythonpath wasn't None
838848
del os.environ["PYTHONPATH"]
839849

840-
with test_sys_path(), patch("os.getcwd") as mock_getcwd:
850+
with _test_sys_path(), patch("os.getcwd") as mock_getcwd:
841851
cwd = "/tmp/pytest-of-root/pytest-0/test_do_not_import_files_from_0"
842852
mock_getcwd.return_value = cwd
843853
default_paths = [
@@ -1284,3 +1294,47 @@ def test_regex_paths_csv_validator() -> None:
12841294
with pytest.raises(SystemExit) as ex:
12851295
Run(["--ignore-paths", "test", join(HERE, "regrtest_data", "empty.py")])
12861296
assert ex.value.code == 0
1297+
1298+
def test_regression_recursive(self):
1299+
self._test_output(
1300+
[join(HERE, "regrtest_data", "directory", "subdirectory"), "--recursive=n"],
1301+
expected_output="No such file or directory",
1302+
)
1303+
1304+
def test_recursive(self):
1305+
self._runtest(
1306+
[join(HERE, "regrtest_data", "directory", "subdirectory"), "--recursive=y"],
1307+
code=0,
1308+
)
1309+
1310+
def test_recursive_current_dir(self):
1311+
with _test_sys_path():
1312+
# pytest is including directory HERE/regrtest_data to sys.path which causes
1313+
# astroid to believe that directory is a package.
1314+
sys.path = [
1315+
path
1316+
for path in sys.path
1317+
if not os.path.basename(path) == "regrtest_data"
1318+
]
1319+
with _test_cwd():
1320+
os.chdir(join(HERE, "regrtest_data", "directory"))
1321+
self._runtest(
1322+
[".", "--recursive=y"],
1323+
code=0,
1324+
)
1325+
1326+
def test_regression_recursive_current_dir(self):
1327+
with _test_sys_path():
1328+
# pytest is including directory HERE/regrtest_data to sys.path which causes
1329+
# astroid to believe that directory is a package.
1330+
sys.path = [
1331+
path
1332+
for path in sys.path
1333+
if not os.path.basename(path) == "regrtest_data"
1334+
]
1335+
with _test_cwd():
1336+
os.chdir(join(HERE, "regrtest_data", "directory"))
1337+
self._test_output(
1338+
["."],
1339+
expected_output="No such file or directory",
1340+
)

0 commit comments

Comments
 (0)