Skip to content

Commit 6f22ca9

Browse files
authored
Refactoring/260 added unit tests for cli param wrappers (#261)
* Added unit tests for SCS CLI parameter wrappers * Updated exasol-transformers-extension
1 parent f38eaaa commit 6f22ca9

File tree

8 files changed

+407
-13
lines changed

8 files changed

+407
-13
lines changed

doc/changes/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ This release marks most of the NC's dependencies in file `pyproject.toml` as _op
1111
## Refactorings
1212

1313
* #253: Made dependencies optional in file `pyproject.toml`
14+
* #260: Added unit tests for CLI param wrappers

exasol/nb_connector/cli/options/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import click
44

5+
from exasol.nb_connector.ai_lab_config import AILabConfig as CKey
56
from exasol.nb_connector.cli.param_wrappers import (
67
ScsArgument,
78
ScsOption,
@@ -31,5 +32,6 @@
3132
metavar="DB_SCHEMA",
3233
type=str,
3334
help="Database schema for installing UDFs of Exasol extensions",
35+
scs_key=CKey.db_schema,
3436
)
3537
]

exasol/nb_connector/cli/param_wrappers.py

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,71 @@
22
Wrappers for adding custom properties to click parameters, e.g. SCS key.
33
"""
44

5+
import getpass
6+
import os
7+
import re
8+
from abc import abstractmethod
9+
from typing import Any
10+
511
import click
612

713
from exasol.nb_connector.ai_lab_config import AILabConfig as CKey
14+
from exasol.nb_connector.cli import reporting as report
15+
from exasol.nb_connector.secret_store import Secrets
816

917

10-
class ScsArgument:
18+
class ScsParam:
1119
"""
12-
Represents a CLI argument for the SCS command.
20+
Abstract base class for ScsArgument and ScsOption.
1321
"""
1422

15-
def __init__(self, *args, scs_key: CKey | None = None, **kwargs):
16-
self._args = args
23+
def __init__(self, scs_key: CKey | None = None, **kwargs):
1724
self.scs_key = scs_key
1825
self._kwargs = kwargs
1926

27+
@property
28+
def arg_name(self) -> str:
29+
return ""
30+
31+
def needs_entry(self, scs: Secrets) -> bool:
32+
return False
33+
34+
@property
35+
def default(self) -> Any:
36+
return self._kwargs.get("default")
37+
38+
def displayed_value(self, scs: Secrets) -> str | None:
39+
return None
40+
41+
@abstractmethod
42+
def decorate(self, func):
43+
"""
44+
This method is to be called when decorating the functions in the
45+
actual CLI declaration.
46+
"""
47+
pass
48+
49+
50+
class ScsArgument(ScsParam):
51+
"""
52+
Represents a CLI argument for the SCS command.
53+
"""
54+
55+
def __init__(self, name: str, scs_key: CKey | None = None, **kwargs):
56+
super().__init__(scs_key, **kwargs)
57+
self.name = name
58+
2059
def decorate(self, func):
2160
"""
2261
This method is to be called when decorating the functions in the
2362
actual CLI declaration. Hence, ScsArgument calls click.argument()
2463
under the hood.
2564
"""
26-
decorator = click.argument(*self._args, **self._kwargs)
65+
decorator = click.argument(self.name, **self._kwargs)
2766
return decorator(func)
2867

2968

30-
class ScsOption(ScsArgument):
69+
class ScsOption(ScsParam):
3170
"""
3271
CLI option for saving and checking values to the Secure Configuration
3372
Storage (SCS).
@@ -53,30 +92,70 @@ class ScsOption(ScsArgument):
5392

5493
def __init__(
5594
self,
95+
cli_option,
5696
*args,
5797
scs_key: CKey | None = None,
5898
scs_alternative_key: CKey | None = None,
5999
scs_required: bool = True,
60100
get_default_from: str | None = None,
61101
**kwargs,
62102
):
63-
super().__init__(*args, scs_key=scs_key, **kwargs)
103+
super().__init__(scs_key=scs_key, **kwargs)
104+
self._cli_option = cli_option
105+
self._args = args
64106
self.scs_alternative_key = scs_alternative_key
65107
self.scs_required = scs_required
66108
self.get_default_from = get_default_from
67109

