docparity 0.1.0__py3-none-any.whl
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.
- docparity/__init__.py +7 -0
- docparity/cli.py +77 -0
- docparity/docstring_parser.py +126 -0
- docparity/scanner.py +108 -0
- docparity-0.1.0.dist-info/METADATA +95 -0
- docparity-0.1.0.dist-info/RECORD +9 -0
- docparity-0.1.0.dist-info/WHEEL +4 -0
- docparity-0.1.0.dist-info/entry_points.txt +2 -0
- docparity-0.1.0.dist-info/licenses/LICENSE +21 -0
docparity/__init__.py
ADDED
docparity/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""docparity command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .scanner import scan_file
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _iter_python_files(paths: list[str]) -> list[Path]:
|
|
13
|
+
files: list[Path] = []
|
|
14
|
+
for raw in paths:
|
|
15
|
+
p = Path(raw)
|
|
16
|
+
if p.is_dir():
|
|
17
|
+
files.extend(sorted(p.rglob("*.py")))
|
|
18
|
+
elif p.suffix == ".py":
|
|
19
|
+
files.append(p)
|
|
20
|
+
return files
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="docparity",
|
|
26
|
+
description=(
|
|
27
|
+
"Detect drift between a function's docstring parameters and its "
|
|
28
|
+
"real signature (Google, NumPy, and Sphinx docstring styles)."
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"paths", nargs="+", help="Python files or directories to scan"
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--stale-only",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help=(
|
|
38
|
+
"Only report stale documented parameters (removed/renamed in "
|
|
39
|
+
"the signature). Suppresses 'missing documentation' findings."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
return parser
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: list[str] | None = None) -> int:
|
|
46
|
+
parser = build_parser()
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
files = _iter_python_files(args.paths)
|
|
50
|
+
if not files:
|
|
51
|
+
print("docparity: no Python files found", file=sys.stderr)
|
|
52
|
+
return 2
|
|
53
|
+
|
|
54
|
+
total = 0
|
|
55
|
+
had_error = False
|
|
56
|
+
for f in files:
|
|
57
|
+
try:
|
|
58
|
+
findings = scan_file(f)
|
|
59
|
+
except SyntaxError as exc:
|
|
60
|
+
print(f"docparity: {f}: syntax error: {exc}", file=sys.stderr)
|
|
61
|
+
had_error = True
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if args.stale_only:
|
|
65
|
+
findings = [fnd for fnd in findings if fnd.kind == "stale"]
|
|
66
|
+
|
|
67
|
+
for finding in findings:
|
|
68
|
+
print(str(finding))
|
|
69
|
+
total += 1
|
|
70
|
+
|
|
71
|
+
if had_error:
|
|
72
|
+
return 2
|
|
73
|
+
return 1 if total else 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Extract documented parameter names from a docstring.
|
|
2
|
+
|
|
3
|
+
Supports the three docstring styles used in practice:
|
|
4
|
+
|
|
5
|
+
- Google style::
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
name (str): description
|
|
9
|
+
other: description
|
|
10
|
+
|
|
11
|
+
- NumPy style::
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
name : str
|
|
16
|
+
description
|
|
17
|
+
|
|
18
|
+
- Sphinx / reST style::
|
|
19
|
+
|
|
20
|
+
:param name: description
|
|
21
|
+
:param str other: description
|
|
22
|
+
|
|
23
|
+
Only the *names* of documented parameters are extracted -- docparity
|
|
24
|
+
compares names against the real signature, it does not attempt to
|
|
25
|
+
validate types or descriptions.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ParsedDocstring:
|
|
36
|
+
documented_params: list[str] = field(default_factory=list)
|
|
37
|
+
has_params_section: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_GOOGLE_SECTION_RE = re.compile(
|
|
41
|
+
r"^\s*(Args|Arguments|Parameters)\s*:\s*$", re.MULTILINE
|
|
42
|
+
)
|
|
43
|
+
_GOOGLE_PARAM_RE = re.compile(
|
|
44
|
+
r"^\s{2,}(\*{0,2}[A-Za-z_][A-Za-z0-9_]*)\s*(\(.*?\))?\s*:", re.MULTILINE
|
|
45
|
+
)
|
|
46
|
+
_GOOGLE_SECTION_STOP_RE = re.compile(
|
|
47
|
+
r"^\s*(Returns|Yields|Raises|Examples?|Note|Notes|Attributes)\s*:\s*$",
|
|
48
|
+
re.MULTILINE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_NUMPY_SECTION_RE = re.compile(
|
|
52
|
+
r"^\s*Parameters\s*\n\s*-{3,}\s*$", re.MULTILINE
|
|
53
|
+
)
|
|
54
|
+
_NUMPY_PARAM_RE = re.compile(
|
|
55
|
+
r"^([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*(:.*)?$"
|
|
56
|
+
)
|
|
57
|
+
_NUMPY_SECTION_STOP_RE = re.compile(
|
|
58
|
+
r"^\s*(Returns|Yields|Raises|Examples?|Notes?|Attributes)\s*\n\s*-{3,}\s*$",
|
|
59
|
+
re.MULTILINE,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_SPHINX_PARAM_RE = re.compile(
|
|
63
|
+
r"^\s*:param\s+(?:[A-Za-z_][A-Za-z0-9_.\[\]]*\s+)?\*{0,2}([A-Za-z_][A-Za-z0-9_]*)\s*:",
|
|
64
|
+
re.MULTILINE,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_google(doc: str) -> list[str] | None:
|
|
69
|
+
m = _GOOGLE_SECTION_RE.search(doc)
|
|
70
|
+
if not m:
|
|
71
|
+
return None
|
|
72
|
+
start = m.end()
|
|
73
|
+
stop_m = _GOOGLE_SECTION_STOP_RE.search(doc, pos=start)
|
|
74
|
+
end = stop_m.start() if stop_m else len(doc)
|
|
75
|
+
section = doc[start:end]
|
|
76
|
+
return [pm.group(1).lstrip("*") for pm in _GOOGLE_PARAM_RE.finditer(section)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_numpy(doc: str) -> list[str] | None:
|
|
80
|
+
m = _NUMPY_SECTION_RE.search(doc)
|
|
81
|
+
if not m:
|
|
82
|
+
return None
|
|
83
|
+
start = m.end()
|
|
84
|
+
stop_m = _NUMPY_SECTION_STOP_RE.search(doc, pos=start)
|
|
85
|
+
end = stop_m.start() if stop_m else len(doc)
|
|
86
|
+
section = doc[start:end]
|
|
87
|
+
names: list[str] = []
|
|
88
|
+
for raw_line in section.splitlines():
|
|
89
|
+
line = raw_line.strip()
|
|
90
|
+
if not line:
|
|
91
|
+
continue
|
|
92
|
+
# Only lines that look like "name : type" or "name" (not indented
|
|
93
|
+
# description text, which NumPy style indents further than the name).
|
|
94
|
+
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
|
95
|
+
if indent > 4:
|
|
96
|
+
continue
|
|
97
|
+
pm = _NUMPY_PARAM_RE.match(line)
|
|
98
|
+
if pm:
|
|
99
|
+
for n in pm.group(1).split(","):
|
|
100
|
+
names.append(n.strip())
|
|
101
|
+
return names
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _extract_sphinx(doc: str) -> list[str] | None:
|
|
105
|
+
names = [pm.group(1) for pm in _SPHINX_PARAM_RE.finditer(doc)]
|
|
106
|
+
return names or None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_docstring(doc: str | None) -> ParsedDocstring:
|
|
110
|
+
"""Parse a docstring and return the set of documented parameter names.
|
|
111
|
+
|
|
112
|
+
Returns an empty, ``has_params_section=False`` result when no
|
|
113
|
+
recognizable parameter section is found. docparity intentionally does
|
|
114
|
+
not flag functions with no params section at all -- only functions
|
|
115
|
+
that already document *some* parameters but have drifted from the
|
|
116
|
+
real signature.
|
|
117
|
+
"""
|
|
118
|
+
if not doc:
|
|
119
|
+
return ParsedDocstring()
|
|
120
|
+
|
|
121
|
+
for extractor in (_extract_google, _extract_numpy, _extract_sphinx):
|
|
122
|
+
names = extractor(doc)
|
|
123
|
+
if names is not None:
|
|
124
|
+
return ParsedDocstring(documented_params=names, has_params_section=True)
|
|
125
|
+
|
|
126
|
+
return ParsedDocstring()
|
docparity/scanner.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Scan Python source for docstring-vs-signature parameter drift."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .docstring_parser import parse_docstring
|
|
10
|
+
|
|
11
|
+
# Parameters that are conventionally excluded from docstrings and should
|
|
12
|
+
# never trigger an "undocumented parameter" finding.
|
|
13
|
+
_IGNORED_PARAMS = {"self", "cls"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Finding:
|
|
18
|
+
file: str
|
|
19
|
+
line: int
|
|
20
|
+
function: str
|
|
21
|
+
kind: str # "stale" (documented but not in signature) or "missing" (in
|
|
22
|
+
# signature but not documented)
|
|
23
|
+
param: str
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
if self.kind == "stale":
|
|
27
|
+
msg = f"docstring documents '{self.param}' which is not a parameter"
|
|
28
|
+
else:
|
|
29
|
+
msg = f"parameter '{self.param}' is not documented"
|
|
30
|
+
return f"{self.file}:{self.line}: {self.function}(): {msg}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _real_params(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
|
|
34
|
+
args = node.args
|
|
35
|
+
names = []
|
|
36
|
+
for a in args.posonlyargs:
|
|
37
|
+
names.append(a.arg)
|
|
38
|
+
for a in args.args:
|
|
39
|
+
names.append(a.arg)
|
|
40
|
+
if args.vararg:
|
|
41
|
+
names.append(args.vararg.arg)
|
|
42
|
+
for a in args.kwonlyargs:
|
|
43
|
+
names.append(a.arg)
|
|
44
|
+
if args.kwarg:
|
|
45
|
+
names.append(args.kwarg.arg)
|
|
46
|
+
return [n for n in names if n not in _IGNORED_PARAMS]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_function(
|
|
50
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef, filename: str
|
|
51
|
+
) -> list[Finding]:
|
|
52
|
+
findings: list[Finding] = []
|
|
53
|
+
doc = ast.get_docstring(node)
|
|
54
|
+
parsed = parse_docstring(doc)
|
|
55
|
+
if not parsed.has_params_section:
|
|
56
|
+
# No documented parameter section -- nothing to compare, and
|
|
57
|
+
# flagging undocumented functions is out of scope (that is a
|
|
58
|
+
# coverage tool's job, not a drift tool's job).
|
|
59
|
+
return findings
|
|
60
|
+
|
|
61
|
+
real = _real_params(node)
|
|
62
|
+
real_set = set(real)
|
|
63
|
+
documented = parsed.documented_params
|
|
64
|
+
documented_set = set(documented)
|
|
65
|
+
|
|
66
|
+
for name in documented:
|
|
67
|
+
if name not in real_set:
|
|
68
|
+
findings.append(
|
|
69
|
+
Finding(
|
|
70
|
+
file=filename,
|
|
71
|
+
line=node.lineno,
|
|
72
|
+
function=node.name,
|
|
73
|
+
kind="stale",
|
|
74
|
+
param=name,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for name in real:
|
|
79
|
+
if name not in documented_set:
|
|
80
|
+
findings.append(
|
|
81
|
+
Finding(
|
|
82
|
+
file=filename,
|
|
83
|
+
line=node.lineno,
|
|
84
|
+
function=node.name,
|
|
85
|
+
kind="missing",
|
|
86
|
+
param=name,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return findings
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def scan_source(source: str, filename: str = "<string>") -> list[Finding]:
|
|
94
|
+
"""Scan a source string and return a list of drift findings."""
|
|
95
|
+
tree = ast.parse(source, filename=filename)
|
|
96
|
+
findings: list[Finding] = []
|
|
97
|
+
for node in ast.walk(tree):
|
|
98
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
99
|
+
findings.extend(_check_function(node, filename))
|
|
100
|
+
findings.sort(key=lambda f: (f.line, f.function, f.param))
|
|
101
|
+
return findings
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scan_file(path: str | Path) -> list[Finding]:
|
|
105
|
+
"""Scan a single Python file on disk and return a list of drift findings."""
|
|
106
|
+
path = Path(path)
|
|
107
|
+
source = path.read_text(encoding="utf-8")
|
|
108
|
+
return scan_source(source, filename=str(path))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: docparity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detects drift between a function's docstring parameters and its real signature (Google, NumPy, and Sphinx styles).
|
|
5
|
+
Author: Jay
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: code-quality,docstring,documentation,linter,static-analysis
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Topic :: Documentation
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# docparity
|
|
22
|
+
|
|
23
|
+
Detects drift between a function's **docstring parameters** and its **real
|
|
24
|
+
signature** -- the classic case of renaming or removing a parameter and
|
|
25
|
+
forgetting to update the docstring, so the documentation quietly lies to
|
|
26
|
+
the next reader (or to an LLM/IDE that trusts it).
|
|
27
|
+
|
|
28
|
+
Supports Google, NumPy, and Sphinx (`:param:`) docstring styles. Zero
|
|
29
|
+
runtime dependencies, pure `ast`-based static analysis.
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
Docstrings drift silently:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
def charge_card(amount, currency):
|
|
37
|
+
"""Charge a card.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
amount (float): the amount to charge
|
|
41
|
+
card_token (str): the card token to charge # <- renamed to `currency`, doc never updated
|
|
42
|
+
"""
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Nothing catches this today. Type checkers don't read docstrings. Linters
|
|
46
|
+
that check docstring *presence* (pydocstyle, flake8-docstrings) don't check
|
|
47
|
+
that documented parameter names still match the signature. The result is
|
|
48
|
+
stale documentation that misleads humans, and misleads LLM coding
|
|
49
|
+
assistants and IDEs that surface docstrings as ground truth.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install docparity
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
docparity src/ # scan a directory recursively
|
|
61
|
+
docparity mymodule.py # scan a single file
|
|
62
|
+
docparity --stale-only src/ # only report renamed/removed params, not missing docs
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Exit codes: `0` clean, `1` findings reported, `2` usage/syntax error.
|
|
66
|
+
|
|
67
|
+
## What it checks (v0.1)
|
|
68
|
+
|
|
69
|
+
1. **Stale documented parameter** -- the docstring documents a parameter
|
|
70
|
+
name that no longer exists in the real signature (renamed or removed).
|
|
71
|
+
2. **Missing documentation for a real parameter** -- the function already
|
|
72
|
+
has a params section, and a real parameter isn't in it (added to the
|
|
73
|
+
signature, forgotten in the docs).
|
|
74
|
+
|
|
75
|
+
docparity only checks functions that **already document at least one
|
|
76
|
+
parameter**. It never asks you to add documentation to an undocumented
|
|
77
|
+
function -- that's a coverage tool's job, not a drift tool's job. This
|
|
78
|
+
keeps false positives near zero.
|
|
79
|
+
|
|
80
|
+
## Library usage
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from docparity import scan_file
|
|
84
|
+
|
|
85
|
+
findings = scan_file("mymodule.py")
|
|
86
|
+
for f in findings:
|
|
87
|
+
print(f)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
See [docs/USAGE.md](docs/USAGE.md) for more, and [DETAILS.md](DETAILS.md)
|
|
91
|
+
for design rationale and scope boundaries.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
docparity/__init__.py,sha256=Z2BuEgsGYUThYVxXPNIeCWcvlddsg1-dt_zM8Y14uz8,214
|
|
2
|
+
docparity/cli.py,sha256=DP3POljVROSZPXS7KsjrX9fD3Eancc0taMFkqIrZNYM,1963
|
|
3
|
+
docparity/docstring_parser.py,sha256=qIhmYs7fhd8YBibODOcrULRiuz6zf-1A9DEXmRiKD1s,3678
|
|
4
|
+
docparity/scanner.py,sha256=I5SzZM330iCynvxn4RIHTLgX9GiP5S_qOztIF8SzQ-I,3332
|
|
5
|
+
docparity-0.1.0.dist-info/METADATA,sha256=Ict1_r3y8uQBDb8ZnlJRnyGKo3MoQiyTEx87lQxJ8kI,3002
|
|
6
|
+
docparity-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
docparity-0.1.0.dist-info/entry_points.txt,sha256=kKZZRagnBj1WPdE2-7whqaRZcJnMAxIJhQziSpsRfIc,49
|
|
8
|
+
docparity-0.1.0.dist-info/licenses/LICENSE,sha256=xEsRvzJ-7Nbqa9ppe87ZOX9Xgmc2CJ7HOEFGRa0rcpU,1060
|
|
9
|
+
docparity-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|