diff --git a/examples/metricq_client.py b/examples/metricq_client.py index 130c3f06..579be272 100755 --- a/examples/metricq_client.py +++ b/examples/metricq_client.py @@ -43,10 +43,10 @@ import logging import aiomonitor # type: ignore -import click import click_log # type: ignore import metricq +from metricq.cli import metricq_command logger = metricq.get_logger() click_log.basic_config(logger) @@ -66,9 +66,7 @@ async def run(server: str, token: str) -> None: await client.stopped() -@click.command() -@click.option("--server", default="amqp://admin:admin@localhost/") -@click.option("--token", default="client-py-example") +@metricq_command(default_token="client-py-example") @click_log.simple_verbosity_option(logger) # type: ignore def main(server: str, token: str) -> None: asyncio.run(run(server, token)) diff --git a/examples/metricq_get_history.py b/examples/metricq_get_history.py index b7f3c65e..377def9f 100755 --- a/examples/metricq_get_history.py +++ b/examples/metricq_get_history.py @@ -36,6 +36,7 @@ import click_log # type: ignore import metricq +from metricq.cli import metricq_command logger = metricq.get_logger() @@ -98,9 +99,7 @@ async def aget_history( click.echo(aggregate) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="history-py-dummy") +@metricq_command(default_token="history-py-dummy") @click.option("--metric", default=None) @click.option("--list-metrics", is_flag=True) @click.option("--list-metadata", is_flag=True) diff --git a/examples/metricq_get_history_raw.py b/examples/metricq_get_history_raw.py index 3fe2a282..7167daa2 100755 --- a/examples/metricq_get_history_raw.py +++ b/examples/metricq_get_history_raw.py @@ -35,6 +35,7 @@ import click_log # type: ignore import metricq +from metricq.cli import metricq_command from metricq.history_client import HistoryRequestType logger = metricq.get_logger() @@ -83,9 +84,7 @@ async def aget_history(server: str, token: str, metric: str) -> None: await client.stop(None) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="history-py-dummy") +@metricq_command(default_token="history-py-dummy") @click.argument("metric") @click_log.simple_verbosity_option(logger) # type: ignore def get_history(server: str, token: str, metric: str) -> None: diff --git a/examples/metricq_pandas.py b/examples/metricq_pandas.py index 1096f079..7985c62f 100755 --- a/examples/metricq_pandas.py +++ b/examples/metricq_pandas.py @@ -35,6 +35,7 @@ import click_log # type: ignore import metricq +from metricq.cli import metricq_command from metricq.pandas import PandasHistoryClient logger = metricq.get_logger() @@ -81,9 +82,7 @@ async def aget_history(server: str, token: str, metric: str) -> None: click.echo("----------") -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="history-py-dummy") +@metricq_command(default_token="history-py-dummy") @click.option("--metric", default="example.quantity") @click_log.simple_verbosity_option(logger) # type: ignore def get_history(server: str, token: str, metric: str) -> None: diff --git a/examples/metricq_sink.py b/examples/metricq_sink.py index 272487d6..a1a2f8f0 100755 --- a/examples/metricq_sink.py +++ b/examples/metricq_sink.py @@ -35,6 +35,7 @@ import metricq from metricq import Metric +from metricq.cli import metricq_command from metricq.logging import get_logger logger = get_logger() @@ -76,9 +77,7 @@ async def on_data( ) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="sink-py-dummy") +@metricq_command(default_token="sink-py-dummy") @click.option("-m", "--metrics", multiple=True, required=True) @click_log.simple_verbosity_option(logger) # type: ignore def source(server: str, token: str, metrics: list[Metric]) -> None: diff --git a/examples/metricq_source.py b/examples/metricq_source.py index 7004834c..c7a189fc 100755 --- a/examples/metricq_source.py +++ b/examples/metricq_source.py @@ -31,10 +31,10 @@ import random from typing import Any -import click import click_log # type: ignore import metricq +from metricq.cli import metricq_command from metricq.logging import get_logger logger = get_logger() @@ -74,9 +74,7 @@ async def update(self) -> None: ) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="source-py-dummy") +@metricq_command(default_token="source-py-dummy") @click_log.simple_verbosity_option(logger) # type: ignore def source(server: str, token: str) -> None: src = DummySource(token=token, url=server) diff --git a/examples/metricq_synchronous_source.py b/examples/metricq_synchronous_source.py index 6050915d..715cc763 100755 --- a/examples/metricq_synchronous_source.py +++ b/examples/metricq_synchronous_source.py @@ -33,10 +33,10 @@ import random import time -import click import click_log # type: ignore from metricq import SynchronousSource, Timestamp, get_logger +from metricq.cli import metricq_command logger = get_logger() @@ -47,9 +47,7 @@ ) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="source-py-dummy") +@metricq_command(default_token="source-py-dummy") @click_log.simple_verbosity_option(logger) # type: ignore def synchronous_source(server: str, token: str) -> None: ssource = SynchronousSource(token=token, url=server) diff --git a/metricq/cli/__init__.py b/metricq/cli/__init__.py new file mode 100644 index 00000000..e96a2a4f --- /dev/null +++ b/metricq/cli/__init__.py @@ -0,0 +1,19 @@ +from .command import metricq_command +from .params import ( + ChoiceParam, + CommandLineChoice, + DurationParam, + OutputFormat, + TemplateStringParam, + TimestampParam, +) + +__all__ = [ + "ChoiceParam", + "CommandLineChoice", + "DurationParam", + "OutputFormat", + "TemplateStringParam", + "TimestampParam", + "metricq_command", +] diff --git a/metricq/cli/command.py b/metricq/cli/command.py new file mode 100644 index 00000000..0e71fa53 --- /dev/null +++ b/metricq/cli/command.py @@ -0,0 +1,84 @@ +import logging +from typing import Callable, cast + +import click +import click_log # type: ignore +from click import option +from dotenv import find_dotenv, load_dotenv + +from .. import get_logger +from .params import FC, TemplateStringParam + +# We do not interpolate (i.e. replace ${VAR} with corresponding environment variables). +# That is because we want to be able to interpolate ourselves for metrics and tokens +# using the same syntax. If it was only ${USER} for the token, we could use the +# override functionality, but most unfortunately there is no standard environment +# variable for the hostname. Even $HOST on zsh is not actually part of the environment. +# ``override=false`` just means that environment variables have priority over the +# env files. +load_dotenv(dotenv_path=find_dotenv(".metricq"), interpolate=False, override=False) + + +def metricq_server_option() -> Callable[[FC], FC]: + return option( + "--server", + type=TemplateStringParam(), + metavar="URL", + required=True, + help="MetricQ server URL.", + ) + + +def metricq_token_option(default: str) -> Callable[[FC], FC]: + return option( + "--token", + type=TemplateStringParam(), + metavar="CLIENT_TOKEN", + default=default, + show_default=True, + help="A token to identify this client on the MetricQ network.", + ) + + +def get_metric_command_looger() -> logging.Logger: + logger = get_logger() + logger.setLevel(logging.WARNING) + click_log.basic_config(logger) + + return logger + + +def metricq_command( + default_token: str, client_version: str | None = None +) -> Callable[[FC], click.Command]: + logger = get_metric_command_looger() + + log_decorator = cast( + Callable[[FC], FC], click_log.simple_verbosity_option(logger, default="warning") + ) + context_settings = {"auto_envvar_prefix": "METRICQ"} + epilog = ( + "All options can be passed as environment variables prefixed with 'METRICQ_'." + "I.e., 'METRICQ_SERVER=amqps://...'.\n" + "\n" + "You can also create a '.metricq' file in the current or home directory that " + "contains environment variable settings in the same format.\n" + "\n" + "Some options, including server and token, can contain placeholders for $USER " + "and $HOST." + ) + + def decorator(func: FC) -> click.Command: + return click.version_option(version=client_version)( + log_decorator( + metricq_token_option(default_token)( + metricq_server_option()( + click.command(context_settings=context_settings, epilog=epilog)( + func + ) + ) + ) + ) + ) + + return decorator diff --git a/metricq/cli/params.py b/metricq/cli/params.py new file mode 100644 index 00000000..60bd5616 --- /dev/null +++ b/metricq/cli/params.py @@ -0,0 +1,194 @@ +import re +from contextlib import suppress +from enum import Enum, auto +from getpass import getuser +from socket import gethostname +from string import Template +from typing import Any, Callable, Generic, List, Optional, Type, TypeVar, Union, cast + +import click +from click import Context, Parameter, ParamType, option + +from ..timeseries import Timedelta, Timestamp + +_C = TypeVar("_C", covariant=True) + + +def camelcase_to_kebabcase(camelcase: str) -> str: + # Match empty string preceeding uppercase character, but not at the start + # of the word. Replace with '-' and make lowercase to get kebab-case word. + return re.sub(r"(? str: + return "".join(part.title() for part in kebabcase.split("-")) + + +class CommandLineChoice: + @classmethod + def as_choice_list(cls) -> List[str]: + return [ + camelcase_to_kebabcase(name) for name in getattr(cls, "__members__").keys() + ] + + def as_choice(self) -> str: + return camelcase_to_kebabcase(getattr(self, "name")) + + @classmethod + def default(cls: Type[_C]) -> Optional[_C]: + return None + + @classmethod + def from_choice(cls: Type[_C], option: str) -> _C: + member_name = kebabcase_to_camelcase(option.lower()) + return cast(_C, getattr(cls, "__members__")[member_name]) + + +ChoiceType = TypeVar("ChoiceType", bound=CommandLineChoice) + + +class ChoiceParam(Generic[ChoiceType], ParamType): + def __init__(self, cls: Type[ChoiceType], name: str): + self.cls = cls + self.name = name + + def get_metavar(self, param: Parameter) -> str: + return f"({'|'.join(self.cls.as_choice_list())})" + + def convert( + self, + value: Union[str, ChoiceType], + param: Optional[Parameter], + ctx: Optional[Context], + ) -> Optional[ChoiceType]: + if value is None: + return None + + try: + if isinstance(value, str): + return self.cls.from_choice(value) + else: + return value + except (KeyError, ValueError): + self.fail( + f"unknown choice {value!r}, expected: {', '.join(self.cls.as_choice_list())}", + param=param, + ctx=ctx, + ) + + +class OutputFormat(CommandLineChoice, Enum): + Pretty = auto() + Json = auto() + + @classmethod + def default(cls) -> "OutputFormat": + return OutputFormat.Pretty + + +FC = TypeVar("FC", bound=Union[Callable[..., Any], click.Command]) + + +def output_format_option() -> Callable[[FC], FC]: + return option( + "--format", + type=ChoiceParam(OutputFormat, "format"), + default=OutputFormat.default(), + show_default=OutputFormat.default().as_choice(), + help="Print results in this format", + ) + + +class DurationParam(ParamType): + name = "duration" + + def __init__(self, default: Optional[Timedelta]): + self.default = default + + def convert( + self, + value: Union[str, Timedelta], + param: Optional[Parameter], + ctx: Optional[Context], + ) -> Optional[Timedelta]: + if value is None: + return None + elif isinstance(value, str): + try: + return Timedelta.from_string(value) + except ValueError: + self.fail( + 'expected a duration: "[]"', + param=param, + ctx=ctx, + ) + else: + return value + + +class TimestampParam(ParamType): + """ + Convert strings to ``metricq.Timestamp`` objects. + + Accepts the following string inputs + - ISO-8601 timestamp (with timezone) + - Past Duration, e.g., '-10h' from now + - Posix timestamp, float seconds since 1.1.1970 midnight. (UTC) + - 'now' + - 'epoch', i.e., 1.1.1970 midnight + """ + + name = "timestamp" + + @staticmethod + def _convert(value: str) -> Timestamp: + if value == "now": + return Timestamp.now() + if value == "epoch": + return Timestamp.from_posix_seconds(0) + if value.startswith("-"): + # Plus because the minus makes negative timedelta + return Timestamp.now() + Timedelta.from_string(value) + with suppress(ValueError): + return Timestamp.from_posix_seconds(float(value)) + + return Timestamp.from_iso8601(value) + + def convert( + self, value: Any, param: Optional[Parameter], ctx: Optional[Context] + ) -> Optional[Timestamp]: + if value is None: + return None + elif isinstance(value, Timestamp): + return value + elif isinstance(value, str): + try: + return self._convert(value) + except ValueError: + self.fail( + "expected an ISO-8601 timestamp (e.g. '2012-12-21T00:00:00Z'), " + "POSIX timestamp, 'now', 'epoch', or a past duration (e.g. '-10h')", + param=param, + ctx=ctx, + ) + else: + self.fail("unexpected type to convert to TimeStamp", param=param, ctx=ctx) + + +class TemplateStringParam(ParamType): + name = "text" + mapping: dict[str, str] + + def __init__(self) -> None: + self.mapping = {} + with suppress(Exception): + self.mapping["USER"] = getuser() + with suppress(Exception): + self.mapping["HOST"] = gethostname() + + def convert( + self, value: Any, param: Optional[Parameter], ctx: Optional[Context] + ) -> str: + if not isinstance(value, str): + raise TypeError("expected a string type for TemplateStringParam") + return Template(value).safe_substitute(self.mapping) diff --git a/setup.cfg b/setup.cfg index 4fd874b9..b497c3ed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -62,9 +62,14 @@ dev = %(examples)s %(typing)s %(docs)s + %(cli)s tox pandas = pandas ~= 2.0.1 +cli = + click + click-log + python-dotenv~=1.0.0 [flake8] application-import-names = @@ -126,5 +131,5 @@ deps = .[lint] commands = flake8 . [testenv:mypy] -deps = .[typing] +deps = .[typing, cli] commands = mypy --strict metricq examples tests setup.py