110+
def cli_option(self, full=False) -> str:
111+
raw = self._cli_option
112+
return raw if full else re.sub(r"/--.*$", "", raw)
113+
114+
@property
115+
def arg_name(self) -> str:
116+
for arg in self._args:
117+
if not arg.startswith("--"):
118+
return arg
119+
name = self.cli_option()
120+
return name[2:].replace("-", "_")
121+
68122
def decorate(self, func):
69123
"""
70124
This method is to be called when decorating the functions in the
71125
actual CLI declaration. ScsOption calls click.option().
72126
"""
73127
decorator = click.option(
128+
self._cli_option,
74129
*self._args,
75130
**self._kwargs,
76131
show_default=True,
77132
)
78133
return decorator(func)
79134

135+
def displayed_value(self, scs: Secrets) -> str | None:
136+
return scs.get(self.scs_key) if self.scs_key else None
137+
138+
def needs_entry(self, scs: Secrets) -> bool:
139+
"""
140+
Return True, if the current option is configured to be saved to
141+
the SCS but SCS does not yet contain a value.
142+
"""
143+
144+
def has_value() -> bool:
145+
if not self.scs_key:
146+
return False
147+
if scs.get(self.scs_key) is not None:
148+
return True
149+
if alt := self.scs_alternative_key:
150+
return scs.get(alt) is not None
151+
return False
152+
153+
return bool(self.scs_key) and self.scs_required and not has_value()
154+
155+
def __repr__(self) -> str:
156+
cls_name = type(self).__name__
157+
return f"{cls_name}<{self.cli_option(full=True)}>"
158+
80159

81160
class ScsSecretOption(ScsOption):
82161
"""
@@ -107,6 +186,22 @@ def __init__(
107186
self.prompt = prompt
108187
self.name = name
109188

189+
def displayed_value(self, scs: Secrets) -> str | None:
190+
return "****" if self.scs_key and scs.get(self.scs_key) else None
191+
192+
def get_secret(self, interactive: Any) -> str:
193+
"""
194+
If interactive is True and the related environment variable is not
195+
set then ask for the secret interactively.
196+
"""
197+
if value := os.getenv(self.envvar):
198+
report.info(f"Reading {self.name} from environment variable {self.envvar}.")
199+
return value
200+
if not interactive:
201+
return ""
202+
prompt = f"{self.prompt} (option {self.name}): "
203+
return getpass.getpass(prompt)
204+
110205

111206
def add_params(scs_options: list[ScsArgument]):
112207
def multi_decorator(func):
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import click
2+
3+
4+
def info(text: str):
5+
click.echo(click.style(text, fg="green"))
6+
7+
8+
def success(text: str):
9+
click.echo(click.style(text, fg="green"))
10+
11+
12+
def error(text: str):
13+
click.echo(click.style(f"Error: {text}", fg="bright_red"))
14+
15+
16+
def warning(text: str):
17+
click.echo(click.style(f"Warning: {text}", fg="yellow"))

poetry.lock

Lines changed: 31 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ force_grid_wrap = 2
116116

117117

118118
[tool.pylint.master]
119+
# Module exasol.ai.text is compiled via Cython, released as binary.
120+
# So pylint cannot verify types there.
119121
ignored-modules = [ "exasol.ai.text" ]
120122
fail-under = 8.0
121123

test/unit/cli/scs_mock.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from exasol.nb_connector.ai_lab_config import AILabConfig as CKey
2+
from exasol.nb_connector.ai_lab_config import StorageBackend
3+
4+
5+
class ScsMock:
6+
"""
7+
Instead of using a real Secure Configuration Storage, this mock
8+
simpulates it using a simple dict().
9+
"""
10+
11+
def __init__(
12+
self,
13+
backend: StorageBackend | None = None,
14+
use_itde: bool | None = None,
15+
):
16+
self._dict = dict()
17+
if backend:
18+
self.save(CKey.storage_backend, backend.name)
19+
if use_itde is not None:
20+
self.save(CKey.use_itde, str(use_itde))
21+
22+
def save(self, key: CKey, value: str) -> None:
23+
self._dict[key.name] = str(value)
24+
25+
def get(self, key: CKey, default: str | None = None) -> str | None:
26+
return self._dict.get(key.name, default)

0 commit comments

Comments
 (0)