From 5e941f54c4ff11580f33652b39fa00c4a158955d Mon Sep 17 00:00:00 2001 From: Amar Paul Date: Mon, 23 Sep 2024 03:51:25 -0400 Subject: [PATCH 1/3] fix: DOC503 catch namespaced exceptions (#168) Co-authored-by: jsh9 <25124332+jsh9@users.noreply.github.com> --- CHANGELOG.md | 13 ++++++++ pydoclint/utils/generic.py | 10 ++++++- pydoclint/utils/return_yield_raise.py | 27 +++++++++++++---- setup.cfg | 2 +- tests/data/google/raises/cases.py | 23 ++++++++++++++ tests/data/numpy/raises/cases.py | 27 +++++++++++++++++ tests/data/sphinx/raises/cases.py | 21 +++++++++++++ tests/test_main.py | 8 +++++ tests/utils/test_returns_yields_raise.py | 38 ++++++++++++++++++++++-- 9 files changed, 160 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e018d..c5fb61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [0.5.8] - 2024-09-23 + +- Fixed + + - Fixed the logic of handling exceptions namespaces (`a.b.c.MyException`) + +- Full diff + - https://github.com/jsh9/pydoclint/compare/0.5.7...0.5.8 + ## [0.5.7] - 2024-09-02 - Added @@ -8,8 +17,12 @@ function body match those in the "Raises" section of the docstring - Changed + - Switched from tab to 4 spaces in baseline +- Full diff + - https://github.com/jsh9/pydoclint/compare/0.5.6...0.5.7 + ## [0.5.6] - 2024-07-17 - Fixed diff --git a/pydoclint/utils/generic.py b/pydoclint/utils/generic.py index b6cc8bd..9f3bde4 100644 --- a/pydoclint/utils/generic.py +++ b/pydoclint/utils/generic.py @@ -1,7 +1,7 @@ import ast import copy import re -from typing import List, Match, Optional, Tuple +from typing import List, Match, Optional, Tuple, Union from pydoclint.utils.astTypes import ClassOrFunctionDef, FuncOrAsyncFuncDef from pydoclint.utils.method_type import MethodType @@ -233,3 +233,11 @@ def specialEqual(str1: str, str2: str) -> bool: return False return True + + +def getFullAttributeName(node: Union[ast.Attribute, ast.Name]) -> str: + """Get the full name of a symbol like a.b.c.foo""" + if isinstance(node, ast.Name): + return node.id + + return getFullAttributeName(node.value) + '.' + node.attr diff --git a/pydoclint/utils/return_yield_raise.py b/pydoclint/utils/return_yield_raise.py index 1984e61..4a316d9 100644 --- a/pydoclint/utils/return_yield_raise.py +++ b/pydoclint/utils/return_yield_raise.py @@ -4,7 +4,7 @@ from pydoclint.utils import walk from pydoclint.utils.annotation import unparseAnnotation from pydoclint.utils.astTypes import BlockType, FuncOrAsyncFuncDef -from pydoclint.utils.generic import stringStartsWith +from pydoclint.utils.generic import getFullAttributeName, stringStartsWith ReturnType = Type[ast.Return] ExprType = Type[ast.Expr] @@ -132,7 +132,17 @@ def _getRaisedExceptions( ): for subnode, _ in walk.walk_dfs(child): if isinstance(subnode, ast.Name): - yield subnode.id + if isinstance(child.exc, ast.Attribute): + # case: looks like m.n.exception + yield getFullAttributeName(child.exc) + elif isinstance(child.exc, ast.Call) and isinstance( + child.exc.func, ast.Attribute + ): + # case: looks like m.n.exception() + yield getFullAttributeName(child.exc.func) + else: + yield subnode.id + break else: # if "raise" statement was alone, it must be inside an "except" @@ -148,10 +158,17 @@ def _extractExceptionsFromExcept( if isinstance(node.type, ast.Name): yield node.type.id + if isinstance(node.type, ast.Attribute): + # case: looks like m.n.exception + yield getFullAttributeName(node.type) + if isinstance(node.type, ast.Tuple): - for child, _ in walk.walk(node.type): - if isinstance(child, ast.Name): - yield child.id + for elt in node.type.elts: + if isinstance(elt, ast.Attribute): + # case: looks like m.n.exception + yield getFullAttributeName(elt) + elif isinstance(elt, ast.Name): + yield elt.id def _hasExpectedStatements( diff --git a/setup.cfg b/setup.cfg index 030d304..9ec6c76 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pydoclint -version = 0.5.7 +version = 0.5.8 description = A Python docstring linter that checks arguments, returns, yields, and raises sections long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/data/google/raises/cases.py b/tests/data/google/raises/cases.py index acc898f..e99a2c4 100644 --- a/tests/data/google/raises/cases.py +++ b/tests/data/google/raises/cases.py @@ -182,3 +182,26 @@ def func13(self) -> None: ValueError: typo! """ raise ValueError + + def func14(self) -> None: + """ + Should fail, expects `exceptions.CustomError`. + + Raises: + CustomError: every time. + """ + exceptions = object() + exceptions.CustomError = CustomError + raise exceptions.CustomError() + + def func15(self) -> None: + """ + Should fail, expects `exceptions.m.CustomError`. + + Raises: + CustomError: every time. + """ + exceptions = object() + exceptions.m = object() + exceptions.m.CustomError = CustomError + raise exceptions.m.CustomError diff --git a/tests/data/numpy/raises/cases.py b/tests/data/numpy/raises/cases.py index 2e7db3f..0e51c66 100644 --- a/tests/data/numpy/raises/cases.py +++ b/tests/data/numpy/raises/cases.py @@ -229,3 +229,30 @@ def func13(self) -> None: typo! """ raise ValueError + + def func14(self) -> None: + """ + Should fail, expects `exceptions.CustomError`. + + Raises + ------ + CustomError + every time. + """ + exceptions = object() + exceptions.CustomError = CustomError + raise exceptions.CustomError() + + def func15(self) -> None: + """ + Should fail, expects `exceptions.m.CustomError`. + + Raises + ------ + CustomError + every time. + """ + exceptions = object() + exceptions.m = object() + exceptions.m.CustomError = CustomError + raise exceptions.m.CustomError diff --git a/tests/data/sphinx/raises/cases.py b/tests/data/sphinx/raises/cases.py index d4368fb..46adc8e 100644 --- a/tests/data/sphinx/raises/cases.py +++ b/tests/data/sphinx/raises/cases.py @@ -153,3 +153,24 @@ def func13(self) -> None: :raises ValueError: typo! """ raise ValueError + + def func14(self) -> None: + """ + Should fail, expects `exceptions.CustomError`. + + :raises CustomError: every time. + """ + exceptions = object() + exceptions.CustomError = CustomError + raise exceptions.CustomError() + + def func15(self) -> None: + """ + Should fail, expects `exceptions.m.CustomError`. + + :raises CustomError: every time. + """ + exceptions = object() + exceptions.m = object() + exceptions.m.CustomError = CustomError + raise exceptions.m.CustomError diff --git a/tests/test_main.py b/tests/test_main.py index 7aae66a..71f2485 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -832,6 +832,14 @@ def testRaises(style: str, skipRaisesCheck: bool) -> None: 'docstring do not match those in the function body Raises values in the ' "docstring: ['ValueError', 'ValueError']. Raised exceptions in the body: " "['ValueError'].", + 'DOC503: Method `B.func14` exceptions in the "Raises" section in the ' + 'docstring do not match those in the function body Raises values in the ' + "docstring: ['CustomError']. Raised exceptions in the body: " + "['exceptions.CustomError'].", + 'DOC503: Method `B.func15` exceptions in the "Raises" section in the ' + 'docstring do not match those in the function body Raises values in the ' + "docstring: ['CustomError']. Raised exceptions in the body: " + "['exceptions.m.CustomError'].", ] expected1 = [] expected = expected1 if skipRaisesCheck else expected0 diff --git a/tests/utils/test_returns_yields_raise.py b/tests/utils/test_returns_yields_raise.py index 15a151f..7b63310 100644 --- a/tests/utils/test_returns_yields_raise.py +++ b/tests/utils/test_returns_yields_raise.py @@ -357,7 +357,7 @@ def func7(arg0): def func8(d): try: d[0][0] - except (KeyError, TypeError): + except (KeyError, TypeError, m.ValueError): raise finally: pass @@ -416,6 +416,30 @@ def func12(a): if a < 3: raise Error3 + +def func13(a): + # ensure we get `Exception`, `Exception()`, and `Exception('something')` + if a < 1: + raise ValueError + elif a < 2: + raise TypeError() + else: + raise IOError('IO Error!') + +def func14(a): + # check that we properly identify submodule exceptions. + if a < 1: + raise m.ValueError + elif a < 2: + raise m.n.ValueError() + else: + raise a.b.c.ValueError(msg="some msg") + +def func15(): + try: + x = 1 + except other.Exception: + raise """ @@ -439,6 +463,9 @@ def testHasRaiseStatements() -> None: (75, 0, 'func10'): True, (83, 0, 'func11'): True, (100, 0, 'func12'): True, + (117, 0, 'func13'): True, + (126, 0, 'func14'): True, + (135, 0, 'func15'): True, } assert result == expected @@ -464,11 +491,18 @@ def testWhichRaiseStatements() -> None: 'RuntimeError', 'TypeError', ], - (54, 0, 'func8'): ['KeyError', 'TypeError'], + (54, 0, 'func8'): ['KeyError', 'TypeError', 'm.ValueError'], (62, 0, 'func9'): ['AssertionError', 'IndexError'], (75, 0, 'func10'): ['GError'], (83, 0, 'func11'): ['ValueError'], (100, 0, 'func12'): ['Error1', 'Error2', 'Error3'], + (117, 0, 'func13'): ['IOError', 'TypeError', 'ValueError'], + (126, 0, 'func14'): [ + 'a.b.c.ValueError', + 'm.ValueError', + 'm.n.ValueError', + ], + (135, 0, 'func15'): ['other.Exception'], } assert result == expected From 27ff76915d548ebc19bcd1e9f802bb7c0386b4a6 Mon Sep 17 00:00:00 2001 From: Andres Monge Date: Mon, 23 Sep 2024 10:10:53 +0200 Subject: [PATCH 2/3] doc(usage): Including a null-ls use with neovim. (#167) Co-authored-by: jsh9 <25124332+jsh9@users.noreply.github.com> --- docs/notes_for_users.md | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/notes_for_users.md b/docs/notes_for_users.md index bffe2e4..ec64706 100644 --- a/docs/notes_for_users.md +++ b/docs/notes_for_users.md @@ -10,6 +10,8 @@ - [4. Notes on writing Sphinx-style docstrings](#4-notes-on-writing-sphinx-style-docstrings) - [5. Notes for Google-style users](#5-notes-for-google-style-users) - [6. How to adopt _pydoclint_ more easily in legacy projects](#6-how-to-adopt-pydoclint-more-easily-in-legacy-projects) +- [7. How to integrate _pydoclint_ with different editors or IDEs](#7-how-to-integrate-pydoclint-with-different-editors-or-ides) + - [7.1. Integrate _pydoclint_ with Neovim using null-ls](#71-integrate-pydoclint-with-neovim-using-null-ls) @@ -187,3 +189,45 @@ somewhere in your repo. For more details, please check out [this section](https://jsh9.github.io/pydoclint/config_options.html#12---baseline). + +## 7. How to integrate _pydoclint_ with different editors or IDEs + +### 7.1. Integrate _pydoclint_ with Neovim using null-ls + +If you use [Neovim](https://neovim.io/), you can integrate _pydoclint_ with +your editor using the [null-ls](https://github.com/nvimtools/none-ls.nvim) +plugin. null-ls allows you to use linters and formatters in Neovim in a simple +and efficient way. First, make sure you have installed null-ls using your +preferred package manager. Next, add the following configuration to your Neovim +config file to register _pydoclint_ as a diagnostic source: + +```lua +local null_ls = require("null-ls") + +null_ls.setup({ + sources = { + null_ls.builtins.diagnostics.pydoclint, + }, +}) +``` + +This will enable _pydoclint_ to provide diagnostic messages for your Python +code directly in Neovim. You can further customize the behavior of _pydoclint_ +by passing additional options: + +```lua +local null_ls = require("null-ls") + +null_ls.setup({ + sources = { + null_ls.builtins.diagnostics.pydoclint.with({ + extra_args = {"--style=google", "--check-return-types=False"}, + }), + }, +}) +``` + +Adjust the extra*args based on your preferred \_pydoclint* configuration. With +this setup, you can now enjoy the benefits of _pydoclint_'s fast and +comprehensive docstring linting directly within your Neovim editing +environment. From 81704368e3368c4f7b6d6ea6b16b163608579d65 Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Sun, 29 Sep 2024 18:30:50 -0400 Subject: [PATCH 3/3] Improve handling of long type annotations (#173) --- CHANGELOG.md | 9 +++++ pydoclint/utils/generic.py | 6 ++- setup.cfg | 2 +- .../15_very_long_annotations/google.py | 32 ++++++++++++++++ .../15_very_long_annotations/numpy.py | 37 +++++++++++++++++++ .../15_very_long_annotations/sphinx.py | 36 ++++++++++++++++++ tests/test_main.py | 3 ++ 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/data/edge_cases/15_very_long_annotations/google.py create mode 100644 tests/data/edge_cases/15_very_long_annotations/numpy.py create mode 100644 tests/data/edge_cases/15_very_long_annotations/sphinx.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c5fb61e..f4a4e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [0.5.9] - 2024-09-29 + +- Fixed + + - Fixed an edge case where type annotations are very long + +- Full diff + - https://github.com/jsh9/pydoclint/compare/0.5.8...0.5.9 + ## [0.5.8] - 2024-09-23 - Fixed diff --git a/pydoclint/utils/generic.py b/pydoclint/utils/generic.py index 9f3bde4..2d7ea72 100644 --- a/pydoclint/utils/generic.py +++ b/pydoclint/utils/generic.py @@ -214,11 +214,15 @@ def appendArgsToCheckToV105( def specialEqual(str1: str, str2: str) -> bool: """ Check string equality but treat any single quotes as the same as - double quotes. + double quotes, and also ignore line breaks in either strings. """ if str1 == str2: return True # using shortcuts to speed up evaluation + if '\n' in str1 or '\n' in str2: + str1 = str1.replace(' ', '').replace('\n', '') + str2 = str2.replace(' ', '').replace('\n', '') + if len(str1) != len(str2): return False # using shortcuts to speed up evaluation diff --git a/setup.cfg b/setup.cfg index 9ec6c76..9a1e34c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pydoclint -version = 0.5.8 +version = 0.5.9 description = A Python docstring linter that checks arguments, returns, yields, and raises sections long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/data/edge_cases/15_very_long_annotations/google.py b/tests/data/edge_cases/15_very_long_annotations/google.py new file mode 100644 index 0000000..56f4658 --- /dev/null +++ b/tests/data/edge_cases/15_very_long_annotations/google.py @@ -0,0 +1,32 @@ +# This edge case was reported in https://github.com/jsh9/pydoclint/issues/164 + +# fmt: off +import numpy as np + + +def func( + arg1: tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + ], +) -> tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray]: + """ + The docstring parser for the Google style does not support line breaking + in type hints. Therefore, in order to pass pydoclint's checks, we can only + put long type hints in one line. + + Args: + arg1 (tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]): A parameter + + Returns: + tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: The return value + """ + return ( + np.array([]), np.array([]), np.array([]), np.array([]), np.array([]), + np.array([]), np.array([]), np.array([]), np.array([]), + ) + +# fmt: on diff --git a/tests/data/edge_cases/15_very_long_annotations/numpy.py b/tests/data/edge_cases/15_very_long_annotations/numpy.py new file mode 100644 index 0000000..ba33007 --- /dev/null +++ b/tests/data/edge_cases/15_very_long_annotations/numpy.py @@ -0,0 +1,37 @@ +# This edge case was reported in https://github.com/jsh9/pydoclint/issues/164 + +# fmt: off + +import numpy as np + + +def func( + arg1: tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + ], +) -> tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray]: + """ + The docstring parser for the numpy style does not support line breaking + in type hints. Therefore, in order to pass pydoclint's checks, we can only + put long type hints in one line. + + Parameters + ---------- + arg1 : tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray] + A parameter + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray] + The return value + """ + return ( + np.array([]), np.array([]), np.array([]), np.array([]), np.array([]), + np.array([]), np.array([]), np.array([]), np.array([]), + ) + +# fmt: on diff --git a/tests/data/edge_cases/15_very_long_annotations/sphinx.py b/tests/data/edge_cases/15_very_long_annotations/sphinx.py new file mode 100644 index 0000000..d3bf3e0 --- /dev/null +++ b/tests/data/edge_cases/15_very_long_annotations/sphinx.py @@ -0,0 +1,36 @@ +# This edge case was reported in https://github.com/jsh9/pydoclint/issues/164 + +# fmt: off + +import numpy as np + + +def func( + arg1: tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + ], +) -> tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray]: + """Something + + :param arg1: A parameter + :type arg1: tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + ] + + :returns: Numpy arrays + :rtype: tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray] + """ + return ( + np.array([]), np.array([]), np.array([]), np.array([]), np.array([]), + np.array([]), np.array([]), np.array([]), np.array([]), + ) + +# fmt: on diff --git a/tests/test_main.py b/tests/test_main.py index 71f2485..0a68f60 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1456,6 +1456,9 @@ def testNonAscii() -> None: 'args do not match: arg1' ], ), + ('15_very_long_annotations/sphinx.py', {'style': 'sphinx'}, []), + ('15_very_long_annotations/google.py', {'style': 'google'}, []), + ('15_very_long_annotations/numpy.py', {'style': 'numpy'}, []), ], ) def testEdgeCases(