privata 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- privata/__init__.py +29 -0
- privata/__main__.py +5 -0
- privata/_checker.py +702 -0
- privata/_version.py +24 -0
- privata/cli.py +14 -0
- privata-0.1.0.dist-info/METADATA +92 -0
- privata-0.1.0.dist-info/RECORD +10 -0
- privata-0.1.0.dist-info/WHEEL +4 -0
- privata-0.1.0.dist-info/entry_points.txt +2 -0
- privata-0.1.0.dist-info/licenses/LICENSE +21 -0
privata/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Python module privacy checks."""
|
|
2
|
+
|
|
3
|
+
from privata._checker import (
|
|
4
|
+
Module,
|
|
5
|
+
PrivateModuleImport,
|
|
6
|
+
Symbol,
|
|
7
|
+
collect_modules,
|
|
8
|
+
collect_private_module_imports,
|
|
9
|
+
find_cross_imports,
|
|
10
|
+
find_private_candidates,
|
|
11
|
+
find_private_module_imports,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from privata._version import __version__
|
|
16
|
+
except ImportError: # pragma: no cover - only used from editable trees before hatch-vcs writes it
|
|
17
|
+
__version__ = "0.0.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Module",
|
|
21
|
+
"PrivateModuleImport",
|
|
22
|
+
"Symbol",
|
|
23
|
+
"__version__",
|
|
24
|
+
"collect_modules",
|
|
25
|
+
"collect_private_module_imports",
|
|
26
|
+
"find_cross_imports",
|
|
27
|
+
"find_private_candidates",
|
|
28
|
+
"find_private_module_imports",
|
|
29
|
+
]
|
privata/__main__.py
ADDED
privata/_checker.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""Detect module privacy issues within ``src/``.
|
|
2
|
+
|
|
3
|
+
- Public top-level symbols that are never imported by other src modules.
|
|
4
|
+
- Private modules imported from outside their containing package subtree.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
privata <project-root>
|
|
8
|
+
|
|
9
|
+
Only imports within ``src/`` count, so test imports are ignored.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import tomllib
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Symbol:
|
|
24
|
+
"""A public top-level symbol found in a module."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
kind: str # "function", "class", or "variable"
|
|
28
|
+
lineno: int
|
|
29
|
+
module: str
|
|
30
|
+
path: Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Module:
|
|
35
|
+
"""A parsed Python module with its top-level symbols."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
path: Path
|
|
39
|
+
package_parts: tuple[str, ...]
|
|
40
|
+
symbols: list[Symbol] = field(default_factory=list)
|
|
41
|
+
tree: ast.Module | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PrivateModuleImport:
|
|
46
|
+
"""A private module imported from outside its containing package subtree."""
|
|
47
|
+
|
|
48
|
+
module: str
|
|
49
|
+
path: Path
|
|
50
|
+
imported_by: str
|
|
51
|
+
imported_by_path: Path
|
|
52
|
+
lineno: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _SymbolCandidate:
|
|
57
|
+
name: str
|
|
58
|
+
kind: str
|
|
59
|
+
lineno: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_ROUTE_DECORATORS = {
|
|
63
|
+
"api_route",
|
|
64
|
+
"delete",
|
|
65
|
+
"get",
|
|
66
|
+
"head",
|
|
67
|
+
"options",
|
|
68
|
+
"patch",
|
|
69
|
+
"post",
|
|
70
|
+
"put",
|
|
71
|
+
"trace",
|
|
72
|
+
"websocket",
|
|
73
|
+
"websocket_route",
|
|
74
|
+
}
|
|
75
|
+
_CLI_DECORATORS = {"callback", "command"}
|
|
76
|
+
_FRAMEWORK_CONSTRUCTORS = {"APIRouter", "FastAPI", "Typer"}
|
|
77
|
+
_FRAMEWORK_REGISTRATION_CALLS = {"add_api_route", "add_api_websocket_route", "include_router"}
|
|
78
|
+
_ALLOWED_PUBLIC_NAMES = {"logger"}
|
|
79
|
+
_ENTRYPOINT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_\\.]*:[A-Za-z_][A-Za-z0-9_]*$")
|
|
80
|
+
_UVICORN_RE = re.compile(r"\buvicorn\s+([A-Za-z_][A-Za-z0-9_\.]*):([A-Za-z_][A-Za-z0-9_]*)\b")
|
|
81
|
+
_MIN_ARG_COUNT = 2
|
|
82
|
+
_SPLIT_MODULE_PART_COUNT = 2
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _find_src_dir(project_root: Path) -> Path | None:
|
|
86
|
+
src = project_root / "src"
|
|
87
|
+
return src if src.is_dir() else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _module_name_from_path(py_file: Path, src_dir: Path) -> str | None:
|
|
91
|
+
"""Derive dotted module name from file path relative to src/."""
|
|
92
|
+
rel = py_file.relative_to(src_dir)
|
|
93
|
+
parts = list(rel.with_suffix("").parts)
|
|
94
|
+
if not parts:
|
|
95
|
+
return None
|
|
96
|
+
if parts[-1] == "__init__":
|
|
97
|
+
parts = parts[:-1]
|
|
98
|
+
if not parts:
|
|
99
|
+
return None
|
|
100
|
+
return ".".join(parts)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _package_parts(module_name: str, *, is_package_init: bool = False) -> tuple[str, ...]:
|
|
104
|
+
if is_package_init:
|
|
105
|
+
return tuple(module_name.split("."))
|
|
106
|
+
parts = module_name.rsplit(".", 1)
|
|
107
|
+
if len(parts) == 1:
|
|
108
|
+
return ()
|
|
109
|
+
return tuple(parts[0].split("."))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_private_module_name(module_name: str) -> bool:
|
|
113
|
+
return any(part.startswith("_") for part in module_name.split("."))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _private_module_owner_package(module_name: str) -> str:
|
|
117
|
+
parts = module_name.rsplit(".", 1)
|
|
118
|
+
return parts[0] if len(parts) == _SPLIT_MODULE_PART_COUNT else module_name
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _module_is_within_package(module_name: str, package_name: str) -> bool:
|
|
122
|
+
return module_name == package_name or module_name.startswith(f"{package_name}.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def collect_modules(src_dir: Path) -> dict[str, Module]: # noqa: C901, PLR0912
|
|
126
|
+
"""Parse every .py under src/ and collect top-level public definitions."""
|
|
127
|
+
modules: dict[str, Module] = {}
|
|
128
|
+
|
|
129
|
+
for py_file in sorted(src_dir.rglob("*.py")):
|
|
130
|
+
if "__pycache__" in py_file.parts:
|
|
131
|
+
continue
|
|
132
|
+
mod_name = _module_name_from_path(py_file, src_dir)
|
|
133
|
+
if mod_name is None:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
source = py_file.read_text(encoding="utf-8")
|
|
137
|
+
try:
|
|
138
|
+
tree = ast.parse(source, filename=str(py_file))
|
|
139
|
+
except SyntaxError:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# Detect __all__ to skip explicitly exported names
|
|
143
|
+
explicit_exports = _extract_all(tree)
|
|
144
|
+
framework_related_names = _collect_framework_related_names(tree)
|
|
145
|
+
pydantic_model_names: set[str] = set()
|
|
146
|
+
|
|
147
|
+
mod = Module(
|
|
148
|
+
name=mod_name,
|
|
149
|
+
path=py_file,
|
|
150
|
+
package_parts=_package_parts(mod_name, is_package_init=py_file.name == "__init__.py"),
|
|
151
|
+
tree=tree,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for node in tree.body:
|
|
155
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
156
|
+
if _is_framework_callback(node):
|
|
157
|
+
continue
|
|
158
|
+
_maybe_add(
|
|
159
|
+
mod,
|
|
160
|
+
_SymbolCandidate(node.name, "function", node.lineno),
|
|
161
|
+
explicit_exports,
|
|
162
|
+
ignored_names=framework_related_names,
|
|
163
|
+
)
|
|
164
|
+
elif isinstance(node, ast.ClassDef):
|
|
165
|
+
if _is_pydantic_model(node, pydantic_model_names):
|
|
166
|
+
pydantic_model_names.add(node.name)
|
|
167
|
+
continue
|
|
168
|
+
_maybe_add(
|
|
169
|
+
mod,
|
|
170
|
+
_SymbolCandidate(node.name, "class", node.lineno),
|
|
171
|
+
explicit_exports,
|
|
172
|
+
ignored_names=framework_related_names,
|
|
173
|
+
)
|
|
174
|
+
elif isinstance(node, ast.Assign):
|
|
175
|
+
if _is_framework_constructor_call(node.value):
|
|
176
|
+
continue
|
|
177
|
+
for target in node.targets:
|
|
178
|
+
for name in _names_from_target(target):
|
|
179
|
+
_maybe_add(
|
|
180
|
+
mod,
|
|
181
|
+
_SymbolCandidate(name, "variable", node.lineno),
|
|
182
|
+
explicit_exports,
|
|
183
|
+
ignored_names=framework_related_names,
|
|
184
|
+
)
|
|
185
|
+
elif isinstance(node, ast.AnnAssign) and node.target:
|
|
186
|
+
if node.value is not None and _is_framework_constructor_call(node.value):
|
|
187
|
+
continue
|
|
188
|
+
for name in _names_from_target(node.target):
|
|
189
|
+
_maybe_add(
|
|
190
|
+
mod,
|
|
191
|
+
_SymbolCandidate(name, "variable", node.lineno),
|
|
192
|
+
explicit_exports,
|
|
193
|
+
ignored_names=framework_related_names,
|
|
194
|
+
)
|
|
195
|
+
elif hasattr(ast, "TypeAlias") and isinstance(node, ast.TypeAlias):
|
|
196
|
+
for name in _names_from_target(node.name):
|
|
197
|
+
_maybe_add(
|
|
198
|
+
mod,
|
|
199
|
+
_SymbolCandidate(name, "variable", node.lineno),
|
|
200
|
+
explicit_exports,
|
|
201
|
+
ignored_names=framework_related_names,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
modules[mod_name] = mod
|
|
205
|
+
|
|
206
|
+
return modules
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _extract_all(tree: ast.Module) -> set[str] | None:
|
|
210
|
+
"""Return the set of names in __all__, or None if not defined."""
|
|
211
|
+
for node in tree.body:
|
|
212
|
+
if isinstance(node, ast.Assign):
|
|
213
|
+
for target in node.targets:
|
|
214
|
+
if isinstance(target, ast.Name) and target.id == "__all__":
|
|
215
|
+
return _strings_from_node(node.value)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _dotted_name(node: ast.expr) -> str | None:
|
|
220
|
+
if isinstance(node, ast.Name):
|
|
221
|
+
return node.id
|
|
222
|
+
if isinstance(node, ast.Attribute):
|
|
223
|
+
parent = _dotted_name(node.value)
|
|
224
|
+
if parent is None:
|
|
225
|
+
return None
|
|
226
|
+
return f"{parent}.{node.attr}"
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _decorator_attr_name(decorator: ast.expr) -> str | None:
|
|
231
|
+
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
232
|
+
if isinstance(target, ast.Attribute):
|
|
233
|
+
return target.attr
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_framework_callback(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
238
|
+
for decorator in node.decorator_list:
|
|
239
|
+
attr_name = _decorator_attr_name(decorator)
|
|
240
|
+
if attr_name in _ROUTE_DECORATORS or attr_name in _CLI_DECORATORS:
|
|
241
|
+
return True
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _framework_callback_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
246
|
+
names: set[str] = set()
|
|
247
|
+
expressions: list[ast.expr] = [*node.decorator_list]
|
|
248
|
+
|
|
249
|
+
if node.returns is not None:
|
|
250
|
+
expressions.append(node.returns)
|
|
251
|
+
|
|
252
|
+
arg_annotations = [
|
|
253
|
+
arg.annotation
|
|
254
|
+
for arg in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
|
|
255
|
+
if arg.annotation is not None
|
|
256
|
+
]
|
|
257
|
+
expressions.extend(arg_annotations)
|
|
258
|
+
if node.args.vararg and node.args.vararg.annotation is not None:
|
|
259
|
+
expressions.append(node.args.vararg.annotation)
|
|
260
|
+
if node.args.kwarg and node.args.kwarg.annotation is not None:
|
|
261
|
+
expressions.append(node.args.kwarg.annotation)
|
|
262
|
+
|
|
263
|
+
defaults = [*node.args.defaults, *(d for d in node.args.kw_defaults if d is not None)]
|
|
264
|
+
expressions.extend(defaults)
|
|
265
|
+
|
|
266
|
+
for expr in expressions:
|
|
267
|
+
names.update(_names_in_expr(expr))
|
|
268
|
+
|
|
269
|
+
return names
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _names_in_expr(node: ast.AST | None) -> set[str]:
|
|
273
|
+
if node is None:
|
|
274
|
+
return set()
|
|
275
|
+
return {child.id for child in ast.walk(node) if isinstance(child, ast.Name)}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _is_framework_registration_call(node: ast.expr) -> bool:
|
|
279
|
+
if not isinstance(node, ast.Call):
|
|
280
|
+
return False
|
|
281
|
+
if not isinstance(node.func, ast.Attribute):
|
|
282
|
+
return False
|
|
283
|
+
return node.func.attr in _FRAMEWORK_REGISTRATION_CALLS
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _collect_framework_related_names(tree: ast.Module) -> set[str]:
|
|
287
|
+
names: set[str] = set()
|
|
288
|
+
for node in tree.body:
|
|
289
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
290
|
+
if _is_framework_callback(node):
|
|
291
|
+
names.update(_framework_callback_names(node))
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
expr: ast.expr | None = None
|
|
295
|
+
if isinstance(node, (ast.Expr, ast.Assign, ast.AnnAssign)):
|
|
296
|
+
expr = node.value
|
|
297
|
+
|
|
298
|
+
if expr is not None and _is_framework_registration_call(expr):
|
|
299
|
+
names.update(_names_in_expr(expr))
|
|
300
|
+
|
|
301
|
+
return names
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _is_pydantic_model(node: ast.ClassDef, known_models: set[str]) -> bool:
|
|
305
|
+
for base in node.bases:
|
|
306
|
+
base_name = _dotted_name(base)
|
|
307
|
+
if base_name is None:
|
|
308
|
+
continue
|
|
309
|
+
if base_name == "BaseModel" or base_name.endswith(".BaseModel"):
|
|
310
|
+
return True
|
|
311
|
+
short = base_name.rsplit(".", 1)[-1]
|
|
312
|
+
if short in known_models:
|
|
313
|
+
return True
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _is_framework_constructor_call(node: ast.expr) -> bool:
|
|
318
|
+
if not isinstance(node, ast.Call):
|
|
319
|
+
return False
|
|
320
|
+
callee = _dotted_name(node.func)
|
|
321
|
+
if callee is None:
|
|
322
|
+
return False
|
|
323
|
+
short = callee.rsplit(".", 1)[-1]
|
|
324
|
+
return short in _FRAMEWORK_CONSTRUCTORS
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _strings_from_node(node: ast.expr) -> set[str] | None:
|
|
328
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
329
|
+
names: set[str] = set()
|
|
330
|
+
for elt in node.elts:
|
|
331
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
332
|
+
names.add(elt.value)
|
|
333
|
+
else:
|
|
334
|
+
return None # non-literal element, bail
|
|
335
|
+
return names
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _names_from_target(node: ast.expr) -> list[str]:
|
|
340
|
+
if isinstance(node, ast.Name):
|
|
341
|
+
return [node.id]
|
|
342
|
+
if isinstance(node, (ast.Tuple, ast.List)):
|
|
343
|
+
result: list[str] = []
|
|
344
|
+
for elt in node.elts:
|
|
345
|
+
result.extend(_names_from_target(elt))
|
|
346
|
+
return result
|
|
347
|
+
return []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _maybe_add(
|
|
351
|
+
mod: Module,
|
|
352
|
+
candidate: _SymbolCandidate,
|
|
353
|
+
explicit_exports: set[str] | None,
|
|
354
|
+
*,
|
|
355
|
+
ignored_names: set[str] | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
name = candidate.name
|
|
358
|
+
if name.startswith("_"):
|
|
359
|
+
return
|
|
360
|
+
if name in _ALLOWED_PUBLIC_NAMES:
|
|
361
|
+
return
|
|
362
|
+
if explicit_exports is not None and name in explicit_exports:
|
|
363
|
+
return
|
|
364
|
+
if ignored_names is not None and name in ignored_names:
|
|
365
|
+
return
|
|
366
|
+
mod.symbols.append(
|
|
367
|
+
Symbol(
|
|
368
|
+
name=name,
|
|
369
|
+
kind=candidate.kind,
|
|
370
|
+
lineno=candidate.lineno,
|
|
371
|
+
module=mod.name,
|
|
372
|
+
path=mod.path,
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _resolve_relative_import(
|
|
378
|
+
importer_package: tuple[str, ...],
|
|
379
|
+
level: int,
|
|
380
|
+
module_attr: str | None,
|
|
381
|
+
) -> str | None:
|
|
382
|
+
"""Resolve a relative import to an absolute dotted module name."""
|
|
383
|
+
if level == 0:
|
|
384
|
+
return module_attr
|
|
385
|
+
|
|
386
|
+
# level=1 means current package, level=2 means parent, etc.
|
|
387
|
+
up = level - 1
|
|
388
|
+
if up > len(importer_package):
|
|
389
|
+
return None
|
|
390
|
+
base = list(importer_package[: len(importer_package) - up])
|
|
391
|
+
if module_attr:
|
|
392
|
+
base.extend(module_attr.split("."))
|
|
393
|
+
return ".".join(base) if base else None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def find_cross_imports(modules: dict[str, Module]) -> set[tuple[str, str]]: # noqa: C901, PLR0912
|
|
397
|
+
"""Return (module_name, symbol_name) pairs that are imported by another src module."""
|
|
398
|
+
known = set(modules)
|
|
399
|
+
used: set[tuple[str, str]] = set()
|
|
400
|
+
|
|
401
|
+
# Build a quick lookup: module -> set of defined public symbol names
|
|
402
|
+
defined: dict[str, set[str]] = {}
|
|
403
|
+
for mod_name, mod in modules.items():
|
|
404
|
+
defined[mod_name] = {s.name for s in mod.symbols}
|
|
405
|
+
|
|
406
|
+
for consumer_name, consumer in modules.items():
|
|
407
|
+
if consumer.tree is None:
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
# Track `import X` / `import X as Y` so we can resolve `X.foo`
|
|
411
|
+
import_aliases: dict[str, str] = {} # local_name -> module_name
|
|
412
|
+
|
|
413
|
+
for node in ast.walk(consumer.tree):
|
|
414
|
+
if isinstance(node, ast.Import):
|
|
415
|
+
for alias in node.names:
|
|
416
|
+
local = alias.asname or alias.name.split(".")[0]
|
|
417
|
+
import_aliases[local] = alias.name
|
|
418
|
+
|
|
419
|
+
elif isinstance(node, ast.ImportFrom):
|
|
420
|
+
source = _resolve_relative_import(
|
|
421
|
+
consumer.package_parts,
|
|
422
|
+
node.level or 0,
|
|
423
|
+
node.module,
|
|
424
|
+
)
|
|
425
|
+
if source is None:
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
for alias in node.names:
|
|
429
|
+
sym = alias.name
|
|
430
|
+
if sym == "*":
|
|
431
|
+
# `from mod import *` marks everything as used
|
|
432
|
+
if source in defined and source != consumer_name:
|
|
433
|
+
for s in defined[source]:
|
|
434
|
+
used.add((source, s))
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# Could be importing a submodule (e.g. `from pkg import submod`)
|
|
438
|
+
sub = f"{source}.{sym}"
|
|
439
|
+
if sub in known:
|
|
440
|
+
local = alias.asname or sym
|
|
441
|
+
import_aliases[local] = sub
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
if source != consumer_name and source in defined and sym in defined[source]:
|
|
445
|
+
used.add((source, sym))
|
|
446
|
+
|
|
447
|
+
# Second pass: resolve attribute access like `module.symbol`
|
|
448
|
+
for node in ast.walk(consumer.tree):
|
|
449
|
+
if not isinstance(node, ast.Attribute):
|
|
450
|
+
continue
|
|
451
|
+
if not isinstance(node.value, ast.Name):
|
|
452
|
+
continue
|
|
453
|
+
obj_name = node.value.id
|
|
454
|
+
attr = node.attr
|
|
455
|
+
aliased_module = import_aliases.get(obj_name)
|
|
456
|
+
if (
|
|
457
|
+
aliased_module
|
|
458
|
+
and aliased_module != consumer_name
|
|
459
|
+
and aliased_module in defined
|
|
460
|
+
and attr in defined[aliased_module]
|
|
461
|
+
):
|
|
462
|
+
used.add((aliased_module, attr))
|
|
463
|
+
|
|
464
|
+
return used
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def collect_private_module_imports(modules: dict[str, Module]) -> list[PrivateModuleImport]:
|
|
468
|
+
"""Return private src modules imported from outside their package subtree."""
|
|
469
|
+
private_modules = {
|
|
470
|
+
module_name for module_name in modules if _is_private_module_name(module_name)
|
|
471
|
+
}
|
|
472
|
+
findings: dict[tuple[str, str, int], PrivateModuleImport] = {}
|
|
473
|
+
|
|
474
|
+
def record(private_module_name: str, consumer: Module, lineno: int) -> None:
|
|
475
|
+
if private_module_name == consumer.name:
|
|
476
|
+
return
|
|
477
|
+
owner_package = _private_module_owner_package(private_module_name)
|
|
478
|
+
if _module_is_within_package(consumer.name, owner_package):
|
|
479
|
+
return
|
|
480
|
+
findings.setdefault(
|
|
481
|
+
(private_module_name, consumer.name, lineno),
|
|
482
|
+
PrivateModuleImport(
|
|
483
|
+
module=private_module_name,
|
|
484
|
+
path=modules[private_module_name].path,
|
|
485
|
+
imported_by=consumer.name,
|
|
486
|
+
imported_by_path=consumer.path,
|
|
487
|
+
lineno=lineno,
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
for consumer in modules.values():
|
|
492
|
+
if consumer.tree is None:
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
for private_module_name, lineno in _find_private_imports_in_module(
|
|
496
|
+
consumer,
|
|
497
|
+
private_modules,
|
|
498
|
+
):
|
|
499
|
+
record(private_module_name, consumer, lineno)
|
|
500
|
+
|
|
501
|
+
return sorted(
|
|
502
|
+
findings.values(),
|
|
503
|
+
key=lambda item: (str(item.imported_by_path), item.lineno, item.module),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _find_private_imports_in_module(
|
|
508
|
+
consumer: Module,
|
|
509
|
+
private_modules: set[str],
|
|
510
|
+
) -> set[tuple[str, int]]:
|
|
511
|
+
findings: set[tuple[str, int]] = set()
|
|
512
|
+
if consumer.tree is None:
|
|
513
|
+
return findings
|
|
514
|
+
|
|
515
|
+
for node in ast.walk(consumer.tree):
|
|
516
|
+
if isinstance(node, ast.Import):
|
|
517
|
+
findings.update(_private_imports_from_import(node, private_modules))
|
|
518
|
+
continue
|
|
519
|
+
if isinstance(node, ast.ImportFrom):
|
|
520
|
+
findings.update(_private_imports_from_import_from(consumer, node, private_modules))
|
|
521
|
+
|
|
522
|
+
return findings
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _private_imports_from_import(
|
|
526
|
+
node: ast.Import,
|
|
527
|
+
private_modules: set[str],
|
|
528
|
+
) -> set[tuple[str, int]]:
|
|
529
|
+
return {(alias.name, node.lineno) for alias in node.names if alias.name in private_modules}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _private_imports_from_import_from(
|
|
533
|
+
consumer: Module,
|
|
534
|
+
node: ast.ImportFrom,
|
|
535
|
+
private_modules: set[str],
|
|
536
|
+
) -> set[tuple[str, int]]:
|
|
537
|
+
source = _resolve_relative_import(
|
|
538
|
+
consumer.package_parts,
|
|
539
|
+
node.level or 0,
|
|
540
|
+
node.module,
|
|
541
|
+
)
|
|
542
|
+
if source is None:
|
|
543
|
+
return set()
|
|
544
|
+
|
|
545
|
+
findings: set[tuple[str, int]] = set()
|
|
546
|
+
if source in private_modules:
|
|
547
|
+
findings.add((source, node.lineno))
|
|
548
|
+
|
|
549
|
+
findings.update(
|
|
550
|
+
(f"{source}.{alias.name}", node.lineno)
|
|
551
|
+
for alias in node.names
|
|
552
|
+
if alias.name != "*" and f"{source}.{alias.name}" in private_modules
|
|
553
|
+
)
|
|
554
|
+
return findings
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _load_pyproject_entrypoints(project_root: Path) -> set[tuple[str, str]]:
|
|
558
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
559
|
+
if not pyproject_path.exists():
|
|
560
|
+
return set()
|
|
561
|
+
|
|
562
|
+
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
563
|
+
project_table = data.get("project", {})
|
|
564
|
+
|
|
565
|
+
pairs: set[tuple[str, str]] = set()
|
|
566
|
+
for table_key in ("scripts", "gui-scripts"):
|
|
567
|
+
table = project_table.get(table_key, {})
|
|
568
|
+
if not isinstance(table, dict):
|
|
569
|
+
continue
|
|
570
|
+
for raw in table.values():
|
|
571
|
+
if not isinstance(raw, str):
|
|
572
|
+
continue
|
|
573
|
+
if not _ENTRYPOINT_RE.fullmatch(raw):
|
|
574
|
+
continue
|
|
575
|
+
module_name, symbol_name = raw.split(":", 1)
|
|
576
|
+
pairs.add((module_name, symbol_name))
|
|
577
|
+
return pairs
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _entrypoint_shell_files(project_root: Path) -> list[Path]:
|
|
581
|
+
files: list[Path] = []
|
|
582
|
+
files.extend(project_root.glob("*.sh"))
|
|
583
|
+
files.extend(project_root.glob("Dockerfile*"))
|
|
584
|
+
scripts_dir = project_root / "scripts"
|
|
585
|
+
if scripts_dir.exists():
|
|
586
|
+
files.extend(scripts_dir.rglob("*.sh"))
|
|
587
|
+
return sorted(set(files))
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _load_shell_uvicorn_entrypoints(project_root: Path) -> set[tuple[str, str]]:
|
|
591
|
+
pairs: set[tuple[str, str]] = set()
|
|
592
|
+
for path in _entrypoint_shell_files(project_root):
|
|
593
|
+
text = path.read_text(encoding="utf-8")
|
|
594
|
+
for module_name, symbol_name in _UVICORN_RE.findall(text):
|
|
595
|
+
pairs.add((module_name, symbol_name))
|
|
596
|
+
return pairs
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _collect_external_entrypoints(project_root: Path) -> set[tuple[str, str]]:
|
|
600
|
+
pairs = _load_pyproject_entrypoints(project_root)
|
|
601
|
+
pairs.update(_load_shell_uvicorn_entrypoints(project_root))
|
|
602
|
+
return pairs
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _load_tach_interface_exports(project_root: Path) -> set[tuple[str, str]]:
|
|
606
|
+
tach_path = project_root / "tach.toml"
|
|
607
|
+
if not tach_path.exists():
|
|
608
|
+
return set()
|
|
609
|
+
|
|
610
|
+
data = tomllib.loads(tach_path.read_text(encoding="utf-8"))
|
|
611
|
+
pairs: set[tuple[str, str]] = set()
|
|
612
|
+
for interface in data.get("interfaces", []):
|
|
613
|
+
source_modules = interface.get("from", [])
|
|
614
|
+
exposed_names = interface.get("expose", [])
|
|
615
|
+
if not isinstance(source_modules, list) or not isinstance(exposed_names, list):
|
|
616
|
+
continue
|
|
617
|
+
for module_name in source_modules:
|
|
618
|
+
if not isinstance(module_name, str):
|
|
619
|
+
continue
|
|
620
|
+
for symbol_name in exposed_names:
|
|
621
|
+
if isinstance(symbol_name, str):
|
|
622
|
+
pairs.add((module_name, symbol_name))
|
|
623
|
+
return pairs
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _collect_privacy_findings(project_root: Path) -> tuple[list[Symbol], list[PrivateModuleImport]]:
|
|
627
|
+
"""Collect public-symbol and private-module boundary findings."""
|
|
628
|
+
src_dir = _find_src_dir(project_root)
|
|
629
|
+
if src_dir is None:
|
|
630
|
+
msg = f"No src/ directory found in {project_root}"
|
|
631
|
+
raise FileNotFoundError(msg)
|
|
632
|
+
|
|
633
|
+
modules = collect_modules(src_dir)
|
|
634
|
+
cross_imports = find_cross_imports(modules)
|
|
635
|
+
external_entrypoints = _collect_external_entrypoints(project_root)
|
|
636
|
+
public_interface_exports = _load_tach_interface_exports(project_root)
|
|
637
|
+
|
|
638
|
+
candidates = [
|
|
639
|
+
sym
|
|
640
|
+
for mod in modules.values()
|
|
641
|
+
for sym in mod.symbols
|
|
642
|
+
if (sym.module, sym.name) not in cross_imports
|
|
643
|
+
and (sym.module, sym.name) not in external_entrypoints
|
|
644
|
+
and (sym.module, sym.name) not in public_interface_exports
|
|
645
|
+
]
|
|
646
|
+
candidates.sort(key=lambda s: (str(s.path), s.lineno))
|
|
647
|
+
private_module_imports = collect_private_module_imports(modules)
|
|
648
|
+
return candidates, private_module_imports
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def find_private_candidates(project_root: Path) -> list[Symbol]:
|
|
652
|
+
"""Find symbols that appear module-local and should be private."""
|
|
653
|
+
candidates, _ = _collect_privacy_findings(project_root)
|
|
654
|
+
return candidates
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def find_private_module_imports(project_root: Path) -> list[PrivateModuleImport]:
|
|
658
|
+
"""Find private src modules imported from outside their package subtree."""
|
|
659
|
+
_, private_module_imports = _collect_privacy_findings(project_root)
|
|
660
|
+
return private_module_imports
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def main() -> int:
|
|
664
|
+
"""Entry point: scan project and report module-local public symbols."""
|
|
665
|
+
if len(sys.argv) < _MIN_ARG_COUNT:
|
|
666
|
+
print(f"Usage: {sys.argv[0]} <project-root>", file=sys.stderr)
|
|
667
|
+
return 2
|
|
668
|
+
|
|
669
|
+
project_root = Path(sys.argv[1]).resolve()
|
|
670
|
+
try:
|
|
671
|
+
candidates, private_module_imports = _collect_privacy_findings(project_root)
|
|
672
|
+
except FileNotFoundError as exc:
|
|
673
|
+
print(str(exc), file=sys.stderr)
|
|
674
|
+
return 1
|
|
675
|
+
|
|
676
|
+
if not candidates and not private_module_imports:
|
|
677
|
+
print("No module privacy issues found.")
|
|
678
|
+
return 0
|
|
679
|
+
|
|
680
|
+
if candidates:
|
|
681
|
+
print(f"Found {len(candidates)} public symbols that could be made private:\n")
|
|
682
|
+
for sym in candidates:
|
|
683
|
+
rel = sym.path.relative_to(project_root)
|
|
684
|
+
print(f" {rel}:{sym.lineno}: {sym.kind} `{sym.name}`")
|
|
685
|
+
|
|
686
|
+
if private_module_imports:
|
|
687
|
+
if candidates:
|
|
688
|
+
print()
|
|
689
|
+
print(
|
|
690
|
+
"Found "
|
|
691
|
+
f"{len(private_module_imports)} "
|
|
692
|
+
"private module imports outside their package subtree:\n",
|
|
693
|
+
)
|
|
694
|
+
for issue in private_module_imports:
|
|
695
|
+
rel = issue.imported_by_path.relative_to(project_root)
|
|
696
|
+
print(f" {rel}:{issue.lineno}: imports private module `{issue.module}`")
|
|
697
|
+
|
|
698
|
+
return 0
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
if __name__ == "__main__":
|
|
702
|
+
raise SystemExit(main())
|
privata/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
privata/cli.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Command-line interface for Privata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from privata._checker import main as _checker_main
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> int:
|
|
9
|
+
"""Run the Privata module privacy checker."""
|
|
10
|
+
return _checker_main()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: privata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python module privacy checker for keeping public interfaces intentional.
|
|
5
|
+
Author: Bas Nijholt
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: mypy>=1.14; extra == 'dev'
|
|
11
|
+
Requires-Dist: pre-commit>=4; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.4; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff>=0.13; extra == 'dev'
|
|
14
|
+
Requires-Dist: ty; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Privata
|
|
18
|
+
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
[](https://github.com/basnijholt/privata/actions/workflows/ci.yml)
|
|
21
|
+
[](https://pypi.org/project/privata/)
|
|
22
|
+
[](https://pypi.org/project/privata/)
|
|
23
|
+
[](https://basnijholt.github.io/privata/)
|
|
24
|
+
|
|
25
|
+
Keep Python module interfaces intentional.
|
|
26
|
+
|
|
27
|
+
Privata scans a `src/` layout Python project and reports public top-level symbols that are only used inside their own module.
|
|
28
|
+
It also reports imports of private modules from outside their owning package subtree.
|
|
29
|
+
Test imports do not count, so tests can still reach internals without forcing those internals to stay public.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv tool install privata
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For local development:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv sync --extra dev
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Run Privata from a project root:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
privata .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Example output:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
Found 2 public symbols that could be made private:
|
|
55
|
+
|
|
56
|
+
src/example/service.py:12: function `helper`
|
|
57
|
+
src/example/service.py:21: class `InternalState`
|
|
58
|
+
|
|
59
|
+
Found 1 private module imports outside their package subtree:
|
|
60
|
+
|
|
61
|
+
src/example/api.py:3: imports private module `example.worker._runtime`
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If the project is clean:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
No module privacy issues found.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## What Privata Checks
|
|
71
|
+
|
|
72
|
+
- Public top-level functions, classes, variables, and type aliases in `src/`.
|
|
73
|
+
- Whether those symbols are imported by another production module under `src/`.
|
|
74
|
+
- Whether private modules such as `pkg._internal` are imported outside their containing package subtree.
|
|
75
|
+
- Console entry points in `pyproject.toml`.
|
|
76
|
+
- Uvicorn entry points in shell scripts and Dockerfiles.
|
|
77
|
+
- Symbols exported through package `__init__.py` and `__all__`.
|
|
78
|
+
- Tach `[[interfaces]]` entries, when `tach.toml` is present.
|
|
79
|
+
|
|
80
|
+
Privata intentionally ignores imports from `tests/`.
|
|
81
|
+
If only tests import a symbol, Privata treats that symbol as private.
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv run --extra dev pytest
|
|
87
|
+
uv run --extra dev ruff check .
|
|
88
|
+
uv run --extra dev ruff format --check .
|
|
89
|
+
uv run --extra dev mypy src tests
|
|
90
|
+
uv run --extra dev ty check
|
|
91
|
+
uv build
|
|
92
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
privata/__init__.py,sha256=9yrIh2dEwtSxLsbILbfhcPbFzBleRbChuqQKsDusxkI,680
|
|
2
|
+
privata/__main__.py,sha256=mllsJCMYDihaRh7NvQM3R74vw7sWkyvUA-UdDtBVCqY,102
|
|
3
|
+
privata/_checker.py,sha256=-0wAiHIsIH5LMYZSZia1YSeGT7lQ9yp2pyrssXbiGUA,23382
|
|
4
|
+
privata/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
5
|
+
privata/cli.py,sha256=DoKsqsxwBqq-JOcXsrwpeeqgjKDNcJSvhC5lf2zEGs8,286
|
|
6
|
+
privata-0.1.0.dist-info/METADATA,sha256=3xLKvdDowbgm-gveOM6NvgQab-pGbMgIGEUqj03rkzE,2804
|
|
7
|
+
privata-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
privata-0.1.0.dist-info/entry_points.txt,sha256=hSARcLfghyCOUcXdpV0Y49sJSsquSXCz9XQO7JKbJFQ,45
|
|
9
|
+
privata-0.1.0.dist-info/licenses/LICENSE,sha256=Z-jViiDEIKskSQzvdihxxQ5Z25DDxXFdiPwx4ZK2Jho,1068
|
|
10
|
+
privata-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bas Nijholt
|
|
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.
|