docsig 0.86.0__tar.gz → 0.87.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {docsig-0.86.0 → docsig-0.87.0}/PKG-INFO +13 -7
  2. {docsig-0.86.0 → docsig-0.87.0}/README.rst +11 -5
  3. {docsig-0.86.0 → docsig-0.87.0}/docsig/_config.py +22 -27
  4. {docsig-0.86.0 → docsig-0.87.0}/docsig/_core.py +11 -4
  5. {docsig-0.86.0 → docsig-0.87.0}/docsig/_decorators.py +28 -10
  6. {docsig-0.86.0 → docsig-0.87.0}/docsig/_files.py +10 -18
  7. {docsig-0.86.0 → docsig-0.87.0}/docsig/_main.py +4 -1
  8. {docsig-0.86.0 → docsig-0.87.0}/docsig/_module.py +22 -13
  9. {docsig-0.86.0 → docsig-0.87.0}/docsig/_report.py +61 -29
  10. {docsig-0.86.0 → docsig-0.87.0}/docsig/_stub.py +3 -1
  11. {docsig-0.86.0 → docsig-0.87.0}/docsig/_utils.py +23 -0
  12. {docsig-0.86.0 → docsig-0.87.0}/docsig/_version.py +1 -1
  13. {docsig-0.86.0 → docsig-0.87.0}/pyproject.toml +10 -4
  14. {docsig-0.86.0 → docsig-0.87.0}/LICENSE +0 -0
  15. {docsig-0.86.0 → docsig-0.87.0}/docsig/__init__.py +0 -0
  16. {docsig-0.86.0 → docsig-0.87.0}/docsig/__main__.py +0 -0
  17. {docsig-0.86.0 → docsig-0.87.0}/docsig/_check.py +0 -0
  18. {docsig-0.86.0 → docsig-0.87.0}/docsig/_directives.py +0 -0
  19. {docsig-0.86.0 → docsig-0.87.0}/docsig/_hooks.py +0 -0
  20. {docsig-0.86.0 → docsig-0.87.0}/docsig/_parsers.py +0 -0
  21. {docsig-0.86.0 → docsig-0.87.0}/docsig/messages.py +0 -0
  22. {docsig-0.86.0 → docsig-0.87.0}/docsig/plugin/__init__.py +0 -0
  23. {docsig-0.86.0 → docsig-0.87.0}/docsig/plugin/_flake8.py +0 -0
  24. {docsig-0.86.0 → docsig-0.87.0}/docsig/plugin/_validate_pyproject.py +0 -0
  25. {docsig-0.86.0 → docsig-0.87.0}/docsig/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docsig
3
- Version: 0.86.0
3
+ Version: 0.87.0
4
4
  Summary: Check signature params for proper documentation
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
- Requires-Dist: Sphinx (>=7,<9)
20
+ Requires-Dist: Sphinx (>=7,<8.2)
21
21
  Requires-Dist: astroid (>=4.0.2,<5.0.0)
22
22
  Requires-Dist: pathspec (>=0.12.1,<1.2.0)
23
23
  Requires-Dist: tomli (>=2.0.1,<3.0.0)
@@ -36,7 +36,7 @@ Description-Content-Type: text/x-rst
36
36
 
37
37
  |
38
38
 
39
- |License| |PyPI| |CI| |CodeQL| |pre-commit.ci status| |codecov.io| |readthedocs.org| |python3.10| |Black| |isort| |pylint| |Security Status|
39
+ |License| |PyPI| |Jetbrains Plugin| |CI| |Jetbrains Build| |CodeQL| |pre-commit.ci status| |codecov.io| |readthedocs.org| |python3.10| |Black| |isort| |pylint| |Security Status|
40
40
 
41
41
  .. |License| image:: https://img.shields.io/badge/License-MIT-yellow.svg
42
42
  :target: https://opensource.org/licenses/MIT
@@ -74,6 +74,12 @@ Description-Content-Type: text/x-rst
74
74
  .. |Security Status| image:: https://img.shields.io/badge/security-bandit-yellow.svg
75
75
  :target: https://github.com/PyCQA/bandit
76
76
  :alt: Security Status
77
+ .. |Jetbrains Plugin| image:: https://img.shields.io/jetbrains/plugin/v/32129.svg
78
+ :target: https://plugins.jetbrains.com/plugin/32129
79
+ :alt: Jetbrains Plugin
80
+ .. |Jetbrains Build| image:: https://github.com/jshwi/docsig/actions/workflows/build-intellij-plugin.yaml/badge.svg
81
+ :target: https://github.com/jshwi/docsig/actions/workflows/build-intellij-plugin.yaml
82
+ :alt: Build
77
83
 
78
84
  Check Python signature params for proper documentation
79
85
  -------------------------------------------------------
@@ -87,7 +93,7 @@ Maintain accurate and up-to-date Python documentation by automatically checking
87
93
  that all parameters in function signatures match their docstring documentation.
88
94
  Use docsig as a standalone tool, integrate it with ``flake8``, or add it as a
89
95
  ``pre-commit`` hook to catch documentation issues before they reach your
90
- repository.
96
+ repository. **docsig** is also available as a ``PyCharm`` plugin
91
97
 
92
98
  Contributing
93
99
  ------------
@@ -195,7 +201,7 @@ ensure your installation has registered `docsig`
195
201
  .. code-block:: console
196
202
 
197
203
  $ flake8 --version
