Skip to content

Scanner

The top-level scan() entry point runs the full scan → generate → validate pipeline in a single call.

lcp.scan

scan(package_name: str, *, include_private: bool = False, recursive: bool = True, include_tests: bool = False, validate: bool = True) -> LCPDocument

Scan a Python package and generate an LCP document.

This is the main entry point for the SDK.

Parameters:

Name Type Description Default
package_name str

The name of an installed Python package to scan.

required
include_private bool

Include private symbols (starting with _).

False
recursive bool

Scan submodules recursively.

True
include_tests bool

When False (default), skip *.tests subpackages. They are not public API and pollute manifests; numpy.testing-style public utilities are always included.

False
validate bool

Validate the output against the LCP schema.

True

Returns:

Type Description
LCPDocument

An LCPDocument containing the scanned library information.

Raises:

Type Description
ImportError

If the package cannot be imported.

LCPValidationError

If validation is enabled and the output is invalid.

Example

from lcp import scan doc = scan("json") doc.to_file("json.lcp.json")

Source code in src/lcp/__init__.py
def scan(
    package_name: str,
    *,
    include_private: bool = False,
    recursive: bool = True,
    include_tests: bool = False,
    validate: bool = True,
) -> LCPDocument:
    """Scan a Python package and generate an LCP document.

    This is the main entry point for the SDK.

    Args:
        package_name: The name of an installed Python package to scan.
        include_private: Include private symbols (starting with _).
        recursive: Scan submodules recursively.
        include_tests: When ``False`` (default), skip ``*.tests`` subpackages.
            They are not public API and pollute manifests; ``numpy.testing``-style
            public utilities are always included.
        validate: Validate the output against the LCP schema.

    Returns:
        An LCPDocument containing the scanned library information.

    Raises:
        ImportError: If the package cannot be imported.
        LCPValidationError: If validation is enabled and the output is invalid.

    Example:
        >>> from lcp import scan
        >>> doc = scan("json")
        >>> doc.to_file("json.lcp.json")
    """
    scanned = scan_package(
        package_name,
        include_private=include_private,
        recursive=recursive,
        include_tests=include_tests,
    )

    lcp_doc = generate_lcp(scanned)

    if validate:
        validate_or_raise(lcp_doc)

    return lcp_doc

lcp.scanner

Scanner module for introspecting Python packages.

ScannedParam dataclass

Scanned parameter information.

Source code in src/lcp/scanner.py
@dataclass
class ScannedParam:
    """Scanned parameter information."""

    name: str
    type_hint: str | None = None
    default: Any = inspect.Parameter.empty
    kind: str = "positional"
    description: str | None = None

    @property
    def has_default(self) -> bool:
        return self.default is not inspect.Parameter.empty

    @property
    def is_variadic(self) -> bool:
        return self.kind in ("rest", "keyword_rest")

ScannedSignature dataclass

Scanned function/method signature.

Source code in src/lcp/scanner.py
@dataclass
class ScannedSignature:
    """Scanned function/method signature."""

    params: list[ScannedParam] = field(default_factory=list)
    return_type: str | None = None
    is_async: bool = False
    raises: list[str] = field(default_factory=list)

ScannedSymbol dataclass

Scanned symbol information.

aliases holds (module_path, name) pairs where the symbol is re-exported inside its own package (e.g. ("requests", "get") for a function defined in requests.api); the definition site stays the canonical identity.

Source code in src/lcp/scanner.py
@dataclass
class ScannedSymbol:
    """Scanned symbol information.

    ``aliases`` holds ``(module_path, name)`` pairs where the symbol is
    re-exported inside its own package (e.g. ``("requests", "get")`` for a
    function defined in ``requests.api``); the definition site stays the
    canonical identity.
    """

    name: str
    qualified_name: str
    module_path: str
    kind: str  # function, class, method, attribute, constant, module
    summary: str | None = None
    description: str | None = None
    docstring: str | None = None
    signature: ScannedSignature | None = None
    members: list[ScannedSymbol] = field(default_factory=list)
    source_file: str | None = None
    source_lines: tuple[int, int] | None = None
    aliases: list[tuple[str, str]] = field(default_factory=list)

ScannedModule dataclass

Scanned module information.

Source code in src/lcp/scanner.py
@dataclass
class ScannedModule:
    """Scanned module information."""

    name: str
    version: str
    symbols: list[ScannedSymbol] = field(default_factory=list)
    unresolved_reexports: list[tuple[str, int, int]] = field(default_factory=list)

