pglift-cli 1.3.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.
@@ -0,0 +1,23 @@
1
+ # SPDX-FileCopyrightText: 2021 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ docs/_build
6
+ build/
7
+ */dist/
8
+ .eggs/
9
+ *.egg-info/
10
+ *.egg
11
+ *.py[cod]
12
+ __pycache__/
13
+ *.so
14
+ *~
15
+
16
+ .cache
17
+ .coverage
18
+ .pybuild
19
+ .pytest_cache
20
+ .mypy_cache
21
+ .tox
22
+
23
+ htmlcov
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.1
2
+ Name: pglift_cli
3
+ Version: 1.3.0
4
+ Summary: Command-line interface for pglift
5
+ Project-URL: Documentation, https://pglift.readthedocs.io/
6
+ Project-URL: Source, https://gitlab.com/dalibo/pglift/
7
+ Project-URL: Tracker, https://gitlab.com/dalibo/pglift/-/issues/
8
+ Author-email: Dalibo SCOP <contact@dalibo.com>
9
+ License: GPLv3
10
+ Keywords: administration,command-line,deployment,postgresql
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Topic :: Database
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: <4,>=3.9
25
+ Requires-Dist: click!=8.1.0,!=8.1.4,>=8.0.0
26
+ Requires-Dist: filelock!=3.12.1,>=3.9.0
27
+ Requires-Dist: pluggy
28
+ Requires-Dist: psycopg>=3.1
29
+ Requires-Dist: pydantic>=2.5.0
30
+ Requires-Dist: pyyaml>=6.0.1
31
+ Requires-Dist: rich>=11.0.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: pglift-cli[test,typing]; extra == 'dev'
34
+ Provides-Extra: test
35
+ Requires-Dist: anyio; extra == 'test'
36
+ Requires-Dist: patroni[etcd]>=2.1.5; extra == 'test'
37
+ Requires-Dist: port-for; extra == 'test'
38
+ Requires-Dist: prysk[pytest-plugin]>=0.14.0; extra == 'test'
39
+ Requires-Dist: pytest; extra == 'test'
40
+ Requires-Dist: pytest-cov; extra == 'test'
41
+ Requires-Dist: trustme; extra == 'test'
42
+ Provides-Extra: typing
43
+ Requires-Dist: mypy>=1.8.0; extra == 'typing'
44
+ Requires-Dist: types-pyyaml>=6.0.12.10; extra == 'typing'
45
+ Description-Content-Type: text/markdown
46
+
47
+ <!--
48
+ SPDX-FileCopyrightText: 2024 Dalibo
49
+
50
+ SPDX-License-Identifier: GPL-3.0-or-later
51
+ -->
52
+
53
+ This package provides the command-line interface for [pglift][]. It is a
54
+ dependency package and should not be installed directly but rather through the
55
+ ``cli`` *optional dependency* (aka "extra") of the pglift package, e.g.:
56
+
57
+ $ pip install "pglift[cli]"
58
+
59
+ [pglift]: https://pglift.readthedocs.io/
@@ -0,0 +1,13 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2024 Dalibo
3
+
4
+ SPDX-License-Identifier: GPL-3.0-or-later
5
+ -->
6
+
7
+ This package provides the command-line interface for [pglift][]. It is a
8
+ dependency package and should not be installed directly but rather through the
9
+ ``cli`` *optional dependency* (aka "extra") of the pglift package, e.g.:
10
+
11
+ $ pip install "pglift[cli]"
12
+
13
+ [pglift]: https://pglift.readthedocs.io/
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2021 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ [version]
6
+ source = "vcs"
7
+ raw-options = { root = ".."}
@@ -0,0 +1,79 @@
1
+ # SPDX-FileCopyrightText: 2024 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ [build-system]
6
+ requires = ["hatchling", "hatch-vcs"]
7
+ build-backend = "hatchling.build"
8
+
9
+ [project]
10
+ name = "pglift_cli"
11
+ description = "Command-line interface for pglift"
12
+ readme = "README.md"
13
+ requires-python = ">=3.9, <4"
14
+ license = { text = "GPLv3" }
15
+ authors = [{ name = "Dalibo SCOP", email = "contact@dalibo.com" }]
16
+ keywords = [
17
+ "postgresql",
18
+ "deployment",
19
+ "administration",
20
+ "command-line",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 5 - Production/Stable",
24
+ "Environment :: Console",
25
+ "Intended Audience :: Developers",
26
+ "Intended Audience :: System Administrators",
27
+ "Topic :: System :: Systems Administration",
28
+ "Topic :: Database",
29
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.9",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3 :: Only",
35
+ "Typing :: Typed",
36
+ ]
37
+ dynamic = ["version"]
38
+
39
+ dependencies = [
40
+ "PyYAML >= 6.0.1",
41
+ "click >= 8.0.0, != 8.1.0, != 8.1.4",
42
+ "filelock >= 3.9.0, != 3.12.1",
43
+ "pluggy",
44
+ "psycopg >= 3.1",
45
+ "pydantic >= 2.5.0",
46
+ "rich >= 11.0.0",
47
+ ]
48
+
49
+ [project.optional-dependencies]
50
+ test = [
51
+ "anyio",
52
+ "patroni[etcd] >= 2.1.5",
53
+ "port-for",
54
+ "prysk[pytest-plugin] >= 0.14.0",
55
+ "pytest",
56
+ "pytest-cov",
57
+ "trustme",
58
+ ]
59
+ typing = [
60
+ "mypy >= 1.8.0",
61
+ "types-PyYAML >= 6.0.12.10",
62
+ ]
63
+ dev = [
64
+ "pglift_cli[test,typing]",
65
+ ]
66
+
67
+ [project.urls]
68
+ Documentation = "https://pglift.readthedocs.io/"
69
+ Source = "https://gitlab.com/dalibo/pglift/"
70
+ Tracker = "https://gitlab.com/dalibo/pglift/-/issues/"
71
+
72
+ [project.entry-points.pglift]
73
+ "pglift_cli.patroni" = "pglift_cli.patroni"
74
+ "pglift_cli.pgbackrest" = "pglift_cli.pgbackrest"
75
+ "pglift_cli.pgbackrest.repo_path" = "pglift_cli.pgbackrest.repo_path"
76
+ "pglift_cli.prometheus" = "pglift_cli.prometheus"
77
+
78
+ [project.scripts]
79
+ pglift = "pglift_cli.main:cli"
@@ -0,0 +1,7 @@
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__)
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2021 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from __future__ import annotations
6
+
7
+ from .main import cli
8
+
9
+ if __name__ == "__main__":
10
+ cli(prog_name="pglift")
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,34 @@
1
+ # SPDX-FileCopyrightText: 2024 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from typing import Final
6
+
7
+ import click
8
+ import pydantic
9
+
10
+ from pglift import exceptions, plugin_manager
11
+ from pglift.models import interface
12
+
13
+ from . import hookspecs
14
+ from ._settings import Settings, SiteSettings
15
+ from .pm import PluginManager
16
+
17
+
18
+ def _load_settings() -> Settings:
19
+ try:
20
+ return SiteSettings()
21
+ except (exceptions.SettingsError, pydantic.ValidationError) as e:
22
+ raise click.ClickException(f"invalid site settings\n{e}") from e
23
+ except exceptions.UnsupportedError as e:
24
+ raise click.ClickException(f"unsupported operation: {e}") from None
25
+
26
+
27
+ SETTINGS: Final = _load_settings()
28
+ DEFAULT_SETTINGS: Final = Settings()
29
+
30
+ PLUGIN_MANAGER = PluginManager.get(SETTINGS, hookspecs)
31
+
32
+ pm = plugin_manager(SETTINGS)
33
+ INSTANCE_MODEL: Final = interface.Instance.composite(pm)
34
+ ROLE_MODEL: Final = interface.Role.composite(pm)
@@ -0,0 +1,53 @@
1
+ # SPDX-FileCopyrightText: 2024 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections import OrderedDict
8
+
9
+ import click
10
+
11
+ from pglift import hooks
12
+
13
+ from . import _site, database
14
+ from . import hookspecs as h
15
+ from . import instance, pgconf, postgres, role
16
+
17
+
18
+ class CLIGroup(click.Group):
19
+ """Group gathering main commands (defined here), commands from submodules
20
+ and commands from plugins.
21
+ """
22
+
23
+ submodules = OrderedDict(
24
+ [
25
+ ("instance", instance.cli),
26
+ ("pgconf", pgconf.cli),
27
+ ("role", role.cli),
28
+ ("database", database.cli),
29
+ ("postgres", postgres.cli),
30
+ ]
31
+ )
32
+
33
+ def list_commands(self, context: click.Context) -> list[str]:
34
+ main_commands = super().list_commands(context)
35
+ plugins_commands: list[str] = sorted(g.name for g in hooks(_site.PLUGIN_MANAGER, h.command)) # type: ignore[misc]
36
+ return main_commands + list(self.submodules) + plugins_commands
37
+
38
+ def get_command(self, context: click.Context, name: str) -> click.Command | None:
39
+ main_command = super().get_command(context, name)
40
+ if main_command is not None:
41
+ return main_command
42
+ try:
43
+ command = self.submodules[name]
44
+ except KeyError:
45
+ pass
46
+ else:
47
+ assert isinstance(command, click.Command), command
48
+ return command
49
+ for group in hooks(_site.PLUGIN_MANAGER, h.command):
50
+ assert isinstance(group, click.Command)
51
+ if group.name == name:
52
+ return group
53
+ return None
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2024 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from typing import Final
6
+
7
+ from rich.console import Console
8
+
9
+ console: Final = Console()
@@ -0,0 +1,260 @@
1
+ # SPDX-FileCopyrightText: 2021 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import IO, Any
11
+
12
+ import click
13
+ import psycopg
14
+ from pydantic.v1.utils import deep_update
15
+
16
+ from pglift import databases, postgresql, privileges, task
17
+ from pglift.models import interface, system
18
+
19
+ from . import model
20
+ from .util import (
21
+ Group,
22
+ Obj,
23
+ OutputFormat,
24
+ async_command,
25
+ dry_run_option,
26
+ instance_identifier_option,
27
+ model_dump,
28
+ output_format_option,
29
+ pass_instance,
30
+ print_argspec,
31
+ print_json_for,
32
+ print_schema,
33
+ print_table_for,
34
+ )
35
+
36
+
37
+ @click.group("database", cls=Group)
38
+ @instance_identifier_option
39
+ @click.option(
40
+ "--schema",
41
+ is_flag=True,
42
+ callback=functools.partial(print_schema, model=interface.Database),
43
+ expose_value=False,
44
+ is_eager=True,
45
+ help="Print the JSON schema of database model and exit.",
46
+ )
47
+ @click.option(
48
+ "--ansible-argspec",
49
+ is_flag=True,
50
+ callback=functools.partial(print_argspec, model=interface.Database),
51
+ expose_value=False,
52
+ is_eager=True,
53
+ hidden=True,
54
+ help="Print the Ansible argspec of database model and exit.",
55
+ )
56
+ def cli(**kwargs: Any) -> None:
57
+ """Manage databases."""
58
+
59
+
60
+ @cli.command("create")
61
+ @model.as_parameters(interface.Database, "create")
62
+ @pass_instance
63
+ @click.pass_obj
64
+ @async_command
65
+ async def create(
66
+ obj: Obj, instance: system.Instance, database: interface.Database
67
+ ) -> None:
68
+ """Create a database in a PostgreSQL instance"""
69
+ with obj.lock:
70
+ async with postgresql.running(instance):
71
+ if await databases.exists(instance, database.name):
72
+ raise click.ClickException("database already exists")
73
+ async with task.async_transaction():
74
+ await databases.apply(instance, database)
75
+
76
+
77
+ @cli.command("alter") # type: ignore[arg-type]
78
+ @model.as_parameters(interface.Database, "update", parse_model=False)
79
+ @click.argument("dbname")
80
+ @pass_instance
81
+ @click.pass_obj
82
+ @async_command
83
+ async def alter(
84
+ obj: Obj, instance: system.Instance, dbname: str, **changes: Any
85
+ ) -> None:
86
+ """Alter a database in a PostgreSQL instance"""
87
+ with obj.lock:
88
+ async with postgresql.running(instance):
89
+ values = (await databases.get(instance, dbname)).model_dump(by_alias=True)
90
+ values = deep_update(values, changes)
91
+ altered = interface.Database.model_validate(values)
92
+ await databases.apply(instance, altered)
93
+
94
+
95
+ @cli.command("apply", hidden=True)
96
+ @click.option("-f", "--file", type=click.File("r"), metavar="MANIFEST", required=True)
97
+ @output_format_option
98
+ @dry_run_option
99
+ @pass_instance
100
+ @click.pass_obj
101
+ @async_command
102
+ async def apply(
103
+ obj: Obj,
104
+ instance: system.Instance,
105
+ file: IO[str],
106
+ output_format: OutputFormat,
107
+ dry_run: bool,
108
+ ) -> None:
109
+ """Apply manifest as a database"""
110
+ database = interface.Database.parse_yaml(file)
111
+ if dry_run:
112
+ ret = interface.ApplyResult(change_state=None)
113
+ else:
114
+ with obj.lock:
115
+ async with postgresql.running(instance):
116
+ ret = await databases.apply(instance, database)
117
+ if output_format == OutputFormat.json:
118
+ print_json_for(ret)
119
+
120
+
121
+ @cli.command("get")
122
+ @output_format_option
123
+ @click.argument("name")
124
+ @pass_instance
125
+ @async_command
126
+ async def get(
127
+ instance: system.Instance, name: str, output_format: OutputFormat
128
+ ) -> None:
129
+ """Get the description of a database"""
130
+ async with postgresql.running(instance):
131
+ db = await databases.get(instance, name)
132
+ if output_format == OutputFormat.json:
133
+ print_json_for(model_dump(db))
134
+ else:
135
+ print_table_for(
136
+ [db],
137
+ functools.partial(model_dump, exclude={"extensions", "schemas"}),
138
+ box=None,
139
+ )
140
+
141
+
142
+ @cli.command("list")
143
+ @output_format_option
144
+ @click.argument("dbname", nargs=-1)
145
+ @pass_instance
146
+ @async_command
147
+ async def ls(
148
+ instance: system.Instance, dbname: Sequence[str], output_format: OutputFormat
149
+ ) -> None:
150
+ """List databases (all or specified ones)
151
+
152
+ Only queried databases are shown when DBNAME is specified.
153
+ """
154
+
155
+ async with postgresql.running(instance):
156
+ dbs = await databases.ls(instance, dbnames=dbname)
157
+ if output_format == OutputFormat.json:
158
+ print_json_for([model_dump(db) for db in dbs])
159
+ else:
160
+ print_table_for(dbs, model_dump)
161
+
162
+
163
+ @cli.command("drop")
164
+ @model.as_parameters(interface.DatabaseDropped, "create")
165
+ @pass_instance
166
+ @click.pass_obj
167
+ @async_command
168
+ async def drop(
169
+ obj: Obj, instance: system.Instance, databasedropped: interface.DatabaseDropped
170
+ ) -> None:
171
+ """Drop a database"""
172
+ with obj.lock:
173
+ async with postgresql.running(instance):
174
+ await databases.drop(instance, databasedropped)
175
+
176
+
177
+ @cli.command("privileges")
178
+ @click.argument("name")
179
+ @click.option("-r", "--role", "roles", multiple=True, help="Role to inspect")
180
+ @click.option("--default", "defaults", is_flag=True, help="Display default privileges")
181
+ @output_format_option
182
+ @pass_instance
183
+ @async_command
184
+ async def list_privileges(
185
+ instance: system.Instance,
186
+ name: str,
187
+ roles: Sequence[str],
188
+ defaults: bool,
189
+ output_format: OutputFormat,
190
+ ) -> None:
191
+ """List privileges on a database."""
192
+ async with postgresql.running(instance):
193
+ await databases.get(instance, name) # check existence
194
+ try:
195
+ prvlgs = await privileges.get(
196
+ instance, databases=(name,), roles=roles, defaults=defaults
197
+ )
198
+ except ValueError as e:
199
+ raise click.ClickException(str(e)) from None
200
+ if output_format == OutputFormat.json:
201
+ print_json_for([model_dump(p) for p in prvlgs])
202
+ else:
203
+ print_table_for(prvlgs, model_dump)
204
+
205
+
206
+ @cli.command("run")
207
+ @click.argument("sql_command")
208
+ @click.option(
209
+ "-d", "--database", "dbnames", multiple=True, help="Database to run command on"
210
+ )
211
+ @click.option(
212
+ "-x",
213
+ "--exclude-database",
214
+ "exclude_dbnames",
215
+ multiple=True,
216
+ help="Database to not run command on",
217
+ )
218
+ @output_format_option
219
+ @pass_instance
220
+ @async_command
221
+ async def run(
222
+ instance: system.Instance,
223
+ sql_command: str,
224
+ dbnames: Sequence[str],
225
+ exclude_dbnames: Sequence[str],
226
+ output_format: OutputFormat,
227
+ ) -> None:
228
+ """Run given command on databases of a PostgreSQL instance"""
229
+ async with postgresql.running(instance):
230
+ try:
231
+ result = await databases.run(
232
+ instance,
233
+ sql_command,
234
+ dbnames=dbnames,
235
+ exclude_dbnames=exclude_dbnames,
236
+ )
237
+ except psycopg.ProgrammingError as e:
238
+ raise click.ClickException(str(e)) from None
239
+ if output_format == OutputFormat.json:
240
+ print_json_for(result)
241
+ else:
242
+ for dbname, rows in result.items():
243
+ print_table_for(rows, lambda m: m, title=f"Database {dbname}")
244
+
245
+
246
+ @cli.command("dump")
247
+ @click.option(
248
+ "-o",
249
+ "--output",
250
+ metavar="DIRECTORY",
251
+ 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'.",
253
+ )
254
+ @click.argument("dbname")
255
+ @pass_instance
256
+ @async_command
257
+ async def dump(instance: system.Instance, dbname: str, output: Path | None) -> None:
258
+ """Dump a database"""
259
+ async with postgresql.running(instance):
260
+ await databases.dump(instance, dbname, output)
@@ -0,0 +1,24 @@
1
+ # SPDX-FileCopyrightText: 2024 Dalibo
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from __future__ import annotations
6
+
7
+ import click
8
+ import pluggy
9
+
10
+ from . import __name__ as pkgname
11
+
12
+ hookspec = pluggy.HookspecMarker(pkgname)
13
+
14
+
15
+ @hookspec
16
+ def command() -> click.Command:
17
+ """Return command-line entry point as click Command (or Group) for the plugin."""
18
+ raise NotImplementedError
19
+
20
+
21
+ @hookspec
22
+ def add_instance_commands(group: click.Group) -> None:
23
+ """Extend instance commands 'group' with extra commands from the plugin."""
24
+ raise NotImplementedError