pglift-cli 1.4.0__tar.gz → 1.6.0__tar.gz

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.
Files changed (27) hide show
  1. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/PKG-INFO +11 -3
  2. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/pyproject.toml +1 -1
  3. pglift_cli-1.6.0/src/pglift_cli/__init__.py +15 -0
  4. pglift_cli-1.6.0/src/pglift_cli/_settings.py +75 -0
  5. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/database.py +50 -5
  6. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/instance.py +24 -15
  7. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/main.py +13 -15
  8. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/model.py +55 -29
  9. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pgbackrest/__init__.py +2 -1
  10. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pgbackrest/repo_path.py +2 -2
  11. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pgconf.py +11 -4
  12. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/prometheus.py +8 -7
  13. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/role.py +4 -3
  14. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/util.py +102 -32
  15. pglift_cli-1.4.0/src/pglift_cli/__init__.py +0 -7
  16. pglift_cli-1.4.0/src/pglift_cli/_settings.py +0 -44
  17. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/.gitignore +0 -0
  18. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/README.md +0 -0
  19. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/hatch.toml +0 -0
  20. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/__main__.py +0 -0
  21. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/_site.py +0 -0
  22. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/base.py +0 -0
  23. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/console.py +0 -0
  24. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/hookspecs.py +0 -0
  25. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/patroni.py +0 -0
  26. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/pm.py +0 -0
  27. {pglift_cli-1.4.0 → pglift_cli-1.6.0}/src/pglift_cli/postgres.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pglift_cli
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: Command-line interface for pglift
5
5
  Project-URL: Documentation, https://pglift.readthedocs.io/
6
6
  Project-URL: Source, https://gitlab.com/dalibo/pglift/
@@ -30,7 +30,15 @@ Requires-Dist: pydantic>=2.5.0
30
30
  Requires-Dist: pyyaml>=6.0.1
31
31
  Requires-Dist: rich>=11.0.0
32
32
  Provides-Extra: dev
33
- Requires-Dist: pglift-cli[test,typing]; extra == 'dev'
33
+ Requires-Dist: anyio; extra == 'dev'
34
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
35
+ Requires-Dist: patroni[etcd]>=2.1.5; extra == 'dev'
36
+ Requires-Dist: port-for; extra == 'dev'
37
+ Requires-Dist: prysk[pytest-plugin]>=0.14.0; extra == 'dev'
38
+ Requires-Dist: pytest; extra == 'dev'
39
+ Requires-Dist: pytest-cov; extra == 'dev'
40
+ Requires-Dist: trustme; extra == 'dev'
41
+ Requires-Dist: types-pyyaml>=6.0.12.10; extra == 'dev'
34
42
  Provides-Extra: test
35
43
  Requires-Dist: anyio; extra == 'test'
36
44
  Requires-Dist: patroni[etcd]>=2.1.5; extra == 'test'
@@ -40,7 +48,7 @@ Requires-Dist: pytest; extra == 'test'
40
48
  Requires-Dist: pytest-cov; extra == 'test'
41
49
  Requires-Dist: trustme; extra == 'test'
42
50
  Provides-Extra: typing
43
- Requires-Dist: mypy>=1.8.0; extra == 'typing'
51
+ Requires-Dist: mypy>=1.10.0; extra == 'typing'
44
52
  Requires-Dist: types-pyyaml>=6.0.12.10; extra == 'typing'
45
53
  Description-Content-Type: text/markdown
46
54
 
@@ -57,7 +57,7 @@ test = [
57
57
  "trustme",
58
58
  ]
59
59
  typing = [
60
- "mypy >= 1.8.0",
60
+ "mypy >= 1.10.0",
61
61
  "types-PyYAML >= 6.0.12.10",
62
62
  ]
