Skip to content

Private imports

private_imports

Flag tests that reach into private (_prefixed) package symbols.

Tests that import _private helpers couple the suite to implementation details, turning refactors into multi-file chores. The rule walks every tests/**/test_*.py file, collects imports of underscore-prefixed symbols from first-party packages and classifies each hit via axm_ast.extract_module_info.

Beyond from pkg import _foo aliases, the rule also flags attribute access on first-party imports — e.g. Cls._method() after from pkg.mod import Cls, or mod._var after import pkg.mod as mod.

Dunders (__version__) are always ignored and _UPPER_CASE constants are ignored by default — flip include_constants=True on the rule instance to surface those as well. Namedtuple methods (_asdict, _replace …) are always ignored.

PrivateImportsRule dataclass

Bases: ProjectRule

Report test imports of private package symbols.

Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/private_imports.py
Python
@dataclass
@register_rule("test_quality")
class PrivateImportsRule(ProjectRule):
    """Report test imports of private package symbols."""

    include_constants: bool = False

    @property
    def rule_id(self) -> str:
        """Stable identifier for this rule."""
        return "TEST_QUALITY_PRIVATE_IMPORTS"

    def check(self, project_path: Path) -> CheckResult:
        """Scan test files in ``project_path`` for private-symbol imports.

        Walks every ``tests/**/test_*.py`` file under ``project_path``,
        collects ``ImportFrom`` nodes that reference first-party packages
        and flags each underscore-prefixed alias.  Dunders are always
        ignored; ``_UPPER_CASE`` constants are ignored unless
        ``include_constants`` is ``True``.

        Returns a :class:`CheckResult` with ``passed=True`` when no
        private imports are found.  Otherwise ``details["findings"]``
        lists each offending import (test file, line, source module,
        symbol, and resolved kind) and ``score`` reports a
        100-point score penalised by ``_SCORE_PENALTY`` per finding.
        """
        early = self.check_src(project_path)
        if early is not None:
            return early

        pkg_prefixes = get_pkg_prefixes(project_path)
        findings: list[_PrivateImportFinding] = []
        mod_cache: dict[str, ModuleInfo | None] = {}

        ctx = _ScanContext(
            project_path=project_path,
            pkg_prefixes=list(pkg_prefixes),
            mod_cache=mod_cache,
            include_constants=self.include_constants,
        )
        for test_file, tree in iter_test_files(project_path):
            if tree is None:
                continue
            test_pkg = _test_owning_package(test_file, project_path, ctx.pkg_prefixes)
            findings.extend(
                self._scan_file_for_private_imports(test_file, tree, ctx, test_pkg)
            )

        return self._build_check_result(findings, project_path)

    def _scan_file_for_private_imports(
        self,
        test_file: Path,
        tree: ast.AST,
        ctx: _ScanContext,
        test_pkg: str | None = None,
    ) -> list[_PrivateImportFinding]:
        """Walk *tree* and return one finding per private-symbol access."""
        findings: list[_PrivateImportFinding] = []
        for node in ast.walk(tree):
            if not isinstance(node, ast.ImportFrom):
                continue
            for mod, name in _iter_private_aliases(node, ctx, test_pkg):
                kind = self._resolve_symbol_kind(
                    mod, name, ctx.project_path, ctx.mod_cache
                )
                findings.append(
                    _build_finding(
                        _FindingSpec(
                            test_file=test_file,
                            line=node.lineno,
                            mod=mod,
                            name=name,
                            kind=kind,
                            access_kind="import",
                        )
                    )
                )

        imports_map = _collect_first_party_imports(tree, ctx)
        for mod, class_name, attr, attr_node in _iter_private_attributes(
            tree, ctx, imports_map
        ):
            kind = self._resolve_attr_kind(mod, class_name, attr, ctx)
            if kind == "unknown":
                continue
            findings.append(
                _build_finding(
                    _FindingSpec(
                        test_file=test_file,
                        line=attr_node.lineno,
                        mod=mod,
                        name=attr,
                        kind=kind,
                        access_kind="attribute",
                    )
                )
            )
        return findings

    def _build_check_result(
        self, findings: list[_PrivateImportFinding], project_path: Path
    ) -> CheckResult:
        n = len(findings)
        score = max(0, 100 - n * _SCORE_PENALTY)
        passed = n == 0
        if passed:
            message = f"No private imports in tests/ (see {_DOCS_ANCHOR})"
        else:
            message = f"{n} private import(s) in tests/ — see {_DOCS_ANCHOR}"
        text = (
            _render_private_imports_text(findings, project_path) if findings else None
        )
        fix_hint = (
            None
            if passed
            else "Re-export the symbol publicly or test via the public API"
        )
        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=message,
            severity=Severity.ERROR,
            score=int(score),
            details={"findings": findings},
            text=text,
            fix_hint=fix_hint,
        )

    def _resolve_symbol_kind(
        self,
        module: str,
        symbol: str,
        pkg_root: Path,
        cache: dict[str, ModuleInfo | None],
    ) -> str:
        """Return the kind of *symbol* in *module*.

        Possible values: function, class, constant, variable, unknown.
        """
        if module not in cache:
            cache[module] = self._load_module_info(module, pkg_root)
        info = cache[module]
        if info is None:
            return "unknown"
        return self._lookup_symbol_in_info(info, symbol)

    def _resolve_attr_kind(
        self,
        module: str,
        class_name: str | None,
        attr: str,
        ctx: _ScanContext,
    ) -> str:
        """Resolve ``attr`` against ``module`` (optionally scoped to a class).

        For ``class_name=None`` (module-level access), reuses
        :meth:`_resolve_symbol_kind`. For ``class_name=Cls``, the name may
        also reference a submodule of *module* (``from pkg import _sub``);
        when ``pkg/_sub.py`` exists, treat as module access. Otherwise look
        up ``attr`` on the class's methods.
        """
        if class_name is not None:
            sub = f"{module}.{class_name}"
            sub_info = self._load_module_info(sub, ctx.project_path)
            if sub_info is not None:
                ctx.mod_cache.setdefault(sub, sub_info)
                return self._lookup_symbol_in_info(sub_info, attr)
        if module not in ctx.mod_cache:
            ctx.mod_cache[module] = self._load_module_info(module, ctx.project_path)
        info = ctx.mod_cache[module]
        if info is None:
            return "unknown"
        if class_name is not None:
            if _class_has_member(info, class_name, attr):
                return "method"
            return "unknown"
        return self._lookup_symbol_in_info(info, attr)

    @staticmethod
    def _lookup_symbol_in_info(info: ModuleInfo, symbol: str) -> str:
        dispatch: list[tuple[Sequence[_NamedEntry], str | Callable[[str], str]]] = [
            (info.functions, "function"),
            (info.classes, "class"),
            (info.variables, _variable_kind),
        ]
        for entries, kind in dispatch:
            for entry in entries:
                if entry.name == symbol:
                    return kind(symbol) if callable(kind) else kind
        return "unknown"

    def _load_module_info(self, module: str, pkg_root: Path) -> ModuleInfo | None:
        path = self._resolve_source_path(module, pkg_root)
        if path is None:
            return None
        try:
            return extract_module_info(path)
        except (FileNotFoundError, ValueError, OSError):
            return None

    @staticmethod
    def _resolve_source_path(module: str, pkg_root: Path) -> Path | None:
        rel = module.replace(".", "/")
        candidates = (
            pkg_root / "src" / f"{rel}.py",
            pkg_root / "src" / rel / "__init__.py",
        )
        for cand in candidates:
            if cand.exists():
                return cand
        return None
