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,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
@@ -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,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](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
20
+ [![CI](https://github.com/basnijholt/privata/actions/workflows/ci.yml/badge.svg)](https://github.com/basnijholt/privata/actions/workflows/ci.yml)
21
+ [![PyPI](https://img.shields.io/pypi/v/privata.svg)](https://pypi.org/project/privata/)
22
+ [![Python Versions](https://img.shields.io/pypi/pyversions/privata.svg)](https://pypi.org/project/privata/)
23
+ [![Docs](https://img.shields.io/badge/docs-basnijholt.github.io%2Fprivata-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ privata = privata.cli:main
@@ -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.