198
- 7.3.0 (docsig: 0.86.0, mccabe: 0.7.0, pycodestyle: 2.14.0, pyflakes: 3.4.0) CPython 3.10.19 on Darwin
204
+ 7.3.0 (docsig: 0.87.0, mccabe: 0.7.0, pycodestyle: 2.14.0, pyflakes: 3.4.0) CPython 3.10.19 on Darwin
199
205
 
200
206
  And now use `flake8` to lint your files
201
207
 
@@ -281,7 +287,7 @@ Standalone
281
287
 
282
288
  repos:
283
289
  - repo: https://github.com/jshwi/docsig
284
- rev: v0.86.0
290
+ rev: v0.87.0
285
291
  hooks:
286
292
  - id: docsig
287
293
  args:
@@ -300,7 +306,7 @@ or integrated with ``flake8``
300
306
  hooks:
301
307
  - id: flake8
302
308
  additional_dependencies:
303
- - docsig==0.86.0
309
+ - docsig==0.87.0
304
310
  args:
305
311
  - "--sig-check-class"
306
312
  - "--sig-check-dunders"
@@ -7,7 +7,7 @@
7
7
 
8
8
  |
9
9
 
10
- |License| |PyPI| |CI| |CodeQL| |pre-commit.ci status| |codecov.io| |readthedocs.org| |python3.10| |Black| |isort| |pylint| |Security Status|
10
+ |License| |PyPI| |Jetbrains Plugin| |CI| |Jetbrains Build| |CodeQL| |pre-commit.ci status| |codecov.io| |readthedocs.org| |python3.10| |Black| |isort| |pylint| |Security Status|
11
11
 
12
12
  .. |License| image:: https://img.shields.io/badge/License-MIT-yellow.svg
13
13
  :target: https://opensource.org/licenses/MIT
@@ -45,6 +45,12 @@
45
45
  .. |Security Status| image:: https://img.shields.io/badge/security-bandit-yellow.svg
46
46
  :target: https://github.com/PyCQA/bandit
47
47
  :alt: Security Status
48
+ .. |Jetbrains Plugin| image:: https://img.shields.io/jetbrains/plugin/v/32129.svg
49
+ :target: https://plugins.jetbrains.com/plugin/32129
50
+ :alt: Jetbrains Plugin
51
+ .. |Jetbrains Build| image:: https://github.com/jshwi/docsig/actions/workflows/build-intellij-plugin.yaml/badge.svg
52
+ :target: https://github.com/jshwi/docsig/actions/workflows/build-intellij-plugin.yaml
53
+ :alt: Build
48
54
 
49
55
  Check Python signature params for proper documentation
50
56
  -------------------------------------------------------
@@ -58,7 +64,7 @@ Maintain accurate and up-to-date Python documentation by automatically checking
58
64
  that all parameters in function signatures match their docstring documentation.
59
65
  Use docsig as a standalone tool, integrate it with ``flake8``, or add it as a
60
66
  ``pre-commit`` hook to catch documentation issues before they reach your
61
- repository.
67
+ repository. **docsig** is also available as a ``PyCharm`` plugin
62
68
 
63
69
  Contributing
64
70
  ------------
@@ -166,7 +172,7 @@ ensure your installation has registered `docsig`
166
172
  .. code-block:: console
167
173
 
168
174
  $ flake8 --version
169
- 7.3.0 (docsig: 0.86.0, mccabe: 0.7.0, pycodestyle: 2.14.0, pyflakes: 3.4.0) CPython 3.10.19 on Darwin
175
+ 7.3.0 (docsig: 0.87.0, mccabe: 0.7.0, pycodestyle: 2.14.0, pyflakes: 3.4.0) CPython 3.10.19 on Darwin
170
176
 
171
177
  And now use `flake8` to lint your files
172
178
 
@@ -252,7 +258,7 @@ Standalone
252
258
 
253
259
  repos:
254
260
  - repo: https://github.com/jshwi/docsig
255
- rev: v0.86.0
261
+ rev: v0.87.0
256
262
  hooks:
257
263
  - id: docsig
258
264
  args:
@@ -271,7 +277,7 @@ or integrated with ``flake8``
271
277
  hooks:
272
278
  - id: flake8
273
279
  additional_dependencies:
274
- - docsig==0.86.0
280
+ - docsig==0.87.0
275
281
  args:
276
282
  - "--sig-check-class"
277
283
  - "--sig-check-dunders"
@@ -6,7 +6,6 @@ Config dataclasses, pyproject.toml loader, and commandline.
6
6
  """
7
7
 
8
8
  import argparse as _argparse
9
- import os as _os
10
9
  import re as _re
11
10
  import typing as _t
12
11
  from dataclasses import dataclass as _dataclass
@@ -15,6 +14,7 @@ from pathlib import Path as _Path
15
14
 
16
15
  import tomli as _tomli
17
16
 
17
+ from ._utils import get_parent_that_has as _get_parent_that_has
18
18
  from ._version import __version__
19
19
  from .messages import Messages as _Messages
20
20
 
@@ -26,31 +26,18 @@ def _split_comma(value: str) -> list[str]:
26
26
  return [i.replace("\\,", ",") for i in _re.split(r"(?<!\\),", value)]
27
27
 
28
28
 
29
- # attempt to locate a pyproject.toml file if one exists in parents
30
- def _find_pyproject_toml(path: _Path | None = None) -> _Path | None:
31
- if not path:
32
- path = _Path.cwd()
33
-
34
- pyproject_toml = path / PYPROJECT_TOML
35
- if pyproject_toml.is_file():
36
- return pyproject_toml
37
-
38
- if str(path) == _os.path.abspath(_os.sep):
39
- return None
40
-
41
- return _find_pyproject_toml(path.parent)
42
-
43
-
44
29
  def get_config(prog: str) -> dict[str, _t.Any]:
45
30
  """Return the program's tool-section config from pyproject.toml.
