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/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