rule_id property

Stable identifier for this rule.

check(project_path)

Scan test files in project_path for private-symbol imports.

Walks every tests/**/test_*.py file under project_path, collects ImportFrom nodes that reference first-party packages and flags each underscore-prefixed alias. Dunders are always ignored; _UPPER_CASE constants are ignored unless include_constants is True.

Returns a :class:CheckResult with passed=True when no private imports are found. Otherwise details["findings"] lists each offending import (test file, line, source module, symbol, and resolved kind) and score reports a 100-point score penalised by _SCORE_PENALTY per finding.

Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/private_imports.py
Python
def check(self, project_path: Path) -> CheckResult:
    """Scan test files in ``project_path`` for private-symbol imports.

    Walks every ``tests/**/test_*.py`` file under ``project_path``,
    collects ``ImportFrom`` nodes that reference first-party packages
    and flags each underscore-prefixed alias.  Dunders are always
    ignored; ``_UPPER_CASE`` constants are ignored unless
    ``include_constants`` is ``True``.

    Returns a :class:`CheckResult` with ``passed=True`` when no
    private imports are found.  Otherwise ``details["findings"]``
    lists each offending import (test file, line, source module,
    symbol, and resolved kind) and ``score`` reports a
    100-point score penalised by ``_SCORE_PENALTY`` per finding.
    """
    early = self.check_src(project_path)
    if early is not None:
        return early

    pkg_prefixes = get_pkg_prefixes(project_path)
    findings: list[_PrivateImportFinding] = []
    mod_cache: dict[str, ModuleInfo | None] = {}

    ctx = _ScanContext(
        project_path=project_path,
        pkg_prefixes=list(pkg_prefixes),
        mod_cache=mod_cache,
        include_constants=self.include_constants,
    )
    for test_file, tree in iter_test_files(project_path):
        if tree is None:
            continue
        test_pkg = _test_owning_package(test_file, project_path, ctx.pkg_prefixes)
        findings.extend(
            self._scan_file_for_private_imports(test_file, tree, ctx, test_pkg)
        )

    return self._build_check_result(findings, project_path)