46
31
 
47
32
  :param prog: Program name.
48
33
  :return: Config dict, or empty dict if no config is found.
49
34
  """
50
- pyproject_file = _find_pyproject_toml()
35
+ # attempt to locate a pyproject.toml file if one exists in parents
36
+ pyproject_file = _get_parent_that_has(PYPROJECT_TOML)
51
37
  if pyproject_file is None:
52
38
  return {}
53
39
 
40
+ pyproject_file /= PYPROJECT_TOML
54
41
  return {
55
42
  k.replace("-", "_"): v
56
43
  for k, v in _tomli.loads(pyproject_file.read_text())
@@ -220,17 +207,10 @@ def build_parser() -> _ArgumentParser:
220
207
  action="store_true",
221
208
  help="ignore kwargs prefixed with two asterisks",
222
209
  )
223
- parser.add_argument(
224
- "-i",
225
- action="store_true",
226
- help=_argparse.SUPPRESS,
227
- dest="ignore_no_params",
228
- )
229
210
  parser.add_argument(
230
211
  "--ignore-no-params",
231
212
  action="store_true",
232
213
  help="ignore docstrings where parameters are not documented",
233
- dest="ignore_no_params",
234
214
  )
235
215
  parser.add_argument(
236
216
  "--ignore-typechecker",
@@ -336,6 +316,23 @@ class Ignore:
336
316
  kwargs: bool = False
337
317
 
338
318
 
319
+ @_dataclass
320
+ # pylint: disable-next=too-few-public-methods
321
+ class Filters:
322
+ """Configuration for which paths to filter.
323
+
324
+ :param exclude: Regular expression of files and dirs to exclude from
325
+ checks.
326
+ :param excludes: Files or dirs to exclude from checks.
327
+ :param include_ignored: Check files even if they match a gitignore
328
+ pattern.
329
+ """
330
+
331
+ exclude: list[str] = _field(default_factory=list)
332
+ excludes: list[str] = _field(default_factory=list)
333
+ include_ignored: bool = False
334
+
335
+
339
336
  @_dataclass(frozen=True)
340
337
  # pylint: disable-next=too-many-instance-attributes,too-few-public-methods
341
338
  class Config:
@@ -347,11 +344,9 @@ class Config:
347
344
 
348
345
  check: Check = _field(default_factory=Check)
349
346
  ignore: Ignore = _field(default_factory=Ignore)
347
+ filters: Filters = _field(default_factory=Filters)
350
348
  target: _Messages = _field(default_factory=_Messages)
351
349
  disable: _Messages = _field(default_factory=_Messages)
352
- exclude: list[str] = _field(default_factory=list)
353
- excludes: list[str] = _field(default_factory=list)
354
350
  list_checks: bool = False
355
- include_ignored: bool = False
356
351
  no_ansi: bool = False
357
352
  verbose: bool = False
@@ -15,12 +15,14 @@ from . import _decorators
15
15
  from ._check import run_checks as _run_checks
16
16
  from ._config import Check as _Check
17
17
  from ._config import Config as _Config
18
+ from ._config import Filters as _Filters
18
19
  from ._config import Ignore as _Ignore
19
20
  from ._files import Files as _Files
20
21
  from ._parsers import parse_from_file as _parse_from_file
21
22
  from ._parsers import parse_from_string as _parse_from_string
22
23
  from ._report import Failures as _Failures
23
24
  from ._report import report as _report
25
+ from ._utils import get_parent_that_has as _get_parent_that_has
24
26
  from ._utils import print_checks as _print_checks
25
27
  from .messages import E as _E
26
28
  from .messages import Messages as _Messages
@@ -191,22 +193,26 @@ def docsig( # pylint: disable=too-many-locals,too-many-arguments
191
193
  args=ignore_args,
192
194
  kwargs=ignore_kwargs,
193
195
  )
196
+ filters = _Filters(
197
+ include_ignored=include_ignored,
198
+ exclude=exclude_,
199
+ excludes=excludes or [],
200
+ )
194
201
  config = _Config(
195
202
  list_checks=list_checks,
196
- include_ignored=include_ignored,
197
203
  check=check,
198
204
  ignore=ignore,
205
+ filters=filters,
199
206
  no_ansi=no_ansi,
200
207
  verbose=verbose,
201
208
  target=target or _Messages(),
202
209
  disable=disable,
203
- exclude=exclude_,
204
- excludes=excludes or [],
205
210
  )
206
211
  setup_logger(config.verbose)
207
212
  logger = _logging.getLogger(__package__)
208
213
  logger.debug(_pformat(config))
209
214
  if config.list_checks:
215
+ # noinspection PyNoneFunctionAssignment
210
216
  return int(bool(_print_checks())) # type: ignore
211
217
 
212
218
  if string:
@@ -215,7 +221,8 @@ def docsig( # pylint: disable=too-many-locals,too-many-arguments
215
221
  return _report(failures, config)
216
222
 
217
223
  retcodes = [0]
218
- files = _Files(path, config)
224
+ repo = _get_parent_that_has(".git/HEAD")
225
+ files = _Files(path, config.filters, repo)
219
226
  for file in files:
220
227
  failures = runner(file, config)
221
228
  retcode = _report(failures, config, str(file))
@@ -4,6 +4,8 @@ docsig._decorators
4
4
  """
