pglift-cli 1.3.1__tar.gz → 1.5.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.3.1 → pglift_cli-1.5.0}/PKG-INFO +12 -4
  2. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/pyproject.toml +1 -1
  3. pglift_cli-1.5.0/src/pglift_cli/__init__.py +15 -0
  4. pglift_cli-1.5.0/src/pglift_cli/_settings.py +75 -0
  5. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/database.py +56 -9
  6. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/instance.py +32 -16
  7. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/main.py +13 -15
  8. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/model.py +67 -33
  9. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/pgbackrest/__init__.py +5 -3
  10. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/pgbackrest/repo_path.py +2 -2
  11. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/pgconf.py +11 -4
  12. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/prometheus.py +22 -16
  13. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/role.py +10 -7
  14. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/util.py +139 -35
  15. pglift_cli-1.3.1/src/pglift_cli/__init__.py +0 -7
  16. pglift_cli-1.3.1/src/pglift_cli/_settings.py +0 -44
  17. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/.gitignore +0 -0
  18. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/README.md +0 -0
  19. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/hatch.toml +0 -0
  20. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/__main__.py +0 -0
  21. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/_site.py +0 -0
  22. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/base.py +0 -0
  23. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/console.py +0 -0
  24. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/hookspecs.py +0 -0
  25. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/patroni.py +0 -0
  26. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/pm.py +0 -0
  27. {pglift_cli-1.3.1 → pglift_cli-1.5.0}/src/pglift_cli/postgres.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: pglift_cli
3
- Version: 1.3.1
3
+ Version: 1.5.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
@@ -7,10 +7,11 @@ from __future__ import annotations
7
7
  import functools
8
8
  from collections.abc import Sequence
9
9
  from pathlib import Path
10
- from typing import IO, Any
10
+ 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
@@ -19,11 +20,14 @@ from pglift.models import interface, system
19
20
  from . import model
