pyinfra 3.4__py2.py3-none-any.whl → 3.5__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.
@@ -4,25 +4,27 @@ the view of the current inventory host. See the :doc:`../connectors/docker` to u
4
4
  as inventory directly.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  from pyinfra import host
8
10
  from pyinfra.api import operation
9
- from pyinfra.facts.docker import DockerContainer, DockerNetwork, DockerVolume
11
+ from pyinfra.facts.docker import DockerContainer, DockerNetwork, DockerPlugin, DockerVolume
10
12
 
11
13
  from .util.docker import ContainerSpec, handle_docker
12
14
 
13
15
 
14
16
  @operation()
15
17
  def container(
16
- container,
17
- image="",
18
- ports=None,
19
- networks=None,
20
- volumes=None,
21
- env_vars=None,
22
- pull_always=False,
23
- present=True,
24
- force=False,
25
- start=True,
18
+ container: str,
19
+ image: str = "",
20
+ ports: list[str] | None = None,
21
+ networks: list[str] | None = None,
22
+ volumes: list[str] | None = None,
23
+ env_vars: list[str] | None = None,
24
+ pull_always: bool = False,
25
+ present: bool = True,
26
+ force: bool = False,
27
+ start: bool = True,
26
28
  ):
27
29
  """
28
30
  Manage Docker containers
@@ -168,7 +170,7 @@ def image(image, present=True):
168
170
 
169
171
 
170
172
  @operation()
171
- def volume(volume, driver="", labels=None, present=True):
173
+ def volume(volume: str, driver: str = "", labels: list[str] | None = None, present: bool = True):
172
174
  """
173
175
  Manage Docker volumes
174
176
 
@@ -220,20 +222,20 @@ def volume(volume, driver="", labels=None, present=True):
220
222
 
221
223
  @operation()
222
224
  def network(
223
- network,
224
- driver="",
225
- gateway="",
226
- ip_range="",
227
- ipam_driver="",
228
- subnet="",
229
- scope="",
230
- aux_addresses=None,
231
- opts=None,
232
- ipam_opts=None,
233
- labels=None,
234
- ingress=False,
235
- attachable=False,
236
- present=True,
225
+ network: str,
226
+ driver: str = "",
227
+ gateway: str = "",
228
+ ip_range: str = "",
229
+ ipam_driver: str = "",
230
+ subnet: str = "",
231
+ scope: str = "",
232
+ aux_addresses: dict[str, str] | None = None,
233
+ opts: list[str] | None = None,
234
+ ipam_opts: list[str] | None = None,
235
+ labels: list[str] | None = None,
236
+ ingress: bool = False,
237
+ attachable: bool = False,
238
+ present: bool = True,
237
239
  ):
238
240
  """
239
241
  Manage docker networks
@@ -245,6 +247,7 @@ def network(
245
247
  + ipam_driver: IP Address Management Driver
246
248
  + subnet: Subnet in CIDR format that represents a network segment
247
249
  + scope: Control the network's scope
250
+ + aux_addresses: named aux addresses for the network
248
251
  + opts: Set driver specific options
249
252
  + ipam_opts: Set IPAM driver specific options
250
253
  + labels: Label list to attach in the network
@@ -303,9 +306,9 @@ def network(
303
306
 
304
307
  @operation(is_idempotent=False)
305
308
  def prune(
306
- all=False,
307
- volumes=False,
308
- filter="",
309
+ all: bool = False,
310
+ volumes: bool = False,
311
+ filter: str = "",
309
312
  ):
310
313
  """
311
314
  Execute a docker system prune.
@@ -344,3 +347,101 @@ def prune(
344
347
  volumes=volumes,
345
348
  filter=filter,
346
349
  )