5
5
 
6
6
  import functools as _functools
7
+ import json as _json
8
+ import os as _os
7
9
  import sys as _sys
8
10
  import typing as _t
9
11
  from pathlib import Path as _Path
@@ -11,7 +13,7 @@ from pathlib import Path as _Path
11
13
  from .messages import E as _E
12
14
 
13
15
  _FuncType = _t.Callable[..., int]
14
- _WrappedFuncType = _t.Callable[..., str | int]
16
+ _WrappedFuncType = _t.Callable[..., int]
15
17
 
16
18
 
17
19
  def parse_msgs(func: _WrappedFuncType) -> _WrappedFuncType:
@@ -25,7 +27,7 @@ def parse_msgs(func: _WrappedFuncType) -> _WrappedFuncType:
25
27
  """
26
28
 
27
29
  @_functools.wraps(func)
28
- def _wrapper(*args: str | _Path, **kwargs: _t.Any) -> str | int:
30
+ def _wrapper(*args: str | _Path, **kwargs: _t.Any) -> int:
29
31
  disable = _E.from_codes(kwargs.get("disable", [])) or None
30
32
  target = _E.from_codes(kwargs.get("target", [])) or None
31
33
  kwargs["disable"] = disable
@@ -35,6 +37,7 @@ def parse_msgs(func: _WrappedFuncType) -> _WrappedFuncType:
35
37
  return _wrapper
36
38
 
37
39
 
40
+ # TODO: make report json by default and wrap with a reporter for cli
38
41
  def validate_args(func: _FuncType) -> _WrappedFuncType:
39
42
  """Validate arguments before calling the wrapped function.
40
43
 
@@ -50,30 +53,32 @@ def validate_args(func: _FuncType) -> _WrappedFuncType:
50
53
  """
51
54
 
52
55
  @_functools.wraps(func)
53
- def _wrapper(*args: str | _Path, **kwargs: _t.Any) -> str | int:
54
- stderr = []
56
+ def _wrapper(*args: str | _Path, **kwargs: _t.Any) -> int:
57
+ format_json = _os.getenv("_DOCSIG_FORMAT_JSON") is not None
58
+ retcode = 2
59
+ errors = []
55
60
  if not kwargs.get("list_checks", False):
56
61
  if not args and not kwargs.get("string"):
57
- stderr.append(
62
+ errors.append(
58
63
  "the following arguments are required: path(s) or string",
59
64
  )
60
65
 
61
66
  for message in kwargs.get("disable") or []:
62
67
  if not message.isknown:
63
- stderr.append(
68
+ errors.append(
64
69
  f"unknown option to disable '{message.description}'",
65
70
  )
66
71
 
67
72
  for message in kwargs.get("target") or []:
68
73
  if not message.isknown:
69
- stderr.append(
74
+ errors.append(
70
75
  f"unknown option to target '{message.description}'",
71
76
  )
72
77
 
73
78
  if kwargs.get("check_class") and kwargs.get(
74
79
  "check_class_constructor",
75
80
  ):
76
- stderr.append(
81
+ errors.append(
77
82
  "argument to check class constructor not allowed with"
78
83
  " argument to check class",
79
84
  )
@@ -84,10 +89,23 @@ def validate_args(func: _FuncType) -> _WrappedFuncType:
84
89
  # passing both commandline args as argparse won't
85
90
  # allow it, therefore, this must be an issue with
86
91
  # the pyproject.toml configuration
87
- stderr.append(
92
+ errors.append(
88
93
  "please check your pyproject.toml configuration",
89
94
  )
95
+ if errors:
96
+ message = "\n".join(errors)
97
+ if format_json:
98
+ obj = [ # pragma: no cover
99
+ {"line": None, "message": message, "exit": retcode},
100
+ ]
101
+ if format_json: # pragma: no cover
102
+ print(_json.dumps(obj).strip()) # pragma: no cover
90
103
 
91
- return "\n".join(stderr) if stderr else func(*args, **kwargs)
104
+ else:
105
+ print(message, file=_sys.stderr)
106
+
107
+ return retcode
108
+
109
+ return func(*args, **kwargs)
92
110
 
93
111
  return _wrapper
@@ -14,24 +14,14 @@ from pathspec import PathSpec as _PathSpec
14
14
  from pathspec.patterns import GitWildMatchPattern as _GitWildMatchPattern
15
15
  from wcmatch.pathlib import Path as _WcPath
16
16
 
17
- from ._config import Config as _Config
17
+ from ._config import Filters as _Filters
18
18
 
19
19
  FILE_INFO = "%s: %s"
20
20
 
21
21
 
22
22
  class _Gitignore(_PathSpec):
23
- def _get_repo_relative_to(self, path: _Path) -> _Path | None:
24
- if (path / ".git" / "HEAD").is_file():
25
- return path
26
-
27
- if str(path) == _os.path.abspath(_os.sep):
28
- return None
29
-
30
- return self._get_repo_relative_to(path.parent)
31
-
32
- def __init__(self) -> None:
23
+ def __init__(self, repo: _Path | None = None) -> None:
33
24
  patterns = []
34
- repo = self._get_repo_relative_to(_Path.cwd())
35
25
  # only consider gitignore patterns valid if inside a git repo
36
26
  # there might be stray gitignore files lying about
37
27
  if repo is not None:
@@ -72,25 +62,27 @@ class Files(list[_Path]):
72
62
  """Collect paths to check (gitignore and exclude applied).
