Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/changes/unreleased.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# Unreleased

## Refactorings

* #127: Refactored class `ParameterFormatters` and docstrings
13 changes: 13 additions & 0 deletions exasol/python_extension_common/cli/_param.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class Param:
"""
Only used internally to distinguish between source and destination
parameters.
"""

name: str | None
value: Any | None
109 changes: 69 additions & 40 deletions exasol/python_extension_common/cli/std_options.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,90 @@
import os
import re
from dataclasses import dataclass
from enum import (
Enum,
Flag,
auto,
)
from pathlib import Path
from typing import (
Any,
no_type_check,
)

from exasol.python_extension_common.cli._param import Param
import click


class ParameterFormatters:
"""
Class facilitating customization of the cli.
Dynamic formatting for customized Click CLI parameters.

The idea is that some of the cli parameters can be programmatically customized based
on values of other parameters and externally supplied formatters. For example a specialized
version of the cli may want to provide its own url. Furthermore, this url will depend on
the user supplied parameter called "version". The solution is to set a formatter for the
url, for instance "http://my_stuff/{version}/my_data". If the user specifies non-empty version
parameter the url will be fully formed.
Example: A specialized variant of the CLI may want to provide a custom URL
"http://prefix/{version}/suffix" depending on CLI parameter "version". If
the user specifies version "1.2.3", then the default value for the URL
should be updated to "http://prefix/1.2.3/suffix".

A formatter may include more than one parameter. In the previous example the url could,
for instance, also include a username: "http://my_stuff/{version}/{user}/my_data".
The URL parameter in this example is called a _destination_ CLI parameter
while the version is called _source_.

Note that customized parameters can only be updated in a callback function. There is no
way to inject them directly into the cli. Also, the current implementation doesn't perform
the update if the value of the parameter dressed with the callback is None.
"""

def __init__(self):
self._formatters: dict[str, str] = {}

def __call__(self, ctx: click.Context, param: click.Parameter, value: Any | None) -> Any | None:
A destination parameter can depend on a single or multiple source
parameters. In the previous example the URL could, for instance, also
include a username: "http://prefix/{version}/{user}/suffix".

def update_parameter(parameter_name: str, formatter: str) -> None:
param_formatter = ctx.params.get(parameter_name, formatter)
if param_formatter:
# Enclose in double curly brackets all other parameters in the formatting string,
# to avoid the missing parameters' error. Below is an example of a formatter string
# before and after applying the regex, assuming the current parameter is 'version'.
# 'something-with-{version}/tailored-for-{user}' => 'something-with-{version}/tailored-for-{{user}}'
# We were looking for all occurrences of a pattern '{some_name}', where some_name is not version.
pattern = r"\{(?!" + (param.name or "") + r"\})\w+\}"
param_formatter = re.sub(pattern, lambda m: f"{{{m.group(0)}}}", param_formatter)
kwargs = {param.name: value}
ctx.params[parameter_name] = param_formatter.format(**kwargs) # type: ignore
The Click API allows updating customized parameters only in a callback
function. There is no way to inject them directly into the CLI, see
https://click.palletsprojects.com/en/stable/api/#click.Command.callback.

if value is not None:
for prm_name, prm_formatter in self._formatters.items():
update_parameter(prm_name, prm_formatter)

return value
The current implementation updates the destination parameter only if the
value of the source parameter is not ``None``.
"""

def set_formatter(self, custom_parameter_name: str, formatter: str) -> None:
"""Sets a formatter for a customizable parameter."""
self._formatters[custom_parameter_name] = formatter
def __init__(self) -> None:
# Each key/value pair represents the name of a destination parameter
# to update, and its default value.
#
# The default value can contain placeholders to be replaced by the
# values of the other parameters, called "source parameters".
self._parameters: dict[str, str] = {}

def __call__(
self, ctx: click.Context, source_param: click.Parameter, value: Any | None
) -> Any | None:
def update(source: Param, dest: Param) -> None:
if not dest.value:
return None
# Enclose in double curly brackets all other parameters in
# dest.value to avoid error "missing parameters".
#
# Below is an example of a formatter string before and after
# applying the regex, assuming the source parameter is 'version'.
#
# "something-with-{version}/tailored-for-{user}" =>
# "something-with-{version}/tailored-for-{{user}}"
#
# We were looking for all occurrences of a pattern "{xxx}", where
# xxx is not "version".
pattern = r"\{(?!" + (source.name or "") + r"\})\w+\}"
template = re.sub(pattern, lambda m: f"{{{m.group(0)}}}", dest.value)
kwargs = {source.name: source.value}
ctx.params[dest.name] = template.format(**kwargs) # type: ignore

source = Param(source_param.name, value)
if source.value is not None:
for name, default in self._parameters.items():
value = ctx.params.get(name, default)
update(source, dest=Param(name, value))

return source.value

def set_formatter(self, param_name: str, default_value: str) -> None:
"""Adds the specified destination parameter to be updated."""
self._parameters[param_name] = default_value

def clear_formatters(self):
"""Deletes all formatters, mainly for testing purposes."""
self._formatters.clear()
"""Deletes all destination parameters to be updated, mainly for testing purposes."""
self._parameters.clear()


# This text will be displayed instead of the actual value for a "secret" option.
Expand Down Expand Up @@ -253,6 +275,13 @@ def select_std_options(
override:
A dictionary of standard options with overridden properties
formatters:
A dictionary, with each key being a source CLI parameter,
see docstring of class ParameterFormatters.

Each value is an instance of ParameterFormatters representing a single
or multiple destination parameters to be updated based on the source
parameter's value. A destination parameter can be updated multiple
times, if it depends on multiple source parameters.
"""
if not isinstance(tags, list) and not isinstance(tags, str):
tags = [tags]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ class CustomizableParameters(Enum):


class _ParameterFormatters:
# See language_container_deployer_main displaying a deprecation warning.
#
# Currently, there is only one other project still using this
# implementation, see
# https://github.com/exasol/advanced-analytics-framework/issues/333.
"""
Class facilitating customization of the cli.
Expand Down
8 changes: 4 additions & 4 deletions test/unit/cli/test_std_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ def func(**kwargs):
assert kwargs[container_name_arg] == expected_name
assert kwargs[container_url_arg] == expected_url

ver_formatter = ParameterFormatters()
ver_formatter.set_formatter(container_url_arg, url_format)
ver_formatter.set_formatter(container_name_arg, name_format)
ver_formatters = ParameterFormatters()
ver_formatters.set_formatter(container_url_arg, url_format)
ver_formatters.set_formatter(container_name_arg, name_format)

opts = select_std_options(StdTags.SLC, formatters={StdParams.version: ver_formatter})
opts = select_std_options(StdTags.SLC, formatters={StdParams.version: ver_formatters})
cmd = click.Command("do_something", params=opts, callback=func)
runner = CliRunner()
runner.invoke(cmd, args=f"--version {version}", catch_exceptions=False, standalone_mode=False)
Expand Down
Loading