255 lines
7.9 KiB
Python
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.",
|
|
)
|