pglift-cli 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pglift_cli/__init__.py +7 -0
- pglift_cli/__main__.py +10 -0
- pglift_cli/_settings.py +44 -0
- pglift_cli/_site.py +34 -0
- pglift_cli/base.py +53 -0
- pglift_cli/console.py +9 -0
- pglift_cli/database.py +260 -0
- pglift_cli/hookspecs.py +24 -0
- pglift_cli/instance.py +461 -0
- pglift_cli/main.py +277 -0
- pglift_cli/model.py +346 -0
- pglift_cli/patroni.py +37 -0
- pglift_cli/pgbackrest/__init__.py +101 -0
- pglift_cli/pgbackrest/repo_path.py +40 -0
- pglift_cli/pgconf.py +151 -0
- pglift_cli/pm.py +19 -0
- pglift_cli/postgres.py +30 -0
- pglift_cli/prometheus.py +138 -0
- pglift_cli/role.py +197 -0
- pglift_cli/util.py +576 -0
- pglift_cli-1.3.0.dist-info/METADATA +59 -0
- pglift_cli-1.3.0.dist-info/RECORD +24 -0
- pglift_cli-1.3.0.dist-info/WHEEL +4 -0
- pglift_cli-1.3.0.dist-info/entry_points.txt +8 -0
pglift_cli/main.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2021 Dalibo
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import pathlib
|
|
9
|
+
import sys
|
|
10
|
+
import warnings
|
|
11
|
+
from functools import partial
|
|
12
|
+
from importlib.metadata import version
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import click.exceptions
|
|
17
|
+
import rich.logging
|
|
18
|
+
import rich.text
|
|
19
|
+
import rich.tree
|
|
20
|
+
import yaml
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.highlighter import NullHighlighter
|
|
23
|
+
from rich.syntax import Syntax
|
|
24
|
+
|
|
25
|
+
from pglift import install, ui
|
|
26
|
+
from pglift._compat import assert_never
|
|
27
|
+
|
|
28
|
+
from . import __name__ as pkgname
|
|
29
|
+
from . import _site
|
|
30
|
+
from ._settings import Settings
|
|
31
|
+
from .base import CLIGroup
|
|
32
|
+
from .console import console as console
|
|
33
|
+
from .util import (
|
|
34
|
+
InteractiveUserInterface,
|
|
35
|
+
LogDisplayer,
|
|
36
|
+
Obj,
|
|
37
|
+
OutputFormat,
|
|
38
|
+
async_command,
|
|
39
|
+
output_format_option,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def completion(
|
|
44
|
+
context: click.Context,
|
|
45
|
+
param: click.Parameter,
|
|
46
|
+
value: Literal["bash", "fish", "zsh"],
|
|
47
|
+
) -> None:
|
|
48
|
+
if not value or context.resilient_parsing:
|
|
49
|
+
return
|
|
50
|
+
shell_complete_class_map = {
|
|
51
|
+
"bash": click.shell_completion.BashComplete,
|
|
52
|
+
"fish": click.shell_completion.FishComplete,
|
|
53
|
+
"zsh": click.shell_completion.ZshComplete,
|
|
54
|
+
}
|
|
55
|
+
click.echo(
|
|
56
|
+
shell_complete_class_map[value](cli, {}, "pglift", "_PGLIFT_COMPLETE").source(),
|
|
57
|
+
nl=False,
|
|
58
|
+
)
|
|
59
|
+
context.exit()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_version(context: click.Context, param: click.Parameter, value: bool) -> None:
|
|
63
|
+
if not value or context.resilient_parsing:
|
|
64
|
+
return
|
|
65
|
+
cli_version, lib_version = version(pkgname), version("pglift")
|
|
66
|
+
if cli_version == lib_version:
|
|
67
|
+
click.echo(f"pglift version {cli_version}")
|
|
68
|
+
else:
|
|
69
|
+
click.echo(
|
|
70
|
+
f"pglift version {version(pkgname)} (library version {version('pglift')})"
|
|
71
|
+
)
|
|
72
|
+
context.exit()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def log_level(
|
|
76
|
+
context: click.Context, param: click.Parameter, value: str | None
|
|
77
|
+
) -> int | None:
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
return getattr(logging, value) # type: ignore[no-any-return]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.group(cls=CLIGroup)
|
|
84
|
+
@click.option(
|
|
85
|
+
"-L",
|
|
86
|
+
"--log-level",
|
|
87
|
+
type=click.Choice(
|
|
88
|
+
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
|
|
89
|
+
),
|
|
90
|
+
default=None,
|
|
91
|
+
callback=log_level,
|
|
92
|
+
help="Set log threshold (default to INFO when logging to stderr or WARNING when logging to a file).",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"-l",
|
|
96
|
+
"--log-file",
|
|
97
|
+
type=click.Path(dir_okay=False, resolve_path=True, path_type=pathlib.Path),
|
|
98
|
+
metavar="LOGFILE",
|
|
99
|
+
help="Write logs to LOGFILE, instead of stderr.",
|
|
100
|
+
)
|
|
101
|
+
@click.option(
|
|
102
|
+
"--debug",
|
|
103
|
+
is_flag=True,
|
|
104
|
+
default=False,
|
|
105
|
+
hidden=True,
|
|
106
|
+
help="Set log level to DEBUG and eventually display tracebacks.",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--interactive/--non-interactive",
|
|
110
|
+
default=True,
|
|
111
|
+
help=(
|
|
112
|
+
"Interactively prompt for confirmation when needed (the default), "
|
|
113
|
+
"or automatically pick the default option for all choices."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--version",
|
|
118
|
+
is_flag=True,
|
|
119
|
+
callback=print_version,
|
|
120
|
+
expose_value=False,
|
|
121
|
+
is_eager=True,
|
|
122
|
+
help="Show program version.",
|
|
123
|
+
)
|
|
124
|
+
@click.option(
|
|
125
|
+
"--completion",
|
|
126
|
+
type=click.Choice(["bash", "fish", "zsh"]),
|
|
127
|
+
callback=completion,
|
|
128
|
+
expose_value=False,
|
|
129
|
+
is_eager=True,
|
|
130
|
+
help="Output completion for specified shell and exit.",
|
|
131
|
+
)
|
|
132
|
+
@click.pass_context
|
|
133
|
+
def cli(
|
|
134
|
+
context: click.Context,
|
|
135
|
+
log_level: int | None,
|
|
136
|
+
log_file: pathlib.Path | None,
|
|
137
|
+
debug: bool,
|
|
138
|
+
interactive: bool,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Deploy production-ready instances of PostgreSQL"""
|
|
141
|
+
if (cli_version := version(pkgname)) != (lib_version := version("pglift")):
|
|
142
|
+
warnings.warn(
|
|
143
|
+
f"possibly incompatible versions of the library and CLI packages: {lib_version}, {cli_version}",
|
|
144
|
+
RuntimeWarning,
|
|
145
|
+
stacklevel=1,
|
|
146
|
+
)
|
|
147
|
+
if not context.obj:
|
|
148
|
+
context.obj = Obj(
|
|
149
|
+
displayer=None if log_file else LogDisplayer(),
|
|
150
|
+
debug=debug,
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
assert isinstance(context.obj, Obj), context.obj
|
|
154
|
+
|
|
155
|
+
ui_token: ui.Token | None = None
|
|
156
|
+
if interactive:
|
|
157
|
+
ui_token = ui.set(InteractiveUserInterface())
|
|
158
|
+
|
|
159
|
+
loggers = [logging.getLogger(n) for n in ("pglift", "dotenv", "filelock")]
|
|
160
|
+
for logger in loggers:
|
|
161
|
+
logger.setLevel(logging.DEBUG)
|
|
162
|
+
if debug:
|
|
163
|
+
log_level = logging.DEBUG
|
|
164
|
+
handler: logging.Handler | rich.logging.RichHandler
|
|
165
|
+
if log_file or not sys.stderr.isatty():
|
|
166
|
+
if log_file:
|
|
167
|
+
handler = logging.FileHandler(log_file)
|
|
168
|
+
context.call_on_close(handler.close)
|
|
169
|
+
else:
|
|
170
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
171
|
+
cli_settings = _site.SETTINGS.cli
|
|
172
|
+
formatter = logging.Formatter(
|
|
173
|
+
fmt=cli_settings.log_format, datefmt=cli_settings.date_format
|
|
174
|
+
)
|
|
175
|
+
handler.setFormatter(formatter)
|
|
176
|
+
handler.setLevel(log_level or logging.WARNING)
|
|
177
|
+
else:
|
|
178
|
+
handler = rich.logging.RichHandler(
|
|
179
|
+
level=log_level or logging.INFO,
|
|
180
|
+
console=Console(stderr=True),
|
|
181
|
+
show_time=False,
|
|
182
|
+
show_path=False,
|
|
183
|
+
highlighter=NullHighlighter(),
|
|
184
|
+
)
|
|
185
|
+
for logger in loggers:
|
|
186
|
+
logger.addHandler(handler)
|
|
187
|
+
# Remove rich handler on close since this would pollute all tests stderr
|
|
188
|
+
# otherwise.
|
|
189
|
+
context.call_on_close(partial(logger.removeHandler, handler))
|
|
190
|
+
# Reset contextvars
|
|
191
|
+
if ui_token is not None:
|
|
192
|
+
context.call_on_close(partial(ui.reset, ui_token))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@cli.command("site-settings", hidden=True)
|
|
196
|
+
@click.option(
|
|
197
|
+
"--defaults/--no-defaults",
|
|
198
|
+
default=None,
|
|
199
|
+
help="Output only default settings, or only site configuration.",
|
|
200
|
+
show_default=True,
|
|
201
|
+
)
|
|
202
|
+
@click.option(
|
|
203
|
+
"--schema", is_flag=True, help="Print the JSON Schema of site settings model."
|
|
204
|
+
)
|
|
205
|
+
@output_format_option
|
|
206
|
+
@click.pass_context
|
|
207
|
+
def site_settings(
|
|
208
|
+
context: click.Context,
|
|
209
|
+
/,
|
|
210
|
+
defaults: bool | None,
|
|
211
|
+
schema: bool,
|
|
212
|
+
output_format: OutputFormat,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Show site settings.
|
|
215
|
+
|
|
216
|
+
Without any option, the combination of site configuration and default
|
|
217
|
+
values is shown.
|
|
218
|
+
With --defaults, only default values and those depending on the
|
|
219
|
+
environment are shown (not accounting for site configuration).
|
|
220
|
+
With --no-defaults, the site configuration is shown alone and default
|
|
221
|
+
values are excluded.
|
|
222
|
+
"""
|
|
223
|
+
if schema:
|
|
224
|
+
value = Settings.model_json_schema()
|
|
225
|
+
else:
|
|
226
|
+
if defaults:
|
|
227
|
+
value = _site.DEFAULT_SETTINGS.model_dump(mode="json")
|
|
228
|
+
else:
|
|
229
|
+
value = _site.SETTINGS.model_dump(
|
|
230
|
+
mode="json", exclude_defaults=defaults is False
|
|
231
|
+
)
|
|
232
|
+
if output_format == OutputFormat.json:
|
|
233
|
+
console.print_json(data=value)
|
|
234
|
+
else:
|
|
235
|
+
assert output_format is None
|
|
236
|
+
output = yaml.safe_dump(value)
|
|
237
|
+
syntax = Syntax(output, "yaml", background_color="default")
|
|
238
|
+
console.print(syntax)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@cli.command(
|
|
242
|
+
"site-configure",
|
|
243
|
+
hidden=True,
|
|
244
|
+
)
|
|
245
|
+
@click.option(
|
|
246
|
+
"--settings",
|
|
247
|
+
"settings_file",
|
|
248
|
+
type=click.Path(exists=True, path_type=pathlib.Path),
|
|
249
|
+
help="Custom settings file.",
|
|
250
|
+
)
|
|
251
|
+
@click.argument(
|
|
252
|
+
"action", type=click.Choice(["install", "uninstall", "check"]), default="install"
|
|
253
|
+
)
|
|
254
|
+
@click.pass_obj
|
|
255
|
+
@click.pass_context
|
|
256
|
+
@async_command
|
|
257
|
+
async def site_configure(
|
|
258
|
+
context: click.Context,
|
|
259
|
+
obj: Obj,
|
|
260
|
+
action: Literal["install", "uninstall", "check"],
|
|
261
|
+
settings_file: pathlib.Path | None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Manage installation of extra data files for pglift.
|
|
264
|
+
|
|
265
|
+
This is an INTERNAL command.
|
|
266
|
+
"""
|
|
267
|
+
with obj.lock:
|
|
268
|
+
if action == "install":
|
|
269
|
+
env = {"SETTINGS": f"@{settings_file}"} if settings_file else {}
|
|
270
|
+
await install.do(_site.SETTINGS, env=env)
|
|
271
|
+
elif action == "uninstall":
|
|
272
|
+
await install.undo(_site.SETTINGS)
|
|
273
|
+
elif action == "check":
|
|
274
|
+
if not install.check(_site.SETTINGS):
|
|
275
|
+
context.exit(1)
|
|
276
|
+
else:
|
|
277
|
+
assert_never(action)
|
pglift_cli/model.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 Dalibo
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import enum
|
|
8
|
+
import functools
|
|
9
|
+
import inspect
|
|
10
|
+
import logging
|
|
11
|
+
import typing
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, ClassVar, TypeVar
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
import pydantic
|
|
20
|
+
from pydantic.fields import FieldInfo
|
|
21
|
+
from pydantic.v1.utils import deep_update, lenient_issubclass
|
|
22
|
+
|
|
23
|
+
from pglift import exceptions
|
|
24
|
+
from pglift._compat import zip
|
|
25
|
+
from pglift.models.helpers import Operation, is_optional, optional_type
|
|
26
|
+
from pglift.types import CLIConfig, StrEnum, field_annotation
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
ModelType = type[pydantic.BaseModel]
|
|
31
|
+
T = TypeVar("T", bound=pydantic.BaseModel)
|
|
32
|
+
Callback = Callable[..., Any]
|
|
33
|
+
ClickDecorator = Callable[[Callback], Callback]
|
|
34
|
+
DEFAULT = object()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def as_parameters(
|
|
38
|
+
model_type: ModelType, operation: Operation, *, parse_model: bool = True
|
|
39
|
+
) -> ClickDecorator:
|
|
40
|
+
"""Attach click parameters (arguments or options) built from a pydantic
|
|
41
|
+
model to the command.
|
|
42
|
+
|
|
43
|
+
The argument in callback function must match the base name (lower-case) of
|
|
44
|
+
the pydantic model class. Otherwise, a TypeError is raised.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def decorator(f: Callback) -> Callback:
|
|
48
|
+
modelnames_and_argnames, paramspecs = zip(
|
|
49
|
+
*reversed(list(_paramspecs_from_model(model_type, operation))), strict=True
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def params_to_modelargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
args = {}
|
|
54
|
+
for modelname, argname in modelnames_and_argnames:
|
|
55
|
+
value = kwargs.pop(argname)
|
|
56
|
+
if value is DEFAULT:
|
|
57
|
+
continue
|
|
58
|
+
args[modelname] = value
|
|
59
|
+
return args
|
|
60
|
+
|
|
61
|
+
if parse_model:
|
|
62
|
+
s = inspect.signature(f)
|
|
63
|
+
model_argname = model_type.__name__.lower()
|
|
64
|
+
try:
|
|
65
|
+
model_param = s.parameters[model_argname]
|
|
66
|
+
except KeyError as e:
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"expecting a '{model_argname}: {model_type.__name__}' parameter in '{f.__name__}{s}'"
|
|
69
|
+
) from e
|
|
70
|
+
ptype = model_param.annotation
|
|
71
|
+
if isinstance(ptype, str):
|
|
72
|
+
# The annotation is "stringized"; we thus follow the wrapper
|
|
73
|
+
# chain as suggested in Python how-to about annotations.
|
|
74
|
+
# Implementation is simplified version of inspect.get_annotations().
|
|
75
|
+
w = f
|
|
76
|
+
while True:
|
|
77
|
+
if hasattr(w, "__wrapped__"):
|
|
78
|
+
w = w.__wrapped__
|
|
79
|
+
elif isinstance(w, functools.partial):
|
|
80
|
+
w = w.func
|
|
81
|
+
else:
|
|
82
|
+
break
|
|
83
|
+
if hasattr(w, "__globals__"):
|
|
84
|
+
f_globals = w.__globals__
|
|
85
|
+
ptype = eval(ptype, f_globals, None) # nosec: B307
|
|
86
|
+
if ptype not in (
|
|
87
|
+
model_type,
|
|
88
|
+
inspect.Signature.empty,
|
|
89
|
+
) and not issubclass(model_type, ptype):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"expecting a '{model_argname}: {model_type.__name__}' parameter in '{f.__name__}{s}'; got {model_param.annotation}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@functools.wraps(f)
|
|
95
|
+
def callback(**kwargs: Any) -> Any:
|
|
96
|
+
args = params_to_modelargs(kwargs)
|
|
97
|
+
with catch_validationerror(*paramspecs):
|
|
98
|
+
model = parse_params_as(model_type, args)
|
|
99
|
+
kwargs[model_argname] = model
|
|
100
|
+
return f(**kwargs)
|
|
101
|
+
|
|
102
|
+
else:
|
|
103
|
+
|
|
104
|
+
@functools.wraps(f)
|
|
105
|
+
def callback(**kwargs: Any) -> Any:
|
|
106
|
+
args = params_to_modelargs(kwargs)
|
|
107
|
+
values = unnest(model_type, args)
|
|
108
|
+
kwargs.update(values)
|
|
109
|
+
with catch_validationerror(*paramspecs):
|
|
110
|
+
return f(**kwargs)
|
|
111
|
+
|
|
112
|
+
cb = callback
|
|
113
|
+
for p in paramspecs:
|
|
114
|
+
cb = p.decorator(cb)
|
|
115
|
+
return cb
|
|
116
|
+
|
|
117
|
+
return decorator
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_params_as(model_type: type[T], params: dict[str, Any]) -> T:
|
|
121
|
+
obj = unnest(model_type, params)
|
|
122
|
+
return model_type.model_validate(obj)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unnest(model_type: type[T], params: dict[str, Any]) -> dict[str, Any]:
|
|
126
|
+
if is_optional(model_type):
|
|
127
|
+
model_type = optional_type(model_type)
|
|
128
|
+
known_fields: dict[str, FieldInfo] = {}
|
|
129
|
+
for fname, f in model_type.model_fields.items():
|
|
130
|
+
if config := field_annotation(f, CLIConfig):
|
|
131
|
+
if config.hide:
|
|
132
|
+
continue
|
|
133
|
+
known_fields[(f.alias or fname)] = f
|
|
134
|
+
obj: dict[str, Any] = {}
|
|
135
|
+
for k, v in params.items():
|
|
136
|
+
if v is None:
|
|
137
|
+
continue
|
|
138
|
+
if k in known_fields:
|
|
139
|
+
obj[k] = v
|
|
140
|
+
elif "_" in k:
|
|
141
|
+
p, subk = k.split("_", 1)
|
|
142
|
+
try:
|
|
143
|
+
field = known_fields[p]
|
|
144
|
+
except KeyError as e:
|
|
145
|
+
raise ValueError(k) from e
|
|
146
|
+
assert field.annotation is not None
|
|
147
|
+
nested = unnest(field.annotation, {subk: v})
|
|
148
|
+
obj[p] = deep_update(obj.get(p, {}), nested)
|
|
149
|
+
else:
|
|
150
|
+
raise ValueError(k)
|
|
151
|
+
return obj
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class ParamSpec(ABC):
|
|
156
|
+
"""Intermediate representation for a future click.Parameter."""
|
|
157
|
+
|
|
158
|
+
param_decls: Sequence[str]
|
|
159
|
+
attrs: dict[str, Any]
|
|
160
|
+
loc: tuple[str, ...]
|
|
161
|
+
|
|
162
|
+
objtype: ClassVar = click.Parameter
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
@abstractmethod
|
|
166
|
+
def decorator(self) -> ClickDecorator:
|
|
167
|
+
"""The click decorator for this parameter."""
|
|
168
|
+
|
|
169
|
+
def match_loc(self, loc: tuple[str | int, ...]) -> bool:
|
|
170
|
+
"""Return True if this parameter spec matches a 'loc' tuple (from
|
|
171
|
+
pydantic.ValidationError).
|
|
172
|
+
"""
|
|
173
|
+
return self.loc == loc
|
|
174
|
+
|
|
175
|
+
def badparameter_exception(self, message: str) -> click.BadParameter:
|
|
176
|
+
return click.BadParameter(
|
|
177
|
+
message, None, param=self.objtype(self.param_decls, **self.attrs)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ArgumentSpec(ParamSpec):
|
|
182
|
+
"""Intermediate representation for a future click.Argument."""
|
|
183
|
+
|
|
184
|
+
objtype: ClassVar = click.Argument
|
|
185
|
+
|
|
186
|
+
def __post_init__(self) -> None:
|
|
187
|
+
assert (
|
|
188
|
+
len(self.param_decls) == 1
|
|
189
|
+
), f"expecting exactly one parameter declaration: {self.param_decls}"
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def decorator(self) -> ClickDecorator:
|
|
193
|
+
return click.argument(*self.param_decls, **self.attrs)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class OptionSpec(ParamSpec):
|
|
197
|
+
"""Intermediate representation for a future click.Option."""
|
|
198
|
+
|
|
199
|
+
objtype: ClassVar = click.Option
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def decorator(self) -> ClickDecorator:
|
|
203
|
+
return click.option(*self.param_decls, **self.attrs)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class _Parent:
|
|
208
|
+
argname: str
|
|
209
|
+
required: bool
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _paramspecs_from_model(
|
|
213
|
+
model_type: ModelType,
|
|
214
|
+
operation: Operation,
|
|
215
|
+
*,
|
|
216
|
+
_parents: tuple[_Parent, ...] = (),
|
|
217
|
+
) -> Iterator[tuple[tuple[str, str], ParamSpec]]:
|
|
218
|
+
"""Yield parameter declarations for click corresponding to fields of a
|
|
219
|
+
pydantic model type.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def default(ctx: click.Context, param: click.Argument, value: Any) -> Any:
|
|
223
|
+
if (param.multiple and value == ()) or (value == param.default):
|
|
224
|
+
return DEFAULT
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
for fname, field in model_type.model_fields.items():
|
|
228
|
+
modelname = argname = field.alias or fname
|
|
229
|
+
if config := field_annotation(field, CLIConfig):
|
|
230
|
+
if config.hide:
|
|
231
|
+
continue
|
|
232
|
+
if config.name is not None:
|
|
233
|
+
argname = config.name
|
|
234
|
+
if (
|
|
235
|
+
operation == "update"
|
|
236
|
+
and isinstance(field.json_schema_extra, dict)
|
|
237
|
+
and field.json_schema_extra.get("readOnly")
|
|
238
|
+
):
|
|
239
|
+
continue
|
|
240
|
+
ftype = field.annotation
|
|
241
|
+
assert ftype is not None
|
|
242
|
+
if is_optional(ftype):
|
|
243
|
+
ftype = optional_type(ftype)
|
|
244
|
+
origin_type = typing.get_origin(ftype)
|
|
245
|
+
if origin_type is typing.Annotated:
|
|
246
|
+
ftype = typing.get_args(ftype)[0]
|
|
247
|
+
assert ftype is not None
|
|
248
|
+
nested = lenient_issubclass(origin_type or ftype, pydantic.BaseModel)
|
|
249
|
+
required = field.is_required()
|
|
250
|
+
if not nested and not _parents and required:
|
|
251
|
+
yield (modelname, argname), ArgumentSpec(
|
|
252
|
+
(argname.replace("_", "-"),), {"type": ftype}, loc=(modelname,)
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
metavar: str | None
|
|
256
|
+
if config and config.metavar is not None:
|
|
257
|
+
metavar = config.metavar
|
|
258
|
+
else:
|
|
259
|
+
metavar = argname
|
|
260
|
+
if metavar is not None:
|
|
261
|
+
metavar = metavar.upper()
|
|
262
|
+
argparts = tuple(p.argname for p in _parents) + tuple(argname.split("_"))
|
|
263
|
+
fname = f"--{'-'.join(argparts)}"
|
|
264
|
+
description = None
|
|
265
|
+
if field.description:
|
|
266
|
+
description = field.description
|
|
267
|
+
description = description[0].upper() + description[1:]
|
|
268
|
+
attrs: dict[str, Any] = {}
|
|
269
|
+
if origin_type is typing.Literal:
|
|
270
|
+
choices = list(typing.get_args(ftype))
|
|
271
|
+
if len(choices) == 1: # const
|
|
272
|
+
continue
|
|
273
|
+
if config and config.choices is not None:
|
|
274
|
+
choices = config.choices
|
|
275
|
+
attrs["type"] = click.Choice(choices)
|
|
276
|
+
metavar = None
|
|
277
|
+
elif lenient_issubclass(ftype, enum.Enum):
|
|
278
|
+
if config and config.choices is not None:
|
|
279
|
+
choices = config.choices
|
|
280
|
+
else:
|
|
281
|
+
choices = choices_from_enum(ftype)
|
|
282
|
+
attrs["type"] = click.Choice(choices)
|
|
283
|
+
elif nested:
|
|
284
|
+
yield from _paramspecs_from_model(
|
|
285
|
+
ftype, operation, _parents=_parents + (_Parent(argname, required),)
|
|
286
|
+
)
|
|
287
|
+
continue
|
|
288
|
+
elif lenient_issubclass(origin_type or ftype, list):
|
|
289
|
+
if operation != "create":
|
|
290
|
+
continue
|
|
291
|
+
attrs["multiple"] = True
|
|
292
|
+
try:
|
|
293
|
+
(itemtype,) = ftype.__args__
|
|
294
|
+
except ValueError:
|
|
295
|
+
pass
|
|
296
|
+
else:
|
|
297
|
+
if lenient_issubclass(itemtype, enum.Enum):
|
|
298
|
+
attrs["type"] = click.Choice(choices_from_enum(itemtype))
|
|
299
|
+
else:
|
|
300
|
+
attrs["metavar"] = metavar
|
|
301
|
+
elif lenient_issubclass(ftype, pydantic.SecretStr):
|
|
302
|
+
attrs["prompt"] = (
|
|
303
|
+
description.rstrip(".") if description is not None else True
|
|
304
|
+
)
|
|
305
|
+
attrs["prompt_required"] = False
|
|
306
|
+
attrs["confirmation_prompt"] = True
|
|
307
|
+
attrs["hide_input"] = True
|
|
308
|
+
elif lenient_issubclass(ftype, bool):
|
|
309
|
+
fname = f"{fname}/--no-{fname[2:]}"
|
|
310
|
+
# Use None to distinguish unspecified option from the default value.
|
|
311
|
+
attrs["default"] = None
|
|
312
|
+
else:
|
|
313
|
+
attrs["metavar"] = metavar
|
|
314
|
+
if description is not None:
|
|
315
|
+
if description[-1] not in ".?":
|
|
316
|
+
description += "."
|
|
317
|
+
attrs["help"] = description
|
|
318
|
+
if field.is_required() and all(p.required for p in _parents):
|
|
319
|
+
attrs["required"] = True
|
|
320
|
+
argname = "_".join(argparts)
|
|
321
|
+
loc = tuple(p.argname for p in _parents) + (modelname,)
|
|
322
|
+
modelname = "_".join(loc)
|
|
323
|
+
yield (modelname, argname), OptionSpec(
|
|
324
|
+
(fname,), {"callback": default, **attrs}, loc=loc
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def choices_from_enum(e: type[enum.Enum]) -> list[Any]:
|
|
329
|
+
if lenient_issubclass(e, StrEnum):
|
|
330
|
+
return list(e)
|
|
331
|
+
else:
|
|
332
|
+
return [v.value for v in e]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@contextmanager
|
|
336
|
+
def catch_validationerror(*paramspec: ParamSpec) -> Iterator[None]:
|
|
337
|
+
try:
|
|
338
|
+
yield None
|
|
339
|
+
except (exceptions.ValidationError, pydantic.ValidationError) as e:
|
|
340
|
+
errors = e.errors()
|
|
341
|
+
for pspec in paramspec:
|
|
342
|
+
for err in errors:
|
|
343
|
+
if pspec.match_loc(err["loc"]):
|
|
344
|
+
raise pspec.badparameter_exception(err["msg"]) from None
|
|
345
|
+
logger.debug("a validation error occurred", exc_info=True)
|
|
346
|
+
raise click.ClickException(str(e)) from None
|
pglift_cli/patroni.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2021 Dalibo
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from pglift import patroni
|
|
12
|
+
from pglift.models import system
|
|
13
|
+
from pglift.patroni import impl
|
|
14
|
+
from pglift.patroni import register_if as register_if # noqa: F401
|
|
15
|
+
|
|
16
|
+
from . import _site, hookimpl
|
|
17
|
+
from .util import Group, instance_identifier_option, pass_instance
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group("patroni", cls=Group)
|
|
21
|
+
@instance_identifier_option
|
|
22
|
+
def cli(**kwargs: Any) -> None:
|
|
23
|
+
"""Handle Patroni service for an instance."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cli.command("logs")
|
|
27
|
+
@pass_instance
|
|
28
|
+
def logs(instance: system.Instance) -> None:
|
|
29
|
+
"""Output Patroni logs."""
|
|
30
|
+
settings = patroni.get_settings(_site.SETTINGS)
|
|
31
|
+
for line in impl.logs(instance.qualname, settings):
|
|
32
|
+
click.echo(line, nl=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@hookimpl
|
|
36
|
+
def command() -> click.Group:
|
|
37
|
+
return cli
|