Skip to content

Commit c5c229c

Browse files
Feat: --known-enum-bases option to support custom enum-like base cl… (#3513)
1 parent f4c70bc commit c5c229c

File tree

8 files changed

+141
-1
lines changed

8 files changed

+141
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Semantic versioning in our case means:
2323

2424
- Allows `__init__.py` files that consist only of imports, #3486
2525
- Adds `--max-conditions` option, #3493
26+
- Adds `--known-enum-bases` option to support custom enum-like base classes, #3513
2627

2728
### Misc
2829

tests/test_visitors/test_ast/test_naming/test_class_attributes.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,48 @@ class Test({0}):
4444
'lower, UPPER = field',
4545
]
4646

47+
custom_enum_base_class = """
48+
class BaseEnum(enum.Enum):
49+
def __init__(self, short: str, long: str) -> None:
50+
self.short = short
51+
self.long = long
52+
53+
class ConcreteEnum(BaseEnum):
54+
HELLO = 'h', 'hello'
55+
WORLD = 'w', 'world'
56+
"""
57+
58+
59+
multiple_custom_enum_bases = """
60+
class FirstEnum(enum.Enum):
61+
pass
62+
63+
class SecondEnum(enum.Enum):
64+
pass
65+
66+
class FirstEnumSuccessor(FirstEnum):
67+
HELLO = 'hello'
68+
WORLD = 'world'
69+
70+
class SecondEnumSuccessor(SecondEnum):
71+
PING = 'pong'
72+
73+
class MultiInheritanceEnum(FirstEnum, SecondEnum):
74+
MULTI = 'multi'
75+
"""
76+
77+
dotted_names_enum_bases = """
78+
from src import constants
79+
80+
class FirstEnum(constants.MyEnum):
81+
HELLO = 'hello'
82+
WORLD = 'world'
83+
84+
class SecondEnum(constants.MyEnum):
85+
HELLO = 'hello'
86+
WORLD = 'world'
87+
"""
88+
4789

