Skip to content

Commit

Permalink
feat: Check sub class names
Browse files Browse the repository at this point in the history
  • Loading branch information
snaselj committed Feb 9, 2024
1 parent 1dc945e commit 1be2676
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 0 deletions.
2 changes: 2 additions & 0 deletions pylint_nautobot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .model_label import NautobotModelLabelChecker
from .replaced_models import NautobotReplacedModelsImportChecker
from .string_field_blank_null import NautobotStringFieldBlankNull
from .sub_class_name import NautobotSubClassNameChecker
from .use_fields_all import NautobotUseFieldsAllChecker
from .utils import MINIMUM_NAUTOBOT_VERSION

Expand All @@ -22,6 +23,7 @@
NautobotModelLabelChecker,
NautobotReplacedModelsImportChecker,
NautobotStringFieldBlankNull,
NautobotSubClassNameChecker,
NautobotUseFieldsAllChecker,
]

Expand Down
44 changes: 44 additions & 0 deletions pylint_nautobot/sub_class_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Check for imports whose paths have changed in 2.0."""

from astroid import ClassDef
from pylint.checkers import BaseChecker

from .utils import find_ancestor
from .utils import get_model_name
from .utils import is_version_compatible

_ANCESTORS = {
"nautobot.extras.filters.NautobotFilterSet": ">=2",
}

_VERSION_COMPATIBLE_ANCESTORS = [key for key, value in _ANCESTORS.items() if is_version_compatible(value)]


class NautobotSubClassNameChecker(BaseChecker):
"""Ensure subclass name is <model class name><ancestor class type>.
This can typically be done via <ancestor class name>.replace("Nautobot", <model class name>)
"""

version_specifier = ">1,<3"

name = "nautobot-sub-class-name"
msgs = {
"E4242": (
"Sub-class name should be %s.",
"nb-sub-class-name",
"All classes should have a sub-class name that is <model class name><ancestor class type>.",
)
}

def visit_classdef(self, node: ClassDef):
"""Visit class definitions."""
ancestor = find_ancestor(node, _VERSION_COMPATIBLE_ANCESTORS)
if not ancestor:
return

class_name = node.name
model_name = get_model_name(ancestor, node)
expected_name = ancestor.split(".")[-1].replace("Nautobot", model_name)
if expected_name != class_name:
self.add_message("nb-sub-class-name", node=node, args=(expected_name,))
58 changes: 58 additions & 0 deletions pylint_nautobot/utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,74 @@
"""Utilities for managing data."""

from importlib import metadata
from pathlib import Path
from typing import List
from typing import Optional
from typing import Union

import toml
from astroid import Assign
from astroid import Attribute
from astroid import ClassDef
from astroid import Name
from importlib_resources import files
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from yaml import safe_load


def get_model_name(ancestor: str, node: ClassDef) -> str:
"""Get the model name from the class definition."""
if ancestor == "from nautobot.apps.views.NautobotUIViewSet":
raise NotImplementedError("This ancestor is not yet supported.")

meta = next((n for n in node.body if isinstance(n, ClassDef) and n.name == "Meta"), None)
if not meta:
raise NotImplementedError("This class does not have a Meta class.")

model_attr = next(
(
attr
for attr in meta.body
if isinstance(attr, Assign)
and any(
isinstance(target, (Name, Attribute))
and getattr(target, "attrname", None) == "model"
or getattr(target, "name", None) == "model"
for target in attr.targets
)
),
None,
)
if not model_attr:
raise NotImplementedError("The Meta class does not define a model attribute.")

if isinstance(model_attr.value, Name):
return model_attr.value.name
if not isinstance(model_attr.value, Attribute):
raise NotImplementedError("This utility supports only direct assignment or attribute based model names.")

model_attr_chain = []
while isinstance(model_attr.value, Attribute):
model_attr_chain.insert(0, model_attr.value.attrname)
model_attr.value = model_attr.value.expr

if isinstance(model_attr.value, Name):
model_attr_chain.insert(0, model_attr.value.name)

return model_attr_chain[-1]


def find_ancestor(node: ClassDef, ancestors: List[str]) -> str:
"""Find the class ancestor from the list of ancestors."""
ancestor_class_types = [ancestor.qname() for ancestor in node.ancestors()]
for checked_ancestor in ancestors:
if checked_ancestor in ancestor_class_types:
return checked_ancestor

return ""


def is_nautobot_v2_installed() -> bool:
"""Return True if Nautobot v2.x is installed."""
return MINIMUM_NAUTOBOT_VERSION.major == 2
Expand Down
15 changes: 15 additions & 0 deletions tests/inputs/sub-class-name/error_filter_set.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from nautobot.apps.filters import NautobotFilterSet
from nautobot.core.models.generics import PrimaryModel


class AddressObject(PrimaryModel):
pass


class MyAddressObjectFilterSet(NautobotFilterSet):
"""Filter for AddressObject."""

class Meta:
"""Meta attributes for filter."""

model = AddressObject
15 changes: 15 additions & 0 deletions tests/inputs/sub-class-name/good_filter_set.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from nautobot.apps.filters import NautobotFilterSet
from nautobot.core.models.generics import PrimaryModel


class AddressObject(PrimaryModel):
pass


class AddressObjectFilterSet(NautobotFilterSet):
"""Filter for AddressObject."""

class Meta:
"""Meta attributes for filter."""

model = AddressObject
39 changes: 39 additions & 0 deletions tests/test_sub_class_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Tests for sub class name checker."""

from pylint.testutils import CheckerTestCase

from pylint_nautobot.sub_class_name import NautobotSubClassNameChecker

from .utils import assert_error_file
from .utils import assert_good_file
from .utils import parametrize_error_files
from .utils import parametrize_good_files


def _find_failing_node(module_node):
return module_node.body[3]


_EXPECTED_ERRORS = {
"filter_set": {
"msg_id": "nb-sub-class-name",
"line": 9,
"col_offset": 0,
"args": ("AddressObjectFilterSet",),
"node": _find_failing_node,
},
}


class TestSubClassNameChecker(CheckerTestCase):
"""Test sub class name checker"""

CHECKER_CLASS = NautobotSubClassNameChecker

@parametrize_error_files(__file__, _EXPECTED_ERRORS)
def test_sub_class_name(self, path, expected_error):
assert_error_file(self, path, expected_error)

@parametrize_good_files(__file__)
def test_no_issues(self, path):
assert_good_file(self, path)

0 comments on commit 1be2676

Please sign in to comment.