Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: module name shadowing #121

Merged
merged 7 commits into from Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGES.rst
Expand Up @@ -6,7 +6,8 @@ Changelog
2.2.1 (unreleased)
------------------

- Nothing changed yet.
- Add rule for builtin module name shadowing (`A005`).
[asfaltboy]


2.2.0 (2023-11-03)
Expand Down
3 changes: 3 additions & 0 deletions README.rst
Expand Up @@ -101,6 +101,9 @@ A003:
A004:
An import statement is shadowing a Python builtin.

A005:
A module is shadowing a Python builtin module (e.g: `logging` or `socket`)

License
-------
GPL 2.0
44 changes: 41 additions & 3 deletions flake8_builtins.py
@@ -1,8 +1,10 @@
from flake8 import utils as stdin_utils
from pathlib import Path

import ast
import builtins
import inspect
import sys


class BuiltinsChecker:
Expand All @@ -12,6 +14,7 @@ class BuiltinsChecker:
argument_msg = 'A002 argument "{0}" is shadowing a Python builtin'
class_attribute_msg = 'A003 class attribute "{0}" is shadowing a Python builtin'
import_msg = 'A004 import statement "{0}" is shadowing a Python builtin'
module_name_msg = 'A005 the module is shadowing a Python builtin module "{0}"'

names = []
ignore_list = {
Expand All @@ -20,6 +23,7 @@ class BuiltinsChecker:
'credits',
'_',
}
ignored_module_names = set()

def __init__(self, tree, filename):
self.tree = tree
Expand All @@ -34,6 +38,13 @@ def add_options(cls, option_manager):
comma_separated_list=True,
help='A comma separated list of builtins to skip checking',
)
option_manager.add_option(
'--builtins-allowed-modules',
metavar='builtins',
parse_from_config=True,
comma_separated_list=True,
help='A comma separated list of builtin module names to allow',
)

@classmethod
def parse_options(cls, options):
Expand All @@ -47,12 +58,26 @@ def parse_options(cls, options):
if flake8_builtins:
cls.names.update(flake8_builtins)

if options.builtins_allowed_modules is not None:
cls.ignored_module_names.update(options.builtins_allowed_modules)

if hasattr(sys, 'stdlib_module_names'):
# stdlib_module_names is only available in Python 3.10+
known_module_names = sys.stdlib_module_names
cls.module_names = {
m for m in known_module_names if m not in cls.ignored_module_names
}
else:
cls.module_names = set()

def run(self):
tree = self.tree

if self.filename == 'stdin':
lines = stdin_utils.stdin_get_value()
tree = ast.parse(lines)
else:
yield from self.check_module_name(self.filename)

for statement in ast.walk(tree):
for child in ast.iter_child_nodes(statement):
Expand Down Expand Up @@ -234,13 +259,26 @@ def check_class(self, statement):
if statement.name in self.names:
yield self.error(statement, variable=statement.name)

def error(self, statement, variable, message=None):
def error(self, statement=None, variable=None, message=None):
if not message:
message = self.assign_msg

# lineno and col_offset must be integers
return (
statement.lineno,
statement.col_offset,
statement.lineno if statement else 0,
statement.col_offset if statement else 0,
message.format(variable),
type(self),
)

def check_module_name(self, filename: str):
if not self.module_names:
return
path = Path(filename)
module_name = path.name.removesuffix('.py')
if module_name in self.module_names:
yield self.error(
None,
module_name,
message=self.module_name_msg,
)
60 changes: 50 additions & 10 deletions run_tests.py
Expand Up @@ -10,15 +10,25 @@
class FakeOptions:
builtins_ignorelist = []
builtins = None
builtins_allowed_modules = None

def __init__(self, ignore_list='', builtins=None):
def __init__(self, ignore_list='', builtins=None, builtins_allowed_modules=None):
if ignore_list:
self.builtins_ignorelist = ignore_list
if builtins:
self.builtins = builtins


def check_code(source, expected_codes=None, ignore_list=None, builtins=None):
if builtins_allowed_modules:
self.builtins_allowed_modules = builtins_allowed_modules


def check_code(
source,
expected_codes=None,
ignore_list=None,
builtins=None,
builtins_allowed_modules=None,
filename='/home/script.py',
):
"""Check if the given source code generates the given flake8 errors

If `expected_codes` is a string is converted to a list,
Expand All @@ -37,8 +47,14 @@ def check_code(source, expected_codes=None, ignore_list=None, builtins=None):
if ignore_list is None:
ignore_list = []
tree = ast.parse(textwrap.dedent(source))
checker = BuiltinsChecker(tree, '/home/script.py')
checker.parse_options(FakeOptions(ignore_list=ignore_list, builtins=builtins))
checker = BuiltinsChecker(tree, filename)
checker.parse_options(
FakeOptions(
ignore_list=ignore_list,
builtins=builtins,
builtins_allowed_modules=builtins_allowed_modules,
)
)
return_statements = list(checker.run())

assert len(return_statements) == len(expected_codes)
Expand Down Expand Up @@ -463,12 +479,36 @@ async def bla():
def test_stdin(stdin_get_value):
source = 'max = 4'
stdin_get_value.return_value = source
checker = BuiltinsChecker('', 'stdin')
checker.parse_options(FakeOptions())
ret = list(checker.run())
assert len(ret) == 1
check_code('', expected_codes='A001', filename='stdin')


def test_tuple_unpacking():
source = 'a, *(b, c) = 1, 2, 3'
check_code(source)


@pytest.mark.skipif(
sys.version_info < (3, 10),
reason='Skip A005, module testing is only supported in Python 3.10 and above',
)
def test_module_name():
source = ''
check_code(source, expected_codes='A005', filename='./temp/logging.py')


@pytest.mark.skipif(
sys.version_info < (3, 10),
reason='Skip A005, module testing is only supported in Python 3.10 and above',
)
def test_module_name_ignore_module():
source = ''
check_code(
source,
filename='./temp/logging.py',
builtins_allowed_modules=['logging'],
)


def test_module_name_not_builtin():
source = ''
check_code(source, filename='log_config')