350
+
351
+
352
+ @operation()
353
+ def plugin(
354
+ plugin: str,
355
+ alias: str | None = None,
356
+ present: bool = True,
357
+ enabled: bool = True,
358
+ plugin_options: dict[str, str] | None = None,
359
+ ):
360
+ """
361
+ Manage Docker plugins
362
+
363
+ + plugin: Plugin name
364
+ + alias: Alias for the plugin (optional)
365
+ + present: Whether the plugin should be installed
366
+ + enabled: Whether the plugin should be enabled
367
+ + plugin_options: Options to pass to the plugin
368
+
369
+ **Examples:**
370
+
371
+ .. code:: python
372
+
373
+ # Install and enable a Docker plugin
374
+ docker.plugin(
375
+ name="Install and enable a Docker plugin",
376
+ plugin="username/my-awesome-plugin:latest",
377
+ alias="my-plugin",
378
+ present=True,
379
+ enabled=True,
380
+ plugin_options={"option1": "value1", "option2": "value2"},
381
+ )
382
+ """
383
+ plugin_name = alias if alias else plugin
384
+ existent_plugin = host.get_fact(DockerPlugin, object_id=plugin_name)
385
+ if existent_plugin:
386
+ existent_plugin = existent_plugin[0]
387
+
388
+ if present:
389
+ if existent_plugin:
390
+ plugin_options_different = (
391
+ plugin_options and existent_plugin["Settings"]["Env"] != plugin_options
392
+ )
393
+ if plugin_options_different:
394
+ # Update options on existing plugin
395
+ if existent_plugin["Enabled"]:
396
+ yield handle_docker(
397
+ resource="plugin",
398
+ command="disable",
399
+ plugin=plugin_name,
400
+ )
401
+ yield handle_docker(
402
+ resource="plugin",
403
+ command="set",
404
+ plugin=plugin_name,
405
+ enabled=enabled,
406
+ existent_options=existent_plugin["Settings"]["Env"],
407
+ required_options=plugin_options,
408
+ )
409
+ if enabled:
410
+ yield handle_docker(
411
+ resource="plugin",
412
+ command="enable",
413
+ plugin=plugin_name,
414
+ )
415
+ else:
416
+ # Options are the same, check if enabled state is different
417
+ if existent_plugin["Enabled"] == enabled:
418
+ host.noop(
419
+ f"Plugin '{plugin_name}' is already installed with the same options "
420
+ f"and {'enabled' if enabled else 'disabled'}."
421
+ )
422
+ return
423
+ else:
424
+ command = "enable" if enabled else "disable"
425
+ yield handle_docker(
426
+ resource="plugin",
427
+ command=command,
428
+ plugin=plugin_name,
429
+ )
430
+ else:
431
+ yield handle_docker(
432
+ resource="plugin",
433
+ command="install",
434
+ plugin=plugin,
435
+ alias=alias,
436
+ enabled=enabled,
437
+ plugin_options=plugin_options,
438
+ )
439
+ else:
440
+ if not existent_plugin:
441
+ host.noop(f"Plugin '{plugin_name}' is not installed.")
442
+ return
443
+ yield handle_docker(
444
+ resource="plugin",
445
+ command="remove",
446
+ plugin=plugin_name,
447
+ )
@@ -8,12 +8,13 @@ import os
8
8
  import posixpath
9
9
  import sys
10
10
  import traceback
11
- from datetime import timedelta
11
+ from datetime import datetime, timedelta, timezone
12
12
  from fnmatch import fnmatch
13
13
  from io import StringIO
14
14
  from pathlib import Path
15
15
  from typing import IO, Any, Union
16
16
 
17
+ import click
17
18
  from jinja2 import TemplateRuntimeError, TemplateSyntaxError, UndefinedError
18
19
 
19
20
  from pyinfra import host, logger, state