scanned_to_dict

scanned_to_dict(module: ScannedModule) -> dict

Serialize a :class:ScannedModule tree into a JSON-safe dict.

Total by construction: complex parameter defaults degrade to a marker rather than raising, so the child can always emit a document.

Source code in src/lcp/scanner.py
def scanned_to_dict(module: ScannedModule) -> dict:
    """Serialize a :class:`ScannedModule` tree into a JSON-safe dict.

    Total by construction: complex parameter defaults degrade to a marker
    rather than raising, so the child can always emit a document.
    """
    return {
        "name": module.name,
        "version": module.version,
        "symbols": [_symbol_to_dict(s) for s in module.symbols],
        "unresolved_reexports": [
            [mod, count, module_count]
            for mod, count, module_count in module.unresolved_reexports
        ],
    }

scanned_from_dict

scanned_from_dict(d: dict) -> ScannedModule

Rebuild a :class:ScannedModule tree from :func:scanned_to_dict output.

Source code in src/lcp/scanner.py
def scanned_from_dict(d: dict) -> ScannedModule:
    """Rebuild a :class:`ScannedModule` tree from :func:`scanned_to_dict` output."""
    return ScannedModule(
        name=d["name"],
        version=d["version"],
        symbols=[_symbol_from_dict(s) for s in d.get("symbols", [])],
        unresolved_reexports=[
            (mod, count, module_count)
            for mod, count, module_count in d.get("unresolved_reexports", [])
        ],
    )

scan_module

scan_module(module: ModuleType, include_private: bool = False, _visited: set | None = None, _package_root: str | None = None, _alias_records: list[_AliasRecord] | None = None, _followable_tops: frozenset[str] | None = None) -> list[ScannedSymbol]

Scan a module for symbols.

Source code in src/lcp/scanner.py
def scan_module(
    module: ModuleType,
    include_private: bool = False,
    _visited: set | None = None,
    _package_root: str | None = None,
    _alias_records: list[_AliasRecord] | None = None,
    _followable_tops: frozenset[str] | None = None,
) -> list[ScannedSymbol]:
    """Scan a module for symbols."""
    if _visited is None:
        _visited = set()

    records = _alias_records if _alias_records is not None else []

    if _package_root is None:
        _package_root = module.__name__.split(".")[0]

    module_id = id(module)
    if module_id in _visited:
        return []
    _visited.add(module_id)

    module_path = module.__name__
    symbols: list[ScannedSymbol] = []

    # Add module as a symbol
    mod_summary, mod_desc = _parse_docstring(module.__doc__)
    symbols.append(
        ScannedSymbol(
            name=module_path,
            qualified_name="",  # Empty entity path for modules
            module_path=module_path,
            kind="module",
            summary=mod_summary or f"Module {module_path}",
            description=mod_desc,
            docstring=_raw_docstring(module.__doc__),
        )
    )

    # Get all public names
    if hasattr(module, "__all__"):
        public_names = set(module.__all__)
    else:
        public_names = None

    for name, obj in _safe_getmembers(module):
        # Skip private symbols
        if not _is_public(name, include_private):
            continue

        # If __all__ is defined, respect it
        if public_names is not None and name not in public_names:
            continue

        try:
            # Classifying/scanning a member can raise even though fetching it
            # did not: objects with hostile ``__getattribute__`` or lazy setup
            # (e.g. ``django.conf.settings`` raising ``ImproperlyConfigured`` on
            # an ``isinstance`` check) blow up here. Skip the member instead of
            # aborting the whole scan.

            # Skip imported modules (they belong to their own package)
            if inspect.ismodule(obj):
                continue

            # Check if this symbol is defined in this module
            obj_module = getattr(obj, "__module__", None)
            if obj_module and obj_module != module_path:
                # Re-exported symbol, documented at its definition site.
                if isinstance(obj_module, str):
                    if obj_module == _package_root or obj_module.startswith(
                        _package_root + "."
                    ):
                        # In-package origin: record the re-export as an alias
                        # on the canonical symbol.
                        target_name = getattr(obj, "__name__", None)
                        if isinstance(target_name, str):
                            records.append(
                                _AliasRecord(
                                    target_module=obj_module,
                                    target_name=target_name,
                                    alias_module=module_path,
                                    alias_name=name,
                                )
                            )
                    elif _is_followable_reexport(obj_module, _followable_tops):
                        # Foreign origin in a declared dependency: capture the
                        # object at the facade (#67). The foreign package is
                        # not scanned.
                        captured = _capture_reexport(
                            name, obj, module_path, include_private
                        )
                        if captured is not None:
                            symbols.append(captured)
                continue

            if inspect.isclass(obj):
                symbols.append(
                    _scan_class(
                        obj, module_path, include_private, package_root=_package_root
                    )
                )
            elif inspect.isfunction(obj):
                symbols.append(_scan_function(obj, module_path, name))
            elif _is_constant(name, obj):
                symbols.append(
                    ScannedSymbol(
                        name=name,
                        qualified_name=name,
                        module_path=module_path,
                        kind="constant",
                        summary=_constant_summary(obj),
                    )
                )
            elif _is_c_function(name, obj):
                # C-implemented function defined in this module (#63): a
                # lowercase library-typed callable inspect.isfunction misses.
                symbols.append(_scan_function(obj, module_path, name))
        except KeyboardInterrupt:
            raise
        except Exception:
            continue

    if _alias_records is None:
        _attach_aliases(symbols, records, module_path)

    return symbols

