test-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 ADDED
@@ -0,0 +1,37 @@
1
+ """Python module privacy checks."""
2
+
3
+ from privata._checker import (
4
+ find_export_issues,
5
+ find_private_candidates,
6
+ find_private_module_imports,
7
+ find_private_symbol_imports,
8
+ )
9
+ from privata._imports import (
10
+ collect_private_module_imports,
11
+ collect_private_symbol_imports,
12
+ find_cross_imports,
13
+ )
14
+ from privata._models import ExportIssue, Module, PrivateModuleImport, PrivateSymbolImport, Symbol
15
+ from privata._modules import collect_modules
16
+
17
+ try:
18
+ from privata._version import __version__
19
+ except ImportError: # pragma: no cover - only used from editable trees before hatch-vcs writes it
20
+ __version__ = "0.0.0"
21
+
22
+ __all__ = [
23
+ "ExportIssue",
24
+ "Module",
25
+ "PrivateModuleImport",
26
+ "PrivateSymbolImport",
27
+ "Symbol",
28
+ "__version__",
29
+ "collect_modules",
30
+ "collect_private_module_imports",
31
+ "collect_private_symbol_imports",
32
+ "find_cross_imports",
33
+ "find_export_issues",
34
+ "find_private_candidates",
35
+ "find_private_module_imports",
36
+ "find_private_symbol_imports",
37
+ ]
privata/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run Privata with ``python -m privata``."""
2
+
3
+ from privata.cli import main
4
+
5
+ raise SystemExit(main())
privata/_checker.py ADDED
@@ -0,0 +1,162 @@
1
+ """Detect module privacy issues within Python source roots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from privata._entrypoints import collect_external_entrypoints, load_tach_interface_exports
8
+ from privata._exports import collect_export_issues
9
+ from privata._imports import (
10
+ collect_private_module_imports,
11
+ collect_private_symbol_imports,
12
+ find_cross_imports,
13
+ )
14
+ from privata._modules import collect_modules
15
+ from privata._source_roots import source_roots
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ from privata._models import ExportIssue, PrivateModuleImport, PrivateSymbolImport, Symbol
21
+
22
+
23
+ def _collect_privacy_findings(
24
+ project_root: Path,
25
+ ) -> tuple[list[Symbol], list[PrivateModuleImport], list[PrivateSymbolImport], list[ExportIssue]]:
26
+ """Collect public-symbol and private-module boundary findings."""
27
+ modules = collect_modules(source_roots(project_root))
28
+ cross_imports = find_cross_imports(modules)
29
+ external_entrypoints = collect_external_entrypoints(project_root)
30
+ public_interface_exports = load_tach_interface_exports(project_root)
31
+
32
+ candidates = [
33
+ sym
34
+ for mod in modules.values()
35
+ for sym in mod.symbols
36
+ if (sym.module, sym.name) not in cross_imports
37
+ and (sym.module, sym.name) not in external_entrypoints
38
+ and (sym.module, sym.name) not in public_interface_exports
39
+ ]
40
+ candidates.sort(key=lambda s: (str(s.path), s.lineno))
41
+ private_module_imports = collect_private_module_imports(modules)
42
+ private_symbol_imports = collect_private_symbol_imports(modules)
43
+ export_issues = collect_export_issues(modules)
44
+ return candidates, private_module_imports, private_symbol_imports, export_issues
45
+
46
+
47
+ def find_private_candidates(project_root: Path) -> list[Symbol]:
48
+ """Find symbols that appear module-local and should be private."""
49
+ candidates, _, _, _ = _collect_privacy_findings(project_root)
50
+ return candidates
51
+
52
+
53
+ def find_private_module_imports(project_root: Path) -> list[PrivateModuleImport]:
54
+ """Find private modules imported from outside their package subtree."""
55
+ _, private_module_imports, _, _ = _collect_privacy_findings(project_root)
56
+ return private_module_imports
57
+
58
+
59
+ def find_private_symbol_imports(project_root: Path) -> list[PrivateSymbolImport]:
60
+ """Find private top-level symbols imported from another production module."""
61
+ _, _, private_symbol_imports, _ = _collect_privacy_findings(project_root)
62
+ return private_symbol_imports
63
+
64
+
65
+ def find_export_issues(project_root: Path) -> list[ExportIssue]:
66
+ """Find literal __all__ declarations that are stale or incomplete."""
67
+ _, _, _, export_issues = _collect_privacy_findings(project_root)
68
+ return export_issues
69
+
70
+
71
+ def check_project(project_root: Path) -> int:
72
+ """Scan project and report module-local public symbols."""
73
+ project_root = project_root.resolve()
74
+ candidates, private_module_imports, private_symbol_imports, export_issues = (
75
+ _collect_privacy_findings(project_root)
76
+ )
77
+
78
+ if (
79
+ not candidates
80
+ and not private_module_imports
81
+ and not private_symbol_imports
82
+ and not export_issues
83
+ ):
84
+ print("No module privacy issues found.")
85
+ return 0
86
+
87
+ if candidates:
88
+ _print_private_candidates(candidates, project_root)
89
+
90
+ if private_module_imports:
91
+ if candidates:
92
+ print()
93
+ _print_private_module_imports(private_module_imports, project_root)
94
+
95
+ if private_symbol_imports:
96
+ if candidates or private_module_imports:
97
+ print()
98
+ _print_private_symbol_imports(private_symbol_imports, project_root)
99
+
100
+ if export_issues:
101
+ if candidates or private_module_imports or private_symbol_imports:
102
+ print()
103
+ _print_export_issues(export_issues, project_root)
104
+
105
+ return 1
106
+
107
+
108
+ def _print_private_candidates(candidates: list[Symbol], project_root: Path) -> None:
109
+ print(f"Found {len(candidates)} public symbols that could be made private:\n")
110
+ for symbol in candidates:
111
+ rel = symbol.path.relative_to(project_root).as_posix()
112
+ print(f" {rel}:{symbol.lineno}: {symbol.kind} `{symbol.name}`")
113
+
114
+
115
+ def _print_private_module_imports(
116
+ private_module_imports: list[PrivateModuleImport],
117
+ project_root: Path,
118
+ ) -> None:
119
+ print(
120
+ "Found "
121
+ f"{len(private_module_imports)} "
122
+ "private module imports outside their package subtree:\n",
123
+ )
124
+ for private_import in private_module_imports:
125
+ rel = private_import.imported_by_path.relative_to(project_root).as_posix()
126
+ print(f" {rel}:{private_import.lineno}: imports private module `{private_import.module}`")
127
+
128
+
129
+ def _print_private_symbol_imports(
130
+ private_symbol_imports: list[PrivateSymbolImport],
131
+ project_root: Path,
132
+ ) -> None:
133
+ print(
134
+ f"Found {len(private_symbol_imports)} private symbol imports from production modules:\n",
135
+ )
136
+ for private_import in private_symbol_imports:
137
+ rel = private_import.imported_by_path.relative_to(project_root).as_posix()
138
+ print(
139
+ f" {rel}:{private_import.lineno}: imports private symbol "
140
+ f"`{private_import.module}.{private_import.name}`",
141
+ )
142
+
143
+
144
+ def _print_export_issues(export_issues: list[ExportIssue], project_root: Path) -> None:
145
+ print(f"Found {len(export_issues)} __all__ export issues:\n")
146
+ for export_issue in export_issues:
147
+ rel = export_issue.path.relative_to(project_root).as_posix()
148
+ if export_issue.kind == "unknown":
149
+ print(
150
+ f" {rel}:{export_issue.lineno}: "
151
+ f"__all__ exports unknown name `{export_issue.name}`",
152
+ )
153
+ elif export_issue.kind == "private":
154
+ print(
155
+ f" {rel}:{export_issue.lineno}: "
156
+ f"__all__ exports private name `{export_issue.name}`",
157
+ )
158
+ else:
159
+ print(
160
+ f" {rel}:{export_issue.lineno}: "
161
+ f"public name `{export_issue.name}` missing from __all__",
162
+ )
@@ -0,0 +1,87 @@
1
+ """External public-interface discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ _ENTRYPOINT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_\\.]*:[A-Za-z_][A-Za-z0-9_]*$")
13
+ _UVICORN_RE = re.compile(r"\buvicorn\s+([A-Za-z_][A-Za-z0-9_\.]*):([A-Za-z_][A-Za-z0-9_]*)\b")
14
+
15
+
16
+ def collect_external_entrypoints(project_root: Path) -> set[tuple[str, str]]:
17
+ """Return symbols made public by external entrypoint declarations."""
18
+ pairs = _load_pyproject_entrypoints(project_root)
19
+ pairs.update(_load_shell_uvicorn_entrypoints(project_root))
20
+ return pairs
21
+
22
+
23
+ def _load_pyproject_entrypoints(project_root: Path) -> set[tuple[str, str]]:
24
+ """Return pyproject console and GUI script entrypoint targets."""
25
+ pyproject_path = project_root / "pyproject.toml"
26
+ if not pyproject_path.exists():
27
+ return set()
28
+
29
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
30
+ project_table = data.get("project", {})
31
+
32
+ pairs: set[tuple[str, str]] = set()
33
+ for table_key in ("scripts", "gui-scripts"):
34
+ table = project_table.get(table_key, {})
35
+ if not isinstance(table, dict):
36
+ continue
37
+ for raw in table.values():
38
+ if not isinstance(raw, str):
39
+ continue
40
+ if not _ENTRYPOINT_RE.fullmatch(raw):
41
+ continue
42
+ module_name, symbol_name = raw.split(":", 1)
43
+ pairs.add((module_name, symbol_name))
44
+ return pairs
45
+
46
+
47
+ def _entrypoint_shell_files(project_root: Path) -> list[Path]:
48
+ """Return shell-like files that may launch Python entrypoints."""
49
+ files: list[Path] = []
50
+ files.extend(project_root.glob("*.sh"))
51
+ files.extend(project_root.glob("Dockerfile*"))
52
+ scripts_dir = project_root / "scripts"
53
+ if scripts_dir.exists():
54
+ files.extend(scripts_dir.rglob("*.sh"))
55
+ return sorted(set(files))
56
+
57
+
58
+ def _load_shell_uvicorn_entrypoints(project_root: Path) -> set[tuple[str, str]]:
59
+ """Return Uvicorn app targets referenced by shell files."""
60
+ pairs: set[tuple[str, str]] = set()
61
+ for path in _entrypoint_shell_files(project_root):
62
+ text = path.read_text(encoding="utf-8")
63
+ for module_name, symbol_name in _UVICORN_RE.findall(text):
64
+ pairs.add((module_name, symbol_name))
65
+ return pairs
66
+
67
+
68
+ def load_tach_interface_exports(project_root: Path) -> set[tuple[str, str]]:
69
+ """Return symbols exposed by Tach interfaces."""
70
+ tach_path = project_root / "tach.toml"
71
+ if not tach_path.exists():
72
+ return set()
73
+
74
+ data = tomllib.loads(tach_path.read_text(encoding="utf-8"))
75
+ pairs: set[tuple[str, str]] = set()
76
+ for interface in data.get("interfaces", []):
77
+ source_modules = interface.get("from", [])
78
+ exposed_names = interface.get("expose", [])
79
+ if not isinstance(source_modules, list) or not isinstance(exposed_names, list):
80
+ continue
81
+ for module_name in source_modules:
82
+ if not isinstance(module_name, str):
83
+ continue
84
+ for symbol_name in exposed_names:
85
+ if isinstance(symbol_name, str):
86
+ pairs.add((module_name, symbol_name))
87
+ return pairs
privata/_exports.py ADDED
@@ -0,0 +1,170 @@
1
+ """Validation for literal __all__ declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from privata._models import ExportIssue, Module
8
+ from privata._modules import names_from_target
9
+
10
+ _IGNORED_PUBLIC_BINDINGS = {"logger"}
11
+
12
+
13
+ def collect_export_issues(modules: dict[str, Module]) -> list[ExportIssue]:
14
+ """Return mismatches between literal __all__ and public bindings."""
15
+ issues: list[ExportIssue] = []
16
+
17
+ for module in modules.values():
18
+ if module.tree is None:
19
+ continue
20
+
21
+ all_names, lineno = _literal_all(module.tree)
22
+ if all_names is None:
23
+ continue
24
+
25
+ all_bindings = _all_bindings(module.tree)
26
+ public_bindings = _public_bindings(module.tree)
27
+
28
+ issues.extend(
29
+ ExportIssue(
30
+ module=module.name,
31
+ path=module.path,
32
+ name=name,
33
+ kind="unknown",
34
+ lineno=lineno,
35
+ )
36
+ for name in sorted(all_names - all_bindings)
37
+ )
38
+
39
+ issues.extend(
40
+ ExportIssue(
41
+ module=module.name,
42
+ path=module.path,
43
+ name=name,
44
+ kind="private",
45
+ lineno=lineno,
46
+ )
47
+ for name in sorted(name for name in all_names & all_bindings if _is_private(name))
48
+ )
49
+
50
+ issues.extend(
51
+ ExportIssue(
52
+ module=module.name,
53
+ path=module.path,
54
+ name=name,
55
+ kind="missing",
56
+ lineno=lineno,
57
+ )
58
+ for name in sorted(public_bindings - all_names)
59
+ )
60
+
61
+ return sorted(issues, key=lambda item: (str(item.path), item.lineno, item.kind, item.name))
62
+
63
+
64
+ def _literal_all(tree: ast.Module) -> tuple[set[str] | None, int]:
65
+ for node in tree.body:
66
+ if isinstance(node, ast.Assign):
67
+ for target in node.targets:
68
+ if isinstance(target, ast.Name) and target.id == "__all__":
69
+ return _strings_from_node(node.value), node.lineno
70
+ return None, 0
71
+
72
+
73
+ def _strings_from_node(node: ast.expr) -> set[str] | None:
74
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
75
+ names: set[str] = set()
76
+ for elt in node.elts:
77
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
78
+ names.add(elt.value)
79
+ else:
80
+ return None
81
+ return names
82
+ return None
83
+
84
+
85
+ def _all_bindings(tree: ast.Module) -> set[str]:
86
+ bindings: set[str] = set()
87
+ _collect_bound_names(tree.body, bindings, public_only=False, include_imports=True)
88
+ return bindings
89
+
90
+
91
+ def _public_bindings(tree: ast.Module) -> set[str]:
92
+ bindings: set[str] = set()
93
+ _collect_bound_names(
94
+ tree.body,
95
+ bindings,
96
+ public_only=True,
97
+ include_imports=False,
98
+ )
99
+ return bindings
100
+
101
+
102
+ def _collect_bound_names(
103
+ statements: list[ast.stmt],
104
+ bindings: set[str],
105
+ *,
106
+ public_only: bool,
107
+ include_imports: bool,
108
+ ) -> None:
109
+ for node in statements:
110
+ if include_imports or not isinstance(node, (ast.Import, ast.ImportFrom)):
111
+ for name in _bound_names(node):
112
+ _add_binding(bindings, name, public_only=public_only)
113
+ for nested_statements in _nested_public_binding_statements(node):
114
+ _collect_bound_names(
115
+ nested_statements,
116
+ bindings,
117
+ public_only=public_only,
118
+ include_imports=include_imports,
119
+ )
120
+
121
+
122
+ def _bound_names(node: ast.stmt) -> list[str]:
123
+ names: list[str] = []
124
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
125
+ names = [node.name]
126
+ elif isinstance(node, ast.Assign):
127
+ names = [
128
+ name
129
+ for target in node.targets
130
+ for name in names_from_target(target)
131
+ if name != "__all__"
132
+ ]
133
+ elif isinstance(node, ast.AnnAssign):
134
+ names = names_from_target(node.target)
135
+ elif hasattr(ast, "TypeAlias") and isinstance(node, ast.TypeAlias):
136
+ names = names_from_target(node.name)
137
+ elif isinstance(node, ast.Import):
138
+ names = [alias.asname or alias.name.split(".")[0] for alias in node.names]
139
+ elif isinstance(node, ast.ImportFrom):
140
+ names = _import_from_bound_names(node)
141
+ return names
142
+
143
+
144
+ def _import_from_bound_names(node: ast.ImportFrom) -> list[str]:
145
+ if node.module == "__future__":
146
+ return []
147
+ return [alias.asname or alias.name for alias in node.names if alias.name != "*"]
148
+
149
+
150
+ def _nested_public_binding_statements(node: ast.stmt) -> list[list[ast.stmt]]:
151
+ if not isinstance(node, ast.Try):
152
+ return []
153
+ return [
154
+ node.body,
155
+ node.orelse,
156
+ node.finalbody,
157
+ *(handler.body for handler in node.handlers),
158
+ ]
159
+
160
+
161
+ def _add_binding(bindings: set[str], name: str, *, public_only: bool) -> None:
162
+ if public_only and name in _IGNORED_PUBLIC_BINDINGS:
163
+ return
164
+ if public_only and _is_private(name):
165
+ return
166
+ bindings.add(name)
167
+
168
+
169
+ def _is_private(name: str) -> bool:
170
+ return name.startswith("_") and not (name.startswith("__") and name.endswith("__"))