diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 5ab5e11..ac444c6 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -4,6 +4,13 @@ Release Notes **pydocstyle** version numbers follow the `Semantic Versioning `_ specification. +Current Development Version +--------------------------- + +Bug Fixes + +* Fix a crash when f-strings are used as docstrings (#653). + 6.3.0 - January 17th, 2023 -------------------------- diff --git a/src/pydocstyle/checker.py b/src/pydocstyle/checker.py index 9b6376b..6d25ea4 100644 --- a/src/pydocstyle/checker.py +++ b/src/pydocstyle/checker.py @@ -35,6 +35,15 @@ __all__ = ('check',) +FSTRING_REGEX = re(r'^([rR]?)[fF]') + + +def eval_docstring(docstring): + """Safely evaluate docstring.""" + if FSTRING_REGEX.match(str(docstring)): + return "" + return ast.literal_eval(docstring) + def check_for(kind, terminal=False): def decorator(f): @@ -241,7 +250,7 @@ def check_docstring_empty(self, definition, docstring): NOTE: This used to report as D10X errors. """ - if docstring and is_blank(ast.literal_eval(docstring)): + if docstring and is_blank(eval_docstring(docstring)): return violations.D419() @check_for(Definition) @@ -253,7 +262,7 @@ def check_one_liners(self, definition, docstring): """ if docstring: - lines = ast.literal_eval(docstring).split('\n') + lines = eval_docstring(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: @@ -329,7 +338,7 @@ def check_blank_after_summary(self, definition, docstring): """ if docstring: - lines = ast.literal_eval(docstring).strip().split('\n') + lines = eval_docstring(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)) @@ -382,7 +391,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 eval_docstring(docstring).split('\n') if not is_blank(l) ] if len(lines) > 1: @@ -393,7 +402,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 = eval_docstring(docstring).split('\n') if ( lines[0].startswith(' ') or len(lines) == 1 @@ -421,7 +430,7 @@ def check_multi_line_summary_start(self, definition, docstring): "ur'''", ] - lines = ast.literal_eval(docstring).split('\n') + lines = eval_docstring(docstring).split('\n') if len(lines) > 1: first = docstring.split("\n")[0].strip().lower() if first in start_triple: @@ -443,7 +452,7 @@ def check_triple_double_quotes(self, definition, docstring): ''' if docstring: - if '"""' in ast.literal_eval(docstring): + if '"""' in eval_docstring(docstring): # Allow ''' quotes if docstring contains """, because # otherwise """ quotes could not be expressed inside # docstring. Not in PEP 257. @@ -487,7 +496,7 @@ def _check_ends_with(docstring, chars, violation): """ if docstring: - summary_line = ast.literal_eval(docstring).strip().split('\n')[0] + summary_line = eval_docstring(docstring).strip().split('\n')[0] if not summary_line.endswith(chars): return violation(summary_line[-1]) @@ -526,7 +535,7 @@ def check_imperative_mood(self, function, docstring): # def context and not function.is_test and not function.is_property(self.property_decorators) ): - stripped = ast.literal_eval(docstring).strip() + stripped = eval_docstring(docstring).strip() if stripped: first_word = strip_non_alphanumeric(stripped.split()[0]) check_word = first_word.lower() @@ -552,7 +561,7 @@ def check_no_signature(self, function, docstring): # def context """ if docstring: - first_line = ast.literal_eval(docstring).strip().split('\n')[0] + first_line = eval_docstring(docstring).strip().split('\n')[0] if function.name + '(' in first_line.replace(' ', ''): return violations.D402() @@ -564,7 +573,7 @@ def check_capitalized(self, function, docstring): """ if docstring: - first_word = ast.literal_eval(docstring).split()[0] + first_word = eval_docstring(docstring).split()[0] if first_word == first_word.upper(): return for char in first_word: @@ -596,7 +605,7 @@ def check_starts_with_this(self, function, docstring): if not docstring: return - stripped = ast.literal_eval(docstring).strip() + stripped = eval_docstring(docstring).strip() if not stripped: return diff --git a/src/tests/test_integration.py b/src/tests/test_integration.py index b812fd6..497fe55 100644 --- a/src/tests/test_integration.py +++ b/src/tests/test_integration.py @@ -1584,4 +1584,13 @@ def test_match_considers_basenames_for_path_args(env): # env.invoke calls pydocstyle with full path to test_a.py out, _, code = env.invoke(target='test_a.py') assert '' == out - assert code == 0 \ No newline at end of file + assert code == 0 + +def test_fstring(env): + """Test that f-strings do not cause a crash.""" + env.write_config(select='D') + with env.open("test.py", 'wt') as fobj: + fobj.write('''f"bar {123}"''') + _, err, code = env.invoke(args="-v") + assert code == 1 + assert "ValueError" not in err