pyinfra 3.5.1__py3-none-any.whl → 3.6.1__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 (64) hide show
  1. pyinfra/api/__init__.py +1 -0
  2. pyinfra/api/arguments.py +9 -2
  3. pyinfra/api/arguments_typed.py +18 -23
  4. pyinfra/api/command.py +9 -3
  5. pyinfra/api/deploy.py +1 -1
  6. pyinfra/api/exceptions.py +12 -0
  7. pyinfra/api/facts.py +20 -4
  8. pyinfra/api/host.py +3 -0
  9. pyinfra/api/inventory.py +2 -2
  10. pyinfra/api/metadata.py +69 -0
  11. pyinfra/api/operation.py +9 -4
  12. pyinfra/api/operations.py +16 -14
  13. pyinfra/api/util.py +22 -5
  14. pyinfra/connectors/docker.py +25 -1
  15. pyinfra/connectors/ssh.py +57 -0
  16. pyinfra/connectors/sshuserclient/client.py +47 -28
  17. pyinfra/connectors/util.py +16 -9
  18. pyinfra/facts/crontab.py +10 -8
  19. pyinfra/facts/files.py +12 -3
  20. pyinfra/facts/flatpak.py +1 -1
  21. pyinfra/facts/npm.py +1 -1
  22. pyinfra/facts/server.py +18 -2
  23. pyinfra/operations/apk.py +2 -1
  24. pyinfra/operations/apt.py +15 -7
  25. pyinfra/operations/brew.py +1 -0
  26. pyinfra/operations/crontab.py +4 -1
  27. pyinfra/operations/dnf.py +4 -1
  28. pyinfra/operations/docker.py +70 -16
  29. pyinfra/operations/files.py +87 -12
  30. pyinfra/operations/flatpak.py +1 -0
  31. pyinfra/operations/gem.py +1 -0
  32. pyinfra/operations/git.py +1 -0
  33. pyinfra/operations/iptables.py +1 -0
  34. pyinfra/operations/lxd.py +1 -0
  35. pyinfra/operations/mysql.py +1 -0
  36. pyinfra/operations/opkg.py +2 -1
  37. pyinfra/operations/pacman.py +1 -0
  38. pyinfra/operations/pip.py +1 -0
  39. pyinfra/operations/pipx.py +1 -0
  40. pyinfra/operations/pkg.py +1 -0
  41. pyinfra/operations/pkgin.py +1 -0
  42. pyinfra/operations/postgres.py +7 -1
  43. pyinfra/operations/puppet.py +1 -0
  44. pyinfra/operations/python.py +1 -0
  45. pyinfra/operations/selinux.py +1 -0
  46. pyinfra/operations/server.py +1 -0
  47. pyinfra/operations/snap.py +2 -1
  48. pyinfra/operations/ssh.py +1 -0
  49. pyinfra/operations/systemd.py +1 -0
  50. pyinfra/operations/sysvinit.py +2 -1
  51. pyinfra/operations/util/docker.py +172 -8
  52. pyinfra/operations/util/packaging.py +2 -0
  53. pyinfra/operations/xbps.py +1 -0
  54. pyinfra/operations/yum.py +4 -1
  55. pyinfra/operations/zfs.py +1 -0
  56. pyinfra/operations/zypper.py +1 -0
  57. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/METADATA +2 -1
  58. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/RECORD +64 -63
  59. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/WHEEL +1 -1
  60. pyinfra_cli/cli.py +20 -4
  61. pyinfra_cli/inventory.py +26 -1
  62. pyinfra_cli/util.py +1 -1
  63. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/entry_points.txt +0 -0
  64. {pyinfra-3.5.1.dist-info → pyinfra-3.6.1.dist-info}/licenses/LICENSE.md +0 -0
