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.
@@ -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