20
21
  from .util import (
21
22
  Group,
23
+ ManifestData,
22
24
  Obj,
23
25
  OutputFormat,
24
26
  async_command,
27
+ audit,
25
28
  dry_run_option,
26
29
  instance_identifier_option,
30
+ manifest_option,
27
31
  model_dump,
28
32
  output_format_option,
29
33
  pass_instance,
@@ -66,7 +70,7 @@ async def create(
66
70
  obj: Obj, instance: system.Instance, database: interface.Database
67
71
  ) -> None:
68
72
  """Create a database in a PostgreSQL instance"""
69
- with obj.lock:
73
+ with obj.lock, audit():
70
74
  async with postgresql.running(instance):
71
75
  if await databases.exists(instance, database.name):
72
76
  raise click.ClickException("database already exists")
@@ -84,7 +88,7 @@ async def alter(
84
88
  obj: Obj, instance: system.Instance, dbname: str, **changes: Any
85
89
  ) -> None:
86
90
  """Alter a database in a PostgreSQL instance"""
87
- with obj.lock:
91
+ with obj.lock, audit():
88
92
  async with postgresql.running(instance):
89
93
  values = (await databases.get(instance, dbname)).model_dump(by_alias=True)
90
94
  values = deep_update(values, changes)
@@ -93,7 +97,7 @@ async def alter(
93
97
 
94
98
 
95
99
  @cli.command("apply", hidden=True)
96
- @click.option("-f", "--file", type=click.File("r"), metavar="MANIFEST", required=True)
100
+ @manifest_option
97
101
  @output_format_option
98
102
  @dry_run_option
99
103
  @pass_instance
@@ -102,16 +106,16 @@ async def alter(
102
106
  async def apply(
103
107
  obj: Obj,
104
108
  instance: system.Instance,
105
- file: IO[str],
109
+ data: ManifestData,
106
110
  output_format: OutputFormat,
107
111
  dry_run: bool,
108
112
  ) -> None:
109
113
  """Apply manifest as a database"""
110
- database = interface.Database.parse_yaml(file)
114
+ database = interface.Database.model_validate(data)
111
115
  if dry_run:
112
116
  ret = interface.ApplyResult(change_state=None)
113
117
  else:
114
- with obj.lock:
118
+ with obj.lock, audit():
115
119
  async with postgresql.running(instance):
116
120
  ret = await databases.apply(instance, database)
117
121
  if output_format == OutputFormat.json:
@@ -169,7 +173,7 @@ async def drop(
169
173
  obj: Obj, instance: system.Instance, databasedropped: interface.DatabaseDropped
170
174
  ) -> None:
171
175
  """Drop a database"""
172
- with obj.lock:
176
+ with obj.lock, audit():
173
177
  async with postgresql.running(instance):
174
178
  await databases.drop(instance, databasedropped)
175
179
 
@@ -249,7 +253,7 @@ async def run(
249
253
  "--output",
250
254
  metavar="DIRECTORY",
251
255
  type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
252
- 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.",
253
257
  )
254
258
  @click.argument("dbname")
255
259
  @pass_instance
@@ -258,3 +262,46 @@ async def dump(instance: system.Instance, dbname: str, output: Path | None) -> N
258
262
  """Dump a database"""
259
263
  async with postgresql.running(instance):
260
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)
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
  import os
8
8
  from collections.abc import Sequence
9
9
  from functools import partial
10
- from typing import IO, Any
10
+ from typing import Any
11
11
 
12
12
  import click
13
13
  from pydantic.v1.utils import deep_update
@@ -22,20 +22,24 @@ 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
27
+ from pglift.types import Operation, Status, validation_context
27
28
 
28
29
  from . import _site
29
30
  from . import hookspecs as h
30
31
  from . import model
31
32
  from .util import (
33
+ ManifestData,
32
34
  Obj,
33
35
  OutputFormat,
34
36
  PluggableCommandGroup,
35
37
  async_command,
38
+ audit,
36
39
  dry_run_option,
37
40
  foreground_option,
38
41
  instance_identifier,
42
+ manifest_option,
39
43
  model_dump,
40
44
  output_format_option,
41
45
  print_argspec,
@@ -102,7 +106,7 @@ def cli() -> None:
102
106
  @async_command
103
107
  async def create(obj: Obj, instance: interface.Instance, drop_on_error: bool) -> None:
104
108
  """Initialize a PostgreSQL instance"""
105
- with obj.lock:
109
+ with obj.lock, audit():
106
110
  if instances.exists(instance.name, instance.version, _site.SETTINGS):
107
111
  raise click.ClickException("instance already exists")
108
112
  async with task.async_transaction(drop_on_error):
@@ -116,32 +120,44 @@ async def create(obj: Obj, instance: interface.Instance, drop_on_error: bool) ->
116
120
  @async_command
117
121
  async def alter(obj: Obj, instance: system.Instance, **changes: Any) -> None:
118
122
  """Alter PostgreSQL INSTANCE"""
119
- with obj.lock:
123
+ with obj.lock, audit():
120
124
  status = await postgresql.status(instance)
121
125
  manifest = await instances._get(instance, status)
122
126
  values = manifest.model_dump(by_alias=True, exclude={"settings"})
123
127
  values = deep_update(values, changes)
124
- altered = _site.INSTANCE_MODEL.model_validate(values)
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"):
131
+ altered = _site.INSTANCE_MODEL.model_validate(values)
125
132
  await instances.apply(
126
133
  _site.SETTINGS, altered, _is_running=status == Status.running
127
134
  )
128
135
 
129
136
 
130
137
  @cli.command("apply", hidden=True)
131
- @click.option("-f", "--file", type=click.File("r"), metavar="MANIFEST", required=True)
138
+ @manifest_option
132
139
  @output_format_option
133
140
  @dry_run_option
134
141
  @click.pass_obj
135
142
  @async_command
136
143
  async def apply(
137
- obj: Obj, file: IO[str], output_format: OutputFormat, dry_run: bool
144
+ obj: Obj, data: ManifestData, output_format: OutputFormat, dry_run: bool
138
145
  ) -> None:
139
146
  """Apply manifest as a PostgreSQL instance"""
140
- instance = _site.INSTANCE_MODEL.parse_yaml(file)
147
+ name, version = data["name"], data.get("version")
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):
156
+ instance = _site.INSTANCE_MODEL.model_validate(data)
141
157
  if dry_run:
142
158
  ret = interface.InstanceApplyResult(change_state=None)
143
159
  else:
144
- with obj.lock:
160
+ with obj.lock, audit():
145
161
  ret = await instances.apply(_site.SETTINGS, instance)
146
162
  if output_format == OutputFormat.json:
147
163
  print_json_for(ret)
@@ -153,7 +169,7 @@ async def apply(
153
169
  @async_command
154
170
  async def promote(obj: Obj, instance: system.Instance) -> None:
155
171
  """Promote standby PostgreSQL INSTANCE"""
156
- with obj.lock:
172
+ with obj.lock, audit():
157
173
  await instances.promote(instance)
158
174
 
159
175
 
@@ -206,7 +222,7 @@ async def ls(version: PostgreSQLVersion | None, output_format: OutputFormat) ->
206
222
  @async_command
207
223
  async def drop(obj: Obj, instance: tuple[system.Instance, ...]) -> None:
208
224
  """Drop PostgreSQL INSTANCE"""
209
- with obj.lock:
225
+ with obj.lock, audit():
210
226
  for i in instance:
211
227
  await instances.drop(i)
212
228
 
@@ -254,7 +270,7 @@ async def start(
254
270
  raise click.UsageError(
255
271
  "only one INSTANCE argument may be given with --foreground"
256
272
  )
257
- with obj.lock:
273
+ with obj.lock, audit():
258
274
  for i in instance:
259
275
  await instances.start(i, foreground=foreground)
260
276
 
@@ -268,7 +284,7 @@ async def stop(
268
284
  obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
269
285
  ) -> None:
270
286
  """Stop PostgreSQL INSTANCE"""
271
- with obj.lock:
287
+ with obj.lock, audit():
272
288
  for i in instance:
273
289
  await instances.stop(i)
274
290
 
@@ -282,7 +298,7 @@ async def reload(
282
298
  obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
283
299
  ) -> None:
284
300
  """Reload PostgreSQL INSTANCE"""
285
- with obj.lock:
301
+ with obj.lock, audit():
286
302
  for i in instance:
287
303
  await instances.reload(i)
288
304
 
@@ -296,7 +312,7 @@ async def restart(
296
312
  obj: Obj, instance: tuple[system.Instance, ...], all_instances: bool
297
313
  ) -> None:
298
314
  """Restart PostgreSQL INSTANCE"""
299
- with obj.lock:
315
+ with obj.lock, audit():
300
316
  for i in instance:
301
317
  await instances.restart(i)
302
318
 
@@ -452,7 +468,7 @@ async def upgrade(
452
468
  jobs: int | None,
453
469
  ) -> None:
454
470
  """Upgrade INSTANCE using pg_upgrade"""
455
- with obj.lock:
471
+ with obj.lock, audit():
456
472
  await postgresql.check_status(instance, Status.not_running)
457
473
  async with task.async_transaction():
458
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)