scan_package

scan_package(package_name: str, include_private: bool = False, recursive: bool = True, include_tests: bool = False) -> ScannedModule

Scan an installed package and return scanned information.

Parameters:

Name Type Description Default
package_name str

Import path of the package to scan.

required
include_private bool

Include private symbols (names starting with _).

False
recursive bool

Walk submodules recursively.

True
include_tests bool

When False (default), skip *.tests subpackages — they are not public API and pollute manifests. Public utilities like numpy.testing are always included.

False
Source code in src/lcp/scanner.py
def scan_package(
    package_name: str,
    include_private: bool = False,
    recursive: bool = True,
    include_tests: bool = False,
) -> ScannedModule:
    """Scan an installed package and return scanned information.

    Args:
        package_name: Import path of the package to scan.
        include_private: Include private symbols (names starting with ``_``).
        recursive: Walk submodules recursively.
        include_tests: When ``False`` (default), skip ``*.tests`` subpackages —
            they are not public API and pollute manifests. Public utilities like
            ``numpy.testing`` are always included.
    """
    try:
        module = importlib.import_module(package_name)
    except ImportError as e:
        raise ImportError(f"Cannot import package '{package_name}': {e}") from e

    version = _get_package_version(package_name)
    visited: set = set()
    package_root = package_name.split(".")[0]
    followable_tops = _followable_top_levels(package_root)
    alias_records: list[_AliasRecord] = []

    # Scan main module
    symbols = scan_module(
        module,
        include_private,
        visited,
        _package_root=package_root,
        _alias_records=alias_records,
        _followable_tops=followable_tops,
    )

    # Scan submodules if it's a package
    if recursive and hasattr(module, "__path__"):
        for submod in _iter_submodules(module, include_tests=include_tests):
            symbols.extend(
                scan_module(
                    submod,
                    include_private,
                    visited,
                    _package_root=package_root,
                    _alias_records=alias_records,
                    # Facade capture is scoped to the entry module: a package's
                    # public re-export surface is the module the user scans, not
                    # its internal submodules. See #67.
                    _followable_tops=None,
                )
            )

    # Facade shape A (#68): a re-export pointing OUTSIDE the scanned subtree but
    # sharing the top-level namespace is a sibling implementation package. If it
    # belongs to the scanned distribution, capture the re-exported names at their
    # def-site so the dangling alias records resolve. The guard keeps the
    # file-list walk off the common path (no out-of-subtree re-exports).
    has_out_of_subtree_reexport = any(
        not (
            rec.target_module == package_name
            or rec.target_module.startswith(package_name + ".")
        )
        for rec in alias_records
    )
    if has_out_of_subtree_reexport:
        sibling_modules = _own_distribution_modules(package_name)
        if sibling_modules:
            captured, synth_modules = _capture_sibling_reexports(
                alias_records, symbols, package_name, sibling_modules, include_private
            )
            symbols.extend(synth_modules)
            symbols.extend(captured)
            value_captured = _capture_sibling_value_reexports(
                module, symbols, package_name, sibling_modules, include_private
            )
            symbols.extend(value_captured)

    unresolved = _attach_aliases(symbols, alias_records, package_name)
    return ScannedModule(
        name=package_name,
        version=version,
        symbols=symbols,
        unresolved_reexports=unresolved,
    )