notignored-sdk 0.1.4__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.
- notignored_sdk-0.1.4/LICENSE +21 -0
- notignored_sdk-0.1.4/PKG-INFO +133 -0
- notignored_sdk-0.1.4/README.md +109 -0
- notignored_sdk-0.1.4/pyproject.toml +87 -0
- notignored_sdk-0.1.4/pyproject.toml.orig +84 -0
- notignored_sdk-0.1.4/src/notignored_sdk/__init__.py +41 -0
- notignored_sdk-0.1.4/src/notignored_sdk/_client.py +223 -0
- notignored_sdk-0.1.4/src/notignored_sdk/_errors.py +64 -0
- notignored_sdk-0.1.4/src/notignored_sdk/_model.py +271 -0
- notignored_sdk-0.1.4/src/notignored_sdk/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nick DeRobertis
|
|
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.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: notignored-sdk
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Typed Python SDK for notignored: every lint and type-check suppression in a source tree, as records.
|
|
5
|
+
Keywords: lint,noqa,suppression,code-review,sdk
|
|
6
|
+
Author: Nick DeRobertis
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: notignored-cli==0.1.4
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/nickderobertis/notignored
|
|
22
|
+
Project-URL: Repository, https://github.com/nickderobertis/notignored
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# notignored-sdk
|
|
26
|
+
|
|
27
|
+
Typed Python access to [`notignored`](https://github.com/nickderobertis/notignored):
|
|
28
|
+
every lint and type-check suppression comment in a source tree — `# noqa`,
|
|
29
|
+
`// eslint-disable-next-line`, `#[allow(...)]`, `# type: ignore`, and the rest —
|
|
30
|
+
as frozen records with the tool, the rules, and the stated reason.
|
|
31
|
+
|
|
32
|
+
The distribution is `notignored-sdk`, the import is `notignored_sdk`, and every
|
|
33
|
+
release depends on the exact `notignored-cli` it was built with, so
|
|
34
|
+
`pip install` brings a binary that speaks the same report contract.
|
|
35
|
+
|
|
36
|
+
```console
|
|
37
|
+
pip install notignored-sdk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from notignored_sdk import scan
|
|
42
|
+
|
|
43
|
+
report = scan(["src"])
|
|
44
|
+
for directive in report.ignores:
|
|
45
|
+
print(
|
|
46
|
+
f"{directive.path}:{directive.line} {directive.tool} {directive.rules} — {directive.reason}"
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## The guard: prove nobody silenced a linter to make the gate pass
|
|
51
|
+
|
|
52
|
+
The case this exists for. A green `just check` means nothing if the way it went
|
|
53
|
+
green was a new `# noqa`, so pin the suppressions a branch is allowed to add and
|
|
54
|
+
fail when it adds one that is not justified:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from notignored_sdk import scan
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_this_branch_added_no_unjustified_suppression() -> None:
|
|
61
|
+
"""Every suppression this change adds has to say why it is there."""
|
|
62
|
+
added = scan(diff=True, diff_base="origin/main")
|
|
63
|
+
|
|
64
|
+
assert added.errors == (), added.errors
|
|
65
|
+
unexplained = [d for d in added.ignores if not d.reason]
|
|
66
|
+
assert not unexplained, "\n".join(
|
|
67
|
+
f"{d.path}:{d.line} adds `{d.raw}` with no stated reason" for d in unexplained
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`diff=True` reports only the suppressions on lines the change added, using the
|
|
72
|
+
same merge-base semantics as `notignored --diff` — so a suppression that was
|
|
73
|
+
already on `main` is never yours.
|
|
74
|
+
|
|
75
|
+
## The surface
|
|
76
|
+
|
|
77
|
+
One entry point, in two forms. `ascan` takes the identical arguments and returns
|
|
78
|
+
the identical report:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
scan(paths=(), *, diff=False, diff_base=None, tools=None, cwd=None, binary=None) -> Report
|
|
82
|
+
await ascan(...) # the same call, on an event loop
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Argument | What it does |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `paths` | Files and/or directories. Directories are walked recursively, honouring `.gitignore`. Empty scans the working directory. |
|
|
88
|
+
| `diff` | Report only the suppressions this change added. |
|
|
89
|
+
| `diff_base` | The git revision `diff` compares against. Without `diff=True` it is a `ValueError`, exactly as the CLI rejects it. |
|
|
90
|
+
| `tools` | Report only these tools (`Tool` members or their names); `None` reports all of them. |
|
|
91
|
+
| `cwd` | Directory to run in. Report paths are relative to it. |
|
|
92
|
+
| `binary` | The `notignored` to run. Defaults to `$NOTIGNORED_BIN`, then to the one on `PATH`. |
|
|
93
|
+
|
|
94
|
+
The records mirror the CLI's JSON contract exactly, as frozen dataclasses:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
Report(version, ignores, errors)
|
|
98
|
+
IgnoreDirective(tool, scope, rules, reason, path, line, end_line, column, raw, suppressed)
|
|
99
|
+
Suppressed(start_line, end_line) # end_line is None when the range runs to end-of-file
|
|
100
|
+
ReportError(path, message)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`Tool` and `Scope` are string enums, so `directive.tool == "ruff"` and
|
|
104
|
+
`directive.scope == "next-line"` both work.
|
|
105
|
+
|
|
106
|
+
A file that could not be read is a `ReportError` in the report, never an
|
|
107
|
+
exception — the CLI exits non-zero for it and the SDK still hands you the report
|
|
108
|
+
that names it, because a tree with an unscannable file is not a clean tree.
|
|
109
|
+
|
|
110
|
+
## Errors
|
|
111
|
+
|
|
112
|
+
Everything raised is a `NotignoredError`:
|
|
113
|
+
|
|
114
|
+
| Error | When |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `NotignoredNotFoundError` | No `notignored` binary could be found. The message says how to install one. |
|
|
117
|
+
| `NotignoredSpawnError` | The binary is there but the process could not start. |
|
|
118
|
+
| `NotignoredExitError` | The scan could not run and printed no report; carries `returncode` and the CLI's `stderr`. |
|
|
119
|
+
| `NotignoredContractError` | The output is not the report contract this SDK reads. |
|
|
120
|
+
|
|
121
|
+
Parsing is strict: an unknown tool, an unknown scope, or a missing field is a
|
|
122
|
+
`NotignoredContractError`, never a silently dropped record. Because the package
|
|
123
|
+
pins its CLI exactly, a supported install cannot hit it.
|
|
124
|
+
|
|
125
|
+
## Working on it
|
|
126
|
+
|
|
127
|
+
From the repository root:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
just bootstrap # provisions every project
|
|
131
|
+
just nx run notignored-sdk-python:check # this project's gate alone
|
|
132
|
+
just check # the whole repo's gate
|
|
133
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# notignored-sdk
|
|
2
|
+
|
|
3
|
+
Typed Python access to [`notignored`](https://github.com/nickderobertis/notignored):
|
|
4
|
+
every lint and type-check suppression comment in a source tree — `# noqa`,
|
|
5
|
+
`// eslint-disable-next-line`, `#[allow(...)]`, `# type: ignore`, and the rest —
|
|
6
|
+
as frozen records with the tool, the rules, and the stated reason.
|
|
7
|
+
|
|
8
|
+
The distribution is `notignored-sdk`, the import is `notignored_sdk`, and every
|
|
9
|
+
release depends on the exact `notignored-cli` it was built with, so
|
|
10
|
+
`pip install` brings a binary that speaks the same report contract.
|
|
11
|
+
|
|
12
|
+
```console
|
|
13
|
+
pip install notignored-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from notignored_sdk import scan
|
|
18
|
+
|
|
19
|
+
report = scan(["src"])
|
|
20
|
+
for directive in report.ignores:
|
|
21
|
+
print(
|
|
22
|
+
f"{directive.path}:{directive.line} {directive.tool} {directive.rules} — {directive.reason}"
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## The guard: prove nobody silenced a linter to make the gate pass
|
|
27
|
+
|
|
28
|
+
The case this exists for. A green `just check` means nothing if the way it went
|
|
29
|
+
green was a new `# noqa`, so pin the suppressions a branch is allowed to add and
|
|
30
|
+
fail when it adds one that is not justified:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from notignored_sdk import scan
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_this_branch_added_no_unjustified_suppression() -> None:
|
|
37
|
+
"""Every suppression this change adds has to say why it is there."""
|
|
38
|
+
added = scan(diff=True, diff_base="origin/main")
|
|
39
|
+
|
|
40
|
+
assert added.errors == (), added.errors
|
|
41
|
+
unexplained = [d for d in added.ignores if not d.reason]
|
|
42
|
+
assert not unexplained, "\n".join(
|
|
43
|
+
f"{d.path}:{d.line} adds `{d.raw}` with no stated reason" for d in unexplained
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`diff=True` reports only the suppressions on lines the change added, using the
|
|
48
|
+
same merge-base semantics as `notignored --diff` — so a suppression that was
|
|
49
|
+
already on `main` is never yours.
|
|
50
|
+
|
|
51
|
+
## The surface
|
|
52
|
+
|
|
53
|
+
One entry point, in two forms. `ascan` takes the identical arguments and returns
|
|
54
|
+
the identical report:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
scan(paths=(), *, diff=False, diff_base=None, tools=None, cwd=None, binary=None) -> Report
|
|
58
|
+
await ascan(...) # the same call, on an event loop
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Argument | What it does |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `paths` | Files and/or directories. Directories are walked recursively, honouring `.gitignore`. Empty scans the working directory. |
|
|
64
|
+
| `diff` | Report only the suppressions this change added. |
|
|
65
|
+
| `diff_base` | The git revision `diff` compares against. Without `diff=True` it is a `ValueError`, exactly as the CLI rejects it. |
|
|
66
|
+
| `tools` | Report only these tools (`Tool` members or their names); `None` reports all of them. |
|
|
67
|
+
| `cwd` | Directory to run in. Report paths are relative to it. |
|
|
68
|
+
| `binary` | The `notignored` to run. Defaults to `$NOTIGNORED_BIN`, then to the one on `PATH`. |
|
|
69
|
+
|
|
70
|
+
The records mirror the CLI's JSON contract exactly, as frozen dataclasses:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
Report(version, ignores, errors)
|
|
74
|
+
IgnoreDirective(tool, scope, rules, reason, path, line, end_line, column, raw, suppressed)
|
|
75
|
+
Suppressed(start_line, end_line) # end_line is None when the range runs to end-of-file
|
|
76
|
+
ReportError(path, message)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`Tool` and `Scope` are string enums, so `directive.tool == "ruff"` and
|
|
80
|
+
`directive.scope == "next-line"` both work.
|
|
81
|
+
|
|
82
|
+
A file that could not be read is a `ReportError` in the report, never an
|
|
83
|
+
exception — the CLI exits non-zero for it and the SDK still hands you the report
|
|
84
|
+
that names it, because a tree with an unscannable file is not a clean tree.
|
|
85
|
+
|
|
86
|
+
## Errors
|
|
87
|
+
|
|
88
|
+
Everything raised is a `NotignoredError`:
|
|
89
|
+
|
|
90
|
+
| Error | When |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `NotignoredNotFoundError` | No `notignored` binary could be found. The message says how to install one. |
|
|
93
|
+
| `NotignoredSpawnError` | The binary is there but the process could not start. |
|
|
94
|
+
| `NotignoredExitError` | The scan could not run and printed no report; carries `returncode` and the CLI's `stderr`. |
|
|
95
|
+
| `NotignoredContractError` | The output is not the report contract this SDK reads. |
|
|
96
|
+
|
|
97
|
+
Parsing is strict: an unknown tool, an unknown scope, or a missing field is a
|
|
98
|
+
`NotignoredContractError`, never a silently dropped record. Because the package
|
|
99
|
+
pins its CLI exactly, a supported install cannot hit it.
|
|
100
|
+
|
|
101
|
+
## Working on it
|
|
102
|
+
|
|
103
|
+
From the repository root:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
just bootstrap # provisions every project
|
|
107
|
+
just nx run notignored-sdk-python:check # this project's gate alone
|
|
108
|
+
just check # the whole repo's gate
|
|
109
|
+
```
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "notignored-sdk"
|
|
7
|
+
version = "0.1.4"
|
|
8
|
+
description = "Typed Python SDK for notignored: every lint and type-check suppression in a source tree, as records."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = [
|
|
14
|
+
"lint",
|
|
15
|
+
"noqa",
|
|
16
|
+
"suppression",
|
|
17
|
+
"code-review",
|
|
18
|
+
"sdk",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = ["notignored-cli==0.1.4"]
|
|
33
|
+
|
|
34
|
+
[[project.authors]]
|
|
35
|
+
name = "Nick DeRobertis"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/nickderobertis/notignored"
|
|
39
|
+
Repository = "https://github.com/nickderobertis/notignored"
|
|
40
|
+
|
|
41
|
+
[tool.uv.build-backend]
|
|
42
|
+
module-name = "notignored_sdk"
|
|
43
|
+
module-root = "src"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
addopts = "--cov=notignored_sdk --cov-report=term-missing --cov-fail-under=95"
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = [
|
|
54
|
+
"E4",
|
|
55
|
+
"E7",
|
|
56
|
+
"E9",
|
|
57
|
+
"F",
|
|
58
|
+
"I",
|
|
59
|
+
"UP",
|
|
60
|
+
"B",
|
|
61
|
+
"ASYNC",
|
|
62
|
+
"S",
|
|
63
|
+
"RUF",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.per-file-ignores]
|
|
67
|
+
"tests/**" = [
|
|
68
|
+
"S101",
|
|
69
|
+
"S404",
|
|
70
|
+
"S603",
|
|
71
|
+
"S607",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.mypy]
|
|
75
|
+
python_version = "3.10"
|
|
76
|
+
strict = true
|
|
77
|
+
warn_unreachable = true
|
|
78
|
+
|
|
79
|
+
[tool.coverage.run]
|
|
80
|
+
branch = true
|
|
81
|
+
source = ["notignored_sdk"]
|
|
82
|
+
|
|
83
|
+
[dependency-groups]
|
|
84
|
+
dev = [
|
|
85
|
+
"pytest>=8,<10",
|
|
86
|
+
"pytest-cov>=5,<8",
|
|
87
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# The typed Python SDK: `pip install notignored-sdk`, `import notignored_sdk`.
|
|
2
|
+
#
|
|
3
|
+
# Pure Python — it drives the `notignored` binary as a subprocess, so there is
|
|
4
|
+
# nothing to compile and one wheel serves every platform. The binary comes from
|
|
5
|
+
# the `notignored-cli` dependency below, which is the same maturin wheel the
|
|
6
|
+
# root pyproject.toml builds.
|
|
7
|
+
#
|
|
8
|
+
# NEITHER the version nor that dependency's pin is a version source. Cargo.toml
|
|
9
|
+
# is the only one (see AGENTS.md, "The registry packages"), and
|
|
10
|
+
# `scripts/python-sdk-build.mjs` stamps both from it at release time. The
|
|
11
|
+
# committed file therefore carries a `.dev0` placeholder and an UNPINNED
|
|
12
|
+
# `notignored-cli`, so a local `uv sync` resolves a real binary while nothing
|
|
13
|
+
# here can drift out of step with a release.
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[project]
|
|
20
|
+
name = "notignored-sdk"
|
|
21
|
+
version = "0.1.4"
|
|
22
|
+
description = "Typed Python SDK for notignored: every lint and type-check suppression in a source tree, as records."
|
|
23
|
+
readme = "README.md"
|
|
24
|
+
license = "MIT"
|
|
25
|
+
license-files = ["LICENSE"]
|
|
26
|
+
requires-python = ">=3.10"
|
|
27
|
+
authors = [{ name = "Nick DeRobertis" }]
|
|
28
|
+
keywords = ["lint", "noqa", "suppression", "code-review", "sdk"]
|
|
29
|
+
classifiers = [
|
|
30
|
+
"Development Status :: 4 - Beta",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
39
|
+
"Typing :: Typed",
|
|
40
|
+
]
|
|
41
|
+
dependencies = ["notignored-cli==0.1.4"]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/nickderobertis/notignored"
|
|
45
|
+
Repository = "https://github.com/nickderobertis/notignored"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-name = "notignored_sdk"
|
|
49
|
+
module-root = "src"
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
dev = ["pytest>=8,<10", "pytest-cov>=5,<8"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
# Coverage is enforced here rather than trusted: the suite drives a real
|
|
57
|
+
# subprocess, so an untested branch is one nobody has ever run.
|
|
58
|
+
addopts = "--cov=notignored_sdk --cov-report=term-missing --cov-fail-under=95"
|
|
59
|
+
|
|
60
|
+
# ruff is not pinned here: `.ruff-version` is this repository's one source for it
|
|
61
|
+
# and `scripts/dev-tool.sh` resolves the binary `just bootstrap` installed. Only
|
|
62
|
+
# the rule selection lives with the project.
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 100
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "ASYNC", "S", "RUF"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.per-file-ignores]
|
|
70
|
+
"tests/**" = [
|
|
71
|
+
"S101", # Assertions are the test contract.
|
|
72
|
+
"S404", # The suite exists to drive a real subprocess.
|
|
73
|
+
"S603", # Its argument vectors are fixed or built from tmp_path.
|
|
74
|
+
"S607", # `cargo`, `git`, and `uv` are resolved from PATH on purpose.
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.mypy]
|
|
78
|
+
python_version = "3.10"
|
|
79
|
+
strict = true
|
|
80
|
+
warn_unreachable = true
|
|
81
|
+
|
|
82
|
+
[tool.coverage.run]
|
|
83
|
+
branch = true
|
|
84
|
+
source = ["notignored_sdk"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Typed Python access to the `notignored` CLI.
|
|
2
|
+
|
|
3
|
+
One entry point, in two forms — :func:`scan` for the synchronous callers this
|
|
4
|
+
exists for (a pytest suite guarding that nobody silenced a linter to make the
|
|
5
|
+
gate pass) and :func:`ascan` for an event loop. Both drive the real `notignored`
|
|
6
|
+
binary as a subprocess and return the same strictly-parsed :class:`Report`.
|
|
7
|
+
|
|
8
|
+
>>> from notignored_sdk import scan
|
|
9
|
+
>>> report = scan(["src"], tools=["ruff"]) # doctest: +SKIP
|
|
10
|
+
>>> [(d.path, d.line, d.rules) for d in report.ignores] # doctest: +SKIP
|
|
11
|
+
[('src/app.py', 12, ('E501',))]
|
|
12
|
+
|
|
13
|
+
The distribution depends on the exact `notignored-cli` it was released with, so
|
|
14
|
+
`pip install notignored-sdk` brings a binary that matches this contract.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._client import ascan, scan
|
|
18
|
+
from ._errors import (
|
|
19
|
+
NotignoredContractError,
|
|
20
|
+
NotignoredError,
|
|
21
|
+
NotignoredExitError,
|
|
22
|
+
NotignoredNotFoundError,
|
|
23
|
+
NotignoredSpawnError,
|
|
24
|
+
)
|
|
25
|
+
from ._model import IgnoreDirective, Report, ReportError, Scope, Suppressed, Tool
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"IgnoreDirective",
|
|
29
|
+
"NotignoredContractError",
|
|
30
|
+
"NotignoredError",
|
|
31
|
+
"NotignoredExitError",
|
|
32
|
+
"NotignoredNotFoundError",
|
|
33
|
+
"NotignoredSpawnError",
|
|
34
|
+
"Report",
|
|
35
|
+
"ReportError",
|
|
36
|
+
"Scope",
|
|
37
|
+
"Suppressed",
|
|
38
|
+
"Tool",
|
|
39
|
+
"ascan",
|
|
40
|
+
"scan",
|
|
41
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""The one entry point, in both forms: `scan` and `ascan`.
|
|
2
|
+
|
|
3
|
+
The SDK is a thin, typed shell over the real `notignored` binary — it never
|
|
4
|
+
re-implements a parser, and it never shells out to anything else. `scan` and
|
|
5
|
+
`ascan` take the same arguments and return the same :class:`Report`; the only
|
|
6
|
+
difference is which subprocess API runs the process, so `pytest` can call one
|
|
7
|
+
directly and an async service the other.
|
|
8
|
+
|
|
9
|
+
Arguments are validated *before* a process is spawned, and paths go after a `--`
|
|
10
|
+
separator so a filename that starts with a dash can never be read as a flag.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from typing import Any, Union
|
|
22
|
+
|
|
23
|
+
from ._errors import (
|
|
24
|
+
INSTALL_HINT,
|
|
25
|
+
NotignoredContractError,
|
|
26
|
+
NotignoredExitError,
|
|
27
|
+
NotignoredNotFoundError,
|
|
28
|
+
NotignoredSpawnError,
|
|
29
|
+
)
|
|
30
|
+
from ._model import Report, Tool, report_from_payload
|
|
31
|
+
|
|
32
|
+
#: Points the SDK at a specific binary without threading one through every call.
|
|
33
|
+
#: An explicit ``binary=`` argument still wins over it.
|
|
34
|
+
BINARY_ENV_VAR = "NOTIGNORED_BIN"
|
|
35
|
+
|
|
36
|
+
#: Anything `os.fspath` accepts: `str`, `pathlib.Path`, or another path-like.
|
|
37
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
38
|
+
|
|
39
|
+
# A report of a large tree is a single JSON document on one line, so the async
|
|
40
|
+
# reader's default 64 KiB line limit is far too small.
|
|
41
|
+
_STREAM_LIMIT = 64 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_binary(binary: PathLike | None) -> str:
|
|
45
|
+
"""Which `notignored` this call runs: the argument, the env override, PATH."""
|
|
46
|
+
if binary is not None:
|
|
47
|
+
return os.fspath(binary)
|
|
48
|
+
override = os.environ.get(BINARY_ENV_VAR)
|
|
49
|
+
if override:
|
|
50
|
+
return override
|
|
51
|
+
found = shutil.which("notignored")
|
|
52
|
+
if found is None:
|
|
53
|
+
raise NotignoredNotFoundError(
|
|
54
|
+
"notignored",
|
|
55
|
+
f"no `notignored` binary on PATH and {BINARY_ENV_VAR} is unset; {INSTALL_HINT}",
|
|
56
|
+
)
|
|
57
|
+
return found
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _paths(paths: Sequence[PathLike]) -> list[str]:
|
|
61
|
+
"""The positional PATHS, validated as a sequence of real paths."""
|
|
62
|
+
if isinstance(paths, (str, bytes, os.PathLike)):
|
|
63
|
+
raise TypeError("paths is a sequence of paths; pass [path] to scan a single one")
|
|
64
|
+
resolved = []
|
|
65
|
+
for index, path in enumerate(paths):
|
|
66
|
+
if isinstance(path, bytes) or not isinstance(path, (str, os.PathLike)):
|
|
67
|
+
raise TypeError(f"paths[{index}] is {type(path).__name__}, not a path")
|
|
68
|
+
text = os.fspath(path)
|
|
69
|
+
if not text:
|
|
70
|
+
raise ValueError(f"paths[{index}] is empty; omit it to scan the current directory")
|
|
71
|
+
resolved.append(text)
|
|
72
|
+
return resolved
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _tools(tools: Sequence[Tool | str] | None) -> list[str]:
|
|
76
|
+
"""The `--tool` filter, rejecting an unknown name before a process is spawned."""
|
|
77
|
+
if tools is None:
|
|
78
|
+
return []
|
|
79
|
+
if isinstance(tools, (str, bytes, Tool)):
|
|
80
|
+
raise TypeError("tools is a sequence of tools; pass [tool] to filter to a single one")
|
|
81
|
+
names = []
|
|
82
|
+
for index, tool in enumerate(tools):
|
|
83
|
+
if not isinstance(tool, str):
|
|
84
|
+
raise TypeError(f"tools[{index}] is {type(tool).__name__}, not a tool or its name")
|
|
85
|
+
try:
|
|
86
|
+
names.append(Tool(tool).value)
|
|
87
|
+
except ValueError:
|
|
88
|
+
known = ", ".join(known_tool.value for known_tool in Tool)
|
|
89
|
+
raise ValueError(f"tools[{index}] is {tool!r}; known tools are {known}") from None
|
|
90
|
+
return names
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _command(
|
|
94
|
+
paths: Sequence[PathLike],
|
|
95
|
+
diff: bool,
|
|
96
|
+
diff_base: str | None,
|
|
97
|
+
tools: Sequence[Tool | str] | None,
|
|
98
|
+
binary: PathLike | None,
|
|
99
|
+
) -> list[str]:
|
|
100
|
+
"""The argument vector this call implies.
|
|
101
|
+
|
|
102
|
+
Every argument is validated before the binary is even resolved, so a typo in
|
|
103
|
+
a call is a `TypeError`/`ValueError` about the typo rather than whatever the
|
|
104
|
+
host happens to have installed.
|
|
105
|
+
"""
|
|
106
|
+
if diff_base is not None:
|
|
107
|
+
if not diff:
|
|
108
|
+
raise ValueError("diff_base needs diff=True; there is nothing to compare it against")
|
|
109
|
+
if not isinstance(diff_base, str):
|
|
110
|
+
raise TypeError(f"diff_base is {type(diff_base).__name__}, not a git revision")
|
|
111
|
+
selected = _paths(paths)
|
|
112
|
+
names = _tools(tools)
|
|
113
|
+
argv = [_resolve_binary(binary), "--format", "json"]
|
|
114
|
+
for name in names:
|
|
115
|
+
argv.extend(("--tool", name))
|
|
116
|
+
if diff:
|
|
117
|
+
argv.append("--diff")
|
|
118
|
+
if diff_base is not None:
|
|
119
|
+
argv.extend(("--diff-base", diff_base))
|
|
120
|
+
# `--` last: a path spelled `-x` is a path, not a flag this SDK forwarded.
|
|
121
|
+
if selected:
|
|
122
|
+
argv.append("--")
|
|
123
|
+
argv.extend(selected)
|
|
124
|
+
return argv
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _spawn_failure(command: str, error: OSError) -> NotignoredSpawnError:
|
|
128
|
+
"""The OS's refusal to start the process, as one of this SDK's errors."""
|
|
129
|
+
if isinstance(error, FileNotFoundError):
|
|
130
|
+
return NotignoredNotFoundError(
|
|
131
|
+
command, f"no `notignored` binary at {command!r}; {INSTALL_HINT}"
|
|
132
|
+
)
|
|
133
|
+
return NotignoredSpawnError(command, f"cannot run `notignored` at {command!r}: {error}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _report(returncode: int, stdout: bytes, stderr: bytes) -> Report:
|
|
137
|
+
"""One finished run, as a report or as the error that stopped it.
|
|
138
|
+
|
|
139
|
+
A non-zero exit with a report on stdout is *not* an error: an unreadable file
|
|
140
|
+
makes the CLI exit 2 and still print the report that names it, and dropping
|
|
141
|
+
that would hide the very thing `Report.errors` exists to carry.
|
|
142
|
+
"""
|
|
143
|
+
if not stdout.strip():
|
|
144
|
+
if returncode != 0:
|
|
145
|
+
raise NotignoredExitError(returncode, stderr.decode("utf-8", errors="replace"))
|
|
146
|
+
raise NotignoredContractError("notignored printed no report")
|
|
147
|
+
try:
|
|
148
|
+
payload: Any = json.loads(stdout)
|
|
149
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
150
|
+
if returncode != 0:
|
|
151
|
+
raise NotignoredExitError(
|
|
152
|
+
returncode, stderr.decode("utf-8", errors="replace")
|
|
153
|
+
) from error
|
|
154
|
+
message = f"notignored printed output that is not JSON: {error}"
|
|
155
|
+
raise NotignoredContractError(message) from error
|
|
156
|
+
return report_from_payload(payload)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def scan(
|
|
160
|
+
paths: Sequence[PathLike] = (),
|
|
161
|
+
*,
|
|
162
|
+
diff: bool = False,
|
|
163
|
+
diff_base: str | None = None,
|
|
164
|
+
tools: Sequence[Tool | str] | None = None,
|
|
165
|
+
cwd: PathLike | None = None,
|
|
166
|
+
binary: PathLike | None = None,
|
|
167
|
+
) -> Report:
|
|
168
|
+
"""Report every suppression comment `notignored` finds.
|
|
169
|
+
|
|
170
|
+
:param paths: Files and/or directories to scan. Directories are walked
|
|
171
|
+
recursively, honouring `.gitignore`. Empty scans the working directory,
|
|
172
|
+
which is the CLI's own default.
|
|
173
|
+
:param diff: Report only the suppressions this change added.
|
|
174
|
+
:param diff_base: The git revision `diff` compares against. Passing it
|
|
175
|
+
without ``diff=True`` is a :class:`ValueError`, exactly as the CLI
|
|
176
|
+
rejects ``--diff-base`` without ``--diff``.
|
|
177
|
+
:param tools: Report only these tools; ``None`` reports all of them.
|
|
178
|
+
:param cwd: Directory to run in. Report paths are relative to it.
|
|
179
|
+
:param binary: The `notignored` to run. Defaults to the ``NOTIGNORED_BIN``
|
|
180
|
+
environment variable, then to the one on ``PATH``.
|
|
181
|
+
:raises NotignoredNotFoundError: No `notignored` binary could be run.
|
|
182
|
+
:raises NotignoredExitError: The scan could not run and printed no report.
|
|
183
|
+
:raises NotignoredContractError: The report is not the contract this SDK reads.
|
|
184
|
+
"""
|
|
185
|
+
argv = _command(paths, diff, diff_base, tools, binary)
|
|
186
|
+
try:
|
|
187
|
+
completed = subprocess.run( # noqa: S603 # a vector this module built, never a shell
|
|
188
|
+
argv,
|
|
189
|
+
cwd=None if cwd is None else os.fspath(cwd),
|
|
190
|
+
capture_output=True,
|
|
191
|
+
check=False,
|
|
192
|
+
)
|
|
193
|
+
except OSError as error:
|
|
194
|
+
raise _spawn_failure(argv[0], error) from error
|
|
195
|
+
return _report(completed.returncode, completed.stdout, completed.stderr)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def ascan(
|
|
199
|
+
paths: Sequence[PathLike] = (),
|
|
200
|
+
*,
|
|
201
|
+
diff: bool = False,
|
|
202
|
+
diff_base: str | None = None,
|
|
203
|
+
tools: Sequence[Tool | str] | None = None,
|
|
204
|
+
cwd: PathLike | None = None,
|
|
205
|
+
binary: PathLike | None = None,
|
|
206
|
+
) -> Report:
|
|
207
|
+
"""Await :func:`scan`'s result without blocking the event loop.
|
|
208
|
+
|
|
209
|
+
Same arguments, same report, same errors; see :func:`scan`.
|
|
210
|
+
"""
|
|
211
|
+
argv = _command(paths, diff, diff_base, tools, binary)
|
|
212
|
+
try:
|
|
213
|
+
process = await asyncio.create_subprocess_exec(
|
|
214
|
+
*argv,
|
|
215
|
+
cwd=None if cwd is None else os.fspath(cwd),
|
|
216
|
+
stdout=asyncio.subprocess.PIPE,
|
|
217
|
+
stderr=asyncio.subprocess.PIPE,
|
|
218
|
+
limit=_STREAM_LIMIT,
|
|
219
|
+
)
|
|
220
|
+
except OSError as error:
|
|
221
|
+
raise _spawn_failure(argv[0], error) from error
|
|
222
|
+
stdout, stderr = await process.communicate()
|
|
223
|
+
return _report(process.returncode or 0, stdout, stderr)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The typed errors this SDK raises, and nothing else.
|
|
2
|
+
|
|
3
|
+
Every failure a caller can hit crosses the same boundary: the CLI could not be
|
|
4
|
+
started, it refused to finish, or what it printed is not the report contract this
|
|
5
|
+
SDK reads. Each of those is a distinct class so a caller can tell "notignored is
|
|
6
|
+
not installed" from "the scan itself failed" without matching on a message.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# The concrete next action for a host with no `notignored` on it. Attached to
|
|
12
|
+
# every not-found error because that error is nearly always a setup problem, and
|
|
13
|
+
# the traceback is the only place the reader is looking.
|
|
14
|
+
INSTALL_HINT = (
|
|
15
|
+
"install it with `pip install notignored-sdk` (which depends on the "
|
|
16
|
+
"notignored-cli binary wheel) or `npm install -g notignored-cli`, or pass "
|
|
17
|
+
"`binary=` / set NOTIGNORED_BIN to point at one you already have"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotignoredError(Exception):
|
|
22
|
+
"""Base class for every error this SDK raises."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotignoredSpawnError(NotignoredError):
|
|
26
|
+
"""The `notignored` subprocess could not be started."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, command: str, message: str) -> None:
|
|
29
|
+
self.command = command
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotignoredNotFoundError(NotignoredSpawnError):
|
|
34
|
+
"""No `notignored` binary could be found to run.
|
|
35
|
+
|
|
36
|
+
A subclass of :class:`NotignoredSpawnError` because it is the same failure —
|
|
37
|
+
the process never started — narrowed to the one cause worth its own `except`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotignoredExitError(NotignoredError):
|
|
42
|
+
"""`notignored` exited non-zero without printing a report.
|
|
43
|
+
|
|
44
|
+
A scan that *does* produce a report is returned even when the CLI exits
|
|
45
|
+
non-zero: unreadable files are part of the contract (``Report.errors``), not
|
|
46
|
+
an SDK-level failure. This is the case where there is no report at all — a
|
|
47
|
+
path that does not exist, an argument the CLI rejected.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, returncode: int, stderr: str) -> None:
|
|
51
|
+
self.returncode = returncode
|
|
52
|
+
self.stderr = stderr
|
|
53
|
+
super().__init__(f"notignored exited {returncode}: {stderr.strip() or '(no stderr)'}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NotignoredContractError(NotignoredError):
|
|
57
|
+
"""What `notignored` printed is not the report contract this SDK reads.
|
|
58
|
+
|
|
59
|
+
Parsing is strict on purpose — an unknown tool, an unknown scope, or a
|
|
60
|
+
missing field is this error rather than a silently dropped record, because a
|
|
61
|
+
scan that quietly reports fewer suppressions than it found is worse than one
|
|
62
|
+
that fails. The SDK depends on an exact `notignored-cli`, so in a supported
|
|
63
|
+
install this cannot happen.
|
|
64
|
+
"""
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""The report contract, as Python objects, and the strict reader that builds them.
|
|
2
|
+
|
|
3
|
+
This mirrors notignored's `src/model.rs` field for field: it is a **versioned
|
|
4
|
+
wire contract**, so the names here are the names on the wire and changing one is
|
|
5
|
+
a breaking change for both sides at once.
|
|
6
|
+
|
|
7
|
+
The reader is strict about what the contract *specifies* — an unknown tool, an
|
|
8
|
+
unknown scope, a missing field, or a wrong type is a
|
|
9
|
+
:class:`~notignored_sdk.NotignoredContractError` — and tolerant of keys it has
|
|
10
|
+
never heard of, because the contract's own rule is that new fields are optional
|
|
11
|
+
and additive. Rejecting those would break this SDK against a CLI that had only
|
|
12
|
+
added something.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ._errors import NotignoredContractError
|
|
22
|
+
|
|
23
|
+
# The one envelope version this SDK reads. Bumped in step with notignored's
|
|
24
|
+
# `REPORT_VERSION`; anything else is rejected at the boundary rather than parsed
|
|
25
|
+
# into a record that may have lost fields.
|
|
26
|
+
SUPPORTED_REPORT_VERSION = 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Tool(str, Enum):
|
|
30
|
+
"""A lint or type-check tool whose suppression comments notignored parses.
|
|
31
|
+
|
|
32
|
+
The values are what the CLI writes in a report and takes on ``--tool``, so a
|
|
33
|
+
plain string is accepted anywhere a :class:`Tool` is.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ESLINT = "eslint"
|
|
37
|
+
BIOME = "biome"
|
|
38
|
+
RUFF = "ruff"
|
|
39
|
+
TYPESCRIPT = "typescript"
|
|
40
|
+
MYPY = "mypy"
|
|
41
|
+
PYRIGHT = "pyright"
|
|
42
|
+
TY = "ty"
|
|
43
|
+
RUST = "rust"
|
|
44
|
+
SHELLCHECK = "shellcheck"
|
|
45
|
+
LLMLINT = "llmlint"
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return str(self.value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Scope(str, Enum):
|
|
52
|
+
"""How far a directive's suppression reaches."""
|
|
53
|
+
|
|
54
|
+
LINE = "line"
|
|
55
|
+
NEXT_LINE = "next-line"
|
|
56
|
+
FILE = "file"
|
|
57
|
+
BLOCK = "block"
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
return str(self.value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Suppressed:
|
|
65
|
+
"""The range of source lines a directive silences."""
|
|
66
|
+
|
|
67
|
+
start_line: int
|
|
68
|
+
"""First 1-based line the directive silences."""
|
|
69
|
+
|
|
70
|
+
end_line: int | None
|
|
71
|
+
"""Last 1-based line, or ``None`` when the range runs to end-of-file or is
|
|
72
|
+
unterminated."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class IgnoreDirective:
|
|
77
|
+
"""One parsed suppression comment."""
|
|
78
|
+
|
|
79
|
+
tool: Tool
|
|
80
|
+
"""The tool whose rules are being silenced."""
|
|
81
|
+
|
|
82
|
+
scope: Scope
|
|
83
|
+
"""How far the suppression reaches."""
|
|
84
|
+
|
|
85
|
+
rules: tuple[str, ...]
|
|
86
|
+
"""Rule names/codes exactly as written. Empty means a blanket suppression of
|
|
87
|
+
every rule the tool would apply."""
|
|
88
|
+
|
|
89
|
+
reason: str | None
|
|
90
|
+
"""The stated justification, or ``None`` when none was given."""
|
|
91
|
+
|
|
92
|
+
path: str
|
|
93
|
+
"""Path to the file, relative to the invocation directory, ``/``-separated."""
|
|
94
|
+
|
|
95
|
+
line: int
|
|
96
|
+
"""1-based line the directive starts on."""
|
|
97
|
+
|
|
98
|
+
end_line: int
|
|
99
|
+
"""1-based line the directive ends on."""
|
|
100
|
+
|
|
101
|
+
column: int
|
|
102
|
+
"""1-based column the directive starts at."""
|
|
103
|
+
|
|
104
|
+
raw: str
|
|
105
|
+
"""The directive exactly as it appears in the source, delimiters included."""
|
|
106
|
+
|
|
107
|
+
suppressed: Suppressed
|
|
108
|
+
"""The range of lines this directive silences."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class ReportError:
|
|
113
|
+
"""A file that could not be read, or a directive that could not be parsed."""
|
|
114
|
+
|
|
115
|
+
path: str
|
|
116
|
+
"""Path the problem was found at, ``/``-separated."""
|
|
117
|
+
|
|
118
|
+
message: str
|
|
119
|
+
"""What went wrong, in one line."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Report:
|
|
124
|
+
"""The report envelope: everything one scan produced."""
|
|
125
|
+
|
|
126
|
+
version: int
|
|
127
|
+
"""Envelope version; always :data:`SUPPORTED_REPORT_VERSION` once parsed."""
|
|
128
|
+
|
|
129
|
+
ignores: tuple[IgnoreDirective, ...]
|
|
130
|
+
"""Every directive found, ordered by path, then line, then column."""
|
|
131
|
+
|
|
132
|
+
errors: tuple[ReportError, ...]
|
|
133
|
+
"""Files that could not be read and directives that could not be parsed."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _object(value: Any, where: str) -> dict[str, Any]:
|
|
137
|
+
"""`value` as a JSON object, or a contract error naming what it was instead."""
|
|
138
|
+
if not isinstance(value, dict):
|
|
139
|
+
raise NotignoredContractError(f"{where} is {_kind(value)}, not an object")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _kind(value: Any) -> str:
|
|
144
|
+
"""How to name a JSON value in a diagnostic."""
|
|
145
|
+
return "null" if value is None else f"a {type(value).__name__}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _field(obj: dict[str, Any], key: str, where: str) -> Any:
|
|
149
|
+
if key not in obj:
|
|
150
|
+
raise NotignoredContractError(f"{where} has no {key!r} field")
|
|
151
|
+
return obj[key]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _text(obj: dict[str, Any], key: str, where: str) -> str:
|
|
155
|
+
value = _field(obj, key, where)
|
|
156
|
+
if not isinstance(value, str):
|
|
157
|
+
raise NotignoredContractError(f"{where}.{key} is {_kind(value)}, not a string")
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _optional_text(obj: dict[str, Any], key: str, where: str) -> str | None:
|
|
162
|
+
value = _field(obj, key, where)
|
|
163
|
+
if value is None or isinstance(value, str):
|
|
164
|
+
return value
|
|
165
|
+
raise NotignoredContractError(f"{where}.{key} is {_kind(value)}, not a string or null")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _number(obj: dict[str, Any], key: str, where: str) -> int:
|
|
169
|
+
value = _field(obj, key, where)
|
|
170
|
+
# `bool` is a subclass of `int`, and `true` is not a line number.
|
|
171
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
172
|
+
raise NotignoredContractError(f"{where}.{key} is {_kind(value)}, not an integer")
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _optional_number(obj: dict[str, Any], key: str, where: str) -> int | None:
|
|
177
|
+
if _field(obj, key, where) is None:
|
|
178
|
+
return None
|
|
179
|
+
return _number(obj, key, where)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _texts(obj: dict[str, Any], key: str, where: str) -> tuple[str, ...]:
|
|
183
|
+
value = _field(obj, key, where)
|
|
184
|
+
if not isinstance(value, list):
|
|
185
|
+
raise NotignoredContractError(f"{where}.{key} is {_kind(value)}, not an array")
|
|
186
|
+
for index, item in enumerate(value):
|
|
187
|
+
if not isinstance(item, str):
|
|
188
|
+
raise NotignoredContractError(f"{where}.{key}[{index}] is {_kind(item)}, not a string")
|
|
189
|
+
return tuple(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _objects(obj: dict[str, Any], key: str, where: str) -> list[dict[str, Any]]:
|
|
193
|
+
value = _field(obj, key, where)
|
|
194
|
+
if not isinstance(value, list):
|
|
195
|
+
raise NotignoredContractError(f"{where}.{key} is {_kind(value)}, not an array")
|
|
196
|
+
return [_object(item, f"{where}.{key}[{index}]") for index, item in enumerate(value)]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _tool(obj: dict[str, Any], where: str) -> Tool:
|
|
200
|
+
name = _text(obj, "tool", where)
|
|
201
|
+
try:
|
|
202
|
+
return Tool(name)
|
|
203
|
+
except ValueError:
|
|
204
|
+
known = ", ".join(tool.value for tool in Tool)
|
|
205
|
+
raise NotignoredContractError(
|
|
206
|
+
f"{where}.tool is {name!r}, which this SDK does not know (known tools: {known}); "
|
|
207
|
+
"upgrade notignored-sdk"
|
|
208
|
+
) from None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _scope(obj: dict[str, Any], where: str) -> Scope:
|
|
212
|
+
name = _text(obj, "scope", where)
|
|
213
|
+
try:
|
|
214
|
+
return Scope(name)
|
|
215
|
+
except ValueError:
|
|
216
|
+
known = ", ".join(scope.value for scope in Scope)
|
|
217
|
+
raise NotignoredContractError(
|
|
218
|
+
f"{where}.scope is {name!r}, which this SDK does not know (known scopes: {known}); "
|
|
219
|
+
"upgrade notignored-sdk"
|
|
220
|
+
) from None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _suppressed(obj: dict[str, Any], where: str) -> Suppressed:
|
|
224
|
+
nested = _object(_field(obj, "suppressed", where), f"{where}.suppressed")
|
|
225
|
+
return Suppressed(
|
|
226
|
+
start_line=_number(nested, "start_line", f"{where}.suppressed"),
|
|
227
|
+
end_line=_optional_number(nested, "end_line", f"{where}.suppressed"),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _directive(obj: dict[str, Any], where: str) -> IgnoreDirective:
|
|
232
|
+
return IgnoreDirective(
|
|
233
|
+
tool=_tool(obj, where),
|
|
234
|
+
scope=_scope(obj, where),
|
|
235
|
+
rules=_texts(obj, "rules", where),
|
|
236
|
+
reason=_optional_text(obj, "reason", where),
|
|
237
|
+
path=_text(obj, "path", where),
|
|
238
|
+
line=_number(obj, "line", where),
|
|
239
|
+
end_line=_number(obj, "end_line", where),
|
|
240
|
+
column=_number(obj, "column", where),
|
|
241
|
+
raw=_text(obj, "raw", where),
|
|
242
|
+
suppressed=_suppressed(obj, where),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def report_from_payload(payload: Any) -> Report:
|
|
247
|
+
"""Read a decoded `--format json` payload as a :class:`Report`, strictly."""
|
|
248
|
+
envelope = _object(payload, "the report")
|
|
249
|
+
version = _number(envelope, "version", "the report")
|
|
250
|
+
if version != SUPPORTED_REPORT_VERSION:
|
|
251
|
+
upgrade = (
|
|
252
|
+
"notignored-sdk" if version > SUPPORTED_REPORT_VERSION else "the notignored binary"
|
|
253
|
+
)
|
|
254
|
+
raise NotignoredContractError(
|
|
255
|
+
f"the report claims version {version}, but this SDK reads version "
|
|
256
|
+
f"{SUPPORTED_REPORT_VERSION}; upgrade {upgrade}"
|
|
257
|
+
)
|
|
258
|
+
return Report(
|
|
259
|
+
version=version,
|
|
260
|
+
ignores=tuple(
|
|
261
|
+
_directive(item, f"the report.ignores[{index}]")
|
|
262
|
+
for index, item in enumerate(_objects(envelope, "ignores", "the report"))
|
|
263
|
+
),
|
|
264
|
+
errors=tuple(
|
|
265
|
+
ReportError(
|
|
266
|
+
path=_text(item, "path", f"the report.errors[{index}]"),
|
|
267
|
+
message=_text(item, "message", f"the report.errors[{index}]"),
|
|
268
|
+
)
|
|
269
|
+
for index, item in enumerate(_objects(envelope, "errors", "the report"))
|
|
270
|
+
),
|
|
271
|
+
)
|
|
File without changes
|