Files
2026-07-13 12:24:33 +08:00

255 lines
7.9 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Abstract base class and shared helpers for CLI subcommands."""
# Future
from __future__ import annotations
# Standard
import abc
import argparse
import sys
# First Party
from lmcache.cli.metrics import (
FileHandler,
Metrics,
StreamHandler,
get_formatter,
)
from lmcache.logging import init_logger
logger = init_logger(__name__)
class BaseCommand(abc.ABC):
"""Abstract base class that all CLI subcommands must inherit from.
Subclasses must implement :meth:`name`, :meth:`help`,
:meth:`add_arguments`, and :meth:`execute`. The :meth:`register`
method wires everything together automatically.
Example::
class PingCommand(BaseCommand):
def name(self) -> str:
return "ping"
def help(self) -> str:
return "Ping the KV cache server."
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument("--url", required=True)
def execute(self, args: argparse.Namespace) -> None:
metrics = self.create_metrics("Ping Result", args)
metrics.add("status", "Status", "OK")
metrics.emit()
"""
@abc.abstractmethod
def name(self) -> str:
"""Return the subcommand name (e.g. ``"mock"``)."""
@abc.abstractmethod
def help(self) -> str:
"""Return short help text shown by ``lmcache -h``."""
@abc.abstractmethod
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
"""Add command-specific arguments to *parser*.
Args:
parser: The ``ArgumentParser`` for this subcommand.
"""
@abc.abstractmethod
def execute(self, args: argparse.Namespace) -> None:
"""Execute the subcommand.
Called by the CLI dispatcher in ``main.py`` via
``args.func(args)`` after argument parsing. The
:meth:`register` method binds this as the dispatch target
using ``parser.set_defaults(func=self.execute)``.
Args:
args: Parsed CLI arguments.
"""
def register(self, subparsers: argparse._SubParsersAction) -> None:
"""Register this command with the CLI argument parser.
This method is not typically overridden. It calls
:meth:`name`, :meth:`help`, and :meth:`add_arguments`, then
binds :meth:`execute` as the dispatch target.
Args:
subparsers: The subparsers action from the root parser.
"""
parser = subparsers.add_parser(self.name(), help=self.help())
self.add_arguments(parser)
_add_output_args(parser)
parser.set_defaults(func=self.execute)
def create_metrics(
self,
title: str,
args: argparse.Namespace,
width: int = 48,
) -> Metrics:
"""Create a :class:`Metrics` with default handlers pre-registered.
Handlers are configured from ``args.format``, ``args.output``,
and ``args.quiet``:
* A :class:`StreamHandler` writing to stdout, unless ``--quiet``
is set. The formatter is determined by ``--format``
(default: ``terminal``).
* A :class:`FileHandler` if ``--output`` is set (uses the same
formatter chosen by ``--format``).
Args:
title: Report title.
args: Parsed CLI arguments (inspects ``format`` and ``output``).
width: Character width for terminal rendering (only used by
formatters that support it, e.g. ``TerminalFormatter``).
Returns:
A ready-to-use ``Metrics`` instance.
"""
metrics = Metrics(title=title)
quiet = getattr(args, "quiet", False)
fmt_name = getattr(args, "format", None) or "terminal"
if not quiet:
metrics.add_handler(StreamHandler(get_formatter(fmt_name, width=width)))
output = getattr(args, "output", None)
if output:
metrics.add_handler(
FileHandler(output, get_formatter(fmt_name, width=width))
)
return metrics
class CompositeCommand(BaseCommand):
"""Base class for commands that contain auto-discovered sub-subcommands.
Subclasses only need to implement :meth:`name` and :meth:`help`.
Sub-subcommands are discovered automatically by scanning the package
for concrete :class:`BaseCommand` subclasses.
Example::
class QueryCommand(CompositeCommand):
def name(self) -> str:
return "query"
def help(self) -> str:
return "Run one inference request and report metrics."
"""
def add_arguments(self, _parser: argparse.ArgumentParser) -> None:
"""No top-level arguments; all args are registered by subcommands."""
def register(self, subparsers: argparse._SubParsersAction) -> None:
"""Register this command and auto-discover all sub-subcommands.
Scans the package where this class is defined for concrete
:class:`BaseCommand` subclasses and registers each one as a
nested subcommand.
Args:
subparsers: The subparsers action from the root parser.
"""
# Deferred import to avoid circular dependency
# First Party
from lmcache.v1.utils.subclass_discovery import discover_subclasses
parser = subparsers.add_parser(
self.name(),
help=self.help(),
description=self.help(),
)
inner = parser.add_subparsers(
dest=f"{self.name()}_target",
required=True,
)
# Discover subcommands in the package where the concrete
# CompositeCommand subclass is defined. Exclude the module
# that defines the composite command itself (the __init__.py).
package = self.__class__.__module__
def _raise(module_name: str, exc: Exception) -> None:
raise exc
self._subcmds: dict[str, BaseCommand] = {}
for cls in discover_subclasses(
package,
BaseCommand, # type: ignore[type-abstract]
module_filter=lambda name: not name.startswith("_"),
require_defined_in_module=True,
on_import_error=_raise,
):
# Skip the composite command class itself
if cls is self.__class__:
continue
inst = cls()
self._subcmds[inst.name()] = inst
inst.register(inner)
logger.debug(
"CompositeCommand[%s] discovered subcommands: %s",
self.name(),
list(self._subcmds.keys()),
)
def execute(self, args: argparse.Namespace) -> None:
"""Dispatch to the appropriate sub-subcommand.
Args:
args: Parsed CLI arguments.
"""
target = getattr(args, f"{self.name()}_target", None)
subcmd = self._subcmds.get(target) if target else None
if subcmd is None:
print(
f"Unknown {self.name()} target: {target}",
file=sys.stderr,
)
sys.exit(1)
subcmd.execute(args)
def _add_output_args(parser: argparse.ArgumentParser) -> None:
"""Add the common ``--format`` and ``--output`` flags.
Called automatically by :meth:`BaseCommand.register` — subcommands
do not need to call this themselves.
Args:
parser: The ``ArgumentParser`` to add the flags to.
"""
parser.add_argument(
"--format",
type=str,
default=None,
metavar="FORMAT",
help=("Stdout output format (default: terminal). Available: terminal, json."),
)
parser.add_argument(
"--output",
type=str,
default=None,
metavar="PATH",
help="Save metrics to a file at PATH (format chosen by --format).",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
default=False,
help="Suppress stdout output. Exit code only.",
)