pyinfra 3.1__py2.py3-none-any.whl → 3.2__py2.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.
Files changed (52) hide show
  1. pyinfra/api/arguments.py +10 -3
  2. pyinfra/api/deploy.py +12 -2
  3. pyinfra/api/host.py +7 -4
  4. pyinfra/connectors/chroot.py +1 -1
  5. pyinfra/connectors/docker.py +17 -6
  6. pyinfra/connectors/local.py +1 -1
  7. pyinfra/connectors/ssh.py +3 -0
  8. pyinfra/connectors/sshuserclient/client.py +26 -14
  9. pyinfra/facts/apk.py +3 -1
  10. pyinfra/facts/apt.py +62 -2
  11. pyinfra/facts/crontab.py +190 -0
  12. pyinfra/facts/docker.py +6 -0
  13. pyinfra/facts/efibootmgr.py +108 -0
  14. pyinfra/facts/files.py +93 -6
  15. pyinfra/facts/git.py +3 -2
  16. pyinfra/facts/hardware.py +1 -0
  17. pyinfra/facts/mysql.py +1 -2
  18. pyinfra/facts/opkg.py +233 -0
  19. pyinfra/facts/pipx.py +74 -0
  20. pyinfra/facts/podman.py +47 -0
  21. pyinfra/facts/postgres.py +2 -0
  22. pyinfra/facts/selinux.py +3 -1
  23. pyinfra/facts/server.py +39 -77
  24. pyinfra/facts/util/units.py +30 -0
  25. pyinfra/facts/zfs.py +22 -19
  26. pyinfra/local.py +3 -2
  27. pyinfra/operations/apt.py +29 -21
  28. pyinfra/operations/crontab.py +189 -0
  29. pyinfra/operations/docker.py +13 -12
  30. pyinfra/operations/files.py +20 -2
  31. pyinfra/operations/git.py +48 -9
  32. pyinfra/operations/opkg.py +88 -0
  33. pyinfra/operations/pip.py +3 -2
  34. pyinfra/operations/pipx.py +90 -0
  35. pyinfra/operations/postgres.py +15 -11
  36. pyinfra/operations/runit.py +2 -0
  37. pyinfra/operations/server.py +4 -178
  38. pyinfra/operations/zfs.py +14 -14
  39. {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/METADATA +11 -12
  40. {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/RECORD +52 -43
  41. pyinfra_cli/inventory.py +26 -9
  42. pyinfra_cli/prints.py +18 -3
  43. pyinfra_cli/util.py +5 -2
  44. tests/test_cli/test_cli_deploy.py +15 -13
  45. tests/test_cli/test_cli_exceptions.py +2 -2
  46. tests/test_cli/test_cli_inventory.py +53 -0
  47. tests/test_cli/test_cli_util.py +2 -4
  48. tests/test_connectors/test_sshuserclient.py +68 -1
  49. {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/LICENSE.md +0 -0
  50. {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/WHEEL +0 -0
  51. {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/entry_points.txt +0 -0
  52. {pyinfra-3.1.dist-info → pyinfra-3.2.dist-info}/top_level.txt +0 -0
@@ -51,6 +51,7 @@ from pyinfra.facts.files import (
51
51
  Md5File,
52
52
  Sha1File,
53
53
  Sha256File,
54
+ Sha384File,
54
55
  )
55
56
  from pyinfra.facts.server import Date, Which
56
57
 
@@ -67,6 +68,7 @@ def download(
67
68
  mode: str | None = None,
68
69
  cache_time: int | None = None,
69
70
  force=False,
71
+ sha384sum: str | None = None,
70
72
  sha256sum: str | None = None,
71
73
  sha1sum: str | None = None,
72
74
  md5sum: str | None = None,
@@ -84,6 +86,7 @@ def download(
84
86
  + mode: permissions of the files
85
87
  + cache_time: if the file exists already, re-download after this time (in seconds)
86
88
  + force: always download the file, even if it already exists
89
+ + sha384sum: sha384 hash to checksum the downloaded file against
87
90
  + sha256sum: sha256 hash to checksum the downloaded file against
88
91
  + sha1sum: sha1 hash to checksum the downloaded file against
89
92
  + md5sum: md5 hash to checksum the downloaded file against
@@ -135,6 +138,10 @@ def download(
135
138
  if sha256sum != host.get_fact(Sha256File, path=dest):
136
139
  download = True
137
140
 
141
+ if sha384sum:
142
+ if sha384sum != host.get_fact(Sha384File, path=dest):
143
+ download = True
144
+
138
145
  if md5sum:
139
146
  if md5sum != host.get_fact(Md5File, path=dest):
140
147
  download = True
@@ -211,6 +218,17 @@ def download(
211
218
  QuoteString("SHA256 did not match!"),
212
219
  )
213
220
 
221
+ if sha384sum:
222
+ yield make_formatted_string_command(
223
+ (
224
+ "(( sha384sum {0} 2> /dev/null || shasum -a 384 {0} ) "
225
+ "| grep {1}) || ( echo {2} && exit 1 )"
226
+ ),
227
+ QuoteString(dest),
228
+ sha384sum,
229
+ QuoteString("SHA384 did not match!"),
230
+ )
231
+
214
232
  if md5sum:
215
233
  yield make_formatted_string_command(
216
234
  (
@@ -256,7 +274,7 @@ def line(
256
274
  change bits of lines, see ``files.replace``.
257
275
 
258
276
  Regex line escaping:
259
- If matching special characters (eg a crontab line containing *), remember to escape
277
+ If matching special characters (eg a crontab line containing ``*``), remember to escape
260
278
  it first using Python's ``re.escape``.
261
279
 
262
280
  Backup:
@@ -523,7 +541,7 @@ def sync(
523
541
  + mode: permissions of the files
524
542
  + dir_mode: permissions of the directories
525
543
  + delete: delete remote files not present locally
526
- + exclude: string or list/tuple of strings to match & exclude files (eg *.pyc)
544
+ + exclude: string or list/tuple of strings to match & exclude files (eg ``*.pyc``)
527
545
  + exclude_dir: string or list/tuple of strings to match & exclude directories (eg node_modules)
528
546
  + add_deploy_dir: interpret src as relative to deploy directory instead of current directory
529
547
 
pyinfra/operations/git.py CHANGED
@@ -16,24 +16,40 @@ from .util.files import chown, unix_path_join
16
16
 
17
17
 
18
18
  @operation()
19
- def config(key: str, value: str, multi_value=False, repo: str | None = None):
19
+ def config(key: str, value: str, multi_value=False, repo: str | None = None, system=False):
20
20
  """
21
- Manage git config for a repository or globally.
21
+ Manage git config at repository, user or system level.
22
22
 
23
23
  + key: the key of the config to ensure
24
24
  + value: the value this key should have
25
25
  + multi_value: Add the value rather than set it for settings that can have multiple values
26
26
  + repo: specify the git repo path to edit local config (defaults to global)
27
+ + system: whether, when ``repo`` is unspecified, to work at system level (or default to global)
27
28
 
28
- **Example:**
29
+ **Examples:**
29
30
 
30
31
  .. code:: python
31
32
 
32
33
  git.config(
33
- name="Ensure user name is set for a repo",
34
+ name="Always prune specified repo",
35
+ key="fetch.prune",
36
+ value="true",
37
+ repo="/usr/local/src/pyinfra",
38
+ )
39
+
40
+ git.config(
41
+ name="Ensure user name is set for all repos of specified user",
34
42
  key="user.name",
35
43
  value="Anon E. Mouse",
36
- repo="/usr/local/src/pyinfra",
44
+ _sudo=True,
45
+ _sudo_user="anon"
46
+ )
47
+
48
+ git.config(
49
+ name="Ensure same date format for all users",
50
+ key="log.date",
51
+ value="iso",
52
+ system=True
37
53
  )
38
54
 
39
55
  """
@@ -41,14 +57,14 @@ def config(key: str, value: str, multi_value=False, repo: str | None = None):
41
57
  existing_config = {}
42
58
 
43
59
  if not repo:
44
- existing_config = host.get_fact(GitConfig)
60
+ existing_config = host.get_fact(GitConfig, system=system)
45
61
 
46
62
  # Only get the config if the repo exists at this stage
47
63
  elif host.get_fact(Directory, path=unix_path_join(repo, ".git")):
48
64
  existing_config = host.get_fact(GitConfig, repo=repo)
49
65
 
50
66
  if repo is None:
51
- base_command = "git config --global"
67
+ base_command = "git config" + (" --system" if system else " --global")
52
68
  else:
53
69
  base_command = "cd {0} && git config --local".format(repo)
54
70
 
@@ -184,7 +200,7 @@ def worktree(
184
200
  + from_remote_branch: a 2-tuple ``(remote, branch)`` that identifies a remote branch
185
201
  + present: whether the working tree should exist
186
202
  + assume_repo_exists: whether to assume the main repo exists
187
- + force: remove unclean working tree if should not exist
203
+ + force: whether to use ``--force`` when adding/removing worktrees
188
204
  + user: chown files to this user after
189
205
  + group: chown files to this group after
190
206
 
@@ -205,6 +221,14 @@ def worktree(
205
221
  commitish="4e091aa0"
206
222
  )
207
223
 
224
+ git.worktree(
225
+ name="Create a worktree from the tag `4e091aa0`, even if already registered",
226
+ repo="/usr/local/src/pyinfra/master",
227
+ worktree="/usr/local/src/pyinfra/2.x",
228
+ commitish="2.x",
229
+ force=True
230
+ )
231
+
208
232
  git.worktree(
209
233
  name="Create a worktree with a new local branch `v1.0`",
210
234
  repo="/usr/local/src/pyinfra/master",
@@ -250,6 +274,15 @@ def worktree(
250
274
  commitish="v1.0"
251
275
  )
252
276
 
277
+ git.worktree(
278
+ name="Idempotent worktree creation, never pulls",
279
+ repo="/usr/local/src/pyinfra/master",
280
+ worktree="/usr/local/src/pyinfra/hotfix",
281
+ new_branch="v1.0",
282
+ commitish="v1.0",
283
+ pull=False
284
+ )
285
+
253
286
  git.worktree(
254
287
  name="Pull an existing worktree already linked to a tracking branch",
255
288
  repo="/usr/local/src/pyinfra/master",
@@ -295,6 +328,9 @@ def worktree(
295
328
  elif detached:
296
329
  command_parts.append("--detach")
297
330
 
331
+ if force:
332
+ command_parts.append("--force")
333
+
298
334
  command_parts.append(worktree)
299
335
 
300
336
  if commitish:
@@ -317,9 +353,12 @@ def worktree(
317
353
 
318
354
  # It exists and we still want it => pull/rebase it
319
355
  elif host.get_fact(Directory, path=worktree) and present:
356
+ if not pull:
357
+ host.noop("Pull is disabled")
358
+
320
359
  # pull the worktree only if it's already linked to a tracking branch or
321
360
  # if a remote branch is set
322
- if host.get_fact(GitTrackingBranch, repo=worktree) or from_remote_branch:
361
+ elif host.get_fact(GitTrackingBranch, repo=worktree) or from_remote_branch:
323
362
  command = "cd {0} && git pull".format(worktree)
324
363
 
325
364
  if rebase:
@@ -0,0 +1,88 @@
1
+ """
2
+ Manage packages on OpenWrt using opkg
3
+ + ``update`` - update local copy of package information
4
+ + ``packages`` - install and remove packages
5
+
6
+ see https://openwrt.org/docs/guide-user/additional-software/opkg
7
+ OpenWrt recommends against upgrading all packages thus there is no ``opkg.upgrade`` function
8
+ """
9
+
10
+ from typing import List, Union
11
+
12
+ from pyinfra import host
13
+ from pyinfra.api import StringCommand, operation
14
+ from pyinfra.facts.opkg import OpkgPackages
15
+ from pyinfra.operations.util.packaging import ensure_packages
16
+
17
+ EQUALS = "="
18
+
19
+
20
+ @operation(is_idempotent=False)
21
+ def update():
22
+ """
23
+ Update the local opkg information.
24
+ """
25
+
26
+ yield StringCommand("opkg update")
27
+
28
+
29
+ _update = update
30
+
31
+
32
+ @operation()
33
+ def packages(
34
+ packages: Union[str, List[str]] = "",
35
+ present: bool = True,
36
+ latest: bool = False,
37
+ update: bool = True,
38
+ ):
39
+ """
40
+ Add/remove/update opkg packages.
41
+
42
+ + packages: package or list of packages to that must/must not be present
43
+ + present: whether the package(s) should be installed (default True) or removed
44
+ + latest: whether to attempt to upgrade the specified package(s) (default False)
45
+ + update: run ``opkg update`` before installing packages (default True)
46
+
47
+ Not Supported:
48
+ Opkg does not support version pinning, i.e. ``<pkg>=<version>`` is not allowed
49
+ and will cause an exception.
50
+
51
+ **Examples:**
52
+
53
+ .. code:: python
54
+
55
+ # Ensure packages are installed∂ (will not force package upgrade)
56
+ opkg.packages(['asterisk', 'vim'], name="Install Asterisk and Vim")
57
+
58
+ # Install the latest versions of packages (always check)
59
+ opkg.packages(
60
+ 'vim',
61
+ latest=True,
62
+ name="Ensure we have the latest version of Vim"
63
+ )
64
+ """
65
+ if str(packages) == "" or (
66
+ isinstance(packages, list) and (len(packages) < 1 or all(len(p) < 1 for p in packages))
67
+ ):
68
+ host.noop("empty or invalid package list provided to opkg.packages")
69
+ return
70
+
71
+ pkg_list = packages if isinstance(packages, list) else [packages]
72
+ have_equals = ",".join([pkg.split(EQUALS)[0] for pkg in pkg_list if EQUALS in pkg])
73
+ if len(have_equals) > 0:
74
+ raise ValueError(f"opkg does not support version pinning but found for: '{have_equals}'")
75
+
76
+ if update:
77
+ yield from _update._inner()
78
+
79
+ yield from ensure_packages(
80
+ host,
81
+ pkg_list,
82
+ host.get_fact(OpkgPackages),
83
+ present,
84
+ install_command="opkg install",
85
+ upgrade_command="opkg upgrade",
86
+ uninstall_command="opkg remove",
87
+ latest=latest,
88
+ )
pyinfra/operations/pip.py CHANGED
@@ -174,12 +174,13 @@ def packages(
174
174
  install_command_args.append(extra_install_args)
175
175
  install_command = " ".join(install_command_args)
176
176
 
177
+ upgrade_command = "{0} --upgrade".format(install_command)
177
178
  uninstall_command = " ".join([pip, "uninstall", "--yes"])
178
179
 
179
180
  # (un)Install requirements
180
181
  if requirements is not None:
181
182
  if present:
182
- yield "{0} -r {1}".format(install_command, requirements)
183
+ yield "{0} -r {1}".format(upgrade_command if latest else install_command, requirements)
183
184
  else:
184
185
  yield "{0} -r {1}".format(uninstall_command, requirements)
185
186
 
@@ -199,7 +200,7 @@ def packages(
199
200
  present,
200
201
  install_command=install_command,
201
202
  uninstall_command=uninstall_command,
202
- upgrade_command="{0} --upgrade".format(install_command),
203
+ upgrade_command=upgrade_command,
203
204
  version_join="==",
204
205
  latest=latest,
205
206
  )
@@ -0,0 +1,90 @@
1
+ """
2
+ Manage pipx (python) applications.
3
+ """
4
+
5
+ from pyinfra import host
6
+ from pyinfra.api import operation
7
+ from pyinfra.facts.pipx import PipxEnvironment, PipxPackages
8
+ from pyinfra.facts.server import Path
9
+
10
+ from .util.packaging import ensure_packages
11
+
12
+
13
+ @operation()
14
+ def packages(
15
+ packages=None,
16
+ present=True,
17
+ latest=False,
18
+ extra_args=None,
19
+ ):
20
+ """
21
+ Install/remove/update pipx packages.
22
+
23
+ + packages: list of packages to ensure
24
+ + present: whether the packages should be installed
25
+ + latest: whether to upgrade packages without a specified version
26
+ + extra_args: additional arguments to the pipx command
27
+
28
+ Versions:
29
+ Package versions can be pinned like pip: ``<pkg>==<version>``.
30
+
31
+ **Example:**
32
+
33
+ .. code:: python
34
+
35
+ pipx.packages(
36
+ name="Install ",
37
+ packages=["pyinfra"],
38
+ )
39
+ """
40
+
41
+ prep_install_command = ["pipx", "install"]
42
+
43
+ if extra_args:
44
+ prep_install_command.append(extra_args)
45
+ install_command = " ".join(prep_install_command)
46
+
47
+ uninstall_command = "pipx uninstall"
48
+ upgrade_command = "pipx upgrade"
49
+
50
+ current_packages = host.get_fact(PipxPackages)
51
+
52
+ # pipx support only one package name at a time
53
+ for package in packages:
54
+ yield from ensure_packages(
55
+ host,
56
+ [package],
57
+ current_packages,
58
+ present,
59
+ install_command=install_command,
60
+ uninstall_command=uninstall_command,
61
+ upgrade_command=upgrade_command,
62
+ version_join="==",
63
+ latest=latest,
64
+ )
65
+
66
+
67
+ @operation()
68
+ def upgrade_all():
69
+ """
70
+ Upgrade all pipx packages.
71
+ """
72
+ yield "pipx upgrade-all"
73
+
74
+
75
+ @operation()
76
+ def ensure_path():
77
+ """
78
+ Ensure pipx bin dir is in the PATH.
79
+ """
80
+
81
+ # Fetch the current user's PATH
82
+ path = host.get_fact(Path)
83
+ # Fetch the pipx environment variables
84
+ pipx_env = host.get_fact(PipxEnvironment)
85
+
86
+ # If the pipx bin dir is already in the user's PATH, we're done
87
+ if "PIPX_BIN_DIR" in pipx_env and pipx_env["PIPX_BIN_DIR"] in path.split(":"):
88
+ host.noop("pipx bin dir is already in the PATH")
89
+ else:
90
+ yield "pipx ensurepath"
@@ -8,6 +8,7 @@ All operations in this module take four optional arguments:
8
8
  + ``psql_password``: the password for the connecting user
9
9
  + ``psql_host``: the hostname of the server to connect to
10
10
  + ``psql_port``: the port of the server to connect to
11
+ + ``psql_database``: the database on the server to connect to
11
12
 
12
13
  See example/postgresql.py for detailed example
13
14
 
@@ -28,28 +29,27 @@ from pyinfra.facts.postgres import (
28
29
  @operation(is_idempotent=False)
29
30
  def sql(
30
31
  sql: str,
31
- database: str | None = None,
32
32
  # Details for speaking to PostgreSQL via `psql` CLI
33
33
  psql_user: str | None = None,
34
34
  psql_password: str | None = None,
35
35
  psql_host: str | None = None,
36
36
  psql_port: int | None = None,
37
+ psql_database: str | None = None,
37
38
  ):
38
39
  """
39
40
  Execute arbitrary SQL against PostgreSQL.
40
41
 
41
42
  + sql: SQL command(s) to execute
42
- + database: optional database to execute against
43
43
  + psql_*: global module arguments, see above
44
44
  """
45
45
 
46
46
  yield make_execute_psql_command(
47
47
  sql,
48
- database=database,
49
48
  user=psql_user,
50
49
  password=psql_password,
51
50
  host=psql_host,
52
51
  port=psql_port,
52
+ database=psql_database,
53
53
  )
54
54
 
55
55
 
@@ -70,6 +70,7 @@ def role(
70
70
  psql_password: str | None = None,
71
71
  psql_host: str | None = None,
72
72
  psql_port: int | None = None,
73
+ psql_database: str | None = None,
73
74
  ):
74
75
  """
75
76
  Add/remove PostgreSQL roles.
@@ -112,6 +113,7 @@ def role(
112
113
  psql_password=psql_password,
113
114
  psql_host=psql_host,
114
115
  psql_port=psql_port,
116
+ psql_database=psql_database,
115
117
  )
116
118
 
117
119
  is_present = role in roles
@@ -125,6 +127,7 @@ def role(
125
127
  password=psql_password,
126
128
  host=psql_host,
127
129
  port=psql_port,
130
+ database=psql_database,
128
131
  )
129
132
  else:
130
133
  host.noop("postgresql role {0} does not exist".format(role))
@@ -157,6 +160,7 @@ def role(
157
160
  password=psql_password,
158
161
  host=psql_host,
159
162
  port=psql_port,
163
+ database=psql_database,
160
164
  )
161
165
  else:
162
166
  host.noop("postgresql role {0} exists".format(role))
@@ -178,6 +182,7 @@ def database(
178
182
  psql_password: str | None = None,
179
183
  psql_host: str | None = None,
180
184
  psql_port: int | None = None,
185
+ psql_database: str | None = None,
181
186
  ):
182
187
  """
183
188
  Add/remove PostgreSQL databases.
@@ -218,6 +223,7 @@ def database(
218
223
  psql_password=psql_password,
219
224
  psql_host=psql_host,
220
225
  psql_port=psql_port,
226
+ psql_database=psql_database,
221
227
  )
222
228
 
223
229
  is_present = database in current_databases
@@ -230,6 +236,7 @@ def database(
230
236
  password=psql_password,
231
237
  host=psql_host,
232
238
  port=psql_port,
239
+ database=psql_database,
233
240
  )
234
241
  else:
235
242
  host.noop("postgresql database {0} does not exist".format(database))
@@ -257,6 +264,7 @@ def database(
257
264
  password=psql_password,
258
265
  host=psql_host,
259
266
  port=psql_port,
267
+ database=psql_database,
260
268
  )
261
269
  else:
262
270
  host.noop("postgresql database {0} exists".format(database))
@@ -265,18 +273,17 @@ def database(
265
273
  @operation(is_idempotent=False)
266
274
  def dump(
267
275
  dest: str,
268
- database: str | None = None,
269
276
  # Details for speaking to PostgreSQL via `psql` CLI
270
277
  psql_user: str | None = None,
271
278
  psql_password: str | None = None,
272
279
  psql_host: str | None = None,
273
280
  psql_port: int | None = None,
281
+ psql_database: str | None = None,
274
282
  ):
275
283
  """
276
284
  Dump a PostgreSQL database into a ``.sql`` file. Requires ``pg_dump``.
277
285
 
278
286
  + dest: name of the file to dump the SQL to
279
- + database: name of the database to dump
280
287
  + psql_*: global module arguments, see above
281
288
 
282
289
  **Example:**
@@ -286,7 +293,6 @@ def dump(
286
293
  postgresql.dump(
287
294
  name="Dump the pyinfra_stuff database",
288
295
  dest="/tmp/pyinfra_stuff.dump",
289
- database="pyinfra_stuff",
290
296
  sudo_user="postgres",
291
297
  )
292
298
 
@@ -295,11 +301,11 @@ def dump(
295
301
  yield StringCommand(
296
302
  make_psql_command(
297
303
  executable="pg_dump",
298
- database=database,
299
304
  user=psql_user,
300
305
  password=psql_password,
301
306
  host=psql_host,
302
307
  port=psql_port,
308
+ database=psql_database,
303
309
  ),
304
310
  ">",
305
311
  QuoteString(dest),
@@ -309,18 +315,17 @@ def dump(
309
315
  @operation(is_idempotent=False)
310
316
  def load(
311
317
  src: str,
312
- database: str | None = None,
313
318
  # Details for speaking to PostgreSQL via `psql` CLI
314
319
  psql_user: str | None = None,
315
320
  psql_password: str | None = None,
316
321
  psql_host: str | None = None,
317
322
  psql_port: int | None = None,
323
+ psql_database: str | None = None,
318
324
  ):
319
325
  """
320
326
  Load ``.sql`` file into a database.
321
327
 
322
328
  + src: the filename to read from
323
- + database: name of the database to import into
324
329
  + psql_*: global module arguments, see above
325
330
 
326
331
  **Example:**
@@ -330,7 +335,6 @@ def load(
330
335
  postgresql.load(
331
336
  name="Import the pyinfra_stuff dump into pyinfra_stuff_copy",
332
337
  src="/tmp/pyinfra_stuff.dump",
333
- database="pyinfra_stuff_copy",
334
338
  sudo_user="postgres",
335
339
  )
336
340
 
@@ -338,11 +342,11 @@ def load(
338
342
 
339
343
  yield StringCommand(
340
344
  make_psql_command(
341
- database=database,
342
345
  user=psql_user,
343
346
  password=psql_password,
344
347
  host=psql_host,
345
348
  port=psql_port,
349
+ database=psql_database,
346
350
  ),
347
351
  "<",
348
352
  QuoteString(src),
@@ -2,6 +2,8 @@
2
2
  Manage runit services.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  from typing import Optional
6
8
 
7
9
  from pyinfra import host