Skip to content

Workspace

workspace

Workspace detection and multi-package analysis.

Detects uv workspaces via [tool.uv.workspace] in pyproject.toml and aggregates PackageInfo across all member packages.

PackageSummary

Bases: TypedDict

Per-package summary entry inside a :class:WorkspaceContext.

total=False so depth-0 outputs (name only) remain valid.

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
class PackageSummary(TypedDict, total=False):
    """Per-package summary entry inside a :class:`WorkspaceContext`.

    ``total=False`` so depth-0 outputs (name only) remain valid.
    """

    name: str
    root: str
    module_count: int
    function_count: int
    class_count: int

WorkspaceContext

Bases: TypedDict

Aggregate workspace context returned by :func:build_workspace_context.

total=False because the depth-0 view from :func:format_workspace_context drops package_graph.

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
class WorkspaceContext(TypedDict, total=False):
    """Aggregate workspace context returned by :func:`build_workspace_context`.

    ``total=False`` because the depth-0 view from
    :func:`format_workspace_context` drops ``package_graph``.
    """

    workspace: str
    root: str
    package_count: int
    packages: list[PackageSummary]
    package_graph: dict[str, list[str]]

analyze_workspace(path, *, detected=None)

Analyze all packages in a uv workspace.

Discovers workspace members, analyzes each with analyze_package(), and builds inter-package dependency edges.

Parameters:

Name Type Description Default
path Path

Path to workspace root.

required
detected WorkspaceInfo | None

A WorkspaceInfo already produced by :func:detect_workspace for the same path. When provided, the redundant internal detection is skipped (the caller has already paid for it). When None (default), detection runs here as before.

None

Returns:

Type Description
WorkspaceInfo

WorkspaceInfo with all packages and dependency edges.

Raises:

Type Description
ValueError

If path is not a workspace root.

Example

ws = analyze_workspace(Path("/path/to/workspace")) len(ws.packages) > 0 True

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def analyze_workspace(
    path: Path, *, detected: WorkspaceInfo | None = None
) -> WorkspaceInfo:
    """Analyze all packages in a uv workspace.

    Discovers workspace members, analyzes each with ``analyze_package()``,
    and builds inter-package dependency edges.

    Args:
        path: Path to workspace root.
        detected: A ``WorkspaceInfo`` already produced by
            :func:`detect_workspace` for the same ``path``. When provided,
            the redundant internal detection is skipped (the caller has
            already paid for it). When ``None`` (default), detection runs
            here as before.

    Returns:
        WorkspaceInfo with all packages and dependency edges.

    Raises:
        ValueError: If path is not a workspace root.

    Example:
        >>> ws = analyze_workspace(Path("/path/to/workspace"))
        >>> len(ws.packages) > 0
        True
    """
    path = Path(path).resolve()
    ws = detected if detected is not None else detect_workspace(path)
    if ws is None:
        msg = f"{path} is not a uv workspace"
        raise ValueError(msg)

    pyproject_text = (path / "pyproject.toml").read_text()
    raw_members = parse_workspace_members(pyproject_text)
    members = _expand_workspace_members(path, raw_members)

    # Build member_names from project names in each member's pyproject.toml
    member_names: set[str] = set()
    for member in members:
        member_pyproject = path / member / "pyproject.toml"
        if member_pyproject.exists():
            name = _parse_project_name(member_pyproject.read_text())
            if name:
                member_names.add(name)
        member_names.add(Path(member).name)

    packages: list[PackageInfo] = []
    for member in members:
        member_path = path / member
        if not member_path.is_dir():
            logger.warning("Workspace member %s not found, skipping", member)
            continue

        pkg_src = _find_package_source(member_path)
        if pkg_src is None:
            logger.warning("No source package found in %s, skipping", member)
            continue

        try:
            pkg = get_package(pkg_src)
            packages.append(pkg)
        except (OSError, ValueError):
            logger.warning("Failed to analyze %s, skipping", member, exc_info=True)

    # Build inter-package dependency edges
    package_edges = _build_package_edges(path, members, member_names)

    ws.packages = packages
    ws.package_edges = package_edges
    return ws

build_workspace_context(path)

Build complete workspace context in one call.

Lists all packages, their mutual dependencies, per-package stats, and the workspace-level dependency graph.

Parameters:

Name Type Description Default
path Path

Path to workspace root.

required

Returns:

Type Description
WorkspaceContext

Workspace context dict.

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def build_workspace_context(path: Path) -> WorkspaceContext:
    """Build complete workspace context in one call.

    Lists all packages, their mutual dependencies, per-package stats,
    and the workspace-level dependency graph.

    Args:
        path: Path to workspace root.

    Returns:
        Workspace context dict.
    """
    ws = analyze_workspace(path)
    graph = build_workspace_dep_graph(ws)

    pkg_summaries: list[PackageSummary] = []
    for pkg in ws.packages:
        fn_count = sum(len(m.functions) for m in pkg.modules)
        cls_count = sum(len(m.classes) for m in pkg.modules)
        pkg_summaries.append(
            PackageSummary(
                name=pkg.name,
                root=str(pkg.root),
                module_count=len(pkg.modules),
                function_count=fn_count,
                class_count=cls_count,
            )
        )

    return WorkspaceContext(
        workspace=ws.name,
        root=str(ws.root),
        package_count=len(ws.packages),
        packages=pkg_summaries,
        package_graph=graph,
    )