@@ -46,6 +47,7 @@ from pyinfra.facts.files import (
46
47
  Block,
47
48
  Directory,
48
49
  File,
50
+ FileContents,
49
51
  FindFiles,
50
52
  FindInFile,
51
53
  Flags,
@@ -59,8 +61,10 @@ from pyinfra.facts.server import Date, Which
59
61
 
60
62
  from .util import files as file_utils
61
63
  from .util.files import (
64
+ MetadataTimeField,
62
65
  adjust_regex,
63
66
  ensure_mode_int,
67
+ generate_color_diff,
64
68
  get_timestamp,
65
69
  sed_delete,
66
70
  sed_replace,
@@ -813,6 +817,56 @@ def get(
813
817
  host.noop("file {0} has already been downloaded".format(dest))
814
818
 
815
819
 
820
+ def _canonicalize_timespec(field: MetadataTimeField, local_file, timespec):
821
+ if isinstance(timespec, datetime):
822
+ if not timespec.tzinfo:
823
+ # specify remote host timezone
824
+ timespec_with_tz = timespec.replace(tzinfo=host.get_fact(Date).tzinfo)
825
+ return timespec_with_tz
826
+ else:
827
+ return timespec
828
+ elif isinstance(timespec, bool) and timespec:
829
+ lf_ts = (
830
+ os.stat(local_file).st_atime
831
+ if field is MetadataTimeField.ATIME
832
+ else os.stat(local_file).st_mtime
833
+ )
834
+ return datetime.fromtimestamp(lf_ts, tz=timezone.utc)
835
+ else:
836
+ try:
837
+ isodatetime = datetime.fromisoformat(timespec)
838
+ if not isodatetime.tzinfo:
839
+ return isodatetime.replace(tzinfo=host.get_fact(Date).tzinfo)
840
+ else:
841
+ return isodatetime
842
+ except ValueError:
843
+ try:
844
+ timestamp = float(timespec)
845
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
846
+ except ValueError:
847
+ # verify there is a remote file matching path in timesrc
848
+ ref_file = host.get_fact(File, path=timespec)
849
+ if ref_file:
850
+ if field is MetadataTimeField.ATIME:
851
+ assert ref_file["atime"] is not None
852
+ return ref_file["atime"].replace(tzinfo=timezone.utc)
853
+ else:
854
+ assert ref_file["mtime"] is not None
855
+ return ref_file["mtime"].replace(tzinfo=timezone.utc)
856
+ else:
857
+ ValueError("Bad argument for `timesspec`: {0}".format(timespec))
858
+
859
+
860
+ # returns True for a visible difference in the second field between the datetime values
861
+ # in the ref's TZ
862
+ def _times_differ_in_s(ref, cand):
863
+ assert ref.tzinfo and cand.tzinfo
864
+ cand_in_ref_tz = cand.astimezone(ref.tzinfo)
865
+ return (abs((cand_in_ref_tz - ref).total_seconds()) >= 1.0) or (
866
+ ref.second != cand_in_ref_tz.second
867
+ )
868
+
869
+
816
870
  @operation()
817
871
  def put(
818
872
  src: str | IO[Any],
@@ -824,6 +878,8 @@ def put(
824
878
  create_remote_dir=True,
825
879
  force=False,
826
880
  assume_exists=False,
881
+ atime: datetime | float | int | str | bool | None = None,
882
+ mtime: datetime | float | int | str | bool | None = None,
827
883
  ):
828
884
  """
829
885
  Upload a local file, or file-like object, to the remote system.
@@ -837,6 +893,8 @@ def put(
837
893
  + create_remote_dir: create the remote directory if it doesn't exist
838
894
  + force: always upload the file, even if the remote copy matches
839
895
  + assume_exists: whether to assume the local file exists
896
+ + atime: value of atime the file should have, use ``True`` to match the local file
897
+ + mtime: value of mtime the file should have, use ``True`` to match the local file
840
898
 
841
899
  ``dest``:
842
900
  If this is a directory that already exists on the remote side, the local
@@ -853,7 +911,21 @@ def put(
853
911
  user & group as passed to ``files.put``. The mode will *not* be copied over,
854
912
  if this is required call ``files.directory`` separately.
855
913
 
856
- Note:
914
+ ``atime`` and ``mtime``:
915
+ When set to values other than ``False`` or ``None``, the respective metadata
916
+ fields on the remote file will updated accordingly. Timestamp values are
917
+ considered equivalent if the difference is less than one second and they have
918
+ the identical number in the seconds field. If set to ``True`` the local
919
+ file is the source of the value. Otherwise, these values can be provided as
920
+ ``datetime`` objects, POSIX timestamps, or strings that can be parsed into
921
+ either of these date and time specifications. They can also be reference file
922
+ paths on the remote host, as with the ``-r`` argument to ``touch``. If a
923
+ ``datetime`` argument has no ``tzinfo`` value (i.e., it is naive), it is
924
+ assumed to be in the remote host's local timezone. There is no shortcut for
925
+ setting both ``atime` and ``mtime`` values with a single time specification,
926
+ unlike the native ``touch`` command.
927
+
928
+ Notes:
857
929
  This operation is not suitable for large files as it may involve copying
858
930
  the file before uploading it.
859
931
 
@@ -862,6 +934,12 @@ def put(
862
934
  behave as if the remote file does not match the specified permissions and
863
935
  requires a change.
864
936
 
937
+ If the ``atime`` argument is set for a given file, unless the remote
938
+ filesystem is mounted ``noatime`` or ``relatime``, multiple runs of this
939
+ operation will trigger the change detection for that file, since the act of
940
+ reading and checksumming the file will cause the host OS to update the file's
941
+ ``atime``.
942
+
865
943
  **Examples:**
866
944
 
867
945
  .. code:: python
@@ -925,6 +1003,16 @@ def put(
925
1003
 
926
1004
  # No remote file, always upload and user/group/mode if supplied
927
1005
  if not remote_file or force:
1006
+ if state.config.DIFF:
1007
+ host.log(f"Will create {click.style(dest, bold=True)}", logger.info)
1008
+
1009
+ with get_file_io(src, "r") as f:
1010
+ desired_lines = f.readlines()
1011
+
1012
+ for line in generate_color_diff([], desired_lines):
1013
+ logger.info(f" {line}")
1014
+ logger.info("")
1015
+
928
1016
  yield FileUploadCommand(
929
1017
  local_file,
930
1018
  dest,
@@ -937,9 +1025,41 @@ def put(
937
1025
  if mode:
938
1026
  yield file_utils.chmod(dest, mode)
939
1027
 
940
- # File exists, check sum and check user/group/mode if supplied
1028
+ # do mtime before atime to ensure atime setting isn't undone by mtime setting
1029
+ if mtime:
1030
+ yield file_utils.touch(
1031
+ dest,
1032
+ MetadataTimeField.MTIME,
1033
+ _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime),
1034
+ )
1035
+
1036
+ if atime:
1037
+ yield file_utils.touch(
1038
+ dest,
1039
+ MetadataTimeField.ATIME,
1040
+ _canonicalize_timespec(MetadataTimeField.ATIME, src, atime),
1041
+ )
1042
+
1043
+ # File exists, check sum and check user/group/mode/atime/mtime if supplied
941
1044
  else:
942
1045
  if not _file_equal(local_sum_path, dest):
1046
+ if state.config.DIFF:
1047
+ # Generate diff when contents change
1048
+ current_contents = host.get_fact(FileContents, path=dest)
1049
+ if current_contents:
1050
+ current_lines = [line + "\n" for line in current_contents]
1051
+ else:
1052
+ current_lines = []
1053
+
1054
+ host.log(f"Will modify {click.style(dest, bold=True)}", logger.info)
1055
+
1056
+ with get_file_io(src, "r") as f:
1057
+ desired_lines = f.readlines()
1058
+
1059
+ for line in generate_color_diff(current_lines, desired_lines):
1060
+ logger.info(f" {line}")
1061
+ logger.info("")
1062
+
943
1063
  yield FileUploadCommand(
944
1064
  local_file,
945
1065
  dest,
@@ -952,6 +1072,20 @@ def put(
952
1072
  if mode:
953
1073
  yield file_utils.chmod(dest, mode)
954
1074
 
1075
+ if mtime:
1076
+ yield file_utils.touch(
1077
+ dest,
1078
+ MetadataTimeField.MTIME,
1079
+ _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime),
1080
+ )
1081
+
1082
+ if atime:
1083
+ yield file_utils.touch(
1084
+ dest,
1085
+ MetadataTimeField.ATIME,
1086
+ _canonicalize_timespec(MetadataTimeField.ATIME, src, atime),
1087
+ )
1088
+
955
1089
  else:
956
1090
  changed = False
957
1091
 
@@ -965,6 +1099,26 @@ def put(
965
1099
  yield file_utils.chown(dest, user, group)
966
1100
  changed = True
967
1101
 
1102
+ # Check mtime
1103
+ if mtime:
1104
+ canonical_mtime = _canonicalize_timespec(MetadataTimeField.MTIME, src, mtime)
1105
+ assert remote_file["mtime"] is not None
1106
+ if _times_differ_in_s(
1107
+ canonical_mtime, remote_file["mtime"].replace(tzinfo=timezone.utc)
1108
+ ):
1109
+ yield file_utils.touch(dest, MetadataTimeField.MTIME, canonical_mtime)
1110
+ changed = True
1111
+
1112
+ # Check atime
1113
+ if atime:
1114
+ canonical_atime = _canonicalize_timespec(MetadataTimeField.ATIME, src, atime)
1115
+ assert remote_file["atime"] is not None
1116
+ if _times_differ_in_s(
1117
+ canonical_atime, remote_file["atime"].replace(tzinfo=timezone.utc)
1118
+ ):
1119
+ yield file_utils.touch(dest, MetadataTimeField.ATIME, canonical_atime)
1120
+ changed = True
1121
+
968
1122
  if not changed:
969
1123
  host.noop("file {0} is already uploaded".format(dest))
970
1124
 
@@ -1594,7 +1748,7 @@ def block(
1594
1748
  path="/etc/hosts",
1595
1749
  content="10.0.0.1 mars-one",
1596
1750
  before=True,
1597
- regex=".*localhost",
1751
+ line=".*localhost",
1598
1752
  )
1599
1753
 
1600
1754
  # have two entries in /etc/host
@@ -1603,7 +1757,7 @@ def block(
1603
1757
  path="/etc/hosts",
1604
1758
  content="10.0.0.1 mars-one\\n10.0.0.2 mars-two",
1605
1759
  before=True,
1606
- regex=".*localhost",
1760
+ line=".*localhost",
1607
1761
  )
1608
1762
 
1609
1763
  # remove marked entry from /etc/hosts
@@ -1618,7 +1772,7 @@ def block(
1618
1772
  name="add out of date warning to web page",
1619
1773
  path="/var/www/html/something.html",
1620
1774
  content= "<p>Warning: this page is out of date.</p>",
1621
- regex=".*<body>.*",
1775
+ line=".*<body>.*",
1622
1776
  after=True
1623
1777
  marker="<!-- {mark} PYINFRA BLOCK -->",
1624
1778
  )
@@ -1665,6 +1819,7 @@ def block(
1665
1819
  )
1666
1820
 
1667
1821
  current = host.get_fact(Block, path=path, marker=marker, begin=begin, end=end)
1822
+ # None means file didn't exist, empty list means marker was not found
1668
1823
  cmd = None
1669
1824
  if present:
1670
1825
  if not content:
@@ -1706,7 +1861,7 @@ def block(
1706
1861
  prog = (
1707
1862
  'awk \'BEGIN {x=ARGV[2]; ARGV[2]=""} '
1708
1863
  f"{print_after} f!=1 && /{regex}/ {{ print x; f=1}} "
1709
- f"END {{if (f==0) print ARGV[2] }} {print_before}'"
1864
+ f"END {{if (f==0) print x }} {print_before}'"
1710
1865
  )
1711
1866
  cmd = StringCommand(
1712
1867
  out_prep,
pyinfra/operations/git.py CHANGED
@@ -148,7 +148,7 @@ def repo(
148
148
  if branch and host.get_fact(GitBranch, repo=dest) != branch:
149
149
  git_commands.append("fetch") # fetch to ensure we have the branch locally
150
150
  git_commands.append("checkout {0}".format(branch))
151
- if branch and branch in host.get_fact(GitTag, repo=dest):
151
+ if branch and branch in (host.get_fact(GitTag, repo=dest) or []):
152
152
  git_commands.append("checkout {0}".format(branch))
153
153
  is_tag = True
154
154
  if pull and not is_tag:
@@ -48,10 +48,16 @@ def service(
48
48
  openrc_enabled = host.get_fact(OpenrcEnabled, runlevel=runlevel)
49
49
  is_enabled = openrc_enabled.get(service, False)
50
50
 
51
- if enabled and not is_enabled:
52
- yield "rc-update add {0}".format(service)
53
- openrc_enabled[service] = True
54
-
55
- if not enabled and is_enabled:
56
- yield "rc-update del {0}".format(service)
57
- openrc_enabled[service] = False
51
+ if enabled is True:
52
+ if not is_enabled:
53
+ yield "rc-update add {0} {1}".format(service, runlevel)
54
+ openrc_enabled[service] = True
55
+ else:
56
+ host.noop("service {0} is enabled".format(service))
57
+
58
+ if enabled is False:
59
+ if is_enabled:
60
+ yield "rc-update del {0} {1}".format(service, runlevel)
61
+ openrc_enabled[service] = False
62
+ else:
63
+ host.noop("service {0} is disabled".format(service))
pyinfra/operations/pip.py CHANGED
@@ -11,7 +11,7 @@ from pyinfra.facts.files import File
11
11
  from pyinfra.facts.pip import PipPackages
12
12
 
13
13
  from . import files
14
- from .util.packaging import ensure_packages
14
+ from .util.packaging import PkgInfo, ensure_packages
15
15
 
16
16
 
17
17
  @operation()
@@ -186,21 +186,20 @@ def packages(
186
186
 
187
187
  # Handle passed in packages
188
188
  if packages:
189
+ if isinstance(packages, str):
190
+ packages = [packages]
191
+ # PEP-0426 states that Python packages should be compared using lowercase, so lowercase the
192
+ # current packages. PkgInfo.from_pep508 takes care of the package name
189
193
  current_packages = host.get_fact(PipPackages, pip=pip)
190
-
191
- # PEP-0426 states that Python packages should be compared using lowercase, so lowercase both
192
- # the input packages and the fact packages before comparison.
193
- packages = [pkg.lower() for pkg in packages]
194
194
  current_packages = {pkg.lower(): versions for pkg, versions in current_packages.items()}
195
195
 
196
196
  yield from ensure_packages(
197
197
  host,
198
- packages,
198
+ list(filter(None, (PkgInfo.from_pep508(package) for package in packages))),
199
199
  current_packages,
200
200
  present,
201
201
  install_command=install_command,
202
202
  uninstall_command=uninstall_command,
203
203
  upgrade_command=upgrade_command,
204
- version_join="==",
205
204
  latest=latest,
206
205
  )
@@ -2,25 +2,27 @@
2
2
  Manage pipx (python) applications.
3
3
  """
4
4
 
5
+ from typing import Optional, Union
6
+
5
7
  from pyinfra import host
6
8
  from pyinfra.api import operation
7
9
  from pyinfra.facts.pipx import PipxEnvironment, PipxPackages
8
10
  from pyinfra.facts.server import Path
9
11
 
10
- from .util.packaging import ensure_packages
12
+ from .util.packaging import PkgInfo, ensure_packages
11
13
 
12
14
 
13
15
  @operation()
14
16
  def packages(
15
- packages=None,
17
+ packages: Optional[Union[str, list[str]]] = None,
16
18
  present=True,
17
19
  latest=False,
18
- extra_args=None,
20
+ extra_args: Optional[str] = None,
19
21
  ):
20
22
  """
21
23
  Install/remove/update pipx packages.
22
24
 
23
- + packages: list of packages to ensure
25
+ + packages: list of packages (PEP-508 format) to ensure
24
26
  + present: whether the packages should be installed
25
27
  + latest: whether to upgrade packages without a specified version
26
28
  + extra_args: additional arguments to the pipx command
@@ -37,6 +39,9 @@ def packages(
37
39
  packages=["pyinfra"],
38
40
  )
39
41
  """
42
+ if packages is None:
43
+ host.noop("no package list provided to pipx.packages")
44
+ return
40
45
 
41
46
  prep_install_command = ["pipx", "install"]
42
47
 
@@ -47,19 +52,26 @@ def packages(
47
52
  uninstall_command = "pipx uninstall"
48
53
  upgrade_command = "pipx upgrade"
49
54
 
50
- current_packages = host.get_fact(PipxPackages)
55
+ # PEP-0426 states that Python packages should be compared using lowercase, so lowercase the
56
+ # current packages. PkgInfo.from_pep508 takes care of it for the package names
57
+ current_packages = {
58
+ pkg.lower(): version for pkg, version in host.get_fact(PipxPackages).items()
59
+ }
60
+ if isinstance(packages, str):
61
+ packages = [packages]
51
62
 
52
63
  # pipx support only one package name at a time
53
64
  for package in packages:
65
+ if (pkg_info := PkgInfo.from_pep508(package)) is None:
66
+ continue # from_pep508 logged a warning
54
67
  yield from ensure_packages(
55
68
  host,
56
- [package],
69
+ [pkg_info],
57
70
  current_packages,
58
71
  present,
59
72
  install_command=install_command,
60
73
  uninstall_command=uninstall_command,
61
74
  upgrade_command=upgrade_command,
62
- version_join="==",
63
75
  latest=latest,
64
76
  )
65
77