sarj-python-lint 0.18.1__tar.gz → 0.19.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/PKG-INFO +26 -1
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/README.md +25 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/pyproject.toml +1 -1
- sarj_python_lint-0.19.0/src/sarj_python_lint/rules/_first_party.py +192 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_registry.py +4 -0
- sarj_python_lint-0.19.0/src/sarj_python_lint/rules/no_first_party_private_import.py +184 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/.gitignore +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -60,6 +60,31 @@ measured against.
|
|
|
60
60
|
- id: sarj-sleep-with-computed-arg-in-test # SARJ047
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
### Private access, first-party only (0.19.0)
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
- id: sarj-no-first-party-private-import # SARJ048
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Reaching past a module's public surface is a design finding when the module is
|
|
70
|
+
ours and an unavoidable fact of life when it is not: a dependency that moves an
|
|
71
|
+
API private in a minor release leaves no edit that satisfies the lint.
|
|
72
|
+
|
|
73
|
+
`SARJ048` fires only when the module declaring the private name resolves to a
|
|
74
|
+
package inside your own project. Third-party privates are never flagged.
|
|
75
|
+
|
|
76
|
+
**It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
|
|
77
|
+
*same top-level package* — a different question, and one that cannot separate
|
|
78
|
+
`from bulbul.stores.task_store import _row_to_task` (real; export it) from
|
|
79
|
+
`from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
|
|
80
|
+
`sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
|
|
81
|
+
reason; if you take that config, turn this hook on, or you lose the check
|
|
82
|
+
entirely.
|
|
83
|
+
|
|
84
|
+
Attribute access (`session._stt`) is out of scope and stays with ruff's
|
|
85
|
+
`SLF001`, which cannot make the distinction either — see the rationale in
|
|
86
|
+
`ruff.strict.toml`.
|
|
87
|
+
|
|
63
88
|
Adopting these against an existing suite is easier through the baseline ratchet
|
|
64
89
|
than as a big-bang fix — snapshot the current counts, then let them only shrink:
|
|
65
90
|
|
|
@@ -42,6 +42,31 @@ measured against.
|
|
|
42
42
|
- id: sarj-sleep-with-computed-arg-in-test # SARJ047
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
### Private access, first-party only (0.19.0)
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
- id: sarj-no-first-party-private-import # SARJ048
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Reaching past a module's public surface is a design finding when the module is
|
|
52
|
+
ours and an unavoidable fact of life when it is not: a dependency that moves an
|
|
53
|
+
API private in a minor release leaves no edit that satisfies the lint.
|
|
54
|
+
|
|
55
|
+
`SARJ048` fires only when the module declaring the private name resolves to a
|
|
56
|
+
package inside your own project. Third-party privates are never flagged.
|
|
57
|
+
|
|
58
|
+
**It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
|
|
59
|
+
*same top-level package* — a different question, and one that cannot separate
|
|
60
|
+
`from bulbul.stores.task_store import _row_to_task` (real; export it) from
|
|
61
|
+
`from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
|
|
62
|
+
`sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
|
|
63
|
+
reason; if you take that config, turn this hook on, or you lose the check
|
|
64
|
+
entirely.
|
|
65
|
+
|
|
66
|
+
Attribute access (`session._stt`) is out of scope and stays with ruff's
|
|
67
|
+
`SLF001`, which cannot make the distinction either — see the rationale in
|
|
68
|
+
`ruff.strict.toml`.
|
|
69
|
+
|
|
45
70
|
Adopting these against an existing suite is easier through the baseline ratchet
|
|
46
71
|
than as a big-bang fix — snapshot the current counts, then let them only shrink:
|
|
47
72
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Shared first-party / third-party module resolution.
|
|
2
|
+
|
|
3
|
+
Every "don't reach into privates" rule needs one thing no purely syntactic
|
|
4
|
+
checker has: whether the module that *declares* the private name is ours or
|
|
5
|
+
somebody else's. Reaching into our own module's underscore names is a design
|
|
6
|
+
problem we can fix by exporting a public surface. Reaching into a dependency's
|
|
7
|
+
underscore names is often the only option available — when a library moves an
|
|
8
|
+
API private in a minor release, the "just use the public API" advice names an
|
|
9
|
+
API that no longer exists, and the lint finding becomes an instruction to
|
|
10
|
+
perform an impossible edit.
|
|
11
|
+
|
|
12
|
+
Resolution is filesystem-based and deliberately conservative:
|
|
13
|
+
|
|
14
|
+
* a module is FIRST-PARTY when its top-level name is a package directory (one
|
|
15
|
+
containing `__init__.py`) found inside the enclosing project;
|
|
16
|
+
* everything else — stdlib, site-packages, anything unresolvable — is treated
|
|
17
|
+
as THIRD-PARTY, because the failure mode of guessing "third-party" is a
|
|
18
|
+
missed finding, while the failure mode of guessing "first-party" is exactly
|
|
19
|
+
the impossible-edit demand these rules exist to avoid.
|
|
20
|
+
|
|
21
|
+
The `__init__.py` requirement is load-bearing, not incidental: bulbul carries a
|
|
22
|
+
`python/bulbul/livekit/` directory of SIP trunk JSON, and a name-only match
|
|
23
|
+
would have classified the `livekit` dependency as first-party and re-flagged
|
|
24
|
+
the very imports this distinction exists to exempt. Requiring an importable
|
|
25
|
+
package makes a top-level name collide only when a real first-party package
|
|
26
|
+
shadows the distribution — at which point flagging it is correct.
|
|
27
|
+
|
|
28
|
+
The project root is the nearest ancestor holding `.git`, falling back to the
|
|
29
|
+
topmost contiguous run of ancestors holding `pyproject.toml` (worktrees,
|
|
30
|
+
sdist checkouts, and vendored trees all resolve). Scanning stops at the first
|
|
31
|
+
package directory on each branch, so only *top-level* package names are
|
|
32
|
+
collected — `agent.lk.custom_models` contributes `agent`, never `lk`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from functools import lru_cache
|
|
38
|
+
import sys
|
|
39
|
+
from typing import TYPE_CHECKING
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# `.git` is a file, not a directory, inside a linked worktree — test existence,
|
|
47
|
+
# never `is_dir()`.
|
|
48
|
+
_GIT_MARKER = ".git"
|
|
49
|
+
_PROJECT_MARKER = "pyproject.toml"
|
|
50
|
+
|
|
51
|
+
# Never descend into these: a virtualenv or vendored tree holds *third-party*
|
|
52
|
+
# packages, and collecting their names would classify every dependency as ours.
|
|
53
|
+
_SKIP_DIR_NAMES = frozenset({
|
|
54
|
+
".git",
|
|
55
|
+
".mypy_cache",
|
|
56
|
+
".next",
|
|
57
|
+
".pytest_cache",
|
|
58
|
+
".ruff_cache",
|
|
59
|
+
".tox",
|
|
60
|
+
".turbo",
|
|
61
|
+
".venv",
|
|
62
|
+
"__pycache__",
|
|
63
|
+
"build",
|
|
64
|
+
"coverage",
|
|
65
|
+
"dist",
|
|
66
|
+
"node_modules",
|
|
67
|
+
"site-packages",
|
|
68
|
+
"venv",
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
# Deep enough for a uv/pnpm-style monorepo (`<root>/packages/python/src/<pkg>`),
|
|
72
|
+
# shallow enough that the scan stays a few hundred `iterdir()` calls.
|
|
73
|
+
_MAX_SCAN_DEPTH = 5
|
|
74
|
+
|
|
75
|
+
# Hard stop so a pathological tree (a huge monorepo, a symlink loop) degrades to
|
|
76
|
+
# "nothing is first-party" — under-flagging — rather than hanging the linter.
|
|
77
|
+
_MAX_DIRS_SCANNED = 3000
|
|
78
|
+
|
|
79
|
+
# Walking up forever from a relative path is how a linter ends up scanning $HOME.
|
|
80
|
+
_MAX_ANCESTORS = 24
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_first_party_module(module: str, path: Path) -> bool:
|
|
84
|
+
"""Report whether dotted `module` is declared inside `path`'s own project.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True when the module's top-level name resolves to a first-party package.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
top = module.partition(".")[0]
|
|
91
|
+
if not top or top in sys.stdlib_module_names:
|
|
92
|
+
return False
|
|
93
|
+
root = _project_root(path)
|
|
94
|
+
if root is None:
|
|
95
|
+
return False
|
|
96
|
+
return top in _first_party_roots(root)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def own_top_package(path: Path) -> str | None:
|
|
100
|
+
"""Return the name of the top-level package `path` itself belongs to.
|
|
101
|
+
|
|
102
|
+
The OUTERMOST importable ancestor wins rather than the outermost of an
|
|
103
|
+
unbroken `__init__.py` run, because PEP 420 namespace subpackages are
|
|
104
|
+
routine — bulbul's `agent/agent/lk/custom_models/` carries no `__init__.py`
|
|
105
|
+
while `agent/agent/` does, and a break-on-first-gap walk would report the
|
|
106
|
+
file as belonging to no package at all.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The top-level package name, or None when `path` is not inside a package.
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
resolved = _resolved(path)
|
|
113
|
+
if resolved is None:
|
|
114
|
+
return None
|
|
115
|
+
root = _project_root(resolved)
|
|
116
|
+
top: str | None = None
|
|
117
|
+
for ancestor in list(resolved.parents)[:_MAX_ANCESTORS]:
|
|
118
|
+
if (ancestor / "__init__.py").exists():
|
|
119
|
+
top = ancestor.name
|
|
120
|
+
if ancestor == root:
|
|
121
|
+
break
|
|
122
|
+
return top
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolved(path: Path) -> Path | None:
|
|
126
|
+
try:
|
|
127
|
+
return path.resolve()
|
|
128
|
+
except OSError:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@lru_cache(maxsize=256)
|
|
133
|
+
def _project_root(path: Path) -> Path | None:
|
|
134
|
+
"""Locate the project boundary above `path`, memoized per file path.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The project root directory, or None when no marker is found.
|
|
138
|
+
|
|
139
|
+
"""
|
|
140
|
+
resolved = _resolved(path)
|
|
141
|
+
if resolved is None:
|
|
142
|
+
return None
|
|
143
|
+
ancestors = list(resolved.parents)[:_MAX_ANCESTORS]
|
|
144
|
+
for ancestor in ancestors:
|
|
145
|
+
if (ancestor / _GIT_MARKER).exists():
|
|
146
|
+
return ancestor
|
|
147
|
+
# No VCS boundary: take the OUTERMOST directory of the unbroken run of
|
|
148
|
+
# pyproject.toml ancestors, which is the workspace root in a uv workspace.
|
|
149
|
+
outermost: Path | None = None
|
|
150
|
+
for ancestor in ancestors:
|
|
151
|
+
if not (ancestor / _PROJECT_MARKER).exists():
|
|
152
|
+
if outermost is not None:
|
|
153
|
+
break
|
|
154
|
+
continue
|
|
155
|
+
outermost = ancestor
|
|
156
|
+
return outermost
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@lru_cache(maxsize=32)
|
|
160
|
+
def _first_party_roots(root: Path) -> frozenset[str]:
|
|
161
|
+
"""Collect the top-level package names declared anywhere under `root`.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Every directory name under `root` that is an importable package.
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
names: set[str] = set()
|
|
168
|
+
queue: list[tuple[Path, int]] = [(root, 0)]
|
|
169
|
+
scanned = 0
|
|
170
|
+
while queue:
|
|
171
|
+
directory, depth = queue.pop()
|
|
172
|
+
scanned += 1
|
|
173
|
+
if scanned > _MAX_DIRS_SCANNED:
|
|
174
|
+
break
|
|
175
|
+
try:
|
|
176
|
+
entries = sorted(directory.iterdir())
|
|
177
|
+
except OSError:
|
|
178
|
+
continue
|
|
179
|
+
for entry in entries:
|
|
180
|
+
if entry.name.startswith(".") or entry.name in _SKIP_DIR_NAMES:
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
if not entry.is_dir():
|
|
184
|
+
continue
|
|
185
|
+
is_package = (entry / "__init__.py").exists()
|
|
186
|
+
except OSError:
|
|
187
|
+
continue
|
|
188
|
+
if is_package:
|
|
189
|
+
names.add(entry.name)
|
|
190
|
+
elif depth + 1 < _MAX_SCAN_DEPTH:
|
|
191
|
+
queue.append((entry, depth + 1))
|
|
192
|
+
return frozenset(names)
|
|
@@ -18,6 +18,9 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
|
18
18
|
)
|
|
19
19
|
from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
|
|
20
20
|
from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
|
|
21
|
+
from sarj_python_lint.rules.no_first_party_private_import import (
|
|
22
|
+
NoFirstPartyPrivateImport,
|
|
23
|
+
)
|
|
21
24
|
from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
22
25
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
23
26
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
@@ -116,6 +119,7 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
116
119
|
XfailRequiresStrict.id: XfailRequiresStrict,
|
|
117
120
|
SleepWithComputedArgInTest.id: SleepWithComputedArgInTest,
|
|
118
121
|
ZeroAssertionTest.id: ZeroAssertionTest,
|
|
122
|
+
NoFirstPartyPrivateImport.id: NoFirstPartyPrivateImport,
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
__all__ = ["REGISTRY"]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""SARJ048: importing a private name — but only when the private name is OURS.
|
|
2
|
+
|
|
3
|
+
Reaching past a module's public surface is a design finding when the module is
|
|
4
|
+
ours and an unavoidable fact of life when it is not. `from bulbul.stores.task_store
|
|
5
|
+
import _row_to_task` says a first-party module has a helper someone needed and
|
|
6
|
+
did not export; the fix is to export it. `from livekit.agents.inference_runner
|
|
7
|
+
import _InferenceRunner` says a dependency moved an API private in a minor
|
|
8
|
+
release — livekit-agents 1.6.6 did exactly this to bulbul's custom EOU runner —
|
|
9
|
+
and there is no edit that satisfies the lint short of vendoring the library or
|
|
10
|
+
pinning it forever. A rule that cannot tell those apart is an instruction to
|
|
11
|
+
perform an impossible edit, which is how blanket suppressions get born.
|
|
12
|
+
|
|
13
|
+
This rule fires ONLY on the first case. Third-party privates are never flagged.
|
|
14
|
+
|
|
15
|
+
Ruff's `PLC2701 import-private-name` is the rule this replaces, and it does not
|
|
16
|
+
make the distinction. Its exemption is *same top-level package*, not
|
|
17
|
+
*first-party*: measured over bulbul's five packages it produced 80 findings, of
|
|
18
|
+
which 77 were first-party (real) and 3 were livekit reaches with no available
|
|
19
|
+
fix. Ruff has no configuration surface that separates them — the check is
|
|
20
|
+
purely lexical and never resolves an import to a location on disk. Pyright's
|
|
21
|
+
`reportPrivateUsage` fires on the same third-party import and likewise has no
|
|
22
|
+
first-party/third-party knob, so it still needs a per-line `# pyright: ignore`
|
|
23
|
+
at the reach site; this rule does not change that, it only stops ruff from
|
|
24
|
+
demanding a second, unfixable suppression on the same line.
|
|
25
|
+
|
|
26
|
+
Fires on:
|
|
27
|
+
|
|
28
|
+
* `from <first-party module> import _name` — a private symbol,
|
|
29
|
+
* `from <first-party package>._private_module import Name`, and
|
|
30
|
+
`import <first-party package>._private_module` — a private *submodule*, which
|
|
31
|
+
is just as much a non-public surface as a private symbol.
|
|
32
|
+
|
|
33
|
+
Deliberately NOT flagged:
|
|
34
|
+
|
|
35
|
+
* **anything third-party.** A module is first-party only when its top-level
|
|
36
|
+
name resolves to a package directory inside the enclosing project (see
|
|
37
|
+
`_first_party.py`); stdlib, site-packages and anything unresolvable are
|
|
38
|
+
third-party. Unresolvable defaults to third-party on purpose: a missed
|
|
39
|
+
finding is a smaller failure than an unfixable one.
|
|
40
|
+
* **relative imports** (`from . import _helper`, `from ._impl import Thing`) —
|
|
41
|
+
a relative import cannot leave its own package by construction, and a
|
|
42
|
+
package's own internals are its own business. This matches PLC2701.
|
|
43
|
+
* **same-top-level-package absolute imports** — `from agent.lk.get_models
|
|
44
|
+
import _wire_fallback_metric` written from inside the `agent` package is the
|
|
45
|
+
spelled-out form of the bullet above. Written from `agent/tests/` — which is
|
|
46
|
+
not inside the package — it fires, because a white-box test reaching into a
|
|
47
|
+
module's internals from outside is the finding, not the exemption.
|
|
48
|
+
* **dunder names** (`__version__`, `__all__`) — conventional module metadata,
|
|
49
|
+
not private internals.
|
|
50
|
+
* **a private TOP-LEVEL package name** — `from _infra.fakes import FakeStt`,
|
|
51
|
+
bulbul's shared test-support package. The underscore there is the package's
|
|
52
|
+
own name, not a hidden corner of somebody else's module: there is no public
|
|
53
|
+
spelling to switch to and no surface to widen. PLC2701 flags these (14 hits
|
|
54
|
+
in bulbul's `agent` package alone) with no available fix. Private *sub*module
|
|
55
|
+
segments still fire.
|
|
56
|
+
* `_`-prefixed *aliases* (`import json as _json`) — the alias is local shorthand
|
|
57
|
+
and the imported name is public.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import ast
|
|
63
|
+
from typing import TYPE_CHECKING, override
|
|
64
|
+
|
|
65
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
66
|
+
from sarj_python_lint.rules._first_party import is_first_party_module, own_top_package
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NoFirstPartyPrivateImport(Rule):
|
|
74
|
+
"""A private name imported out of one of OUR modules — export it instead."""
|
|
75
|
+
|
|
76
|
+
id: str = "no-first-party-private-import"
|
|
77
|
+
code: str = "SARJ048"
|
|
78
|
+
description: str = (
|
|
79
|
+
"Importing a private (`_`-prefixed) name or module from a FIRST-PARTY module reaches past a "
|
|
80
|
+
"surface we control and can widen. Third-party privates are never flagged — that API is not ours to change."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
85
|
+
"""Flag every private import whose defining module is first-party.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
One diagnostic per private name (or private module) imported from
|
|
89
|
+
one of this project's own modules, sorted by position.
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
tree = parse_or_none(path, source)
|
|
93
|
+
if tree is None:
|
|
94
|
+
return []
|
|
95
|
+
own_top = own_top_package(path)
|
|
96
|
+
diags = [
|
|
97
|
+
Diagnostic(path=path, line=line, col=col, code=self.code, message=_message(module, name))
|
|
98
|
+
for line, col, module, name in _private_imports(tree)
|
|
99
|
+
if _is_ours(module, path, own_top)
|
|
100
|
+
]
|
|
101
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
102
|
+
return diags
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _message(module: str, name: str) -> str:
|
|
106
|
+
return (
|
|
107
|
+
f"`{name}` is private to `{module}`, which is first-party — importing it reaches past a public "
|
|
108
|
+
f"surface we own and can widen. Export it under a public name, or move the caller behind a "
|
|
109
|
+
f"function `{module}` already exports. (Private imports from third-party packages are never flagged.)"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_ours(module: str, path: Path, own_top: str | None) -> bool:
|
|
114
|
+
"""Report whether `module` is a first-party module OUTSIDE the file's own package.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True when the private import crosses into another first-party package.
|
|
118
|
+
|
|
119
|
+
"""
|
|
120
|
+
top = module.partition(".")[0]
|
|
121
|
+
if own_top is not None and top == own_top:
|
|
122
|
+
return False
|
|
123
|
+
return is_first_party_module(module, path)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _private_imports(tree: ast.Module) -> list[tuple[int, int, str, str]]:
|
|
127
|
+
"""Collect `(line, col, defining module, private name)` for every private import.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
One entry per private symbol or private module segment imported
|
|
131
|
+
absolutely; relative imports are skipped.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
hits: list[tuple[int, int, str, str]] = []
|
|
135
|
+
for node in ast.walk(tree):
|
|
136
|
+
if isinstance(node, ast.ImportFrom):
|
|
137
|
+
hits.extend(_from_import_hits(node))
|
|
138
|
+
elif isinstance(node, ast.Import):
|
|
139
|
+
hits.extend(_plain_import_hits(node))
|
|
140
|
+
return hits
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _from_import_hits(node: ast.ImportFrom) -> list[tuple[int, int, str, str]]:
|
|
144
|
+
# `node.level` > 0 is a relative import: inside its own package by construction.
|
|
145
|
+
if node.level or not node.module:
|
|
146
|
+
return []
|
|
147
|
+
private_segment = _private_segment(node.module)
|
|
148
|
+
if private_segment is not None:
|
|
149
|
+
return [(node.lineno, node.col_offset + 1, node.module, private_segment)]
|
|
150
|
+
return [
|
|
151
|
+
(alias.lineno, alias.col_offset + 1, node.module, alias.name)
|
|
152
|
+
for alias in node.names
|
|
153
|
+
if _is_private_name(alias.name)
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _plain_import_hits(node: ast.Import) -> list[tuple[int, int, str, str]]:
|
|
158
|
+
hits: list[tuple[int, int, str, str]] = []
|
|
159
|
+
for alias in node.names:
|
|
160
|
+
private_segment = _private_segment(alias.name)
|
|
161
|
+
if private_segment is not None:
|
|
162
|
+
hits.append((alias.lineno, alias.col_offset + 1, alias.name, private_segment))
|
|
163
|
+
return hits
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _private_segment(module: str) -> str | None:
|
|
167
|
+
"""Return the first private component BELOW the top level of a dotted module path.
|
|
168
|
+
|
|
169
|
+
The top-level name is deliberately excluded. `pkg._internals` is a module
|
|
170
|
+
`pkg` chose not to publish and could publish; a top-level package that is
|
|
171
|
+
simply *named* `_infra` — bulbul's shared test-support package — has no
|
|
172
|
+
other spelling and no wider surface to widen, so flagging its import asks
|
|
173
|
+
for an edit that does not exist.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
The private segment, or None when every component below the top is public.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
return next((part for part in module.split(".")[1:] if _is_private_name(part)), None)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _is_private_name(name: str) -> bool:
|
|
183
|
+
# `__version__` / `__all__` are module metadata by convention, not internals.
|
|
184
|
+
return name.startswith("_") and not (name.startswith("__") and name.endswith("__"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.18.1 → sarj_python_lint-0.19.0}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|