From 1be26765382b10a8d4a3e9e48c67187f053a9805 Mon Sep 17 00:00:00 2001 From: Jan Snasel Date: Fri, 9 Feb 2024 13:07:11 +0000 Subject: [PATCH] feat: Check sub class names --- pylint_nautobot/__init__.py | 2 + pylint_nautobot/sub_class_name.py | 44 ++++++++++++++ pylint_nautobot/utils.py | 58 +++++++++++++++++++ .../inputs/sub-class-name/error_filter_set.py | 15 +++++ .../inputs/sub-class-name/good_filter_set.py | 15 +++++ tests/test_sub_class_name.py | 39 +++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 pylint_nautobot/sub_class_name.py create mode 100644 tests/inputs/sub-class-name/error_filter_set.py create mode 100644 tests/inputs/sub-class-name/good_filter_set.py create mode 100644 tests/test_sub_class_name.py diff --git a/pylint_nautobot/__init__.py b/pylint_nautobot/__init__.py index 34c52af..d3fec8e 100644 --- a/pylint_nautobot/__init__.py +++ b/pylint_nautobot/__init__.py @@ -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 @@ -22,6 +23,7 @@ NautobotModelLabelChecker, NautobotReplacedModelsImportChecker, NautobotStringFieldBlankNull, + NautobotSubClassNameChecker, NautobotUseFieldsAllChecker, ] diff --git a/pylint_nautobot/sub_class_name.py b/pylint_nautobot/sub_class_name.py new file mode 100644 index 0000000..648759e --- /dev/null +++ b/pylint_nautobot/sub_class_name.py @@ -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 . + + This can typically be done via .replace("Nautobot", ) + """ + + 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 .", + ) + } + + 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,)) diff --git a/pylint_nautobot/utils.py b/pylint_nautobot/utils.py index f4eb89e..f30da76 100644 --- a/pylint_nautobot/utils.py +++ b/pylint_nautobot/utils.py @@ -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 diff --git a/tests/inputs/sub-class-name/error_filter_set.py b/tests/inputs/sub-class-name/error_filter_set.py new file mode 100644 index 0000000..d1fd241 --- /dev/null +++ b/tests/inputs/sub-class-name/error_filter_set.py @@ -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 diff --git a/tests/inputs/sub-class-name/good_filter_set.py b/tests/inputs/sub-class-name/good_filter_set.py new file mode 100644 index 0000000..0545356 --- /dev/null +++ b/tests/inputs/sub-class-name/good_filter_set.py @@ -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 diff --git a/tests/test_sub_class_name.py b/tests/test_sub_class_name.py new file mode 100644 index 0000000..c20d16d --- /dev/null +++ b/tests/test_sub_class_name.py @@ -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)