build_workspace_dep_graph(ws)

Build an adjacency-list dependency graph between packages.

Parameters:

Name Type Description Default
ws WorkspaceInfo

Analyzed workspace info.

required

Returns:

Type Description
dict[str, list[str]]

Dict mapping package dir name to list of packages it depends on.

Example

graph = build_workspace_dep_graph(ws) graph["axm-mcp"] ['axm']

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def build_workspace_dep_graph(ws: WorkspaceInfo) -> dict[str, list[str]]:
    """Build an adjacency-list dependency graph between packages.

    Args:
        ws: Analyzed workspace info.

    Returns:
        Dict mapping package dir name to list of packages it depends on.

    Example:
        >>> graph = build_workspace_dep_graph(ws)
        >>> graph["axm-mcp"]
        `['axm']`
    """
    graph: dict[str, list[str]] = {}
    for src, target in ws.package_edges:
        graph.setdefault(src, []).append(target)
    return graph

build_workspace_module_graph(ws)

Build a merged module-level import graph across all packages.

Reuses :func:build_import_graph per package and namespaces every node as {package_name}.{module}. Cross-package import targets are resolved to their owning package so edges stay namespaced rather than bare module names (lets anvil tell which package each node belongs to).

Parameters:

Name Type Description Default
ws WorkspaceInfo

Analyzed workspace info with .packages.

required

Returns:

Type Description
dict[str, list[str]]

Adjacency-list dict mapping {pkg}.{module} to the list of

dict[str, list[str]]

{pkg}.{module} nodes it imports.

Example

graph = build_workspace_module_graph(ws) graph["axm-mcp.cli"] ['axm.tools']

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def build_workspace_module_graph(ws: WorkspaceInfo) -> dict[str, list[str]]:
    """Build a merged module-level import graph across all packages.

    Reuses :func:`build_import_graph` per package and namespaces every
    node as ``{package_name}.{module}``. Cross-package import targets are
    resolved to their owning package so edges stay namespaced rather than
    bare module names (lets anvil tell which package each node belongs to).

    Args:
        ws: Analyzed workspace info with ``.packages``.

    Returns:
        Adjacency-list dict mapping ``{pkg}.{module}`` to the list of
        ``{pkg}.{module}`` nodes it imports.

    Example:
        >>> graph = build_workspace_module_graph(ws)
        >>> graph["axm-mcp.cli"]
        `['axm.tools']`
    """
    module_sets = {pkg.name: _package_module_names(pkg) for pkg in ws.packages}
    owners: dict[str, str] = {}
    for pkg in ws.packages:
        for name in module_sets[pkg.name]:
            owners.setdefault(name, pkg.name)

    graph: dict[str, list[str]] = {}
    for pkg in ws.packages:
        own_modules = module_sets[pkg.name]
        for src, targets in build_import_graph(pkg).items():
            src_node = f"{pkg.name}.{src}"
            graph.setdefault(src_node, []).extend(
                _resolve_target_node(target, pkg.name, own_modules, owners)
                for target in targets
            )
    # Cross-package edges are absent from per-package dependency_edges, so
    # derive them from raw module imports (AC5).
    for pkg in ws.packages:
        _collect_cross_package_edges(pkg, module_sets, graph)
    return graph

detect_workspace(path)

Detect a uv workspace at the given path.

Delegates uv-workspace resolution to :func:axm_ingot.uv.resolve_workspace (the canonical, stdlib-only resolver) and then projects the resulting ResolvedWorkspace onto a :class:WorkspaceInfo (name + root). The Pydantic WorkspaceInfo model stays defined in axm-ast; ingot remains leaf and never sees a Pydantic type. Returns None if not a workspace (no [tool.uv.workspace] table, missing/malformed pyproject, or no resolvable members).

Parameters:

Name Type Description Default
path Path

Path to potential workspace root.

required

Returns:

Type Description
WorkspaceInfo | None

WorkspaceInfo if workspace detected, None otherwise.

Example

