-
Notifications
You must be signed in to change notification settings - Fork 189
Add error D303 when an f-string is used as a docstring #381
base: master
Are you sure you want to change the base?
Changes from all commits
c987558
0854a95
9e96a78
7146353
52e1e10
f2a944f
e39f80b
e6a7c82
afd0030
2f9f337
b46db49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,6 +44,27 @@ def decorator(f): | |
return decorator | ||
|
||
|
||
FSTRING_REGEX = re(r'^([rR]?)[fF]') | ||
|
||
|
||
def is_fstring(docstring): | ||
"""Return True if docstring is an f-string.""" | ||
return FSTRING_REGEX.match(str(docstring)) | ||
|
||
|
||
def safe_literal_eval(string): | ||
"""Safely evaluate a literal even if it is an fstring.""" | ||
try: | ||
return ast.literal_eval(string) | ||
except ValueError as error: | ||
# If the docstring is a fstring, it is | ||
# not considered a valid docstring. See | ||
# https://bugs.python.org/issue28739 | ||
raise ParseError( | ||
info="f-strings are not valid as docstrings." | ||
) from error | ||
|
||
|
||
class ConventionChecker: | ||
"""Checker for PEP 257, NumPy and Google conventions. | ||
|
||
|
@@ -176,8 +197,24 @@ def checks(self): | |
for this_check in vars(type(self)).values() | ||
if hasattr(this_check, '_check_for') | ||
] | ||
# This returns the checks in the order they are | ||
# listed in the file (since py3.6) if their priority is the same | ||
return sorted(all, key=lambda this_check: not this_check._terminal) | ||
|
||
# Note - this needs to be listed before other checks | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checks should not rely on order, other checker functions, etc. Let's find a better solution. |
||
# as f string evalutaion may cause malformed AST Nodes. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit - other comments spell this out as "f-string". Please add a hyphen here as well. |
||
# So we need to run this check first and terminate early. | ||
@check_for(Definition, terminal=True) | ||
def check_docstring_fstring(self, definition, docstring): | ||
"""D303: Docstrings may not be f-strings. | ||
|
||
f-strings are not treated as string literals, but they look similar | ||
and users may attempt to use them as docstrings. This is an | ||
outright mistake so we issue a specific error code. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This text will be emitted when running with
(trim to appropriate line length) |
||
""" | ||
if is_fstring(docstring): | ||
return violations.D303() | ||
|
||
@check_for(Definition, terminal=True) | ||
def check_docstring_missing(self, definition, docstring): | ||
"""D10{0,1,2,3}: Public definitions should have docstrings. | ||
|
@@ -196,7 +233,7 @@ def check_docstring_missing(self, definition, docstring): | |
not docstring | ||
and definition.is_public | ||
or docstring | ||
and is_blank(ast.literal_eval(docstring)) | ||
and is_blank(safe_literal_eval(docstring)) | ||
): | ||
codes = { | ||
Module: violations.D100, | ||
|
@@ -232,7 +269,7 @@ def check_one_liners(self, definition, docstring): | |
|
||
""" | ||
if docstring: | ||
lines = ast.literal_eval(docstring).split('\n') | ||
lines = safe_literal_eval(docstring).split('\n') | ||
if len(lines) > 1: | ||
non_empty_lines = sum(1 for l in lines if not is_blank(l)) | ||
if non_empty_lines == 1: | ||
|
@@ -308,7 +345,7 @@ def check_blank_after_summary(self, definition, docstring): | |
|
||
""" | ||
if docstring: | ||
lines = ast.literal_eval(docstring).strip().split('\n') | ||
lines = safe_literal_eval(docstring).strip().split('\n') | ||
if len(lines) > 1: | ||
post_summary_blanks = list(map(is_blank, lines[1:])) | ||
blanks_count = sum(takewhile(bool, post_summary_blanks)) | ||
|
@@ -361,7 +398,7 @@ def check_newline_after_last_paragraph(self, definition, docstring): | |
if docstring: | ||
lines = [ | ||
l | ||
for l in ast.literal_eval(docstring).split('\n') | ||
for l in safe_literal_eval(docstring).split('\n') | ||
if not is_blank(l) | ||
] | ||
if len(lines) > 1: | ||
|
@@ -372,7 +409,7 @@ def check_newline_after_last_paragraph(self, definition, docstring): | |
def check_surrounding_whitespaces(self, definition, docstring): | ||
"""D210: No whitespaces allowed surrounding docstring text.""" | ||
if docstring: | ||
lines = ast.literal_eval(docstring).split('\n') | ||
lines = safe_literal_eval(docstring).split('\n') | ||
if ( | ||
lines[0].startswith(' ') | ||
or len(lines) == 1 | ||
|
@@ -400,7 +437,7 @@ def check_multi_line_summary_start(self, definition, docstring): | |
"ur'''", | ||
] | ||
|
||
lines = ast.literal_eval(docstring).split('\n') | ||
lines = safe_literal_eval(docstring).split('\n') | ||
if len(lines) > 1: | ||
first = docstring.split("\n")[0].strip().lower() | ||
if first in start_triple: | ||
|
@@ -422,7 +459,7 @@ def check_triple_double_quotes(self, definition, docstring): | |
|
||
''' | ||
if docstring: | ||
if '"""' in ast.literal_eval(docstring): | ||
if '"""' in safe_literal_eval(docstring): | ||
# Allow ''' quotes if docstring contains """, because | ||
# otherwise """ quotes could not be expressed inside | ||
# docstring. Not in PEP 257. | ||
|
@@ -466,7 +503,7 @@ def _check_ends_with(docstring, chars, violation): | |
|
||
""" | ||
if docstring: | ||
summary_line = ast.literal_eval(docstring).strip().split('\n')[0] | ||
summary_line = safe_literal_eval(docstring).strip().split('\n')[0] | ||
if not summary_line.endswith(chars): | ||
return violation(summary_line[-1]) | ||
|
||
|
@@ -501,7 +538,7 @@ def check_imperative_mood(self, function, docstring): # def context | |
|
||
""" | ||
if docstring and not function.is_test: | ||
stripped = ast.literal_eval(docstring).strip() | ||
stripped = safe_literal_eval(docstring).strip() | ||
if stripped: | ||
first_word = strip_non_alphanumeric(stripped.split()[0]) | ||
check_word = first_word.lower() | ||
|
@@ -527,7 +564,7 @@ def check_no_signature(self, function, docstring): # def context | |
|
||
""" | ||
if docstring: | ||
first_line = ast.literal_eval(docstring).strip().split('\n')[0] | ||
first_line = safe_literal_eval(docstring).strip().split('\n')[0] | ||
if function.name + '(' in first_line.replace(' ', ''): | ||
return violations.D402() | ||
|
||
|
@@ -539,7 +576,7 @@ def check_capitalized(self, function, docstring): | |
|
||
""" | ||
if docstring: | ||
first_word = ast.literal_eval(docstring).split()[0] | ||
first_word = safe_literal_eval(docstring).split()[0] | ||
if first_word == first_word.upper(): | ||
return | ||
for char in first_word: | ||
|
@@ -571,7 +608,7 @@ def check_starts_with_this(self, function, docstring): | |
if not docstring: | ||
return | ||
|
||
stripped = ast.literal_eval(docstring).strip() | ||
stripped = safe_literal_eval(docstring).strip() | ||
if not stripped: | ||
return | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,4 @@ | ||
"""General shared utilities.""" | ||
import ast | ||
import logging | ||
import re | ||
from itertools import tee, zip_longest | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
"""Test for warning about f-strings as docstrings.""" | ||
|
||
from .expected import Expectation | ||
|
||
expectation = Expectation() | ||
expect = expectation.expect | ||
_GIZMO = "gizmo" | ||
D303 = expect("D303: f-strings are not valid as docstrings") | ||
|
||
|
||
@D303 | ||
def fstring(): | ||
f"""Toggle the gizmo.""" | ||
|
||
|
||
@D303 | ||
def another_fstring(): | ||
F"""Toggle the gizmo.""" | ||
|
||
|
||
@D303 | ||
def fstring_with_raw(): | ||
rF"""Toggle the gizmo.""" | ||
|
||
|
||
@D303 | ||
def fstring_with_raw_caps(): | ||
RF"""Toggle the gizmo.""" | ||
|
||
|
||
@D303 | ||
def fstring_with_raw_variable(): | ||
RF"""Toggle the {_GIZMO}.""" | ||
|
||
|
||
@D303 | ||
def fstring_with_variable(): | ||
f"""Toggle the {_GIZMO.upper()}.""" | ||
|
||
|
||
@D303 | ||
def fstring_with_other_errors(arg=1, missing_arg=2): | ||
f"""Toggle the {_GIZMO.upper()} | ||
|
||
This should not raise any other errors since fstrings | ||
are a terminal check. | ||
""" | ||
|
||
|
||
@D303 | ||
def fstring_with_blank_doc_string(): | ||
f""" """ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mistakenly using an f-string for a docstring does not cause a syntax error, so we shouldn't raise a
ParseError
here.Instead, I suggest we rename this as
eval_docstring
and just return an empty string if we find an f-string.