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

Sub class name #74

Merged
merged 8 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ exclude =
__pycache__,
.venv,
tests/inputs
var
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,))
62 changes: 61 additions & 1 deletion pylint_nautobot/utils.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,91 @@
"""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


def is_version_compatible(specifier_set: Union[str, SpecifierSet]) -> bool:
"""Return True if the Nautobot version is compatible with the given version specifier_set."""
if not specifier_set:
return True
if isinstance(specifier_set, str):
specifier_set = SpecifierSet(specifier_set)
return specifier_set.contains(MINIMUM_NAUTOBOT_VERSION)


def load_v2_code_location_changes():
"""Black magic data transform, needs schema badly."""
with open(files("pylint_nautobot") / "data" / "v2" / "v2-code-location-changes.yaml", encoding="utf-8") as rules:
with open(files("pylint_nautobot") / "data" / "v2" / "v2-code-location-changes.yaml", encoding="utf-8") as rules: # type: ignore
changes = safe_load(rules)
changes_map = {}
for change in changes:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ exclude = '''
| buck-out
| build
| dist
| var
)/
| settings.py # This is where you define files that should not be stylized by black
# the root of the project
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):
snaselj marked this conversation as resolved.
Show resolved Hide resolved
"""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
13 changes: 6 additions & 7 deletions tests/test_deprecated_status_model.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Tests for deprecated status model."""
from pathlib import Path

from pylint.testutils import CheckerTestCase
from pytest import mark

from pylint_nautobot.deprecated_status_model import NautobotDeprecatedStatusModelChecker

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

_INPUTS_PATH = Path(__file__).parent / "inputs/deprecated-status-model/"
_EXPECTED_ERRORS = {
"status_model": {
"msg_id": "nb-status-field-instead-of-status-model",
Expand All @@ -25,11 +24,11 @@ class TestDeprecatedStatusModelChecker(CheckerTestCase):

CHECKER_CLASS = NautobotDeprecatedStatusModelChecker

@mark.parametrize("path", _INPUTS_PATH.glob("error_*.py"))
def test_deprecated_status_model(self, path):
assert_error_file(self, path, _EXPECTED_ERRORS)
@parametrize_error_files(__file__, _EXPECTED_ERRORS)
def test_sub_class_name(self, path, expected_error):
assert_error_file(self, path, expected_error)

# TBD: Missing test for good_status_model.py
@mark.parametrize("path", _INPUTS_PATH.glob("good_*.py"))
@parametrize_good_files(__file__)
def test_no_issues(self, path):
assert_good_file(self, path)
15 changes: 7 additions & 8 deletions tests/test_incorrect_base_class.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
"""Tests for incorrect base class checker."""
from pathlib import Path

from pylint.testutils import CheckerTestCase
from pytest import mark

from pylint_nautobot.incorrect_base_class import NautobotIncorrectBaseClassChecker

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

_INPUTS_PATH = Path(__file__).parent / "inputs/incorrect-base-class"
_EXPECTED_ERROR_ARGS = {
_EXPECTED_ERRORS = {
"model": {
"msg_id": "nb-incorrect-base-class",
"line": 4,
Expand All @@ -26,10 +25,10 @@ class TestIncorrectBaseClassChecker(CheckerTestCase):

CHECKER_CLASS = NautobotIncorrectBaseClassChecker

@mark.parametrize("path", _INPUTS_PATH.glob("error_*.py"))
def test_incorrect_base_class(self, path):
assert_error_file(self, path, _EXPECTED_ERROR_ARGS)
@parametrize_error_files(__file__, _EXPECTED_ERRORS)
def test_sub_class_name(self, path, expected_error):
assert_error_file(self, path, expected_error)

@mark.parametrize("path", _INPUTS_PATH.glob("good_*.py"))
@parametrize_good_files(__file__)
def test_no_issues(self, path):
assert_good_file(self, path)
13 changes: 6 additions & 7 deletions tests/test_string_field_blank_null.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Tests for blank and null StringFields."""
from pathlib import Path

from pylint.testutils import CheckerTestCase
from pytest import mark

from pylint_nautobot.string_field_blank_null import NautobotStringFieldBlankNull

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

_INPUTS_PATH = Path(__file__).parent / "inputs/string-field-blank-null/"
_EXPECTED_ERRORS = {
"field": {
"msg_id": "nb-string-field-blank-null",
Expand All @@ -25,10 +24,10 @@ class TestStringFieldBlankNullChecker(CheckerTestCase):

CHECKER_CLASS = NautobotStringFieldBlankNull

@mark.parametrize("path", _INPUTS_PATH.glob("error_*.py"))
def test_string_field_blank_null(self, path):
assert_error_file(self, path, _EXPECTED_ERRORS)
@parametrize_error_files(__file__, _EXPECTED_ERRORS)
def test_sub_class_name(self, path, expected_error):
assert_error_file(self, path, expected_error)

@mark.parametrize("path", _INPUTS_PATH.glob("good_*.py"))
@parametrize_good_files(__file__)
def test_no_issues(self, path):
assert_good_file(self, path)
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)
13 changes: 6 additions & 7 deletions tests/test_use_fields_all.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Tests for use fields all"""
from pathlib import Path

from pylint.testutils import CheckerTestCase
from pytest import mark

from pylint_nautobot.use_fields_all import NautobotUseFieldsAllChecker

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_fields_node(module_node):
Expand All @@ -17,7 +17,6 @@ def _find_fields_node(module_node):
return list(meta.get_children())[1].value


_INPUTS_PATH = Path(__file__).parent / "inputs/use-fields-all/"
_EXPECTED_ERRORS = {
"table": {
"msg_id": "nb-use-fields-all",
Expand All @@ -33,10 +32,10 @@ class TestUseFieldsAllChecker(CheckerTestCase):

CHECKER_CLASS = NautobotUseFieldsAllChecker

@mark.parametrize("path", _INPUTS_PATH.glob("error_*.py"))
def test_use_fields_all(self, path):
assert_error_file(self, path, _EXPECTED_ERRORS)
@parametrize_error_files(__file__, _EXPECTED_ERRORS)
def test_sub_class_name(self, path, expected_error):
assert_error_file(self, path, expected_error)

@mark.parametrize("path", _INPUTS_PATH.glob("good_*.py"))
@parametrize_good_files(__file__)
def test_no_issues(self, path):
assert_good_file(self, path)
Loading
Loading