4890
@pytest.mark.parametrize(
4991
'code',
@@ -202,3 +244,56 @@ def test_regression423(
202244
visitor.run()
203245

204246
assert_errors(visitor, [])
247+
248+
249+
@pytest.mark.parametrize('with_configuration', [True, False])
250+
def test_custom_enum_base_class_with_config(
251+
assert_errors,
252+
parse_ast_tree,
253+
options,
254+
with_configuration,
255+
):
256+
"""Testing that custom enum base classes work with configuration."""
257+
tree = parse_ast_tree(custom_enum_base_class)
258+
259+
if with_configuration:
260+
options_with_config = options(known_enum_bases=('BaseEnum',))
261+
visitor = WrongNameVisitor(options_with_config, tree=tree)
262+
visitor.run()
263+
assert_errors(visitor, [])
264+
else:
265+
options_without_config = options()
266+
visitor = WrongNameVisitor(options_without_config, tree=tree)
267+
visitor.run()
268+
assert_errors(
269+
visitor,
270+
[UpperCaseAttributeViolation, UpperCaseAttributeViolation],
271+
)
272+
273+
274+
def test_multiple_custom_enum_bases_with_config(
275+
assert_errors,
276+
parse_ast_tree,
277+
options,
278+
):
279+
"""Testing that multiple custom enum base classes work with config."""
280+
tree = parse_ast_tree(multiple_custom_enum_bases)
281+
282+
options = options(known_enum_bases=('FirstEnum', 'SecondEnum'))
283+
visitor = WrongNameVisitor(options, tree=tree)
284+
visitor.run()
285+
assert_errors(visitor, [])
286+
287+
288+
def test_dotted_names_in_enum_bases_config(
289+
assert_errors,
290+
parse_ast_tree,
291+
options,
292+
):
293+
"""Testing that dotted names in enum bases config work correctly."""
294+
tree = parse_ast_tree(dotted_names_enum_bases)
295+
296+
options = options(known_enum_bases=('constants.MyEnum',))
297+
visitor = WrongNameVisitor(options, tree=tree)
298+
visitor.run()
299+
assert_errors(visitor, [])

wemake_python_styleguide/logic/naming/enums.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Final
44

55
from wemake_python_styleguide.logic.source import node_to_string
6+
from wemake_python_styleguide.options.validation import ValidatedOptions
67

78
_CONCRETE_ENUM_NAMES: Final = (
89
'enum.StrEnum',
@@ -68,3 +69,26 @@ def has_enum_like_base(defn: ast.ClassDef) -> bool:
6869
https://docs.djangoproject.com/en/5.1/ref/models/fields/#choices
6970
"""
7071
return _has_one_of_base_classes(defn, _ENUM_LIKE_NAMES)
72+
73+
74+
def has_enum_like_base_with_config(
75+
defn: ast.ClassDef,
76+
config: ValidatedOptions,
77+
) -> bool:
78+
"""
79+
Tells if some class has `Enum` or semantically similar class as its base.
80+
81+
This function also checks user-defined enum-like base classes from
82+
configuration.
83+
"""
84+
if has_enum_like_base(defn):
85+
return True
86+
87+
enum_bases = config.known_enum_bases
88+
if enum_bases:
89+
normalized: tuple[str, ...] = tuple({
90+
name for base in enum_bases for name in (base, base.split('.')[-1])
91+
})
92+
return _has_one_of_base_classes(defn, normalized)
93+
94+
return False

wemake_python_styleguide/options/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class of violations that are forbidden to ignore inline, defaults to
5858
- ``exps-for-one-empty-line`` - number of expressions in
5959
function or method body for available empty line (without code)
6060
:str:`wemake_python_styleguide.options.defaults.EXPS_FOR_ONE_EMPTY_LINE`
61+
- ``known-enum-bases`` - list of additional enum-like base class names that
62+
should be treated as enums, defaults to
63+
:str:`wemake_python_styleguide.options.defaults.KNOWN_ENUM_BASES`
6164
6265
6366
.. rubric:: Complexity options
@@ -283,6 +286,14 @@ class Configuration:
283286
defaults.EXPS_FOR_ONE_EMPTY_LINE,
284287
'Count of expressions for one empty line in a function body.',
285288
),
289+
_Option(
290+
'--known-enum-bases',
291+
defaults.KNOWN_ENUM_BASES,
292+
'List of additional enum-like base class names that should be '
293+
'treated as enums.',
294+
type=String,
295+
comma_separated_list=True,
296+
),
286297
# Complexity:
287298
_Option(
288299
'--max-returns',

wemake_python_styleguide/options/defaults.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
'Config', # pydantic specific
3535
)
3636

37+
#: List of additional enum-like base class names.
38+
KNOWN_ENUM_BASES: Final = ()
39+
3740
#: Domain names that are removed from variable names' blacklist.
3841
ALLOWED_DOMAIN_NAMES: Final = ()
3942

wemake_python_styleguide/options/validation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class ValidatedOptions:
6363
validator=[_min_max(min=1, max=defaults.MAX_NOQA_COMMENTS)],
6464
)
6565
nested_classes_whitelist: tuple[str, ...] = attr.ib(converter=tuple)
66+
known_enum_bases: tuple[str, ...] = attr.ib(converter=tuple)
6667
allowed_domain_names: tuple[str, ...] = attr.ib(converter=tuple)
6768
forbidden_domain_names: tuple[str, ...] = attr.ib(converter=tuple)
6869
allowed_module_metadata: tuple[str, ...] = attr.ib(converter=tuple)

wemake_python_styleguide/violations/naming.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,11 @@ class UpperCaseAttributeViolation(ASTViolation):
448448
Rename your variables so that they conform
449449
to ``snake_case`` convention.
450450
451+
Configuration:
452+
This rule is configurable with ``--known-enum-bases``.
453+
Default:
454+
:str:`wemake_python_styleguide.options.defaults.KNOWN_ENUM_BASES`
455+
451456
Example::
452457
453458
# Correct:

wemake_python_styleguide/visitors/ast/naming/validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ def check_attribute_names(self, node: ast.ClassDef) -> None:
228228
node,
229229
include_annotated=True,
230230
)
231-
is_enum_like = enums.has_enum_like_base(node)
231+
is_enum_like = enums.has_enum_like_base_with_config(node, self._options)
232232

233233
for assign in class_attributes:
234234
for target in get_assign_targets(assign):

0 commit comments

Comments
 (0)