ws = detect_workspace(Path("/path/to/workspace")) ws is not None True

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def detect_workspace(path: Path) -> WorkspaceInfo | None:
    """Detect a uv workspace at the given path.

    Delegates uv-workspace resolution to :func:`axm_ingot.uv.resolve_workspace`
    (the canonical, stdlib-only resolver) and then *projects* the resulting
    ``ResolvedWorkspace`` onto a :class:`WorkspaceInfo` (name + root). The
    Pydantic ``WorkspaceInfo`` model stays defined in axm-ast; ingot remains
    leaf and never sees a Pydantic type. Returns ``None`` if not a workspace
    (no ``[tool.uv.workspace]`` table, missing/malformed pyproject, or no
    resolvable members).

    Args:
        path: Path to potential workspace root.

    Returns:
        WorkspaceInfo if workspace detected, None otherwise.

    Example:
        >>> ws = detect_workspace(Path("/path/to/workspace"))
        >>> ws is not None
        True
    """
    path = Path(path).resolve()
    resolved = resolve_workspace(path)
    if resolved is None:
        return None

    pyproject_text = (path / "pyproject.toml").read_text()
    # A workspace must DECLARE members; an empty ``members = []`` is not a
    # workspace. ingot's ``ResolvedWorkspace`` collapses declared-but-yet-
    # unresolvable members (e.g. a glob matching nothing) to an empty tuple,
    # so the declared-members check stays on the raw text to preserve the
    # historical contract: a glob workspace with no current matches is still
    # a workspace (analyze_workspace then reports zero packages + a warning).
    if not parse_workspace_members(pyproject_text):
        return None

    ws_name = _parse_project_name(pyproject_text) or path.name

    return WorkspaceInfo(
        name=ws_name,
        root=resolved.root,
        packages=[],
        package_edges=[],
    )

format_workspace_context(ctx, *, depth=1)

Apply depth-based filtering to workspace context.

Parameters:

Name Type Description Default
ctx WorkspaceContext

Full workspace context from :func:build_workspace_context.

required
depth int

Detail level. 0 = compact (names only, no graph),

= 1 = full output.

1

Returns:

Type Description
WorkspaceContext

Filtered workspace context dict.

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def format_workspace_context(
    ctx: WorkspaceContext, *, depth: int = 1
) -> WorkspaceContext:
    """Apply depth-based filtering to workspace context.

    Args:
        ctx: Full workspace context from :func:`build_workspace_context`.
        depth: Detail level. 0 = compact (names only, no graph),
            >= 1 = full output.

    Returns:
        Filtered workspace context dict.
    """
    if depth >= 1:
        return ctx

    return WorkspaceContext(
        workspace=ctx["workspace"],
        root=ctx["root"],
        package_count=ctx["package_count"],
        packages=[PackageSummary(name=pkg["name"]) for pkg in ctx["packages"]],
    )

format_workspace_graph_mermaid(ws)

Format the inter-package dependency graph as Mermaid.

Parameters:

Name Type Description Default
ws WorkspaceInfo

Analyzed workspace info.

required

Returns:

Type Description
str

Mermaid diagram string.

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def format_workspace_graph_mermaid(ws: WorkspaceInfo) -> str:
    """Format the inter-package dependency graph as Mermaid.

    Args:
        ws: Analyzed workspace info.

    Returns:
        Mermaid diagram string.
    """
    graph = build_workspace_dep_graph(ws)
    lines = ["graph TD"]

    # Add package nodes
    for pkg in ws.packages:
        name = pkg.name
        safe = name.replace("-", "_").replace(".", "_")
        lines.append(f'    {safe}["{name}"]')

    for src, targets in graph.items():
        safe_src = src.replace("-", "_").replace(".", "_")
        for target in targets:
            safe_target = target.replace("-", "_").replace(".", "_")
            lines.append(f"    {safe_src} --> {safe_target}")

    return "\n".join(lines)

format_workspace_text(ctx)

Format workspace context as compact plain text for ToolResult.text.

Parameters:

Name Type Description Default
ctx WorkspaceContext

Workspace context dict from :func:build_workspace_context or :func:format_workspace_context.

required

Returns:

Type Description
str

Compact text string.

Source code in packages/axm-ast/src/axm_ast/core/workspace.py
Python
def format_workspace_text(ctx: WorkspaceContext) -> str:
    """Format workspace context as compact plain text for ToolResult.text.

    Args:
        ctx: Workspace context dict from :func:`build_workspace_context`
            or :func:`format_workspace_context`.

    Returns:
        Compact text string.
    """
    lines: list[str] = []
    lines.append(
        f"{ctx.get('workspace', '')} | workspace | "
        f"{ctx.get('package_count', 0)} packages"
    )

    packages = ctx.get("packages", [])
    if packages:
        lines.append("")
        lines.append("Packages:")
        for pkg in packages:
            mod_c = pkg.get("module_count")
            if mod_c is not None:
                fn_c = pkg.get("function_count", 0)
                cls_c = pkg.get("class_count", 0)
                lines.append(f"  {pkg['name']}: {mod_c} mod, {fn_c} fn, {cls_c} cls")
            else:
                lines.append(f"  {pkg['name']}")

    graph = ctx.get("package_graph", {})
    if graph:
        lines.append("")
        lines.append("Dependencies:")
        for src in sorted(graph):
            lines.append(f"  {src}{', '.join(graph[src])}")

    return "\n".join(lines)