From 70eb3c88129d5eb2d3951de225d5e61fa596fd9e Mon Sep 17 00:00:00 2001 From: "jsh9, PhD" <25124332+jsh9@users.noreply.github.com> Date: Sat, 17 Feb 2024 02:17:45 -0500 Subject: [PATCH] Fix double quotes in Literal type hint (#123) --- CHANGELOG.md | 6 ++ pydoclint/utils/generic.py | 24 ++++++++ pydoclint/utils/visitor_helper.py | 11 ++-- pyproject.toml | 1 + setup.cfg | 2 +- .../09_double_quotes_in_Literal/google.py | 46 +++++++++++++++ .../09_double_quotes_in_Literal/numpy.py | 57 +++++++++++++++++++ tests/test_main.py | 2 + tests/utils/test_generic.py | 20 ++++++- 9 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 tests/data/edge_cases/09_double_quotes_in_Literal/google.py create mode 100644 tests/data/edge_cases/09_double_quotes_in_Literal/numpy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ef39b..702f94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [unpublished] - 2024-02-17 + +- Fixed + - A bug where using double quotes in Literal type (such as `Literal["foo"]` + could produce a false positive `DOC203` violation. + ## [0.4.0] - 2024-02-08 - Changed diff --git a/pydoclint/utils/generic.py b/pydoclint/utils/generic.py index b5e28bb..4739562 100644 --- a/pydoclint/utils/generic.py +++ b/pydoclint/utils/generic.py @@ -188,3 +188,27 @@ def appendArgsToCheckToV105( argsToCheck: List['Arg'] = funcArgs.findArgsWithDifferentTypeHints(docArgs) # noqa: F821 argNames: str = ', '.join(_.name for _ in argsToCheck) return original_v105.appendMoreMsg(moreMsg=argNames) + + +def specialEqual(str1: str, str2: str) -> bool: + """ + Check string equality but treat any single quotes as the same as + double quotes. + """ + if str1 == str2: + return True # using shortcuts to speed up evaluation + + if len(str1) != len(str2): + return False # using shortcuts to speed up evaluation + + quotes = {'"', "'"} + for char1, char2 in zip(str1, str2): + if char1 == char2: + continue + + if char1 in quotes and char2 in quotes: + continue + + return False + + return True diff --git a/pydoclint/utils/visitor_helper.py b/pydoclint/utils/visitor_helper.py index 86a640d..522be04 100644 --- a/pydoclint/utils/visitor_helper.py +++ b/pydoclint/utils/visitor_helper.py @@ -4,7 +4,7 @@ from typing import List, Optional from pydoclint.utils.annotation import unparseAnnotation -from pydoclint.utils.generic import stripQuotes +from pydoclint.utils.generic import specialEqual, stripQuotes from pydoclint.utils.return_anno import ReturnAnnotation from pydoclint.utils.return_arg import ReturnArg from pydoclint.utils.violation import Violation @@ -75,7 +75,10 @@ def checkReturnTypesForNumpyStyle( msg += f' {len(returnSection)} type(s).' violationList.append(violation.appendMoreMsg(moreMsg=msg)) else: - if returnSecTypes != returnAnnoItems: + if not all( + specialEqual(x, y) + for x, y in zip(returnSecTypes, returnAnnoItems) + ): msg1 = f'Return annotation types: {returnAnnoItems}; ' msg2 = f'docstring return section types: {returnSecTypes}' violationList.append(violation.appendMoreMsg(msg1 + msg2)) @@ -97,12 +100,12 @@ def checkReturnTypesForGoogleOrSphinxStyle( # use one compound style for tuples. if len(returnSection) > 0: - retArgType = stripQuotes(returnSection[0].argType) + retArgType: str = stripQuotes(returnSection[0].argType) if returnAnnotation.annotation is None: msg = 'Return annotation has 0 type(s); docstring' msg += ' return section has 1 type(s).' violationList.append(violation.appendMoreMsg(moreMsg=msg)) - elif retArgType != returnAnnotation.annotation: + elif not specialEqual(retArgType, returnAnnotation.annotation): msg = 'Return annotation types: ' msg += str([returnAnnotation.annotation]) + '; ' msg += 'docstring return section types: ' diff --git a/pyproject.toml b/pyproject.toml index 8ede5a6..9bd5e75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ skip = ['unparser.py'] [tool.cercis] wrap-line-with-long-string = true +extend-exclude = 'tests/data' [tool.pydoclint] style = 'numpy' diff --git a/setup.cfg b/setup.cfg index 012f442..37b9352 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,7 +15,7 @@ classifiers = [options] packages = find: install_requires = - click>=8.0.0 + click>=8.1.0 docstring_parser_fork>=0.0.5 tomli>=2.0.1; python_version<'3.11' python_requires = >=3.8 diff --git a/tests/data/edge_cases/09_double_quotes_in_Literal/google.py b/tests/data/edge_cases/09_double_quotes_in_Literal/google.py new file mode 100644 index 0000000..4f6b5ca --- /dev/null +++ b/tests/data/edge_cases/09_double_quotes_in_Literal/google.py @@ -0,0 +1,46 @@ +# fmt: off + +# This edge case comes from https://github.com/jsh9/pydoclint/issues/105 + +from __future__ import annotations + +from typing import Literal + + +def func_1(arg1: Literal["foo"]) -> Literal["foo"]: + """ + Test literal. + + Args: + arg1 (Literal["foo"]): Arg 1 + + Returns: + Literal["foo"]: The literal string "foo". + """ + return "foo" + + +def func_2(arg1: Literal['foo']) -> Literal['foo']: + """ + Test literal. + + Args: + arg1 (Literal['foo']): Arg 1 + + Returns: + Literal['foo']: The literal string "foo". + """ + return "foo" + + +def func_3(arg1: Literal['foo']) -> tuple[Literal['foo'], Literal["bar"]]: + """ + Test literal. + + Args: + arg1 (Literal['foo']): Arg 1 + + Returns: + tuple[Literal['foo'], Literal["bar"]]: The literal strings 'foo' & "bar" + """ + return 'foo', "bar" diff --git a/tests/data/edge_cases/09_double_quotes_in_Literal/numpy.py b/tests/data/edge_cases/09_double_quotes_in_Literal/numpy.py new file mode 100644 index 0000000..98808a0 --- /dev/null +++ b/tests/data/edge_cases/09_double_quotes_in_Literal/numpy.py @@ -0,0 +1,57 @@ +# fmt: off + +# This edge case comes from https://github.com/jsh9/pydoclint/issues/105 + +from __future__ import annotations + +from typing import Literal + + +def func_1(arg1: Literal["foo"]) -> Literal["foo"]: + """ + Test literal. + + Parameters + ---------- + arg1 : Literal["foo"] + Arg 1 + + Returns + ------- + Literal["foo"] + The literal string "foo". + """ + return "foo" + + +def func_2(arg1: Literal['foo']) -> Literal['foo']: + """ + Test literal. + + Parameters + ---------- + arg1 : Literal['foo'] + Arg 1 + + Returns + ------- + Literal['foo'] + The literal string 'foo'. + """ + return "foo" + + +def func_3() -> tuple[Literal['foo'], Literal["bar"]]: + """ + Test literal. + + Returns + ------- + Literal['foo'] + The literal string 'foo'. And the quote style (single) must match + the function signature. + Literal["bar"] + The literal string "bar". And the quote style (double) must match + the function signature. + """ + return "foo", 'bar' diff --git a/tests/test_main.py b/tests/test_main.py index dfe091c..b85b672 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1127,6 +1127,8 @@ def testNonAscii() -> None: ], ), ('08_return_section_parsing/google.py', {'style': 'google'}, []), + ('09_double_quotes_in_Literal/google.py', {'style': 'google'}, []), + ('09_double_quotes_in_Literal/numpy.py', {'style': 'numpy'}, []), ], ) def testEdgeCases( diff --git a/tests/utils/test_generic.py b/tests/utils/test_generic.py index c3b24fa..f245380 100644 --- a/tests/utils/test_generic.py +++ b/tests/utils/test_generic.py @@ -3,7 +3,7 @@ import pytest -from pydoclint.utils.generic import collectFuncArgs, stripQuotes +from pydoclint.utils.generic import collectFuncArgs, specialEqual, stripQuotes src1 = """ def func1( @@ -91,3 +91,21 @@ def testCollectFuncArgs(src: str, expected: List[str]) -> None: ) def testStripQuotes(string: str, expected: str) -> None: assert stripQuotes(string) == expected + + +@pytest.mark.parametrize( + 'str1, str2, expected', + [ + ('', '', True), # truly equal + ('"', '"', True), # truly equal + ("'", "'", True), # truly equal + ('"', "'", True), + ('Hello" world\' 123', 'Hello" world\' 123', True), # truly equal + ('Hello" world\' 123', "Hello' world' 123", True), + ('Hello" world\' 123', 'Hello\' world" 123', True), + ('Hello" world\' 123', "Hello' world` 123", False), + ('Hello" world\' 123', 'Hello\' world" 1234', False), + ], +) +def testSpecialEqual(str1: str, str2: str, expected: bool) -> None: + assert specialEqual(str1, str2) == expected