@@ -117,6 +117,7 @@ def download(
117
117
 
118
118
  .. code:: python
119
119
 
120
+ from pyinfra.operations import files
120
121
  files.download(
121
122
  name="Download the Docker repo file",
122
123
  src="https://download.docker.com/linux/centos/docker-ce.repo",
@@ -167,8 +168,12 @@ def download(
167
168
 
168
169
  # If we download, always do user/group/mode as SSH user may be different
169
170
  if download:
171
+ # Use explicit temp_dir if provided, otherwise fall back to _temp_dir global argument
172
+ effective_temp_dir = temp_dir
173
+ if effective_temp_dir is None and host.current_op_global_arguments:
174
+ effective_temp_dir = host.current_op_global_arguments.get("_temp_dir")
170
175
  temp_file = host.get_temp_filename(
171
- dest, temp_directory=str(temp_dir) if temp_dir is not None else None
176
+ dest, temp_directory=str(effective_temp_dir) if effective_temp_dir is not None else None
172
177
  )
173
178
 
174
179
  curl_args: list[Union[str, StringCommand]] = ["-sSLf"]
@@ -751,6 +756,15 @@ def _file_equal(local_path: str | IO[Any] | None, remote_path: str) -> bool:
751
756
  return False
752
757
 
753
758
 
759
+ def _remote_file_equal(remote_path_a: str, remote_path_b: str) -> bool:
760
+ for fact in [Sha1File, Md5File, Sha256File]:
761
+ sum_a = host.get_fact(fact, path=remote_path_a)
762
+ sum_b = host.get_fact(fact, path=remote_path_b)
763
+ if sum_a and sum_b:
764
+ return sum_a == sum_b
765
+ return False
766
+
767
+
754
768
  @operation(
755
769
  # We don't (currently) cache the local state, so there's nothing we can
756
770
  # update to flag the local file as present.
@@ -797,19 +811,32 @@ def get(
797
811
 
798
812
  remote_file = host.get_fact(File, path=src)
799
813
 
814
+ # Use _temp_dir global argument if provided
815
+ temp_dir = None
816
+ if host.current_op_global_arguments:
817
+ temp_dir = host.current_op_global_arguments.get("_temp_dir")
818
+
800
819
  # No remote file, so assume exists and download it "blind"
801
820
  if not remote_file or force:
802
- yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
821
+ yield FileDownloadCommand(
822
+ src, dest, remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir)
823
+ )
803
824
 
804
825
  # No local file, so always download
805
826
  elif not os.path.exists(dest):
806
- yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
827
+ yield FileDownloadCommand(
828
+ src, dest, remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir)
829
+ )
807
830
 
808
831
  # Remote file exists - check if it matches our local
809
832
  else:
810
833
  # Check hash sum, download if needed
811
834
  if not _file_equal(dest, src):
812
- yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
835
+ yield FileDownloadCommand(
836
+ src,
837
+ dest,
838
+ remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
839
+ )
813
840
  else:
814
841
  host.noop("file {0} has already been downloaded".format(dest))
815
842
 
@@ -998,6 +1025,11 @@ def put(
998
1025
  if create_remote_dir:
999
1026
  yield from _create_remote_dir(dest, user, group)
1000
1027
 
1028
+ # Use _temp_dir global argument if provided
1029
+ temp_dir = None
1030
+ if host.current_op_global_arguments:
1031
+ temp_dir = host.current_op_global_arguments.get("_temp_dir")
1032
+
1001
1033
  # No remote file, always upload and user/group/mode if supplied
1002
1034
  if not remote_file or force:
1003
1035
  if state.config.DIFF:
@@ -1013,7 +1045,7 @@ def put(
1013
1045
  yield FileUploadCommand(
1014
1046
  local_file,
1015
1047
  dest,
1016
- remote_temp_filename=host.get_temp_filename(dest),
1048
+ remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
1017
1049
  )
1018
1050
 
1019
1051
  if user or group:
@@ -1060,7 +1092,7 @@ def put(
1060
1092
  yield FileUploadCommand(
1061
1093
  local_file,
1062
1094
  dest,
1063
- remote_temp_filename=host.get_temp_filename(dest),
1095
+ remote_temp_filename=host.get_temp_filename(dest, temp_directory=temp_dir),
1064
1096
  )
1065
1097
 
1066
1098
  if user or group:
@@ -1156,11 +1188,14 @@ def template(
1156
1188
  if not set explicitly.
1157
1189
 
1158
1190
  Notes:
1159
- Common convention is to store templates in a "templates" directory and
1160
- have a filename suffix with '.j2' (for jinja2).
1191
+ Common convention is to store templates in a "templates" directory and
1192
+ have a filename suffix with '.j2' (for jinja2).
1193
+
1194
+ The default template lookup directory (used with jinjas ``extends``, ``import`` and
1195
+ ``include`` statements) is the current working directory.
1161
1196
 
1162
- For information on the template syntax, see
1163
- `the jinja2 docs <https://jinja.palletsprojects.com>`_.
1197
+ For information on the template syntax, see
1198
+ `the jinja2 docs <https://jinja.palletsprojects.com>`_.
1164
1199
 
1165
1200
  **Examples:**
1166
1201
 
@@ -1309,6 +1344,39 @@ def move(src: str, dest: str, overwrite=False):
1309
1344
  yield StringCommand("mv", QuoteString(src), QuoteString(dest))
1310
1345
 
1311
1346
 
1347
+ @operation()
1348
+ def copy(src: str, dest: str, overwrite=False):
1349
+ """
1350
+ Copy remote file/directory/link into remote directory
1351
+
1352
+ + src: remote file/directory to copy
1353
+ + dest: remote directory to copy `src` into
1354
+ + overwrite: whether to overwrite dest, if present
1355
+ """
1356
+ src_is_dir = host.get_fact(Directory, src)
1357
+ if not host.get_fact(File, src) and not src_is_dir:
1358
+ raise OperationError(f"src {src} does not exist")
1359
+
1360
+ if not host.get_fact(Directory, dest):
1361
+ raise OperationError(f"dest {dest} is not an existing directory")
1362
+
1363
+ dest_file_path = os.path.join(dest, os.path.basename(src))
1364
+ dest_file_exists = host.get_fact(File, dest_file_path)
1365
+ if dest_file_exists and not overwrite:
1366
+ if _remote_file_equal(src, dest_file_path):
1367
+ host.noop(f"{dest_file_path} already exists")
1368
+ return
1369
+ else:
1370
+ raise OperationError(f"{dest_file_path} already exists and is different than src")
1371
+
1372
+ cp_cmd = ["cp -r"]
1373
+
1374
+ if overwrite:
1375
+ cp_cmd.append("-f")
1376
+
1377
+ yield StringCommand(*cp_cmd, QuoteString(src), QuoteString(dest))
1378
+
1379
+
1312
1380
  def _validate_path(path):
1313
1381
  try:
1314
1382
  return os.fspath(path)
@@ -1786,7 +1854,7 @@ def block(
1786
1854
  # put complex alias into .zshrc
1787
1855
  files.block(
1788
1856
  path="/home/user/.zshrc",
1789
- content="eval $(thefuck -a)",
1857
+ content="eval $(thef -a)",
1790
1858
  try_prevent_shell_expansion=True,
1791
1859
  marker="## {mark} ALIASES ##"
1792
1860
  )
@@ -1800,6 +1868,13 @@ def block(
1800
1868
  current = host.get_fact(Block, path=path, marker=marker, begin=begin, end=end)
1801
1869
  cmd = None
1802
1870
 
1871
+ # Use _temp_dir global argument if provided, otherwise fall back to config
1872
+ tmp_dir = None
1873
+ if host.current_op_global_arguments:
1874
+ tmp_dir = host.current_op_global_arguments.get("_temp_dir")
1875
+ if not tmp_dir:
1876
+ tmp_dir = host.get_temp_dir_config()
1877
+
1803
1878
  # standard awk doesn't have an "in-place edit" option so we write to a tempfile and
1804
1879
  # if edits were successful move to dest i.e. we do: <out_prep> ... do some work ... <real_out>
1805
1880
  q_path = QuoteString(path)
@@ -1815,7 +1890,7 @@ def block(
1815
1890
  )
1816
1891
  )
1817
1892
  out_prep = StringCommand(
1818
- 'OUT="$(TMPDIR=/tmp mktemp -t pyinfra.XXXXXX)" && ',
1893
+ f'OUT="$(TMPDIR={tmp_dir} mktemp -t pyinfra.XXXXXX)" && ',
1819
1894
  *mode_get,
1820
1895
  'OWNER="$(stat -c "%u:%g"',
1821
1896
  q_path,
@@ -26,6 +26,7 @@ def packages(
26
26
 
27
27
  .. code:: python
28
28
 
29
+ from pyinfra.operations import flatpak
29
30
  # Install vlc flatpak
30
31
  flatpak.package(
31
32
  name="Install vlc",
pyinfra/operations/gem.py CHANGED
@@ -27,6 +27,7 @@ def packages(packages: str | list[str] | None = None, present=True, latest=False
27
27
 
28
28
  .. code:: python
29
29
 
30
+ from pyinfra.operations import gem
30
31
  # Note: Assumes that 'gem' is installed.
31
32
  gem.packages(
32
33
  name="Install rspec",
pyinfra/operations/git.py CHANGED
@@ -30,6 +30,7 @@ def config(key: str, value: str, multi_value=False, repo: str | None = None, sys
30
30
 
31
31
  .. code:: python
32
32
 
33
+ from pyinfra.operations import git
33
34
  git.config(
34
35
  name="Always prune specified repo",
35
36
  key="fetch.prune",
@@ -118,6 +118,7 @@ def rule(
118
118
 
119
119
  .. code:: python
120
120
 
121
+ from pyinfra.operations import iptables
121
122
  iptables.rule(
122
123
  name="Block SSH traffic",
123
124
  chain="INPUT",
pyinfra/operations/lxd.py CHANGED
@@ -38,6 +38,7 @@ def container(
38
38
 
39
39
  .. code:: python
40
40
 
41
+ from pyinfra.operations import lxd
41
42
  lxd.container(
42
43
  name="Add an ubuntu container",
43
44
  id="ubuntu19",
@@ -101,6 +101,7 @@ def user(
101
101
 
102
102
  .. code:: python
103
103
 
104
+ from pyinfra.operations import mysql
104
105
  mysql.user(
105
106
  name="Create the pyinfra@localhost MySQL user",
106
107
  user="pyinfra",
@@ -52,7 +52,8 @@ def packages(
52
52
 
53
53
  .. code:: python
54
54
 
55
- # Ensure packages are installed∂ (will not force package upgrade)
55
+ from pyinfra.operations import opkg
56
+ # Ensure packages are installed (will not force package upgrade)
56
57
  opkg.packages(['asterisk', 'vim'], name="Install Asterisk and Vim")
57
58
 
58
59
  # Install the latest versions of packages (always check)
@@ -57,6 +57,7 @@ def packages(
57
57
 
58
58
  .. code:: python
59
59
 
60
+ from pyinfra.operations import pacman
60
61
  pacman.packages(
61
62
  name="Install Vim and a plugin",
62
63
  packages=["vim-fugitive", "vim"],
pyinfra/operations/pip.py CHANGED
@@ -36,6 +36,7 @@ def virtualenv(
36
36
 
37
37
  .. code:: python
38
38
 
39
+ from pyinfra.operations import pip
39
40
  pip.virtualenv(
40
41
  name="Create a virtualenv",
41
42
  path="/usr/local/bin/venv",
@@ -34,6 +34,7 @@ def packages(
34
34
 
35
35
  .. code:: python
36
36
 
37
+ from pyinfra.operations import pipx
37
38
  pipx.packages(
38
39
  name="Install ",
39
40
  packages=["pyinfra"],
pyinfra/operations/pkg.py CHANGED
@@ -35,6 +35,7 @@ def packages(packages: str | list[str] | None = None, present=True, pkg_path: st
35
35
 
36
36
  .. code:: python
37
37
 
38
+ from pyinfra.operations import pkg
38
39
  pkg.packages(
39
40
  name="Install Vim and Vim Addon Manager",
40
41
  packages=["vim-addon-manager", "vim"],
@@ -56,6 +56,7 @@ def packages(
56
56
 
57
57
  .. code:: python
58
58
 
59
+ from pyinfra.operations import pkgin
59
60
  # Update package list and install packages
60
61
  pkgin.packages(
61
62
  name="Install tmux and Vim",
@@ -53,7 +53,12 @@ def sql(
53
53
  )
54
54
 
55
55
 
56
- @operation()
56
+ @operation(
57
+ idempotent_notice=(
58
+ "This operation will always execute commands when a password is provided, "
59
+ "as pyinfra cannot reliably validate the current password."
60
+ ),
61
+ )
57
62
  def role(
58
63
  role: str,
59
64
  present: bool = True,
@@ -96,6 +101,7 @@ def role(
96
101
 
97
102
  .. code:: python
98
103
 
104
+ from pyinfra.operations import postgresql
99
105
  postgresql.role(
100
106
  name="Create the pyinfra PostgreSQL role",
101
107
  role="pyinfra",
@@ -19,6 +19,7 @@ def agent(server: str | None = None, port: int | None = None):
19
19
 
20
20
  .. code:: python
21
21
 
22
+ from pyinfra.operations import puppet
22
23
  puppet.agent()
23
24
 
24
25
  # We also expect a return code of:
@@ -22,6 +22,7 @@ def call(function: Callable, *args, **kwargs):
22
22
 
23
23
  .. code:: python
24
24
 
25
+ from pyinfra.operations import python
25
26
  def my_callback(hello=None):
26
27
  command = 'echo hello'
27
28
  if hello:
@@ -39,6 +39,7 @@ def boolean(bool_name: str, value: Boolean, persistent=False):
39
39
 
40
40
  .. code:: python
41
41
 
42
+ from pyinfra.operations import selinux
42
43
  selinux.boolean(
43
44
  name='Allow Apache to connect to LDAP server',
44
45
  'httpd_can_network_connect',
@@ -68,6 +68,7 @@ def reboot(delay=10, interval=1, reboot_timeout=300):
68
68
 
69
69
  .. code:: python
70
70
 
71
+ from pyinfra.operations import server
71
72
  server.reboot(
72
73
  name="Reboot the server and wait to reconnect",
73
74
  delay=60,
@@ -32,7 +32,8 @@ def package(
32
32
 
33
33
  .. code:: python
34
34
 
35
- # Install vlc snap
35
+ from pyinfra.operations import snap
36
+ # Install vlc via snap
36
37
  snap.package(
37
38
  name="Install vlc",
38
39
  packages="vlc",
pyinfra/operations/ssh.py CHANGED
@@ -28,6 +28,7 @@ def keyscan(hostname: str, force=False, port=22):
28
28
 
29
29
  .. code:: python
30
30
 
31
+ from pyinfra.operations import ssh
31
32
  ssh.keyscan(
32
33
  name="Set add server two to known_hosts on one",
33
34
  hostname="two.example.com",
@@ -66,6 +66,7 @@ def service(
66
66
 
67
67
  .. code:: python
68
68
 
69
+ from pyinfra.operations import systemd
69
70
  systemd.service(
70
71
  name="Restart and enable the dnsmasq service",
71
72
  service="dnsmasq.service",
@@ -38,7 +38,7 @@ def service(
38
38
  support enabling/disabling services:
39
39
 
40
40
  + Ubuntu/Debian (``update-rc.d``)
41
- + CentOS/Fedora/RHEL (``chkconfig``)
41
+ + Fedora/RHEL (``chkconfig``)
42
42
  + Gentoo (``rc-update``)
43
43
 
44
44
  For other distributions and more granular service control, see the
@@ -48,6 +48,7 @@ def service(
48
48
 
49
49
  .. code:: python
50
50
 
51
+ from pyinfra.operations import sysvinit
51
52
  sysvinit.service(
52
53
  name="Restart and enable rsyslog",
53
54
  service="rsyslog",
@@ -1,17 +1,172 @@
1
- import dataclasses
2
- from typing import Any, Dict, List
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
3
 
4
4
  from pyinfra.api import OperationError
5
5
 
6
6
 
7
- @dataclasses.dataclass
7
+ @dataclass
8
+ class ImageReference:
9
+ """Represents a parsed Docker image reference."""
10
+
11
+ repository: str
12
+ namespace: str | None = None
13
+ tag: str | None = None
14
+ digest: str | None = None
15
+ registry_host: str | None = None
16
+ registry_port: int | None = None
17
+
18
+ @property
19
+ def registry(self) -> str | None:
20
+ """Get the full registry address (host:port)."""
21
+ if not self.registry_host:
22
+ return None
23
+ if self.registry_port:
24
+ return f"{self.registry_host}:{self.registry_port}"
25
+ return self.registry_host
26
+
27
+ @property
28
+ def name(self) -> str:
29
+ """Get the full image name without tag or digest."""
30
+ parts = []
31
+ if self.registry:
32
+ parts.append(self.registry)
33
+ if self.namespace:
34
+ parts.append(self.namespace)
35
+ parts.append(self.repository)
36
+ return "/".join(parts)
37
+
38
+ @property
39
+ def full_reference(self) -> str:
40
+ """Get the complete image reference string."""
41
+ ref = self.name
42
+ if self.tag:
43
+ ref += f":{self.tag}"
44
+ if self.digest:
45
+ ref += f"@{self.digest}"
46
+ return ref
47
+
48
+
49
+ def parse_registry(registry: str) -> tuple[str, int | None]:
50
+ """
51
+ Parse a registry string into host and port components.
52
+
53
+ Args:
54
+ registry: String like "registry.io:5000" or "registry.io"
55
+
56
+ Returns:
57
+ tuple: (host, port) where port is None if not specified
58
+
59
+ Raises:
60
+ ValueError: If port is specified but not a valid integer
61
+ """
62
+ if ":" in registry:
63
+ host, port_str = registry.rsplit(":", 1)
64
+ if port_str: # Only try to parse if port_str is not empty
65
+ try:
66
+ port = int(port_str)
67
+ if port < 0 or port > 65535:
68
+ raise ValueError(
69
+ f"Invalid port number: {port}. Port must be between 0 and 65535"
70
+ )
71
+ return host, port
72
+ except ValueError as e:
73
+ if "invalid literal" in str(e):
74
+ raise ValueError(
75
+ f"Invalid port in registry '{registry}': '{port_str}' is not a valid port number"
76
+ )
77
+ raise # Re-raise port range error
78
+ else:
79
+ # Empty port (e.g., "registry.io:")
80
+ raise ValueError(f"Invalid registry format '{registry}': port cannot be empty")
81
+ else:
82
+ return registry, None
83
+
84
+
85
+ def parse_image_reference(image: str) -> ImageReference:
86
+ """
87
+ Parse a Docker image reference into components.
88
+
89
+ Format: [HOST[:PORT]/]NAMESPACE/REPOSITORY[:TAG][@DIGEST]
90
+
91
+ Raises:
92
+ ValueError: If the image reference is empty or invalid
93
+ """
94
+ if not image or not image.strip():
95
+ raise ValueError("Image reference cannot be empty")
96
+
97
+ original = image.strip()
98
+ registry_host = None
99
+ registry_port = None
100
+ namespace = None
101
+ repository = None
102
+ tag = None
103
+ digest = None
104
+
105
+ # Extract digest first (format: name@digest)
106
+ if "@" in original:
107
+ original, digest = original.rsplit("@", 1)
108
+
109
+ # Extract tag (format: name:tag)
110
+ if ":" in original:
111
+ parts = original.split(":")
112
+ if len(parts) >= 2:
113
+ potential_tag = parts[-1]
114
+ # Tag cannot contain '/' - if it does, the colon is part of the registry, separating host and port
115
+ if "/" not in potential_tag:
116
+ original = ":".join(parts[:-1])
117
+ tag = potential_tag
118
+
119
+ # Split by '/' to separate registry/namespace/repository
120
+ parts = original.split("/")
121
+
122
+ if len(parts) == 1:
123
+ # Just repository name (e.g., "nginx")
124
+ repository = parts[0]
125
+ elif len(parts) == 2:
126
+ # Could be namespace/repository or registry/repository
127
+ if "." in parts[0] or ":" in parts[0]:
128
+ # Likely a registry (registry.io:5000/repo or registry.io/repo)
129
+ registry_host, registry_port = parse_registry(parts[0])
130
+ repository = parts[1]
131
+ else:
132
+ # Likely namespace/repository
133
+ namespace = parts[0]
134
+ repository = parts[1]
135
+ elif len(parts) >= 3:
136
+ # registry/namespace/repository or registry/nested/namespace/repository
137
+ registry_host, registry_port = parse_registry(parts[0])
138
+ namespace = "/".join(parts[1:-1])
139
+ repository = parts[-1]
140
+
141
+ # Validate that we found a repository
142
+ if not repository:
143
+ raise ValueError(f"Invalid image reference: no repository found in '{image}'")
144
+
145
+ # Default tag to 'latest' if neither tag nor digest specified. This is Docker's default behavior.
146
+ if tag is None and digest is None:
147
+ tag = "latest"
148
+
149
+ return ImageReference(
150
+ repository=repository,
151
+ namespace=namespace,
152
+ tag=tag,
153
+ digest=digest,
154
+ registry_host=registry_host,
155
+ registry_port=registry_port,
156
+ )
157
+
158
+
159
+ @dataclass
8
160
  class ContainerSpec:
9
161
  image: str = ""
10
- ports: List[str] = dataclasses.field(default_factory=list)
11
- networks: List[str] = dataclasses.field(default_factory=list)
12
- volumes: List[str] = dataclasses.field(default_factory=list)
13
- env_vars: List[str] = dataclasses.field(default_factory=list)
162
+ ports: list[str] = field(default_factory=list)
163
+ networks: list[str] = field(default_factory=list)
164
+ volumes: list[str] = field(default_factory=list)
165
+ env_vars: list[str] = field(default_factory=list)
166
+ labels: list[str] = field(default_factory=list)
14
167
  pull_always: bool = False
168
+ restart_policy: str | None = None
169
+ auto_remove: bool = False
15
170
 
16
171
  def container_create_args(self):
17
172
  args = []
@@ -27,14 +182,23 @@ class ContainerSpec:
27
182
  for env_var in self.env_vars:
28
183
  args.append("-e {0}".format(env_var))
29
184
 
185
+ for label in self.labels:
186
+ args.append("--label {0}".format(label))
187
+
30
188
  if self.pull_always:
31
189
  args.append("--pull always")
32
190
 
191
+ if self.restart_policy:
192
+ args.append("--restart {0}".format(self.restart_policy))
193
+
194
+ if self.auto_remove:
195
+ args.append("--rm")
196
+
33
197
  args.append(self.image)
34
198
 
35
199
  return args
36
200
 
37
- def diff_from_inspect(self, inspect_dict: Dict[str, Any]) -> List[str]:
201
+ def diff_from_inspect(self, inspect_dict: dict[str, Any]) -> list[str]:
38
202
  # TODO(@minor-fixes): Diff output of "docker inspect" against this spec
39
203
  # to determine if the container needs to be recreated. Currently, this
40
204
  # function will never recreate when attributes change, which is
@@ -172,6 +172,8 @@ def ensure_packages(
172
172
  return
173
173
  if isinstance(packages_to_ensure, str):
174
174
  packages_to_ensure = [packages_to_ensure]
175
+ if len(packages_to_ensure) == 0:
176
+ return
175
177
 
176
178
  packages: list[PkgInfo] = []
177
179
  if isinstance(packages_to_ensure[0], PkgInfo):
@@ -54,6 +54,7 @@ def packages(
54
54
 
55
55
  .. code:: python
56
56
 
57
+ from pyinfra.operations import xbps
57
58
  xbps.packages(
58
59
  name="Install Vim and Vim Pager",
59
60
  packages=["vimpager", "vim"],
pyinfra/operations/yum.py CHANGED
@@ -25,6 +25,9 @@ def key(src: str):
25
25
 
26
26
  .. code:: python
27
27
 
28
+ from pyinfra import host
29
+ from pyinfra.operations import dnf
30
+ from pyinfra.facts.server import LinuxDistribution
28
31
  linux_id = host.get_fact(LinuxDistribution)["release_meta"].get("ID")
29
32
  yum.key(
30
33
  name="Add the Docker CentOS gpg key",
@@ -75,7 +78,7 @@ def repo(
75
78
 
76
79
  # Create the repository file from baseurl/etc
77
80
  yum.repo(
78
- name="Add the Docker CentOS repo",
81
+ name="Add the Docker repo",
79
82
  src="DockerCE",
80
83
  baseurl="https://download.docker.com/linux/centos/7/$basearch/stable",
81
84
  )
pyinfra/operations/zfs.py CHANGED
@@ -33,6 +33,7 @@ def dataset(
33
33
 
34
34
  .. code:: python
35
35
 
36
+ from pyinfra.operations import zfs
36
37
  zfs.dataset(
37
38
  "tank/srv",
38
39
  mountpoint="/srv",
@@ -42,6 +42,7 @@ def repo(
42
42
 
43
43
  .. code:: python
44
44
 
45
+ from pyinfra.operations import zypper
45
46
  # Download a repository file
46
47
  zypper.repo(
47
48
  name="Install container virtualization repo via URL",