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 +37 -0
- privata/__main__.py +5 -0
- privata/_checker.py +162 -0
- privata/_entrypoints.py +87 -0
- privata/_exports.py +170 -0
- privata/_imports.py +265 -0
- privata/_models.py +76 -0
- privata/_modules.py +317 -0
- privata/_source_roots.py +81 -0
- privata/_version.py +24 -0
- privata/cli.py +38 -0
- test_privata-0.1.0.dist-info/METADATA +175 -0
- test_privata-0.1.0.dist-info/RECORD +16 -0
- test_privata-0.1.0.dist-info/WHEEL +4 -0
- test_privata-0.1.0.dist-info/entry_points.txt +2 -0
- test_privata-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
)
|
privata/_entrypoints.py
ADDED
|
@@ -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("__"))
|