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: [FC-0074] add inline code annotation linter for Open edX Events #478

Merged
merged 10 commits into from
Jan 22, 2025
11 changes: 11 additions & 0 deletions edx_lint/pylint/events_annotation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""
edx_lint events_annotation module (optional plugin for events inline code-annotations
checks).

Add this to your pylintrc::
load-plugins=edx_lint.pylint.events_annotation
"""

from .events_annotation_check import register_checkers

register = register_checkers
182 changes: 182 additions & 0 deletions edx_lint/pylint/events_annotation/events_annotation_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""
Pylint plugin: checks that Open edX Events are properly annotated.
"""

from astroid.nodes.node_classes import Const, Name
from pylint.checkers import utils

from edx_lint.pylint.annotations_check import AnnotationBaseChecker, check_all_messages
from edx_lint.pylint.common import BASE_ID


def register_checkers(linter):
"""
Register checkers.
"""
linter.register_checker(EventsAnnotationChecker(linter))


class EventsAnnotationChecker(AnnotationBaseChecker):
"""
Perform checks on events annotations.
"""

CONFIG_FILENAMES = ["openedx_events_annotations.yaml"]

name = "events-annotations"

NO_TYPE_MESSAGE_ID = "event-no-type"
NO_NAME_MESSAGE_ID = "event-no-name"
NO_DATA_MESSAGE_ID = "event-no-data"
NO_STATUS_MESSAGE_ID = "event-no-status"
NO_DESCRIPTION_MESSAGE_ID = "event-empty-description"
MISSING_ANNOTATION = "event-missing-annotation"

msgs = {
("E%d80" % BASE_ID): (
"event annotation has no type",
NO_TYPE_MESSAGE_ID,
"Events annotations type must be present and be the first annotation",
),
("E%d81" % BASE_ID): (
"event annotation (%s) has no name",
NO_NAME_MESSAGE_ID,
"Events annotations name must be present",
),
("E%d82" % BASE_ID): (
"event annotation (%s) has no data argument",
NO_DATA_MESSAGE_ID,
"Events annotations must include data argument",
),
("E%d83" % BASE_ID): (
"event annotation (%s) has no status",
NO_STATUS_MESSAGE_ID,
"Events annotations must include the status of event",
),
("E%d84" % BASE_ID): (
"event annotation (%s) does not have a description",
NO_DESCRIPTION_MESSAGE_ID,
"Events annotations must include a short description",
),
("E%d85" % BASE_ID): (
"missing event annotation",
MISSING_ANNOTATION,
(
"When an Open edX event object is created, a corresponding annotation must be present above in the"
" same module and with a matching name",
)
),
}

EVENT_CLASS_NAMES = ["OpenEdxPublicSignal"]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.current_module_annotated_event_names = set()
self.current_module_annotation_group_line_numbers = []
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current use of self.current_module_annotation_group_line_numbers for tracking annotation lines looks functional but may be brittle if annotations are not perfectly aligned with the corresponding events so this reliance on order may lead to false positives or negatives in certain scenarios.

I think we could consider using a mapping from line numbers to event names (something like self.current_module_annotations = {line_number: event_name}) to directly associate each annotation with its corresponding event, this could make the logic more robust and reduce potential issues caused by misalignment or unexpected ordering

Copy link
Member Author

@mariajgrimaldi mariajgrimaldi Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! This implementation was inspired by the toggle annotations checker, here: https://github.com/openedx/edx-lint/blob/master/edx_lint/pylint/annotations_check.py#L329-L519 and then modified to fit the events inline code-annotations.

