Skip to content

Commit 44536e1

Browse files
authored
Merge pull request #478 from eduNEXT/MJG/events-code-annotations
feat: [FC-0074] add inline code annotation linter for Open edX Events
2 parents 76543fc + c14e40c commit 44536e1

File tree

4 files changed

+221
-1
lines changed

4 files changed

+221
-1
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ Change Log
1313
Unreleased
1414
~~~~~~~~~~
1515

16+
5.5.0 - 2025-01-22
17+
~~~~~~~~~~~~~~~~~~
18+
19+
* Add inline code annotation linter for Open edX Events.
20+
1621
5.4.1 - 2024-10-28
1722
~~~~~~~~~~~~~~~~~~
1823

edx_lint/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
edx_lint standardizes lint configuration and additional plugins for use in
33
Open edX code.
44
"""
5-
__version__ = "5.4.1"
5+
__version__ = "5.5.0"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""
2+
edx_lint events_annotation module (optional plugin for events inline code-annotations
3+
checks).
4+
5+
Add this to your pylintrc::
6+
load-plugins=edx_lint.pylint.events_annotation
7+
"""
8+
9+
from .events_annotation_check import register_checkers
10+
11+
register = register_checkers
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""
2+
Pylint plugin: checks that Open edX Events are properly annotated.
3+
"""
4+
5+
from astroid.nodes.node_classes import Name
6+
from pylint.checkers import utils
7+
8+
from edx_lint.pylint.annotations_check import AnnotationBaseChecker, check_all_messages
9+
from edx_lint.pylint.common import BASE_ID
10+
11+
12+
def register_checkers(linter):
13+
"""
14+
Register checkers.
15+
"""
16+
linter.register_checker(EventsAnnotationChecker(linter))
17+
18+
19+
class EventsAnnotationChecker(AnnotationBaseChecker):
20+
"""
21+
Perform checks on events annotations.
22+
"""
23+
24+
CONFIG_FILENAMES = ["openedx_events_annotations.yaml"]
25+
26+
name = "events-annotations"
27+
28+
NO_TYPE_MESSAGE_ID = "event-no-type"
29+
NO_NAME_MESSAGE_ID = "event-no-name"
30+
NO_DATA_MESSAGE_ID = "event-no-data"
31+
NO_STATUS_MESSAGE_ID = "event-no-status"
32+
NO_DESCRIPTION_MESSAGE_ID = "event-empty-description"
33+
MISSING_OR_INCORRECT_ANNOTATION = "missing-or-incorrect-annotation"
34+
35+
msgs = {
36+
("E%d80" % BASE_ID): (
37+
"Event type must be present and be the first annotation",
38+
NO_TYPE_MESSAGE_ID,
39+
"event type must be present and be the first annotation",
40+
),
41+
("E%d81" % BASE_ID): (
42+
"Event annotation (%s) has no name",
43+
NO_NAME_MESSAGE_ID,
44+
"Event annotations must include a name",
45+
),
46+
("E%d82" % BASE_ID): (
47+
"Event annotation (%s) has no data",
48+
NO_DATA_MESSAGE_ID,
49+
"event annotations must include a data",
50+
),
51+
("E%d84" % BASE_ID): (
52+
"Event annotation (%s) has no description",
53+
NO_DESCRIPTION_MESSAGE_ID,
54+
"Events annotations must include a short description",
55+
),
56+
("E%d85" % BASE_ID): (
57+
"Event annotation is missing or incorrect",
58+
MISSING_OR_INCORRECT_ANNOTATION,
59+
(
60+
"When an Open edX event object is created, a corresponding annotation must be present above in the"
61+
" same module and with a matching type",
62+
)
63+
),
64+
}
65+
66+
EVENT_CLASS_NAMES = ["OpenEdxPublicSignal"]
67+
68+
def __init__(self, *args, **kwargs):
69+
super().__init__(*args, **kwargs)
70+
self.current_module_annotated_event_types = []
71+
self.current_module_event_data = []
72+
self.current_module_annotation_group_line_numbers = []
73+
self.current_module_annotation_group_map = {}
74+
75+
@check_all_messages(msgs)
76+
def visit_module(self, node):
77+
"""
78+
Run all checks on a single module.
79+
"""
80+
self.check_module(node)
81+
82+
def leave_module(self, _node):
83+
self.current_module_annotation_group_line_numbers.clear()
84+
self.current_module_annotation_group_map.clear()
85+
86+
def check_annotation_group(self, search, annotations, node):
87+
"""
88+
Perform checks on a single annotation group.
89+
"""
90+
if not annotations:
91+
return
92+
93+
event_type = ""
94+
event_name = ""
95+
event_data = ""
96+
event_description = ""
97+
line_number = None
98+
for annotation in annotations:
99+
if line_number is None:
100+
line_number = annotation["line_number"]
101+
self.current_module_annotation_group_line_numbers.append(line_number)
102+
self.current_module_annotation_group_map[line_number] = ()
103+
if annotation["annotation_token"] == ".. event_type:":
104+
event_type = annotation["annotation_data"]
105+
elif annotation["annotation_token"] == ".. event_name:":
106+
event_name = annotation["annotation_data"]
107+
elif annotation["annotation_token"] == ".. event_data:":
108+
event_data = annotation["annotation_data"]
109+
elif annotation["annotation_token"] == ".. event_description:":
110+
event_description = annotation["annotation_data"]
111+
if event_type and event_data and event_name:
112+
self.current_module_annotation_group_map[line_number] = (event_type, event_data, event_name,)
113+
114+
if not event_type:
115+
self.add_message(
116+
self.NO_TYPE_MESSAGE_ID,
117+
node=node,
118+
line=line_number,
119+
)
120+
121+
if not event_name:
122+
self.add_message(
123+
self.NO_NAME_MESSAGE_ID,
124+
args=(event_type,),
125+
node=node,
126+
line=line_number,
127+
)
128+
129+
if not event_data:
130+
self.add_message(
131+
self.NO_DATA_MESSAGE_ID,
132+
args=(event_type,),
133+
node=node,
134+
line=line_number,
135+
)
136+
137+
if not event_description:
138+
self.add_message(
139+
self.NO_DESCRIPTION_MESSAGE_ID,
140+
args=(event_type,),
141+
node=node,
142+
line=line_number,
143+
)
144+
145+
@utils.only_required_for_messages(MISSING_OR_INCORRECT_ANNOTATION)
146+
def visit_call(self, node):
147+
"""
148+
Check for missing annotations.
149+
"""
150+
if self._is_annotation_missing_or_incorrect(node):
151+
self.add_message(
152+
self.MISSING_OR_INCORRECT_ANNOTATION,
153+
node=node,
154+
)
155+
156+
def _is_annotation_missing_or_incorrect(self, node):
157+
"""
158+
Check if an annotation is missing or incorrect for an event.
159+
160+
An annotation is considered missing when:
161+
- The annotation is not present above the event object.
162+
163+
An annotation is considered incorrect when:
164+
- The annotation is present above the event object but the type of the annotation does
165+
not match the type of the event object.
166+
- The annotation is present above the event object but the data of the annotation does
167+
not match the data of the event object.
168+
"""
169+
if (
170+
not isinstance(node.func, Name)
171+
or node.func.name not in self.EVENT_CLASS_NAMES
172+
):
173+
return False
174+
175+
if not self.current_module_annotation_group_line_numbers:
176+
# There are no annotations left
177+
return True
178+
179+
annotation_line_number = self.current_module_annotation_group_line_numbers[0]
180+
if annotation_line_number > node.tolineno:
181+
# The next annotation is located after the current node
182+
return True
183+
annotation_line_number = self.current_module_annotation_group_line_numbers.pop(0)
184+
185+
current_annotation_group = self.current_module_annotation_group_map[annotation_line_number]
186+
if not current_annotation_group:
187+
# The annotation group with type or data or name for the line is empty, but should be caught by the
188+
# annotation checks
189+
return False
190+
191+
event_type, event_data, event_name = current_annotation_group
192+
# All event definitions have two keyword arguments, the first is the event type and the second is the
193+
# event data. It also has a name associated with it. For example:
194+
# MyEvent = OpenEdxPublicSignal(
195+
# event_type="org.openedx.subdomain.action.emitted.v1",
196+
# event_data={"my_data": MyEventData},
197+
# )
198+
node_event_type = node.keywords[0].value.value
199+
node_event_data = node.keywords[1].value.items[0][1].name
200+
node_event_name = node.parent.targets[0].name
201+
if node_event_type != event_type or node_event_data != event_data or node_event_name != event_name:
202+
return True
203+
204+
return False

0 commit comments

Comments
 (0)