73
63
 
74
64
  :param paths: Path(s) to collect (files or directories).
75
- :param config: Configuration object.
65
+ :param filters: Filters object.
66
+ :param repo: Path to the repo root.
76
67
  """
77
68
 
78
69
  def __init__(
79
70
  self,
80
71
  paths: tuple[str | _Path, ...],
81
- config: _Config,
72
+ filters: _Filters,
73
+ repo: _Path | None = None,
82
74
  ) -> None:
83
75
  super().__init__()
84
- self._include_ignored = config.include_ignored
85
- self._gitignore = _Gitignore()
76
+ self._include_ignored = filters.include_ignored
77
+ self._gitignore = _Gitignore(repo)
86
78
  logger = _logging.getLogger(__package__)
87
79
  for path in paths:
88
80
  self._populate(_Path(path))
89
81
 
90
82
  for path in list(self):
91
83
  if str(path) != "." and (
92
- any(_re.match(i, str(path)) for i in config.exclude)
93
- or any(_glob(path, i) for i in config.excludes)
84
+ any(_re.match(i, str(path)) for i in filters.exclude)
85
+ or any(_glob(path, i) for i in filters.excludes)
94
86
  ):
95
87
  logger.debug(FILE_INFO, path, "in exclude list, skipping")
96
88
  self.remove(path)
@@ -5,6 +5,7 @@ docsig._main
5
5
  CLI entry point that parses args and runs docsig.
6
6
  """
7
7
 
8
+ import os as _os
8
9
  import sys as _sys
9
10
  import warnings as _warnings
10
11
 
@@ -20,7 +21,6 @@ def _warn_on_deprecated_short_flags() -> None:
20
21
  "-o": "--check-overridden",
21
22
  "-p": "--check-protected",
22
23
  "-P": "--check-property-returns",
23
- "-i": "--ignore-no-params",
24
24
  }
25
25
  raw_args = _sys.argv[1:]
26
26
  expanded_flags = []
@@ -47,6 +47,9 @@ def main() -> str | int:
47
47
 
48
48
  :return: Exit code (non-zero if any check failed).
49
49
  """
50
+ if _os.getenv("_DOCSIG_FORMAT_JSON"):
51
+ _warnings.simplefilter("ignore", FutureWarning) # pragma: no cover
52
+
50
53
  _warn_on_deprecated_short_flags()
51
54
  a = _parse_args()
52
55
  _excepthook(a.no_ansi)
@@ -57,7 +57,6 @@ class Parent: # pylint: disable=too-many-instance-attributes
57
57
  _ast.nodes.Module
58
58
  | _ast.nodes.ClassDef
59
59
  | _ast.nodes.FunctionDef
60
- | _ast.nodes.NodeNG
61
60
  | None
62
61
  ) = None,
63
62
  directives: _Directives | None = None,
@@ -82,7 +81,7 @@ class Parent: # pylint: disable=too-many-instance-attributes
82
81
  # later inspected to report on the module error (the
83
82
  # report doesn't analyze modules or classes - it
84
83
  # analyzes functions)
85
- self._children.append(Function(file, error=error))
84
+ self._children.append(Function(error=error))
86
85
  else:
87
86
  self._name = node.name
88
87
  self._parse_ast(node, file)
@@ -101,13 +100,13 @@ class Parent: # pylint: disable=too-many-instance-attributes
101
100
  # resolved in the directive object, they are needed to notify
102
101
  # the user in the case that they are invalid
103
102
  parent_comments, parent_disabled = self._directives.get(
104
- node.lineno,
103
+ node.lineno or 0,
105
104
  (_Comments(), _Messages()),
106
105
  )
107
106
  if hasattr(node, "body"):
108
107
  for subnode in node.body:
109
108
  comments, disabled = self._directives.get(
110
- subnode.lineno,
109
+ subnode.lineno or 0,
111
110
  (_Comments(), _Messages()),
112
111
  )
113
112
  comments.extend(parent_comments)
@@ -119,7 +118,7 @@ class Parent: # pylint: disable=too-many-instance-attributes
119
118
  for name in subnode.names:
120
119
  original, alias = name
121
120
  self._imports[original] = alias or original
122
- elif isinstance(subnode, _ast.FunctionDef):
121
+ elif isinstance(subnode, _ast.nodes.FunctionDef):
123
122
  func = Function(
124
123
  subnode,
125
124
  comments,
@@ -147,7 +146,7 @@ class Parent: # pylint: disable=too-many-instance-attributes
147
146
  )
148
147
 
149
148
  self._children.append(func)
150
- elif isinstance(subnode, _ast.ClassDef):
149
+ elif isinstance(subnode, _ast.nodes.ClassDef):
151
150
  self._children.append(
152
151
  Parent(
153
152
  subnode,
@@ -192,7 +191,7 @@ class Function(Parent): # pylint: disable=too-many-instance-attributes
192
191
  # pylint: disable-next=too-many-arguments,too-many-positional-arguments
193
192
  def __init__(
194
193
  self,
195
- node: _ast.nodes.FunctionDef | _ast.nodes.NodeNG | None = None,
194
+ node: _ast.nodes.FunctionDef | None = None,
196
195
  comments: _Comments | None = None,
197
196
  directives: _Directives | None = None,
198
197
  messages: _Messages | None = None,
@@ -216,7 +215,7 @@ class Function(Parent): # pylint: disable=too-many-instance-attributes
216
215
  self._docstring = _Docstring()
217
216
  self._lineno = 0
218
217
  self._error = error
219
- if node is not None:
218
+ if node is not None and node.parent is not None:
220
219
  self._parent = node.parent.frame()
221
220
  self._decorators = node.decorators
222
221
  self._lineno = node.lineno
@@ -230,7 +229,11 @@ class Function(Parent): # pylint: disable=too-many-instance-attributes
230
229
  node,
231
230
  self._config.ignore,
232
231
  )
233
- if self.isinit and not self._config.check.class_constructor:
232
+ if (
233
+ self.isinit
234
+ and not self._config.check.class_constructor
235
+ and not isinstance(self._parent, _ast.nodes.Lambda)
236
+ ):
234
237
  # docstring for __init__ is expected on the class
235
238
  # docstring
236
239
  relevant_doc_node = self._parent.doc_node
@@ -259,7 +262,7 @@ class Function(Parent): # pylint: disable=too-many-instance-attributes
259
262
  @property
260
263
  def ismethod(self) -> bool:
261
264
  """Whether this function is defined in a class (method)."""
262
- return isinstance(self._parent, _ast.ClassDef)
265
+ return isinstance(self._parent, _ast.nodes.ClassDef)
263
266
 
264
267
  @property
265
268
  def isproperty(self) -> bool:
@@ -282,11 +285,16 @@ class Function(Parent): # pylint: disable=too-many-instance-attributes
282
285
  @property
283
286
  def isoverridden(self) -> bool:
284
287
  """Whether this function overrides a base class method."""
285
- if self.ismethod and not self.isinit and self._parent is not None:
288
+ if (
289
+ self.ismethod
290
+ and not self.isinit
291
+ and self._parent is not None
292
+ and isinstance(self._parent, _ast.nodes.ClassDef)
293
+ ):
286
294
  for ancestor in self._parent.ancestors():
287
295
  if self.name in ancestor and isinstance(
288
296
  ancestor[self.name],
289
- _ast.FunctionDef,
297
+ _ast.nodes.FunctionDef,
290
298
  ):
291
299
  return True
292
300
 
@@ -324,6 +332,7 @@ class Function(Parent): # pylint: disable=too-many-instance-attributes
324
332
  | _ast.nodes.Module
325
333
  | _ast.nodes.ClassDef
326
334
  | _ast.nodes.Lambda
335
+ | None
327
336
  ):
328
337
  """Function's parent node."""