As far as I understand, annotations are visited in check_annotation_group using the ASTWalker, which visits annotation groups (starting with # ..) for each module. It finds the line where the annotation group starts, saves it in a queue-like list and saves an identifier, in this case, the event name for the annotation. Then is_annotation_missing is called for each OpenEdxPublicSignal instantiation (called call node). Since they are also visited using the same ASTWalker, the nodes are traversed in the same order, so the annotation groups should match the call node:

# .. event_type: org.openedx.learning.auth.session.login.completed.v1 # <- ANNOTATION GROUP
# .. event_name: SESSION_LOGIN_COMPLETED
# .. event_key_field: user.pii.username
# .. event_description: emitted when the user's login process in the LMS is completed.
# .. event_data: UserData
SESSION_LOGIN_COMPLETED = OpenEdxPublicSignal( # <- CALL NODE
    event_type="org.openedx.learning.auth.session.login.completed.v1",
    data={
        "user": UserData,
    }
)

That's why I don't think we would have an issue with unexpected ordering since we're traversing the modules using the same strategy. Also, since we are using the line number queue of each annotation group as our stopping condition, then we'd need the dict to behave like a queue so is_annotation_missing stops when needed.

Those are the main reasons why I think changing the set and queue for a dict would add more overhead than leaving it as it is. Let me know if you disagree or have any other suggestions considering what I mentioned.

EDIT: maybe something like this could happen, where the event names are switched and the linter won't raise any issues. I'll try to invest some time into figuring out how to cover these cases, I'll let you know what I find!

Copy link
Member Author

@mariajgrimaldi mariajgrimaldi Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Alec4r: I think I've addressed your concerns with these commits:

  • Use a dict to store types and other annotations to check against nodes later in _is_annotation_missing_or_incorrect, the current annotation group should match the event init arguments (since they're traversed in order) so the check is successful: d1adeda
  • Check that the event name matches the annotation group besides event type and data: dadaa92

Let me know what you think!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mariajgrimaldi looks good for me.


@check_all_messages(msgs)
def visit_module(self, node):
"""
Run all checks on a single module.
"""
self.check_module(node)

def leave_module(self, _node):
self.current_module_annotated_event_names.clear()
self.current_module_annotation_group_line_numbers.clear()

def check_annotation_group(self, search, annotations, node):
"""
Perform checks on a single annotation group.
"""
if not annotations:
return

event_type = ""
event_name = ""
event_data = ""
event_description = ""
line_number = None
for annotation in annotations:
if line_number is None:
line_number = annotation["line_number"]
self.current_module_annotation_group_line_numbers.append(line_number)
if annotation["annotation_token"] == ".. event_type:":
event_type = annotation["annotation_data"]
elif annotation["annotation_token"] == ".. event_name:":
event_name = annotation["annotation_data"]
self.current_module_annotated_event_names.add(event_name)
elif annotation["annotation_token"] == ".. event_data:":
event_data = annotation["annotation_data"]
elif annotation["annotation_token"] == ".. event_description:":
event_description = annotation["annotation_data"]

if not event_type:
self.add_message(
self.NO_TYPE_MESSAGE_ID,
node=node,
line=line_number,
)

if not event_name:
self.add_message(
self.NO_NAME_MESSAGE_ID,
args=(event_type,),
node=node,
line=line_number,
)

if not event_data:
self.add_message(
self.NO_DATA_MESSAGE_ID,
args=(event_type,),
node=node,
line=line_number,
)

if not event_description:
self.add_message(
self.NO_DESCRIPTION_MESSAGE_ID,
args=(event_type,),
node=node,
line=line_number,
)

@utils.only_required_for_messages(MISSING_ANNOTATION)
def visit_call(self, node):
"""
Check for missing annotations.
"""
if self.is_annotation_missing(node):
self.add_message(
self.MISSING_ANNOTATION,
node=node,
)

def is_annotation_missing(self, node):
"""
Check whether the node corresponds to a toggle instance creation. if yes, check that it is annotated.
"""
if (
not isinstance(node.func, Name)
or node.func.name not in self.EVENT_CLASS_NAMES
):
return False

if not self.current_module_annotation_group_line_numbers:
# There are no annotations left
return True

annotation_line_number = self.current_module_annotation_group_line_numbers[0]
if annotation_line_number > node.tolineno:
# The next annotation is located after the current node
return True
self.current_module_annotation_group_line_numbers.pop(0)

# Check literal event name arguments
if node.args and isinstance(node.args[0], Const) and isinstance(node.args[0].value, str):
event_name = node.args[0].value
if event_name not in self.current_module_annotated_event_names:
return True
return False
Loading