Skip to content

Commit ccd41e0

Browse files
committed
Support fixme's in docstrings
1 parent a5a77f6 commit ccd41e0

File tree

6 files changed

+227
-15
lines changed

6 files changed

+227
-15
lines changed

pylint/checkers/misc.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,18 +90,33 @@ class EncodingChecker(BaseTokenChecker, BaseRawFileChecker):
9090
"default": "",
9191
},
9292
),
93+
(
94+
"check-fixme-in-docstring",
95+
{
96+
"type": "yn",
97+
"metavar": "<y or n>",
98+
"default": False,
99+
"help": "Whether or not to search for fixme's in docstrings."
100+
}
101+
)
93102
)
94103

95104
def open(self) -> None:
96105
super().open()
97106

98107
notes = "|".join(re.escape(note) for note in self.linter.config.notes)
99108
if self.linter.config.notes_rgx:
100-
regex_string = rf"#\s*({notes}|{self.linter.config.notes_rgx})(?=(:|\s|\Z))"
109+
comment_regex = rf"#\s*({notes}|{self.linter.config.notes_rgx})(?=(:|\s|\Z))"
110+
self._comment_fixme_pattern = re.compile(comment_regex, re.I)
111+
if self.linter.config.check_fixme_in_docstring:
112+
docstring_regex = rf"\"\"\"\s*({notes}|{self.linter.config.notes_rgx})(?=(:|\s|\Z))"
113+
self._docstring_fixme_pattern = re.compile(docstring_regex, re.I)
101114
else:
102-
regex_string = rf"#\s*({notes})(?=(:|\s|\Z))"
103-
104-
self._fixme_pattern = re.compile(regex_string, re.I)
115+
comment_regex = rf"#\s*({notes})(?=(:|\s|\Z))"
116+
self._comment_fixme_pattern = re.compile(comment_regex, re.I)
117+
if self.linter.config.check_fixme_in_docstring:
118+
docstring_regex = rf"\"\"\"\s*({notes})(?=(:|\s|\Z))"
119+
self._docstring_fixme_pattern = re.compile(docstring_regex, re.I)
105120

106121
def _check_encoding(
107122
self, lineno: int, line: bytes, file_encoding: str
@@ -133,16 +148,29 @@ def process_tokens(self, tokens: list[tokenize.TokenInfo]) -> None:
133148
if not self.linter.config.notes:
134149
return
135150
for token_info in tokens:
136-
if token_info.type != tokenize.COMMENT:
137-
continue
138-
comment_text = token_info.string[1:].lstrip() # trim '#' and white-spaces
139-
if self._fixme_pattern.search("#" + comment_text.lower()):
140-
self.add_message(
141-
"fixme",
142-
col_offset=token_info.start[1] + 1,
143-
args=comment_text,
144-
line=token_info.start[0],
145-
)
151+
if token_info.type == tokenize.COMMENT:
152+
comment_text = token_info.string[1:].lstrip() # trim '#' and white-spaces
153+
if self._comment_fixme_pattern.search("#" + comment_text.lower()):
154+
self.add_message(
155+
"fixme",
156+
col_offset=token_info.start[1] + 1,
157+
args=comment_text,
158+
line=token_info.start[0],
159+
)
160+
elif self.linter.config.check_fixme_in_docstring and self._is_docstring_comment(token_info):
161+
docstring_lines = token_info.string.split("\n")
162+
for line_no, line in enumerate(docstring_lines):
163+
comment_text = line.removeprefix('"""').lstrip().removesuffix('"""') # trim '""""' and whitespace
164+
if self._docstring_fixme_pattern.search('"""' + comment_text.lower()):
165+
self.add_message(
166+
"fixme",
167+
col_offset=token_info.start[1] + 1,
168+
args=comment_text,
169+
line=token_info.start[0] + line_no,
170+
)
171+
172+
def _is_docstring_comment(self, token_info: tokenize.TokenInfo) -> bool:
173+
return token_info.type == tokenize.STRING and token_info.line.lstrip().startswith('"""')
146174

147175

148176
def register(linter: PyLinter) -> None:

tests/checkers/unittest_misc.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,128 @@ def test_dont_trigger_on_todoist(self) -> None:
116116
"""
117117
with self.assertNoMessages():
118118
self.checker.process_tokens(_tokenize_str(code))
119+
120+
@set_config(check_fixme_in_docstring=True)
121+
def test_docstring_with_message(self) -> None:
122+
code = """
123+
\"\"\"FIXME message\"\"\"
124+
"""
125+
with self.assertAddsMessages(
126+
MessageTest(msg_id="fixme", line=2, args="FIXME message", col_offset=9)
127+
128+
):
129+
self.checker.process_tokens(_tokenize_str(code))
130+
131+
@set_config(check_fixme_in_docstring=True)
132+
def test_docstring_with_nl_message(self) -> None:
133+
code = """
134+
\"\"\"
135+
FIXME message
136+
\"\"\"
137+
"""
138+
with self.assertAddsMessages(
139+
MessageTest(msg_id="fixme", line=3, args="FIXME message", col_offset=9)
140+
):
141+
self.checker.process_tokens(_tokenize_str(code))
142+
143+
@set_config(check_fixme_in_docstring=True)
144+
def test_docstring_with_nl_message_multi(self) -> None:
145+
code = """
146+
\"\"\"
147+
FIXME this
148+
TODO: that
149+
\"\"\"
150+
"""
151+
with self.assertAddsMessages(
152+
MessageTest(msg_id="fixme", line=3, args="FIXME this", col_offset=9),
153+
MessageTest(msg_id="fixme", line=4, args="TODO: that", col_offset=9)
154+
):
155+
self.checker.process_tokens(_tokenize_str(code))
156+
157+
@set_config(check_fixme_in_docstring=True)
158+
def test_docstring_with_comment(self) -> None:
159+
code = """
160+
# XXX message1
161+
\"\"\"
162+
FIXME message2
163+
TODO message3
164+
\"\"\"
165+
"""
166+
with self.assertAddsMessages(
167+
MessageTest(msg_id="fixme", line=2, args="XXX message1", col_offset=9),
168+
MessageTest(msg_id="fixme", line=4, args="FIXME message2", col_offset=9),
169+
MessageTest(msg_id="fixme", line=5, args="TODO message3", col_offset=9)
170+
):
171+
self.checker.process_tokens(_tokenize_str(code))
172+
173+
@set_config(check_fixme_in_docstring=True)
174+
def test_docstring_with_comment_prefix(self) -> None:
175+
code = """
176+
# \"\"\" XXX should not trigger
177+
\"\"\" # XXX should not trigger \"\"\"
178+
"""
179+
with self.assertNoMessages():
180+
self.checker.process_tokens(_tokenize_str(code))
181+
182+
@set_config(check_fixme_in_docstring=True)
183+
def test_docstring_todo_middle_nl(self) -> None:
184+
code = """
185+
\"\"\"
186+
something FIXME message
187+
\"\"\"
188+
"""
189+
with self.assertNoMessages():
190+
self.checker.process_tokens(_tokenize_str(code))
191+
192+
@set_config(check_fixme_in_docstring=True)
193+
def test_docstring_todo_middle(self) -> None:
194+
code = """
195+
\"\"\"something FIXME message
196+
\"\"\"
197+
"""
198+
with self.assertNoMessages():
199+
self.checker.process_tokens(_tokenize_str(code))
200+
201+
@set_config(check_fixme_in_docstring=True)
202+
def test_docstring_todo_mult(self) -> None:
203+
code = """
204+
\"\"\"
205+
FIXME this TODO that
206+
\"\"\"
207+
"""
208+
with self.assertAddsMessages(
209+
MessageTest(msg_id="fixme", line=3, args="FIXME this TODO that", col_offset=9),
210+
):
211+
self.checker.process_tokens(_tokenize_str(code))
212+
213+
@set_config(
214+
check_fixme_in_docstring=True,
215+
notes=["CODETAG"]
216+
)
217+
def test_docstring_custom_note(self) -> None:
218+
code = """
219+
\"\"\"
220+
CODETAG implement this
221+
\"\"\"
222+
"""
223+
with self.assertAddsMessages(
224+
MessageTest(msg_id="fixme", line=3, args="CODETAG implement this", col_offset=9),
225+
):
226+
self.checker.process_tokens(_tokenize_str(code))
227+
228+
@set_config(
229+
check_fixme_in_docstring=True,
230+
notes_rgx="FIX.*"
231+
)
232+
def test_docstring_custom_rgx(self) -> None:
233+
code = """
234+
\"\"\"
235+
FIXME implement this
236+
FIXTHIS also implement this
237+
\"\"\"
238+
"""
239+
with self.assertAddsMessages(
240+
MessageTest(msg_id="fixme", line=3, args="FIXME implement this", col_offset=9),
241+
MessageTest(msg_id="fixme", line=4, args="FIXTHIS also implement this", col_offset=9),
242+
):
243+
self.checker.process_tokens(_tokenize_str(code))

tests/functional/f/fixme.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Tests for fixme and its disabling and enabling."""
2-
# pylint: disable=missing-function-docstring, unused-variable
2+
# pylint: disable=missing-function-docstring, unused-variable, pointless-string-statement
33

44
# +1: [fixme]
55
# FIXME: beep
@@ -35,6 +35,8 @@ def function():
3535
# pylint: disable-next=fixme
3636
# FIXME: Don't raise when the message is disabled
3737

38+
"""TODO: Don't raise when docstring fixmes are disabled"""
39+
3840
# This line needs to be at the end of the file to make sure it doesn't end with a comment
3941
# Pragma's compare against the 'lineno' attribute of the respective nodes which
4042
# would stop too soon otherwise.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Tests for fixme in docstrings"""
2+
# pylint: disable=missing-function-docstring, pointless-string-statement
3+
4+
# +1: [fixme]
5+
"""TODO resolve this"""
6+
7+
"""
8+
FIXME don't forget this # [fixme]
9+
XXX also remember this # [fixme]
10+
??? but not this
11+
12+
TODO yes this # [fixme]
13+
FIXME: and this line, but treat it as one FIXME TODO # [fixme]
14+
# TODO not this, however, even though it looks like a comment
15+
also not if there's stuff in front TODO
16+
XXX spaces are okay though # [fixme]
17+
"""
18+
19+
# +1: [fixme]
20+
# FIXME should still work
21+
22+
# +1: [fixme]
23+
# TODO """ should work
24+
25+
# """ TODO should not work
26+
"""# TODO neither should this"""
27+
28+
"""TODOist API should not result in a message"""
29+
30+
# +2: [fixme]
31+
"""
32+
TO make something DO: look a regex
33+
"""
34+
35+
# pylint: disable-next=fixme
36+
"""TODO won't work anymore"""
37+
38+
# +2: [fixme]
39+
def function():
40+
"""./TODO implement this"""
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[MISCELLANEOUS]
2+
# List of note tags to take in consideration, separated by a comma.
3+
notes=XXX,TODO,./TODO
4+
# Regular expression of note tags to take in consideration.
5+
notes-rgx=FIXME(?!.*ISSUE-\d+)|TO.*DO
6+
# enable checking for fixme's in docstrings
7+
check-fixme-in-docstring=yes
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
fixme:5:1:None:None::TODO resolve this:UNDEFINED
2+
fixme:8:1:None:None::FIXME don't forget this # [fixme]:UNDEFINED
3+
fixme:9:1:None:None::XXX also remember this # [fixme]:UNDEFINED
4+
fixme:12:1:None:None::TODO yes this # [fixme]:UNDEFINED
5+
fixme:13:1:None:None::"FIXME: and this line, but treat it as one FIXME TODO # [fixme]":UNDEFINED
6+
fixme:16:1:None:None::XXX spaces are okay though # [fixme]:UNDEFINED
7+
fixme:20:1:None:None::FIXME should still work:UNDEFINED
8+
fixme:23:1:None:None::"TODO """""" should work":UNDEFINED
9+
fixme:32:1:None:None::"TO make something DO: look a regex":UNDEFINED
10+
fixme:40:5:None:None::./TODO implement this:UNDEFINED

0 commit comments

Comments
 (0)