329
338
  return self._parent
@@ -331,7 +340,7 @@ class Function(Parent): # pylint: disable=too-many-instance-attributes
331
340
  @property
332
341
  def lineno(self) -> int:
333
342
  """Line number of function declaration."""
334
- return self._lineno
343
+ return self._lineno or 0
335
344
 
336
345
  @property
337
346
  def signature(self) -> _Signature:
@@ -9,6 +9,8 @@ return the highest exit code.
9
9
  """
10
10
 
11
11
  import contextlib as _contextlib
12
+ import json as _json
13
+ import os as _os
12
14
  import sys as _sys
13
15
  import typing as _t
14
16
  from warnings import warn as _warn
@@ -71,12 +73,14 @@ class Failure(list[Failed]):
71
73
  self._name = self._func.name
72
74
  if (
73
75
  self._func.parent is not None
76
+ and hasattr(self._func.parent, "name")
74
77
  and self._func.parent.name
75
78
  and not isinstance(self._func.parent, _ast.nodes.Module)
76
79
  ):
77
80
  self._name = f"{self._func.parent.name}.{self._name}"
78
81
 
79
82
  if self._func.error is not None:
83
+ self._retcode.append(2)
80
84
  self._sig9xx_error()
81
85
  else:
82
86
  self._sig0xx_config()
@@ -97,7 +101,7 @@ class Failure(list[Failed]):
97
101
  def _add(
98
102
  self,
99
103
  value: _Message,
100
- hint: bool = False,
104
+ include_hint: bool = False,
101
105
  **kwargs: _t.Any,
102
106
  ) -> None:
103
107
  self._retcode.append(int(not value.new))
@@ -107,7 +111,7 @@ class Failure(list[Failed]):
107
111
  value.description.format(**kwargs),
108
112
  value.symbolic,
109
113
  self.lineno,
110
- value.hint if hint else None,
114
+ value.hint if include_hint else None,
111
115
  value.new,
112
116
  )
113
117
  if value not in self._func.messages and failed not in self:
@@ -223,9 +227,15 @@ class Failure(list[Failed]):
223
227
  self._add(_E[203])
224
228
 
225
229
  def _sig3xx_description(self, doc: _Param) -> None:
226
- if doc.description is None and doc.name is not None:
230
+ # freeze result as it is a property and PyCharm complains
231
+ # `Member 'None' of 'str | None' does not have attribute
232
+ # 'startswith'` as property could theoretically have different
233
+ # result from doc.description is None to
234
+ # doc.description.startswith
235
+ doc_description = doc.description
236
+ if doc_description is None and doc.name is not None:
227
237
  self._add(_E[301])
228
- elif doc.description is not None and not doc.description.startswith(
238
+ elif doc_description is not None and not doc_description.startswith(
229
239
  " ",
230
240
  ):
231
241
  # syntax-error-in-description
@@ -237,19 +247,19 @@ class Failure(list[Failed]):
237
247
  self._add(_E[303])
238
248
  elif doc.closing_token != ":":
239
249
  # bad-closing-token
240
- self._add(_E[304], token=doc.closing_token, hint=True)
241
- if doc.description is not None and not all(
250
+ self._add(_E[304], token=doc.closing_token, include_hint=True)
251
+ if doc_description is not None and not all(
242
252
  stripped[0].isupper()
243
- for i in _sentence_tokenizer(doc.description)
253
+ for i in _sentence_tokenizer(doc_description)
244
254
  if i and (stripped := i.strip())[0].isalpha()
245
255
  ):
246
256
  # description is not capitalized
247
257
  self._add(_E[305])
248
258
  # description-missing-period
249
259
  if (
250
- doc.description
251
- and doc.description.strip()
252
- and doc.description.strip()[-1]
260
+ doc_description
261
+ and doc_description.strip()
262
+ and doc_description.strip()[-1]
253
263
  not in (
254
264
  "`",
255
265
  ".",
@@ -258,22 +268,28 @@ class Failure(list[Failed]):
258
268
  self._add(_E[306])
259
269
 
260
270
  def _sig4xx_parameters(self, doc: _Param, sig: _Param) -> None:
271
+ # freeze result as it is a property and PyCharm complains
272
+ # `Expected type 'str', got 'str | None' instead ' in
273
+ # _almost_equal as property could theoretically have different
274
+ # result from sig.name is not None and doc.name is not None
275
+ sig_name = sig.name
276
+ doc_name = doc.name
261
277
  if doc.indent > 0:
262
278
  # incorrect-indent
263
279
  self._add(_E[401])
264
280
  elif doc != sig:
265
281
  if (
266
- sig.name in self._func.docstring.args.names
267
- or doc.name in self._func.signature.args.names
282
+ sig_name in self._func.docstring.args.names
283
+ or doc_name in self._func.signature.args.names
268
284
  ) and len(self._func.docstring.args) > 1:
269
285
  # params-out-of-order
270
286
  self._add(_E[402])
271
287
  elif (
272
- doc.name != _UNNAMED
273
- and sig.name is not None
274
- and doc.name is not None
288
+ doc_name != _UNNAMED
289
+ and sig_name is not None
290
+ and doc_name is not None
275
291
  ):
276
- if _almost_equal(sig.name, doc.name, _MIN_MATCH, _MAX_MATCH):
292
+ if _almost_equal(sig_name, doc_name, _MIN_MATCH, _MAX_MATCH):
277
293
  # spelling-error
278
294
  self._add(_E[403])
279
295
  else:
@@ -287,7 +303,7 @@ class Failure(list[Failed]):
287
303
  # no types, cannot know either way
288
304
  if self._func.signature.returns.type == _RetType.UNTYPED:
289
305
  # confirm-return-needed
290
- self._add(_E[501], hint=True)
306
+ self._add(_E[501], include_hint=True)
291
307
  # return-type is none, so no return should be documented
292
308
  elif self._func.docstring.returns.returns:
293
309
  if self._func.signature.returns.type == _RetType.NONE:
@@ -301,7 +317,7 @@ class Failure(list[Failed]):
301
317
  lines = str(self._func.docstring.string).splitlines()
302
318
  self._add(
303
319
  _E[503],
304
- hint=(
320
+ include_hint=(
305
321
  len(lines) > 1
306
322
  and "return" in lines[-1]
307
323
  and ":param" not in lines[-1]
@@ -311,11 +327,11 @@ class Failure(list[Failed]):
311
327
  # this method is init, so no return should be documented
312
328
  if self._func.isinit:
313
329
  # class-return-documented
314
- self._add(_E[504], hint=True)
330
+ self._add(_E[504], include_hint=True)
315
331
  # method is property and not set to document property
316
332
  elif self._func.isproperty and not check_property_returns:
317
333
  # return-documented-for-property
318
- self._add(_E[505], hint=True)
334
+ self._add(_E[505], include_hint=True)
319
335
 
320
336
  def _sig9xx_error(self) -> None:
321
337
  # invalid-syntax
@@ -348,6 +364,7 @@ class Failure(list[Failed]):
348
364
  return max(self._retcode)
349
365
 
350
366
 
367
+ # TODO: make report json by default and wrap with a reporter for cli
351
368
  def report(
352
369
  failures: Failures,
353
370
  config: _Config,
@@ -363,7 +380,10 @@ def report(
363
380
  :param file: Module path when failures came from a file (optional).
364
381
  :return: Exit code (non-zero if any check failed).
365
382
  """
