From 61dd5945c0d13369eb3336f399c32c5c18b27ac2 Mon Sep 17 00:00:00 2001 From: Max KvR Date: Mon, 9 Sep 2024 13:32:28 +0200 Subject: [PATCH] feat: @metricq_command as a standardized click command --- docs/api/cli.rst | 34 +++ docs/api/index.rst | 1 + examples/metricq_client.py | 14 +- examples/metricq_get_history.py | 22 +- examples/metricq_get_history_raw.py | 21 +- examples/metricq_pandas.py | 19 +- examples/metricq_sink.py | 21 +- examples/metricq_source.py | 16 +- examples/metricq_synchronous_source.py | 20 +- metricq/cli/__init__.py | 27 ++ metricq/cli/decorator.py | 196 +++++++++++++++ metricq/cli/params.py | 334 +++++++++++++++++++++++++ setup.cfg | 9 +- setup.py | 2 +- tests/test_cli.py | 123 +++++++++ 15 files changed, 753 insertions(+), 106 deletions(-) create mode 100644 docs/api/cli.rst create mode 100644 metricq/cli/__init__.py create mode 100644 metricq/cli/decorator.py create mode 100644 metricq/cli/params.py create mode 100644 tests/test_cli.py diff --git a/docs/api/cli.rst b/docs/api/cli.rst new file mode 100644 index 00000000..4352866a --- /dev/null +++ b/docs/api/cli.rst @@ -0,0 +1,34 @@ +CLI +=== + +This module provides CLI utilities and decorators for MetricQ applications, including custom parameter types for choices, durations, timestamps, templates, and metrics. + +The main focus of this module is to standardize the CLI interfaces of different programs. + +To use this part of the package you need to install additional dependencies. You can install the dependencies using the following command: + +.. code-block:: bash + + pip install "metricq[cli]" + +.. + +Decorators +---------- + +.. automodule:: metricq.cli.decorator + :members: metricq_command, metricq_metric_option, metricq_server_option, metricq_token_option, + + +Parameter +--------- +For you convenience, we provide a set of custom parameter types that you can use as custom types in your click option definitions. + +.. automodule:: metricq.cli.params + :members: + :exclude-members: get_metavar, convert, name + + + + + diff --git a/docs/api/index.rst b/docs/api/index.rst index 0fbe4b44..079db1b1 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -14,3 +14,4 @@ API Reference client-common exceptions misc + cli diff --git a/examples/metricq_client.py b/examples/metricq_client.py index 130c3f06..7108078b 100755 --- a/examples/metricq_client.py +++ b/examples/metricq_client.py @@ -40,20 +40,13 @@ `telnet localhost 50101` (or `netcat`), inspect tasks and run code in a REPL. """ import asyncio -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) -logger.setLevel("INFO") -logger.handlers[0].formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)-8s] [%(name)-20s] %(message)s" -) async def run(server: str, token: str) -> None: @@ -66,10 +59,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") -@click_log.simple_verbosity_option(logger) # type: ignore +@metricq_command(default_token="client-py-example") 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..6fe3ef83 100755 --- a/examples/metricq_get_history.py +++ b/examples/metricq_get_history.py @@ -28,24 +28,17 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import asyncio -import logging import pprint from datetime import timedelta import click -import click_log # type: ignore import metricq +from metricq.cli import metricq_command +from metricq.cli.decorator import metricq_metric_option +from metricq.logging import get_logger -logger = metricq.get_logger() - -click_log.basic_config(logger) -logger.setLevel("INFO") -# Use this if we ever use threads -# logger.handlers[0].formatter = logging.Formatter(fmt='%(asctime)s %(threadName)-16s %(levelname)-8s %(message)s') -logger.handlers[0].formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)-8s] [%(name)-20s] %(message)s" -) +logger = get_logger() async def aget_history( @@ -98,13 +91,10 @@ async def aget_history( click.echo(aggregate) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="history-py-dummy") -@click.option("--metric", default=None) +@metricq_command(default_token="history-py-dummy") +@metricq_metric_option() @click.option("--list-metrics", is_flag=True) @click.option("--list-metadata", is_flag=True) -@click_log.simple_verbosity_option(logger) # type: ignore def get_history( server: str, token: str, metric: str, list_metrics: bool, list_metadata: bool ) -> None: diff --git a/examples/metricq_get_history_raw.py b/examples/metricq_get_history_raw.py index 3fe2a282..cc1205de 100755 --- a/examples/metricq_get_history_raw.py +++ b/examples/metricq_get_history_raw.py @@ -28,25 +28,15 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import asyncio -import logging from datetime import timedelta import click -import click_log # type: ignore import metricq +from metricq.cli import metricq_command +from metricq.cli.decorator import metricq_metric_option from metricq.history_client import HistoryRequestType -logger = metricq.get_logger() - -click_log.basic_config(logger) -logger.setLevel("INFO") -# Use this if we ever use threads -# logger.handlers[0].formatter = logging.Formatter(fmt='%(asctime)s %(threadName)-16s %(levelname)-8s %(message)s') -logger.handlers[0].formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)-8s] [%(name)-20s] %(message)s" -) - async def aget_history(server: str, token: str, metric: str) -> None: client = metricq.HistoryClient(token=token, url=server) @@ -83,11 +73,8 @@ 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") -@click.argument("metric") -@click_log.simple_verbosity_option(logger) # type: ignore +@metricq_command(default_token="history-py-dummy") +@metricq_metric_option() def get_history(server: str, token: str, metric: str) -> None: asyncio.run(aget_history(server, token, metric)) diff --git a/examples/metricq_pandas.py b/examples/metricq_pandas.py index 1096f079..a3b0e2fc 100755 --- a/examples/metricq_pandas.py +++ b/examples/metricq_pandas.py @@ -28,25 +28,17 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import asyncio -import logging from datetime import timedelta import click -import click_log # type: ignore import metricq +from metricq.cli import metricq_command +from metricq.cli.decorator import metricq_metric_option from metricq.pandas import PandasHistoryClient logger = metricq.get_logger() -click_log.basic_config(logger) -logger.setLevel("INFO") -# Use this if we ever use threads -# logger.handlers[0].formatter = logging.Formatter(fmt='%(asctime)s %(threadName)-16s %(levelname)-8s %(message)s') -logger.handlers[0].formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)-8s] [%(name)-20s] %(message)s" -) - async def aget_history(server: str, token: str, metric: str) -> None: async with PandasHistoryClient(token=token, url=server) as client: @@ -81,11 +73,8 @@ 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") -@click.option("--metric", default="example.quantity") -@click_log.simple_verbosity_option(logger) # type: ignore +@metricq_command(default_token="history-py-dummy") +@metricq_metric_option(default="example.quantity") def get_history(server: str, token: str, metric: str) -> None: asyncio.run(aget_history(server, token, metric)) diff --git a/examples/metricq_sink.py b/examples/metricq_sink.py index 272487d6..980e75ab 100755 --- a/examples/metricq_sink.py +++ b/examples/metricq_sink.py @@ -27,24 +27,18 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import logging from typing import Any import click -import click_log # type: ignore import metricq from metricq import Metric +from metricq.cli import metricq_command +from metricq.cli.decorator import metricq_metric_option from metricq.logging import get_logger logger = get_logger() -click_log.basic_config(logger) -logger.setLevel("INFO") -logger.handlers[0].formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)-8s] [%(name)-20s] %(message)s" -) - # To implement a MetricQ Sink, subclass metricq.Sink class DummySink(metricq.Sink): @@ -76,15 +70,12 @@ async def on_data( ) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="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: +@metricq_command(default_token="sink-py-dummy") +@metricq_metric_option(multiple=True) +def source(server: str, token: str, metric: list[Metric]) -> None: # Initialize the DummySink class with a list of metrics given on the # command line. - sink = DummySink(metrics=metrics, token=token, url=server) + sink = DummySink(metrics=metric, token=token, url=server) # Run the sink. This call will block until the connection is closed. sink.run() diff --git a/examples/metricq_source.py b/examples/metricq_source.py index 7004834c..edd9e635 100755 --- a/examples/metricq_source.py +++ b/examples/metricq_source.py @@ -27,24 +27,15 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import logging 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() -click_log.basic_config(logger) -logger.setLevel("INFO") -logger.handlers[0].formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)-8s] [%(name)-20s] %(message)s" -) - class DummySource(metricq.IntervalSource): def __init__(self, *args: Any, **kwargs: Any): @@ -74,10 +65,7 @@ async def update(self) -> None: ) -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="source-py-dummy") -@click_log.simple_verbosity_option(logger) # type: ignore +@metricq_command(default_token="source-py-dummy") def source(server: str, token: str) -> None: src = DummySource(token=token, url=server) src.run() diff --git a/examples/metricq_synchronous_source.py b/examples/metricq_synchronous_source.py index 6050915d..930cb984 100755 --- a/examples/metricq_synchronous_source.py +++ b/examples/metricq_synchronous_source.py @@ -29,28 +29,18 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import logging + import random import time -import click -import click_log # type: ignore - -from metricq import SynchronousSource, Timestamp, get_logger +from metricq import SynchronousSource, Timestamp +from metricq.cli import metricq_command +from metricq.logging import get_logger logger = get_logger() -click_log.basic_config(logger) -logger.setLevel("INFO") -logger.handlers[0].formatter = logging.Formatter( - fmt="%(asctime)s [%(levelname)-8s] [%(name)-20s] %(message)s" -) - -@click.command() -@click.option("--server", default="amqp://localhost/") -@click.option("--token", default="source-py-dummy") -@click_log.simple_verbosity_option(logger) # type: ignore +@metricq_command(default_token="source-py-dummy") def synchronous_source(server: str, token: str) -> None: ssource = SynchronousSource(token=token, url=server) ssource.declare_metrics( diff --git a/metricq/cli/__init__.py b/metricq/cli/__init__.py new file mode 100644 index 00000000..d8a799c8 --- /dev/null +++ b/metricq/cli/__init__.py @@ -0,0 +1,27 @@ +from .decorator import ( + metricq_command, + metricq_metric_option, + metricq_server_option, + metricq_token_option, +) +from .params import ( + ChoiceParam, + CommandLineChoice, + DurationParam, + MetricParam, + TemplateStringParam, + TimestampParam, +) + +__all__ = [ + "ChoiceParam", + "CommandLineChoice", + "DurationParam", + "TemplateStringParam", + "TimestampParam", + "MetricParam", + "metricq_command", + "metricq_metric_option", + "metricq_server_option", + "metricq_token_option", +] diff --git a/metricq/cli/decorator.py b/metricq/cli/decorator.py new file mode 100644 index 00000000..d0ec1b3e --- /dev/null +++ b/metricq/cli/decorator.py @@ -0,0 +1,196 @@ +import logging +from typing import Callable, Optional, cast, TypeVar, Union, Any + +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 MetricParam, 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) + + +FC = TypeVar("FC", bound=Union[Callable[..., Any], click.Command]) + + +def metricq_server_option() -> Callable[[FC], FC]: + """ + Allows the User to provide a -\\-server option. This option has no input validation and therefore can be any string. + + It is recommended to use the :py:func:`~metricq.cli.decorator.metricq_command` decorator instead + of using this function directly. + + """ + return option( + "--server", + type=TemplateStringParam(), + metavar="URL", + required=True, + help="MetricQ server URL.", + ) + + +def metricq_token_option(default: str) -> Callable[[FC], FC]: + """ + Allows the User to provide a -\\-metric option. The input must follow the specification provided + `here `_. + + It is recommended to use the :py:func:`~metricq.cli.decorator.metricq_command` decorator instead of using this + function directly. + """ + 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 metricq_metric_option( + default: Optional[str] = None, multiple: bool = False, required: bool = False +) -> Callable[[FC], FC]: + """ + The metric option can be used to select one or more metrics the program should use. + The metric can be set with the -\\-metric or -m parameter. If no default value is set, the parameter is required. + The Metric syntax is specified by the :py:class:`~metricq.cli.params.MetricParam`. + + Args: + default: The default metric. Defaults to `None`. You can only set one default, even if the program allows + multiple inputs. + multiple: If `True`, allows multiple metrics to be specified. Defaults to `False`. + required: If `True`, makes the -\\-metric option required. Defaults to `False`. If required is set and no + default is provided, at least one metric input is required. + + **Example**:: + + @metricq_command(default_token="example.program") + @metricq_metricq_option(required=true, default="example.metric") + def metric_example( + server: str, token: str, metric: str + ) -> None: + # Initialize the DummySink class with a list of metrics given on the + # command line. + sink = DummySink(metrics=metric, token=token, url=server) + + # Run the sink. This call will block until the connection is closed. + sink.run() + + @metricq_command(default_token="example.program") + @metricq_metricq_option(required=true, multiple=True) # <-- multiple is set + def multi_metric_example( + server: str, token: str, metric: List[str] + ) -> None: + sink = DummySink(metrics=metric, token=token, url=server) + sink.run() + + """ + response_default = default if (default is None or not multiple) else [default] + help = "Use the -–metric / -m parameter to specify which metric the program should use." + if multiple: + help += " Can be used multiple times to specify several metrics." + + return option( + "--metric", + "-m", + type=MetricParam(), + metavar="METRIC", + show_default=True, + required=required, + default=response_default, + multiple=multiple, + help=help, + ) + + +def get_metric_command_logger() -> 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]: + """Standardized wrapper for click commands + + Args: + default_token: default token that will be used if no token is provided + client_version: version of the client + + Returns: + Callable[[FC], click.Command]: click command + + The :py:func:`~metricq.cli.wrapper.metricq_command` is the first parameter of any given click/cli command. The main purpose is to provide the most used parameters. + These parameters are 'server' and 'token'. + + - -\\-server: + The Server param is used to specify the amqp url of the Network. for example: amqp://localhost/ + + The server param can be set using the environment variable METRICQ_SERVER or adding the --server {value} option to the cli command + + - -\\-token: + The Token is used to identify each program on the metricq network. for example: sink-py-dummy + + The token param can be set using the environment variable METRICQ_TOKEN or adding the --token {value} option to the cli command + + Full example: + ``metricq-check --server amqp://localhost/ --token sink-py-dummy`` + + **Example**:: + + @metricq_command(default_token="source-py-dummy") + def dummy( + server: str, token: str + ) -> None: + src = DummySource(token=token, url=server) + src.run() + + + if __name__ == "__main__": + dummy() + + """ + logger = get_metric_command_logger() + + 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..b687a0c7 --- /dev/null +++ b/metricq/cli/params.py @@ -0,0 +1,334 @@ +import re +from contextlib import suppress +from getpass import getuser +from socket import gethostname +from string import Template +from typing import Any, Generic, List, Optional, Type, TypeVar, Union, cast + +import click +from click import Context, ParamType + +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): + """ + A custom parameter type for Click, enabling choice-based selection. + + **Example**:: + + + from enum import Enum, auto + from click import option + + from metricq.cli import metricq_command, ChoiceParam, CommandLineChoice + + + class OutputFormat(CommandLineChoice, Enum): + Pretty = auto() + Json = auto() + + @classmethod + def default(cls) -> "OutputFormat": + return OutputFormat.Pretty + + + FORMAT = ChoiceParam(OutputFormat, "format") + + + @metricq_command(default_token="example.program") + @option( + "--format", + type=FORMAT, + default=OutputFormat.default(), + show_default=OutputFormat.default().as_choice(), + help="Print results in this format", + ) + def dummy_program( + server: str, token: str, format: str + ) -> None: + Dummy(server=server, token=token, format=format).run() + + + if __name__ == "__main__": + dummy_program() + + """ + + def __init__(self, cls: Type[ChoiceType], name: str): + self.cls = cls + self.name = name + + def get_metavar(self, param: click.Parameter) -> str: + return f"({'|'.join(self.cls.as_choice_list())})" + + def convert( + self, + value: Union[str, ChoiceType], + param: Optional[click.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 DurationParam(ParamType): + """ + A custom parameter type for Click, enabling Time values. + + Accepts the following string inputs + - [] eg: + + The following units are supported: + - 's' / 'seconds[s]' for seconds + - 'min' / 'minute[s]' for minutes + - 'h' / 'hour[s]' for hours + - 'd' / 'day[s]' for days + + **Example**:: + + from typing import Optional + + from click import option + + from metricq import Timedelta + from metricq.cli import metricq_command, DurationParam + + TIMEOUT = DurationParam(default=None) + + @metricq_command(default_token="example.program") + @option( + "--timeout", + type=TIMEOUT, + default=TIMEOUT.default, + help="A timeout for the program", + ) + def dummy_program( + server: str, token: str, timeout: Optional[Timedelta] + ) -> None: + Dummy(server=server, token=token, timeout=timeout).run() + + + if __name__ == "__main__": + dummy_program() + + + """ + + name = "duration" + + def __init__(self, default: Optional[Timedelta]): + self.default = default + + def convert( + self, + value: Union[str, Timedelta], + param: Optional[click.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 :py:class:`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 + + **Example**:: + + from click import option + + from metricq.cli import metricq_command, TimestampParam + + + @metricq_command(default_token="example.program") + @option( + "--start", + type=TimestampParam(), + ) + def metric_example( + server: str, token: str, start: str + ) -> None: + print(server, token, start) + + + if __name__ == "__main__": + metric_example() + + """ + + 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[click.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): + """ + A custom parameter type for Click, enabling template-based string substitution + with dynamic values for 'USER' and 'HOST'. + + In this example, the user can provide a username with the --user option. If not set, $USER will be used. + The TemplateString Param will then replace the $USER with the actual username. + + **Example**:: + + from click import option + + from metricq.cli import metricq_command, TemplateStringParam + + + @metricq_command(default_token="example.program") + @option( + "--user", + type=TemplateStringParam(), + metavar="Test", + default="$USER", + ) + def metric_example( + server: str, token: str, user: str + ) -> None: + print(server, token, user) + + + if __name__ == "__main__": + metric_example() + + """ + + 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[click.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) + + +class MetricParam(ParamType): + """ + Converts the input to a valid metric. + + The String has to follow the + `Metric Syntax `_ + + It is recommended to use the :py:func:`~metricq.cli.decorator.metricq_metric_option` or the + :py:func:`~metricq.cli.decorator.metricq_token_option` decorator instead of using this class directly. + + """ + + pattern = re.compile(r"([a-zA-Z][a-zA-Z0-9_]+\.)+[a-zA-Z][a-zA-Z0-9_]+") + + def convert( + self, value: Any, param: Optional[click.Parameter], ctx: Optional[Context] + ) -> str: + if not isinstance(value, str): + raise TypeError("expected a string type for the MetricParam") + if not self.pattern.match(value): + raise ValueError(f"Invalid metric format: '{value}'.") + return value diff --git a/setup.cfg b/setup.cfg index 4fd874b9..9fcbdb27 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ lint = check-manifest pre-commit test = + %(cli)s pytest pytest-asyncio pytest-mock @@ -51,6 +52,7 @@ typing = %(examples)s %(test)s docs = + %(pandas)s sphinx ~= 6.1.3 sphinx_rtd_theme ~= 1.2.0 sphinx_autodoc_typehints ~= 1.22.0 @@ -62,9 +64,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 +133,5 @@ deps = .[lint] commands = flake8 . [testenv:mypy] -deps = .[typing] +deps = .[typing, cli] commands = mypy --strict metricq examples tests setup.py diff --git a/setup.py b/setup.py index 57b3db4e..5e61d0d0 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ from bisect import bisect_right from datetime import datetime from distutils.errors import DistutilsFileError -from distutils.log import ERROR, INFO # type: ignore +from distutils.log import ERROR, INFO from distutils.spawn import find_executable from operator import itemgetter from typing import Any, Iterable, Optional diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..a4bba9f0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,123 @@ +import getpass +import socket +from enum import Enum, auto +from typing import Union + +import pytest +from click import ParamType + +from metricq import Timedelta, Timestamp +from metricq.cli import ( + ChoiceParam, + CommandLineChoice, + DurationParam, + TemplateStringParam, + TimestampParam, +) + +TIMESTAMP = TimestampParam() + + +class Choice(CommandLineChoice, Enum): + Foo = auto() + BarBaz = auto() + + +@pytest.mark.parametrize( + "param_class", + [DurationParam(default=None), TimestampParam(), ChoiceParam(Choice, name="test")], +) +def test_click_param_contracts(param_class: ParamType) -> None: + """Custom parameter types should meet these requirements. + + See https://click.palletsprojects.com/en/7.x/api/#click.ParamType. + """ + assert isinstance(param_class.name, str) + assert ( + param_class.convert( + None, + param=None, + ctx=None, + ) + is None + ) + + +@pytest.mark.parametrize( + ("value", "converted"), + [ + ("foo", Choice.Foo), + ("bar-baz", Choice.BarBaz), + (Choice.Foo, Choice.Foo), + ], +) +def test_choice_param_to_instance(value: Union[str, Choice], converted: Choice) -> None: + CHOICE = ChoiceParam(Choice, name="test") + + assert CHOICE.convert(value, param=None, ctx=None) is converted + + +def test_choice_to_param_list() -> None: + CHOICE = ChoiceParam(Choice, name="test") + + assert CHOICE.get_metavar(param=None) == "(foo|bar-baz)" # type: ignore + + +def test_timestamp_param() -> None: + value = "2021-05-02T00:00:00Z" + assert TIMESTAMP.convert(value, param=None, ctx=None) == Timestamp.from_iso8601( + value + ) + + +def test_timestamp_posix_param() -> None: + value = "1685782873.5" + assert TIMESTAMP.convert( + value, param=None, ctx=None + ) == Timestamp.from_posix_seconds(float(value)) + + +def test_timestamp_now_param() -> None: + value = "now" + now = Timestamp.now() + converted = TIMESTAMP.convert(value, param=None, ctx=None) + assert isinstance(converted, Timestamp) + assert converted - now < Timedelta.from_s(1) + + +def test_timestamp_epoch_param() -> None: + value = "epoch" + assert TIMESTAMP.convert( + value, param=None, ctx=None + ) == Timestamp.from_posix_seconds(0) + + +def test_timestamp_past_param() -> None: + value = "-1h" + ref = Timestamp.now() - Timedelta.from_s(3600) + converted = TIMESTAMP.convert(value, param=None, ctx=None) + assert isinstance(converted, Timestamp) + assert converted - ref < Timedelta.from_s(1) + + +def test_duration_param() -> None: + value = "30s" + DURATION = DurationParam(default=None) + assert DURATION.convert(value, param=None, ctx=None) == Timedelta.from_string(value) + + +def test_template_string_param_user() -> None: + value = "foo-$USER-bar" + ref = f"foo-{getpass.getuser()}-bar" + assert TemplateStringParam()(value, param=None, ctx=None) == ref + + +def test_template_string_param_host() -> None: + value = "foo-$HOST-bar" + ref = f"foo-{socket.gethostname()}-bar" + assert TemplateStringParam()(value, param=None, ctx=None) == ref + + +def test_template_string_param_unknown() -> None: + value = "foo-$NOONEWILLEVERSETTHISVARIABLE" + assert TemplateStringParam()(value, param=None, ctx=None) == value