63
63
  dev = [
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: 2024 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import logging
6
+ import os
7
+
8
+ import pluggy
9
+
10
+ logger = logging.getLogger(__name__)
11
+ try:
12
+ loggers = list(os.environ["PGLIFT_LOGGERS"].split(","))
13
+ except KeyError:
14
+ loggers = [__name__, "pglift"]
15
+ hookimpl = pluggy.HookimplMarker(__name__)
@@ -0,0 +1,75 @@
1
+ # SPDX-FileCopyrightText: 2021 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+ from typing import Annotated, Any, Optional
8
+
9
+ from pydantic import AfterValidator, Field, ValidationInfo
10
+
11
+ from pglift.settings import Settings as BaseSettings
12
+ from pglift.settings import SiteSettings as BaseSiteSettings
13
+ from pglift.settings.base import BaseModel, LogPath, RunPath
14
+
15
+
16
+ def deprecated(value: Any, info: ValidationInfo) -> Any:
17
+ if value is not None:
18
+ warnings.warn(
19
+ f"{info.field_name!r} setting is deprecated", FutureWarning, stacklevel=2
20
+ )
21
+ return value
22
+
23
+
24
+ class AuditSettings(BaseModel):
25
+ """Settings for change operations auditing."""
26
+
27
+ path: Annotated[
28
+ Annotated[Path, LogPath],
29
+ Field(description="Log file path"),
30
+ ]
31
+ log_format: Annotated[
32
+ str,
33
+ Field(description="Format for log messages"),
34
+ ] = "%(levelname)-8s - %(asctime)s - %(name)s - %(message)s"
35
+ date_format: Annotated[
36
+ str,
37
+ Field(description="Date format in log messages"),
38
+ ] = "%Y-%m-%d %H:%M:%S"
39
+
40
+
41
+ class CLISettings(BaseModel):
42
+ """Settings for pglift's command-line interface."""
43
+
44
+ logpath: Annotated[
45
+ Annotated[Optional[Path], LogPath],
46
+ Field(
47
+ description="Directory where temporary debug files from command executions will be stored (DEPRECATED).",
48
+ ),
49
+ AfterValidator(deprecated),
50
+ ] = None
51
+
52
+ log_format: Annotated[
53
+ str, Field(description="Format for log messages when written to a file")
54
+ ] = "%(asctime)s %(levelname)-8s %(name)s - %(message)s"
55
+
56
+ date_format: Annotated[
57
+ str, Field(description="Date format in log messages when written to a file")
58
+ ] = "%Y-%m-%d %H:%M:%S"
59
+
60
+ lock_file: Annotated[
61
+ Path, RunPath, Field(description="Path to lock file dedicated to pglift")
62
+ ] = Path(".pglift.lock")
63
+
64
+ audit: Annotated[
65
+ Optional[AuditSettings],
66
+ Field(description="Settings for change operations auditing"),
67
+ ] = None
68
+
69
+
70
+ class Settings(BaseSettings):
71
+ cli: Annotated[CLISettings, Field(default_factory=CLISettings)]
72
+
73
+
74
+ class SiteSettings(Settings, BaseSiteSettings):
75
+ pass
@@ -11,6 +11,7 @@ from typing import Any
11
11
 
12
12
  import click
13
13
  import psycopg
14
+ from attrs import asdict
14
15
  from pydantic.v1.utils import deep_update
15
16
 
16
17
  from pglift import databases, postgresql, privileges, task
@@ -23,6 +24,7 @@ from .util import (
23
24
  Obj,
24
25
  OutputFormat,
25
26
  async_command,
27
+ audit,
26
28
  dry_run_option,
27
29
  instance_identifier_option,
28
30
  manifest_option,
@@ -68,7 +70,7 @@ async def create(
68
70
  obj: Obj, instance: system.Instance, database: interface.Database
69
71
  ) -> None:
70
72
  """Create a database in a PostgreSQL instance"""
71
- with obj.lock:
73
+ with obj.lock, audit():
72
74
  async with postgresql.running(instance):
73
75
  if await databases.exists(instance, database.name):
74
76
  raise click.ClickException("database already exists")
@@ -86,7 +88,7 @@ async def alter(
86
88
  obj: Obj, instance: system.Instance, dbname: str, **changes: Any
87
89
  ) -> None:
88
90
  """Alter a database in a PostgreSQL instance"""
89
- with obj.lock:
91
+ with obj.lock, audit():
90
92
  async with postgresql.running(instance):
91
93
  values = (await databases.get(instance, dbname)).model_dump(by_alias=True)
92
94
  values = deep_update(values, changes)
@@ -113,7 +115,7 @@ async def apply(
113
115
  if dry_run:
114
116
  ret = interface.ApplyResult(change_state=None)
115
117
  else:
116
- with obj.lock:
118
+ with obj.lock, audit():
117
119
  async with postgresql.running(instance):
118
120
  ret = await databases.apply(instance, database)
119
121
  if output_format == OutputFormat.json:
@@ -171,7 +173,7 @@ async def drop(
171
173
  obj: Obj, instance: system.Instance, databasedropped: interface.DatabaseDropped
172
174
  ) -> None:
173
175
  """Drop a database"""
174
- with obj.lock:
176
+ with obj.lock, audit():
175
177
  async with postgresql.running(instance):
176
178
  await databases.drop(instance, databasedropped)
177
179
 
@@ -251,7 +253,7 @@ async def run(
251
253
  "--output",
252
254
  metavar="DIRECTORY",
253
255
  type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
254
- help="Write dump file(s) to DIRECTORY instead of configured 'dumps_directory'.",
256
+ help="Write dump file(s) to DIRECTORY instead of default dumps directory.",
255
257
  )
256
258
  @click.argument("dbname")
257
259
  @pass_instance
@@ -260,3 +262,46 @@ async def dump(instance: system.Instance, dbname: str, output: Path | None) -> N
260
262
  """Dump a database"""
261
263
  async with postgresql.running(instance):
262
264
  await databases.dump(instance, dbname, output)
265
+
266
+
267
+ @cli.command("dumps")
268
+ @click.argument("dbname", nargs=-1)
269
+ @output_format_option
270
+ @pass_instance
271
+ @async_command
272
+ async def dumps(
273
+ instance: system.Instance, dbname: Sequence[str], output_format: OutputFormat
274
+ ) -> None:
275
+ """List the database dumps
276
+
277
+ Only dumps created in the default dumps directory are listed.
278
+ """
279
+ values = [asdict(dump) async for dump in databases.dumps(instance, dbnames=dbname)]
280
+ if output_format == OutputFormat.json:
281
+ print_json_for(values)
282
+ else:
283
+ dbnames = ", ".join(dbname) if dbname else "all databases"
284
+ print_table_for(values, lambda d: d, title=f"Dumps for {dbnames}")
285
+
286
+
287
+ @cli.command("restore")
288
+ @click.argument("dump_id")
289
+ @click.argument("targetdbname", required=False)
290
+ @pass_instance
291
+ @async_command
292
+ async def restore(
293
+ instance: system.Instance, dump_id: str, targetdbname: str | None
294
+ ) -> None:
295
+ """Restore a database dump
296
+
297
+ DUMP_ID identifies the dump id.
298
+
299
+ TARGETDBNAME identifies the (optional) name of the database in which the
300
+ dump is reloaded. If provided, the database needs to be created beforehand.
301
+
302
+ If TARGETDBNAME is not provided, the dump is reloaded using the database
303
+ name that appears in the dump. In this case, the restore command will
304
+ create the database so it needs to be dropped before running the command.
305
+ """
306
+ async with postgresql.running(instance):
307
+ await databases.restore(instance, dump_id, targetdbname)
@@ -22,8 +22,9 @@ from pglift import (
22
22
  task,
23
23
  )
24
24
  from pglift.models import interface, system
25
+ from pglift.settings import default_postgresql_version
25
26
  from pglift.settings._postgresql import PostgreSQLVersion
26
- from pglift.types import Status, validation_context
27
+ from pglift.types import Operation, Status, validation_context
27
28
 
28
29
  from . import _site
29
30
  from . import hookspecs as h
@@ -34,6 +35,7 @@ from .util import (
34
35
  OutputFormat,
35
36
  PluggableCommandGroup,
36
37
  async_command,
38
+ audit,
37
39
  dry_run_option,
38
40
  foreground_option,
39
41
  instance_identifier,
@@ -104,7 +106,7 @@ def cli() -> None:
104
106
  @async_command
105
107
  async def create(obj: Obj, instance: interface.Instance, drop_on_error: bool) -> None:
106
108
  """Initialize a PostgreSQL instance"""
107
- with obj.lock:
109
+ with obj.lock, audit():
108
110
  if instances.exists(instance.name, instance.version, _site.SETTINGS):
109
111
  raise click.ClickException("instance already exists")
110
112
  async with task.async_transaction(drop_on_error):
@@ -118,12 +120,14 @@ async def create(obj: Obj, instance: interface.Instance, drop_on_error: bool) ->
118
120
  @async_command
119
121
  async def alter(obj: Obj, instance: system.Instance, **changes: Any) -> None:
120
122
  """Alter PostgreSQL INSTANCE"""
121
- with obj.lock:
123
+ with obj.lock, audit():
122
124
  status = await postgresql.status(instance)
123
125
  manifest = await instances._get(instance, status)
124
126
  values = manifest.model_dump(by_alias=True, exclude={"settings"})
125
127
  values = deep_update(values, changes)
126
- with validation_context({"operation": "update"}):
128
+ # No need for 'settings' in validation_context() as a 'version' key
129
+ # must be present in 'values' when altering.
130
+ with validation_context(operation="update"):
127
131
  altered = _site.INSTANCE_MODEL.model_validate(values)
128
132
  await instances.apply(
129
133
  _site.SETTINGS, altered, _is_running=status == Status.running
@@ -141,14 +145,19 @@ async def apply(
141
145
  ) -> None:
142
146
  """Apply manifest as a PostgreSQL instance"""
143
147
  name, version = data["name"], data.get("version")
144
- assert isinstance(version, (str, type(None)))
145
- op = "update" if instances.exists(name, version, _site.SETTINGS) else "create"
146
- with validation_context({"operation": op}):
148
+ if version is None:
149
+ version = default_postgresql_version(_site.SETTINGS.postgresql)
150
+ elif not isinstance(version, str):
151
+ version = str(version)
152
+ op: Operation = (
153
+ "update" if instances.exists(name, version, _site.SETTINGS) else "create"
154
+ )
155
+ with validation_context(operation=op, settings=_site.SETTINGS):
147
156
  instance = _site.INSTANCE_MODEL.model_validate(data)
148
157
  if dry_run:
149
158
  ret = interface.InstanceApplyResult(change_state=None)
150
159
  else:
151
- with obj.lock:
160
+ with obj.lock, audit():
152
161
  ret = await instances.apply(_site.SETTINGS, instance)
153
162
  if output_format == OutputFormat.json:
154
163
  print_json_for(ret)
@@ -160,7 +169,7 @@ async def apply(
160
169
  @async_command
161
170
  async def promote(obj: Obj, instance: system.Instance) -> None:
162
171
  """Promote standby PostgreSQL INSTANCE"""
163
- with obj.lock:
172
+ with obj.lock, audit():
164
173
  await instances.promote(instance)
165
174
 
166
175
 
@@ -213,7 +222,7 @@ async def ls(version: PostgreSQLVersion | None, output_format: OutputFormat) ->
213
222
  @async_command
214
223
  async def drop(obj: Obj, instance: tuple[system.Instance, ...]) -> None:
215
224
  """Drop PostgreSQL INSTANCE"""
216
- with obj.lock:
225
+ with obj.lock, audit():
217
226
  for i in instance:
218
227
  await instances.drop(i)
219
228
 
@@ -261,7 +270,7 @@ async def start(
261
270
  raise click.UsageError(
262
271
  "only one INSTANCE argument may be given with --foreground"
263
272
  )
264
- with obj.lock:
273
+ with obj.lock, audit():
265
274
  for i in instance:
266
275
  await instances.start(i, foreground=foreground)
267
276
 
@@ -275,7 +284,7 @@ async def stop(
275
284
  obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
276
285
  ) -> None:
277
286
  """Stop PostgreSQL INSTANCE"""
278
- with obj.lock:
287
+ with obj.lock, audit():
279
288
  for i in instance:
280
289
  await instances.stop(i)
281
290
 
@@ -289,7 +298,7 @@ async def reload(
289
298
  obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
290
299
  ) -> None:
291
300
  """Reload PostgreSQL INSTANCE"""
292
- with obj.lock:
301
+ with obj.lock, audit():
293
302
  for i in instance:
294
303
  await instances.reload(i)
295
304
 
@@ -303,7 +312,7 @@ async def restart(
303
312
  obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
304
313
  ) -> None:
305
314
  """Restart PostgreSQL INSTANCE"""
306
- with obj.lock:
315
+ with obj.lock, audit():
307
316
  for i in instance:
308
317
  await instances.restart(i)
309
318
 
@@ -459,7 +468,7 @@ async def upgrade(
459
468
  jobs: int | None,
460
469
  ) -> None:
461
470
  """Upgrade INSTANCE using pg_upgrade"""
462
- with obj.lock:
471
+ with obj.lock, audit():
463
472
  await postgresql.check_status(instance, Status.not_running)
464
473
  async with task.async_transaction():
465
474
  new_instance = await instances.upgrade(
@@ -26,16 +26,16 @@ from pglift import install, ui
26
26
  from pglift._compat import assert_never
27
27
 
28
28
  from . import __name__ as pkgname
29
- from . import _site
29
+ from . import _site, loggers
30
30
  from ._settings import Settings
31
31
  from .base import CLIGroup
32
32
  from .console import console as console
33
33
  from .util import (
34
34
  InteractiveUserInterface,
35
- LogDisplayer,
36
35
  Obj,
37
36
  OutputFormat,
38
37
  async_command,
38
+ audit,
39
39
  output_format_option,
40
40
  )
41
41
 
@@ -145,10 +145,7 @@ def cli(
145
145
  stacklevel=1,
146
146
  )
147
147
  if not context.obj:
148
- context.obj = Obj(
149
- displayer=None if log_file else LogDisplayer(),
150
- debug=debug,
151
- )
148
+ context.obj = Obj(debug=debug)
152
149
  else:
153
150
  assert isinstance(context.obj, Obj), context.obj
154
151
 
@@ -156,12 +153,9 @@ def cli(
156
153
  if interactive:
157
154
  ui_token = ui.set(InteractiveUserInterface())
158
155
 
159
- loggers = [logging.getLogger(n) for n in ("pglift", "dotenv", "filelock")]
160
- for logger in loggers:
161
- logger.setLevel(logging.DEBUG)
156
+ handler: logging.Handler | rich.logging.RichHandler
162
157
  if debug:
163
158
  log_level = logging.DEBUG
164
- handler: logging.Handler | rich.logging.RichHandler
165
159
  if log_file or not sys.stderr.isatty():
166
160
  if log_file:
167
161
  handler = logging.FileHandler(log_file)
@@ -182,11 +176,15 @@ def cli(
182
176
  show_path=False,
183
177
  highlighter=NullHighlighter(),
184
178
  )
185
- for logger in loggers:
179
+
180
+ for name in loggers:
181
+ logger = logging.getLogger(name)
182
+ logger.setLevel(logging.DEBUG)
186
183
  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))
184
+ # Remove rich handler on close since this would pollute all tests
185
+ # stderr otherwise.
186
+ context.call_on_close(partial(logger.removeHandler, handler))
187
+
190
188
  # Reset contextvars
191
189
  if ui_token is not None:
192
190
  context.call_on_close(partial(ui.reset, ui_token))
@@ -264,7 +262,7 @@ async def site_configure(
264
262
 
265
263
  This is an INTERNAL command.
266
264
  """
267
- with obj.lock:
265
+ with obj.lock, audit():
268
266
  if action == "install":
269
267
  env = {"SETTINGS": f"@{settings_file}"} if settings_file else {}
270
268
  await install.do(_site.SETTINGS, env=env)
@@ -7,7 +7,6 @@ from __future__ import annotations
7
7
  import enum
8
8
  import functools
9
9
  import inspect
10
- import logging
11
10
  import typing
12
11
  from abc import ABC, abstractmethod
13
12
  from collections.abc import Callable, Iterator, Sequence
@@ -30,7 +29,7 @@ from pglift.types import (
30
29
  validation_context,
31
30
  )
32
31
 
33
- logger = logging.getLogger(__name__)
32
+ from . import _site, logger
34
33
 
35
34
  ModelType = type[pydantic.BaseModel]
36
35
  T = TypeVar("T", bound=pydantic.BaseModel)
@@ -101,7 +100,7 @@ def as_parameters(
101
100
  args = params_to_modelargs(kwargs)
102
101
  with (
103
102
  catch_validationerror(*paramspecs),
104
- validation_context({"operation": operation}),
103
+ validation_context(operation=operation, settings=_site.SETTINGS),
105
104
  ):
106
105
  model = parse_params_as(model_type, args)
107
106
  kwargs[model_argname] = model
@@ -110,7 +109,7 @@ def as_parameters(
110
109
  else:
111
110
 
112
111
  @functools.wraps(f)
113
- def callback(**kwargs: Any) -> Any:
112
+ def callback(**kwargs: Any) -> Any: # type: ignore[misc]
114
113
  args = params_to_modelargs(kwargs)
115
114
  values = unnest(model_type, args)
116
115
  kwargs.update(values)
@@ -164,6 +163,7 @@ class ParamSpec(ABC):
164
163
  """Intermediate representation for a future click.Parameter."""
165
164
 
166
165
  param_decls: Sequence[str]
166
+ field_info: FieldInfo
167
167
  attrs: dict[str, Any]
168
168
  loc: tuple[str, ...]
169
169
 
@@ -208,7 +208,15 @@ class OptionSpec(ParamSpec):
208
208
 
209
209
  @property
210
210
  def decorator(self) -> ClickDecorator:
211
- return click.option(*self.param_decls, **self.attrs)
211
+ return click.option(*self.param_decls, help=self._help(), **self.attrs)
212
+
213
+ def _help(self) -> str | None:
214
+ if description := self.field_info.description:
215
+ description = description[0].upper() + description[1:]
216
+ if description[-1] not in ".?":
217
+ description += "."
218
+ return description
219
+ return None
212
220
 
213
221
 
214
222
  @dataclass(frozen=True)
@@ -255,10 +263,31 @@ def _paramspecs_from_model(
255
263
  assert ftype is not None
256
264
  nested = lenient_issubclass(origin_type or ftype, pydantic.BaseModel)
257
265
  required = field.is_required()
258
- if not nested and not _parents and required:
259
- yield (modelname, argname), ArgumentSpec(
260
- (argname.replace("_", "-"),), {"type": ftype}, loc=(modelname,)
266
+ attrs: dict[str, Any]
267
+
268
+ if nested:
269
+ yield from _paramspecs_from_model(
270
+ ftype, operation, _parents=_parents + (_Parent(argname, required),)
261
271
  )
272
+
273
+ elif not _parents and required:
274
+ attrs = {}
275
+ if origin_type is typing.Literal:
276
+ choices = list(typing.get_args(ftype))
277
+ if config is not None and config.choices is not None:
278
+ choices = config.choices
279
+ attrs["type"] = click.Choice(choices)
280
+ if config is not None and config.as_option:
281
+ attrs["required"] = True
282
+ yield (modelname, argname), OptionSpec(
283
+ (f"--{argname.replace('_', '-')}",), field, attrs, loc=(modelname,)
284
+ )
285
+
286
+ else:
287
+ yield (modelname, argname), ArgumentSpec(
288
+ (argname.replace("_", "-"),), field, attrs, loc=(modelname,)
289
+ )
290
+
262
291
  else:
263
292
  metavar: str | None
264
293
  if config and config.metavar is not None:
@@ -268,12 +297,15 @@ def _paramspecs_from_model(
268
297
  if metavar is not None:
269
298
  metavar = metavar.upper()
270
299
  argparts = tuple(p.argname for p in _parents) + tuple(argname.split("_"))
300
+ argname = "_".join(argparts)
301
+ loc = tuple(p.argname for p in _parents) + (modelname,)
302
+ modelname = "_".join(loc)
271
303
  fname = f"--{'-'.join(argparts)}"
272
- description = None
273
- if field.description:
274
- description = field.description
275
- description = description[0].upper() + description[1:]
276
- attrs: dict[str, Any] = {}
304
+
305
+ attrs = {}
306
+ if required and all(p.required for p in _parents):
307
+ attrs["required"] = True
308
+
277
309
  if origin_type is typing.Literal:
278
310
  choices = list(typing.get_args(ftype))
279
311
  if len(choices) == 1: # const
@@ -282,17 +314,14 @@ def _paramspecs_from_model(
282
314
  choices = config.choices
283
315
  attrs["type"] = click.Choice(choices)
284
316
  metavar = None
317
+
285
318
  elif lenient_issubclass(ftype, enum.Enum):
286
319
  if config and config.choices is not None:
287
320
  choices = config.choices
288
321
  else:
289
322
  choices = choices_from_enum(ftype)
290
323
  attrs["type"] = click.Choice(choices)
291
- elif nested:
292
- yield from _paramspecs_from_model(
293
- ftype, operation, _parents=_parents + (_Parent(argname, required),)
294
- )
295
- continue
324
+
296
325
  elif lenient_issubclass(origin_type or ftype, list):
297
326
  if operation != "create":
298
327
  continue
@@ -306,30 +335,27 @@ def _paramspecs_from_model(
306
335
  attrs["type"] = click.Choice(choices_from_enum(itemtype))
307
336
  else:
308
337
  attrs["metavar"] = metavar
338
+
309
339
  elif lenient_issubclass(ftype, pydantic.SecretStr):
310
340
  attrs["prompt"] = (
311
- description.rstrip(".") if description is not None else True
341
+ field.description.rstrip(".")
342
+ if field.description is not None
343
+ else True
312
344
  )
313
345
  attrs["prompt_required"] = False
314
346
  attrs["confirmation_prompt"] = True
315
347
  attrs["hide_input"] = True
348
+
316
349
  elif lenient_issubclass(ftype, bool):
317
350
  fname = f"{fname}/--no-{fname[2:]}"
318
351
  # Use None to distinguish unspecified option from the default value.
319
352
  attrs["default"] = None
353
+
320
354
  else:
321
355
  attrs["metavar"] = metavar
322
- if description is not None:
323
- if description[-1] not in ".?":
324
- description += "."
325
- attrs["help"] = description
326
- if field.is_required() and all(p.required for p in _parents):
327
- attrs["required"] = True
328
- argname = "_".join(argparts)
329
- loc = tuple(p.argname for p in _parents) + (modelname,)
330
- modelname = "_".join(loc)
356
+
331
357
  yield (modelname, argname), OptionSpec(
332
- (fname,), {"callback": default, **attrs}, loc=loc
358
+ (fname,), field, {"callback": default, **attrs}, loc=loc
333
359
  )
334
360
 
335
361
 
@@ -22,6 +22,7 @@ from ..util import (
22
22
  Obj,
23
23
  OutputFormat,
24
24
  async_command,
25
+ audit,
25
26
  instance_identifier,
26
27
  instance_identifier_option,
27
28
  model_dump,
@@ -69,7 +70,7 @@ async def instance_restore(
69
70
  "--label and --date arguments are mutually exclusive"
70
71
  ) from None
71
72
  settings = pgbackrest.get_settings(_site.SETTINGS)
72
- with obj.lock:
73
+ with obj.lock, audit():
73
74
  await base.restore(instance, settings, label=label, date=date)
74
75
 
75
76
 
@@ -12,7 +12,7 @@ from pglift.pgbackrest import repo_path
12
12
  from pglift.pgbackrest.repo_path import register_if as register_if # noqa: F401
13
13
 
14
14
  from .. import _site, hookimpl
15
- from ..util import Command, Obj, async_command, instance_identifier
15
+ from ..util import Command, Obj, async_command, audit, instance_identifier
16
16
 
17
17
 
18
18
  @click.command("backup", cls=Command)
@@ -31,7 +31,7 @@ async def instance_backup(
31
31
  ) -> None:
32
32
  """Back up PostgreSQL INSTANCE"""
33
33
  settings = pgbackrest.get_settings(_site.SETTINGS)
34
- with obj.lock:
34
+ with obj.lock, audit():
35
35
  await repo_path.backup(instance, settings, type=backup_type)
36
36
 
37
37
 
@@ -14,7 +14,14 @@ from pglift import h, hook, instances, postgresql
14
14
  from pglift.models import system
15
15
  from pglift.types import ConfigChanges, Status
16
16
 
17
- from .util import Group, Obj, async_command, instance_identifier_option, pass_instance
17
+ from .util import (
18
+ Group,
19
+ Obj,
20
+ async_command,
21
+ audit,
22
+ instance_identifier_option,
23
+ pass_instance,
24
+ )
18
25
 
19
26
 
20
27
  @click.group(cls=Group)
@@ -92,7 +99,7 @@ def validate_configuration_parameters(
92
99
  @async_command
93
100
  async def set_(obj: Obj, instance: system.Instance, parameters: dict[str, Any]) -> None:
94
101
  """Set configuration items."""
95
- with obj.lock:
102
+ with obj.lock, audit():
96
103
  status = await postgresql.status(instance)
97
104
  manifest = await instances._get(instance, status)
98
105
  manifest.settings.update(parameters)
@@ -109,7 +116,7 @@ async def set_(obj: Obj, instance: system.Instance, parameters: dict[str, Any])
109
116
  @async_command
110
117
  async def remove(obj: Obj, instance: system.Instance, parameters: tuple[str]) -> None:
111
118
  """Remove configuration items."""
112
- with obj.lock:
119
+ with obj.lock, audit():
113
120
  status = await postgresql.status(instance)
114
121
  manifest = await instances._get(instance, status)
115
122
  for p in parameters:
@@ -131,7 +138,7 @@ async def remove(obj: Obj, instance: system.Instance, parameters: tuple[str]) ->
131
138
  @async_command
132
139
  async def edit(obj: Obj, instance: system.Instance) -> None:
133
140
  """Edit managed configuration."""
134
- with obj.lock:
141
+ with obj.lock, audit():
135
142
  actual_config = hook(
136
143
  instance._settings, h.postgresql_editable_conf, instance=instance
137
144
  )
@@ -21,6 +21,7 @@ from .util import (
21
21
  Obj,
22
22
  OutputFormat,
23
23
  async_command,
24
+ audit,
24
25
  dry_run_option,
25
26
  foreground_option,
26
27
  manifest_option,
@@ -65,13 +66,13 @@ async def apply(
65
66
  """Apply manifest as a Prometheus postgres_exporter."""
66
67
  settings = prometheus.get_settings(_site.SETTINGS)
67
68
  name = data["name"]
68
- op = "update" if impl.exists(name, settings) else "create"
69
- with types.validation_context({"operation": op}):
69
+ op: types.Operation = "update" if impl.exists(name, settings) else "create"
70
+ with types.validation_context(operation=op):
70
71
  exporter = PostgresExporter.model_validate(data)
71
72
  if dry_run:
72
73
  ret = interface.ApplyResult(change_state=None)
73
74
  else:
74
- with obj.lock:
75
+ with obj.lock, audit():
75
76
  ret = await impl.apply(exporter, _site.SETTINGS, settings)
76
77
  if output_format == OutputFormat.json:
77
78
  print_json_for(ret)
@@ -84,7 +85,7 @@ async def apply(
84
85
  async def install(obj: Obj, postgresexporter: PostgresExporter) -> None:
85
86
  """Install the service for a (non-local) instance."""
86
87
  settings = prometheus.get_settings(_site.SETTINGS)
87
- with obj.lock:
88
+ with obj.lock, audit():
88
89
  async with task.async_transaction():
89
90
  await impl.apply(postgresexporter, _site.SETTINGS, settings)
90
91
 
@@ -95,7 +96,7 @@ async def install(obj: Obj, postgresexporter: PostgresExporter) -> None:
95
96
  @async_command
96
97
  async def uninstall(obj: Obj, name: str) -> None:
97
98
  """Uninstall the service."""
98
- with obj.lock:
99
+ with obj.lock, audit():
99
100
  await impl.drop(_site.SETTINGS, name)
100
101
 
101
102
 
@@ -112,7 +113,7 @@ async def start(obj: Obj, name: str, foreground: bool) -> None:
112
113
  <version>-<name>.
113
114
  """
114
115
  settings = prometheus.get_settings(_site.SETTINGS)
115
- with obj.lock:
116
+ with obj.lock, audit():
116
117
  service = impl.system_lookup(name, settings)
117
118
  if service is None:
118
119
  raise exceptions.InstanceNotFound(name)
@@ -131,7 +132,7 @@ async def stop(obj: Obj, name: str) -> None:
131
132
  <version>-<name>.
132
133
  """
133
134
  settings = prometheus.get_settings(_site.SETTINGS)
134
- with obj.lock:
135
+ with obj.lock, audit():
135
136
  service = impl.system_lookup(name, settings)
136
137
  if service is None:
137
138
  raise exceptions.InstanceNotFound(name)
@@ -21,6 +21,7 @@ from .util import (
21
21
  Obj,
22
22
  OutputFormat,
23
23
  async_command,
24
+ audit,
24
25
  dry_run_option,
25
26
  instance_identifier_option,
26
27
  manifest_option,
@@ -76,7 +77,7 @@ def cli(**kwargs: Any) -> None:
76
77
  @async_command
77
78
  async def create(obj: Obj, instance: system.Instance, role: interface.Role) -> None:
78
79
  """Create a role in a PostgreSQL instance"""
79
- with obj.lock:
80
+ with obj.lock, audit():
80
81
  async with postgresql.running(instance):
81
82
  if await roles.exists(instance, role.name):
82
83
  raise click.ClickException("role already exists")
@@ -93,7 +94,7 @@ async def alter(
93
94
  obj: Obj, instance: system.Instance, rolname: str, **changes: Any
94
95
  ) -> None:
95
96
  """Alter a role in a PostgreSQL instance"""
96
- with obj.lock:
97
+ with obj.lock, audit():
97
98
  async with postgresql.running(instance):
98
99
  values = (await roles.get(instance, rolname)).model_dump(by_alias=True)
99
100
  values = deep_update(values, changes)
@@ -120,7 +121,7 @@ async def apply(
120
121
  if dry_run:
121
122
  ret = interface.ApplyResult(change_state=None)
122
123
  else:
123
- with obj.lock:
124
+ with obj.lock, audit():
124
125
  async with postgresql.running(instance):
125
126
  ret = await roles.apply(instance, role)
126
127
  if output_format == OutputFormat.json:
@@ -9,18 +9,23 @@ import asyncio
9
9
  import enum
10
10
  import json
11
11
  import logging
12
+ import logging.handlers
12
13
  import os
13
14
  import pathlib
15
+ import shlex
16
+ import sys
14
17
  import tempfile
15
18
  import time
16
19
  import typing
17
20
  from collections.abc import Coroutine, Iterable, Iterator, Sequence
18
21
  from contextlib import contextmanager
22
+ from datetime import timedelta
19
23
  from functools import cache, cached_property, singledispatch, wraps
20
24
  from typing import IO, Any, Callable, TypedDict, TypeVar
21
25
 
22
26
  import click
23
27
  import filelock
28
+ import humanize
24
29
  import psycopg
25
30
  import pydantic
26
31
  import pydantic_core
@@ -31,19 +36,18 @@ from click.shell_completion import CompletionItem
31
36
  from rich.console import Console
32
37
  from rich.table import Table
33
38
 
34
- from pglift import exceptions, install, instances, task
39
+ from pglift import exceptions, install, instances
35
40
  from pglift._compat import ParamSpec
36
41
  from pglift.models import helpers, system
37
42
  from pglift.settings import Settings
38
43
  from pglift.settings._postgresql import PostgreSQLVersion
39
- from pglift.task import Displayer
40
44
  from pglift.types import AutoStrEnum, ByteSizeType
41
45
 
42
- from . import _site, model
46
+ from . import __name__ as pkgname
47
+ from . import _site, logger, loggers, model
48
+ from ._settings import CLISettings
43
49
  from .console import console
44
50
 
45
- logger = logging.getLogger("pglift")
46
-
47
51
 
48
52
  def model_dump(
49
53
  m: pydantic.BaseModel, by_alias: bool = True, **kwargs: Any
@@ -429,18 +433,92 @@ foreground_option = click.option(
429
433
 
430
434
 
431
435
  @contextmanager
432
- def command_logging(logdir: pathlib.Path) -> Iterator[None]:
436
+ def audit(
437
+ command: Sequence[str] = sys.argv, settings: CLISettings | None = None
438
+ ) -> Iterator[None]:
439
+ """Context manager handling log messages to the audit file, if configured
440
+ in site settings.
441
+ """
442
+ if settings is None:
443
+ settings = _site.SETTINGS.cli
444
+ if (audit_settings := settings.audit) is None:
445
+ yield None
446
+ return
447
+
448
+ audit_file = audit_settings.path
449
+ if not audit_file.parent.exists():
450
+ logger.debug("creating audit file parent directory")
451
+ audit_file.parent.mkdir(parents=True, exist_ok=True)
452
+ logger.debug("using audit file %s", audit_file)
453
+
454
+ handler = logging.handlers.WatchedFileHandler(audit_file)
455
+ handler.setLevel(logging.DEBUG)
456
+ formatter = logging.Formatter(
457
+ fmt=audit_settings.log_format, datefmt=audit_settings.date_format
458
+ )
459
+ handler.setFormatter(formatter)
460
+
461
+ loggrs = [logging.getLogger(n) for n in loggers]
462
+ for loggr in loggrs:
463
+ loggr.addHandler(handler)
464
+
465
+ # The audit logger, as defined here, is only meant to emit start/end
466
+ # messages below; and we avoid them to get propagated to higher logger.
467
+ audit_logger = logging.getLogger(pkgname).getChild("audit")
468
+ audit_logger.propagate = False
469
+ audit_logger.addHandler(handler)
470
+ audit_logger.setLevel(logging.DEBUG)
471
+
472
+ audit_logger.info("command: %s", shlex.join(command))
473
+ started_at = time.monotonic()
474
+
475
+ try:
476
+ yield None
477
+ except Exception as exc:
478
+ if isinstance(exc, exceptions.Cancelled):
479
+ msg, level = "command cancelled (%s)", logging.WARNING
480
+ else:
481
+ msg, level = "command failed (%s)", logging.ERROR
482
+ raise
483
+ else:
484
+ msg, level = "command completed (%s)", logging.INFO
485
+ finally:
486
+ # Note: by removing the audit handler from loggers managed above, we
487
+ # avoid termination messages (typically those emitted in
488
+ # Command.invoke()) to be emitted in this handler.
489
+ # If this appears to be a bad idea, removeHandler() should be invoked
490
+ # through context.call_on_close().
491
+ for loggr in loggrs:
492
+ loggr.removeHandler(handler)
493
+ elapsed = humanize.precisedelta(
494
+ timedelta(seconds=time.monotonic() - started_at)
495
+ )
496
+ audit_logger.log(level, msg, elapsed)
497
+ audit_logger.removeHandler(handler)
498
+ handler.close()
499
+
500
+
501
+ @contextmanager
502
+ def command_logging(logdir: pathlib.Path | None) -> Iterator[None]:
433
503
  logdir_created = False
434
504
  logfilename = f"{time.time()}.log"
435
- logfile = logdir / logfilename
436
- try:
437
- if not logdir.exists():
438
- logdir.mkdir(parents=True)
439
- logdir_created = True
440
- except OSError:
441
- # Might be, e.g. PermissionError, if log file path is not writable.
505
+ if logdir is not None: # pragma: nocover (DEPRECATED)
506
+ logdir_created = False
507
+ logfile = logdir / logfilename
508
+ try:
509
+ if not logdir.exists():
510
+ logdir.mkdir(parents=True)
511
+ logdir_created = True
512
+ except OSError:
513
+ # Might be, e.g. PermissionError, if log file path is not writable.
514
+ logfile = pathlib.Path(
515
+ tempfile.NamedTemporaryFile(
516
+ prefix="pglift-", suffix="-" + logfilename
517
+ ).name
518
+ )
519
+ else:
442
520
  logfile = pathlib.Path(
443
- tempfile.NamedTemporaryFile(prefix="pglift", suffix=logfilename).name
521
+ tempfile.NamedTemporaryFile(prefix="pglift-", suffix="-" + logfilename).name
444
522
  )
445
523
  handler = logging.FileHandler(logfile)
446
524
  formatter = logging.Formatter(
@@ -449,9 +527,9 @@ def command_logging(logdir: pathlib.Path) -> Iterator[None]:
449
527
  )
450
528
  handler.setFormatter(formatter)
451
529
  logger.addHandler(handler)
452
- if logdir_created:
530
+ if logdir is not None and logdir_created: # pragma: nocover (DEPRECATED)
453
531
  logger.debug("created log directory %s", logdir)
454
- logger.debug("logging command at %s", logfile)
532
+ logger.debug("debug logging at %s", logfile)
455
533
  keep_logfile = False
456
534
  try:
457
535
  yield None
@@ -467,16 +545,15 @@ def command_logging(logdir: pathlib.Path) -> Iterator[None]:
467
545
  finally:
468
546
  if not keep_logfile:
469
547
  os.unlink(logfile)
470
- if logdir_created and next(logdir.iterdir(), None) is None:
548
+ if (
549
+ logdir is not None
550
+ and logdir_created
551
+ and next(logdir.iterdir(), None) is None
552
+ ): # pragma: nocover (DEPRECATED)
471
553
  logger.debug("removing log directory %s", logdir)
472
554
  logdir.rmdir()
473
555
 
474
556
 
475
- class LogDisplayer:
476
- def handle(self, msg: str) -> None:
477
- logger.info(msg)
478
-
479
-
480
557
  class InteractiveUserInterface:
481
558
  """An interactive UI that prompts for confirmation."""
482
559
 
@@ -499,13 +576,7 @@ class Obj:
499
576
  # instance_identifier_option decorator's callback.
500
577
  _instance: str | system.Instance
501
578
 
502
- def __init__(
503
- self,
504
- *,
505
- displayer: Displayer | None = None,
506
- debug: bool = False,
507
- ) -> None:
508
- self.displayer = displayer
579
+ def __init__(self, *, debug: bool = False) -> None:
509
580
  self.debug = debug
510
581
 
511
582
  @cached_property
@@ -537,17 +608,16 @@ def async_command(
537
608
  class Command(click.Command):
538
609
  def invoke(self, context: click.Context) -> Any:
539
610
  obj: Obj = context.obj
540
- displayer = obj.displayer
541
611
  with command_logging(_site.SETTINGS.cli.logpath):
542
612
  try:
543
- with task.displayer_installed(displayer):
544
- return super().invoke(context)
613
+ return super().invoke(context)
545
614
  except filelock.Timeout:
546
615
  raise click.ClickException("another operation is in progress") from None
547
616
  except exceptions.Cancelled as e:
548
617
  logger.warning(str(e))
549
618
  raise click.Abort from None
550
619
  except pydantic.ValidationError as e:
620
+ logger.debug("a validation error occurred", exc_info=obj.debug)
551
621
  raise click.ClickException(str(e)) from None
552
622
  except exceptions.Error as e:
553
623
  logger.debug("an internal error occurred", exc_info=obj.debug)
@@ -1,7 +0,0 @@
1
- # SPDX-FileCopyrightText: 2024 Dalibo
2
- #
3
- # SPDX-License-Identifier: GPL-3.0-or-later
4
-
5
- import pluggy
6
-
7
- hookimpl = pluggy.HookimplMarker(__name__)
@@ -1,44 +0,0 @@
1
- # SPDX-FileCopyrightText: 2021 Dalibo
2
- #
3
- # SPDX-License-Identifier: GPL-3.0-or-later
4
-
5
- from pathlib import Path
6
- from typing import Annotated
7
-
8
- from pydantic import Field
9
-
10
- from pglift.settings import Settings as BaseSettings
11
- from pglift.settings import SiteSettings as BaseSiteSettings
12
- from pglift.settings.base import BaseModel, LogPath, RunPath
13
-
14
-
15
- class CLISettings(BaseModel):
16
- """Settings for pglift's command-line interface."""
17
-
18
- logpath: Annotated[
19
- Annotated[Path, LogPath],
20
- Field(
21
- description="Directory where temporary log files from command executions will be stored",
22
- title="CLI log directory",
23
- ),
24
- ] = Path()
25
-
26
- log_format: Annotated[
27
- str, Field(description="Format for log messages when written to a file")
28
- ] = "%(asctime)s %(levelname)-8s %(name)s - %(message)s"
29
-
30
- date_format: Annotated[
31
- str, Field(description="Date format in log messages when written to a file")
32
- ] = "%Y-%m-%d %H:%M:%S"
33
-
34
- lock_file: Annotated[
35
- Path, RunPath, Field(description="Path to lock file dedicated to pglift")
36
- ] = Path(".pglift.lock")
37
-
38
-
39
- class Settings(BaseSettings):
40
- cli: Annotated[CLISettings, Field(default_factory=CLISettings)]
41
-
42
-
43
- class SiteSettings(Settings, BaseSiteSettings):
44
- pass
File without changes
File without changes
File without changes