383
+ format_json = _os.getenv("_DOCSIG_FORMAT_JSON") is not None
366
384
  retcodes = [0]
385
+ output = []
386
+ obj = []
367
387
  for failure in failures:
368
388
  retcodes.append(failure.retcode)
369
389
  path_prefix = f"{file}:" if file is not None else ""
@@ -371,7 +391,7 @@ def report(
371
391
  if not config.no_ansi and _sys.stdout.isatty():
372
392
  header = f"\033[35m{header}\033[0m"
373
393
 
374
- print(header)
394
+ output.append(header)
375
395
  for item in failure:
376
396
  extra = None
377
397
  if item.hint:
@@ -381,15 +401,27 @@ def report(
381
401
  extra = "warning: please remember to fix this or disable it"
382
402
  _warn(_NEW.format(ref=item.ref), FutureWarning, stacklevel=3)
383
403
 
384
- print(
385
- " "
386
- + _TEMPLATE.format(
387
- ref=item.ref,
388
- description=item.description,
389
- symbolic=item.symbolic,
390
- ),
404
+ msg = _TEMPLATE.format(
405
+ ref=item.ref,
406
+ description=item.description,
407
+ symbolic=item.symbolic,
391
408
  )
409
+ output.append(f" {msg}")
392
410
  if extra is not None:
393
- print(f" {extra}")
411
+ output.append(f" {extra}")
412
+
413
+ obj.append(
414
+ {
415
+ "line": None if failure.retcode == 2 else item.lineno,
416
+ "message": msg,
417
+ "exit": failure.retcode,
418
+ },
419
+ )
420
+
421
+ if format_json:
422
+ print(_json.dumps(obj).strip()) # pragma: no cover
423
+
424
+ elif output:
425
+ print("\n".join(output))
394
426
 
395
427
  return max(retcodes)
@@ -260,7 +260,7 @@ class Signature(_Stub):
260
260
  @classmethod
261
261
  def from_ast(
262
262
  cls,
263
- node: _ast.nodes.Module | _ast.nodes.ClassDef | _ast.nodes.FunctionDef,
263
+ node: _ast.nodes.FunctionDef,
264
264
  ignore: _Ignore,
265
265
  ) -> Signature:
266
266
  """Build Signature from a function or class AST node.
@@ -368,6 +368,8 @@ class Docstring(_Stub):
368
368
  description_missing=not match or not match.group(1),
369
369
  )
370
370
  docstring = cls(string, returns)
371
+ # the suggestion is broken
372
+ # noinspection RegExpSingleCharAlternation
371
373
  for match in _re.findall(
372
374
  r":([\w\s]+(?:\s\|\s[\w\s]+|\w+))([^\w\s])((?:.|\n)*?)(?=\n:|$)",
373
375
  string,
@@ -8,6 +8,7 @@ Shared helpers.
8
8
  import re as _re
9
9
  import sys as _sys
10
10
  from difflib import SequenceMatcher as _SequenceMatcher
11
+ from pathlib import Path as _Path
11
12
 
12
13
  from .messages import TEMPLATE as _TEMPLATE
13
14
  from .messages import E as _E
@@ -84,3 +85,25 @@ def sentence_tokenizer(text: str) -> list[str]:
84
85
  result.append(text[start:].strip())
85
86
 
86
87
  return result
88
+
89
+
90
+ def get_parent_that_has(file: str, start: _Path | None = None) -> _Path | None:
91
+ """Find the parent directory that contains the given file.
92
+
93
+ Start from the current working directory and walk up to root. If
94
+ no required file is found, return None.
95
+
96
+ :param file: File to find.
97
+ :param start: Starting director.
98
+ :return: Parent directory containing the file or None if not found.
99
+ """
100
+ if start is None:
101
+ start = _Path.cwd()
102
+
103
+ if (start / file).is_file():
104
+ return start
105
+
106
+ if start.parent == start:
107
+ return None
108
+
109
+ return get_parent_that_has(file, start.parent)
@@ -8,4 +8,4 @@ Allows for access to the version internally without cyclic imports
8
8
  caused by accessing it through __init__.
9
9
  """
10
10
 
11
- __version__ = "0.86.0"
11
+ __version__ = "0.87.0"
@@ -11,7 +11,7 @@ line-length = 79
11
11
  allow_dirty = true
12
12
  commit = true
13
13
  commit_args = "-sS"
14
- current_version = "0.86.0"
14
+ current_version = "0.87.0"
15
15
  message = "bump: version {current_version} → {new_version}"
16
16
  sign_tags = true
17
17
  tag = true
@@ -46,7 +46,7 @@ search = "- docsig=={current_version}"
46
46
  filename = "SECURITY.md"
47
47
 
48
48
  [tool.codespell]
49
- ignore-words-list = "astroid"
49
+ ignore-words-list = "astroid,atLeast"
50
50
 
51
51
  [tool.coverage.report]
52
52
  exclude_lines = [
@@ -117,10 +117,10 @@ maintainers = [
117
117
  name = "docsig"
118
118
  readme = "README.rst"
119
119
  repository = "https://github.com/jshwi/docsig"
120
- version = "0.86.0"
120
+ version = "0.87.0"
121
121
 
122
122
  [tool.poetry.dependencies]
123
- Sphinx = ">=7,<9"
123
+ Sphinx = ">=7,<8.2"
124
124
  astroid = "^4.0.2"
125
125
  pathspec = ">=0.12.1,<1.2.0"
126
126
  python = "^3.10"
@@ -159,6 +159,9 @@ templatest = ">=0.10.1,<0.13.0"
159
159
  [tool.poetry.group.flake8.dependencies]
160
160
  flake8 = "^7.1.0"
161
161
 
162
+ [tool.poetry.group.plugin.dependencies]
163
+ shiv = "^1.0.8"
164
+
162
165
  [tool.poetry.group.tests.dependencies]
163
166
  pytest = ">=8.2,<10.0"
164
167
  pytest-benchmark = ">=4,<6"
@@ -175,6 +178,9 @@ SIG = "docsig.plugin:Flake8"
175
178
  [tool.poetry.plugins."validate_pyproject.tool_schema"]
176
179
  docsig = "docsig.plugin:ValidatePyproject"
177
180
 
181
+ [tool.poetry.requires-plugins]
182
+ poetry-plugin-export = ">=1.8"
183
+
178
184
  [tool.poetry.scripts]
179
185
  docsig = "docsig.__main__:main"
180
186
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes