Source code for xclif.compiler

"""Manifest compiler for xclif routes.

Walks a routes package once (at build/install time) and emits a
``_xclif_manifest.py`` file next to the routes package.  At runtime the
manifest is loaded by ``Cli.from_manifest()`` instead of re-walking the
filesystem, skipping ``pkgutil.walk_packages`` + ``inspect.getmembers``
overhead (~13-15 ms on Apple Silicon).

Usage
-----
From the command line::

    python -m xclif compile myapp.routes

Or programmatically::

    from xclif.compiler import compile_routes
    import myapp.routes as routes
    compile_routes(routes)
"""

from __future__ import annotations

import importlib
import inspect
import pkgutil
import types
from pathlib import Path

from xclif.command import Command
from xclif.importer import is_private_route_module

__all__ = ["compile_routes"]


def _find_command_attr(module: types.ModuleType) -> str | None:
    """Return the attribute name of the single Command in *module*, or None.

    Raises
    ------
    ValueError
        If more than one Command is found in the module.
    """
    members = [
        (name, obj)
        for name, obj in inspect.getmembers(module, lambda x: isinstance(x, Command))
    ]
    if not members:
        return None
    if len(members) > 1:
        raise ValueError(f"Multiple commands found in module {module.__name__!r}")
    name, _ = members[0]
    return name


[docs] def compile_routes(routes: types.ModuleType, output_dir: Path | None = None) -> Path: """Walk *routes* and write a manifest file. Parameters ---------- routes: The routes package module (e.g. ``import myapp.routes as routes``). output_dir: Directory to write ``_xclif_manifest.py`` into. Defaults to the directory containing the routes package itself (i.e. sits next to it). Returns ------- Path The path of the written manifest file. """ if routes.__package__ is None: raise ImportError( f"Routes module {routes.__name__!r} must be part of a package" ) # Validate root has exactly one Command root_members = inspect.getmembers(routes, lambda x: isinstance(x, Command)) if len(root_members) == 0: raise ValueError(f"No commands found in root module {routes.__name__!r}") if len(root_members) > 1: raise ValueError(f"Multiple commands found in root module {routes.__name__!r}") root_attr = root_members[0][0] package_name = routes.__package__ # Discover all sub-modules entries: list[tuple[str, str]] = [] # (dotted_module_name, attr_name) for _, mod_name, _ispkg in pkgutil.walk_packages( routes.__path__, routes.__name__ + ".", ): if is_private_route_module(mod_name.removeprefix(routes.__name__ + ".")): continue mod = importlib.import_module(mod_name) attr = _find_command_attr(mod) if attr is not None: entries.append((mod_name, attr)) # Determine output location if output_dir is None: if routes.__file__ is None: raise ImportError( f"Cannot determine location of routes package {routes.__name__!r}" ) routes_package_path = Path(routes.__file__).parent # .../routes/ output_dir = routes_package_path.parent # next to routes/ output_path = output_dir / "_xclif_manifest.py" # Build the manifest source root_path = package_name + "." lines: list[str] = [ "# Generated by `xclif compile` — do not edit by hand.", "# Re-run `python -m xclif compile <routes_module>` after adding/removing routes.", "from __future__ import annotations", "", "from xclif import Cli", "", "", "def _build_cli(version: str | None = None, env_prefix: str | None = None, config_name: str | None = None, local_config: str | None = None, show_no_description: bool | None = None) -> Cli:", ] # Import lines (inside the function so they remain lazy at module-load time # but are all imported when _build_cli() is called — same behaviour as # from_routes, but without the filesystem walk overhead) lines.append(f" from {routes.__name__} import {root_attr} as _root") for mod_name, attr in entries: alias = "_" + mod_name.replace(".", "_") lines.append(f" from {mod_name} import {attr} as {alias}") lines.append("") # Reuse the imported Command objects directly so all fields # (subcommands, implicit_options, version, etc.) are preserved. lines.append(" root = _root") # Build each sub-command, grouping by path segments # We need to emit add_command calls in depth-first order so parent # namespace commands exist before children are attached. # Collect (path_list, alias) pairs sorted by depth then name. sub_entries: list[tuple[list[str], str]] = [] for mod_name, _ in entries: rel = mod_name.removeprefix(root_path) path = rel.split(".") alias = "_" + mod_name.replace(".", "_") sub_entries.append((path, alias)) # Sort by depth then path so namespaces are created before their children sub_entries.sort(key=lambda x: (len(x[0]), x[0])) lines.append("") lines.append(" cli = Cli(root_command=root, version=version, env_prefix=env_prefix, config_name=config_name, local_config=local_config, show_no_description=show_no_description)") for path, alias in sub_entries: path_repr = repr(path) lines.append(f" cli.add_command({path_repr}, {alias})") lines.append(" cli._finalize()") lines.append(" return cli") lines.append("") source = "\n".join(lines) + "\n" # Validate WithConfig conflicts at compile time — build the full tree # so we see leaf-route params too (not just root) from xclif.validation import check_with_config_conflicts temp_cli = cls_placeholder = type('_Temp', (), {'root_command': None})() root_cmd = getattr(routes, root_attr) temp_root = Command(root_cmd.name, root_cmd.run, root_cmd.arguments.copy(), dict(root_cmd.options), dict(root_cmd.subcommands)) for mod_name, attr in entries: mod = importlib.import_module(mod_name) sub_cmd = getattr(mod, attr) rel = mod_name.removeprefix(root_path) path = rel.split(".") cursor = temp_root for part in path[:-1]: cursor = cursor.subcommands.setdefault(part, Command(part, lambda: 0)) cursor.subcommands[sub_cmd.name] = sub_cmd for a in sub_cmd.aliases: cursor._assert_no_collision(a, registering=sub_cmd.name) cursor.subcommands[a] = sub_cmd if temp_root.name: check_with_config_conflicts(temp_root, temp_root.name.upper()) output_path.write_text(source, encoding="utf-8") return output_path