Skip to content
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
130 changes: 90 additions & 40 deletions exasol/python_extension_common/cli/std_options.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,108 @@
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,
)

import click

from exasol.python_extension_common.cli._param import Param

class ParameterFormatters:
"""
Class facilitating customization of the cli.

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.

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".

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.
class ParameterFormatters:
"""
The idea is that some of the CLI parameters can be programmatically
customized based on values of other parameters and externally supplied
patterns, called "formatters".

def __init__(self):
self._formatters: dict[str, str] = {}
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".

def __call__(self, ctx: click.Context, param: click.Parameter, value: Any | None) -> Any | None:
The URL parameter in this example is called a _destination_ CLI parameter
while the version is called _source_.

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
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".

if value is not None:
for prm_name, prm_formatter in self._formatters.items():
update_parameter(prm_name, prm_formatter)
The Click API allows updating customized parameters only in a callback
function. There is no way to inject them directly into the CLI, see the
docs, as ``click.Option`` inherits from ``click.Parameter``:
https://click.palletsprojects.com/en/stable/api/#click.Parameter.

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:
Copy link
Collaborator

@tkilias tkilias Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def set_formatter(self, param_name: str, default_value: str) -> None:
def set_formatter(self, destination_parameter: str, format_pattern: str) -> None:

we can't rename, because this would break the public interface, for this reason we add this information to the docstrings

"""
Adds the specified destination parameter to be updated.

Better arg names could be `destination_parameter` and `format_pattern`
but renaming the arguments would break the public interface.

Parameters:

param_name: Name of the destination parameter to be updated.

default_value: Pattern for the value to assign to the destination
parameter. The pattern may contain place holders to be
replaced by the values of other CLI parameters, called "source
parameters".
"""
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 +293,16 @@ 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.

If a particular destination parameter D depends on multiple source
parameters S1, S2, ..., then the Click API will iterate through the
source parameters Si and update D multiple times.
"""
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