33import shlex
44from collections import defaultdict
55from io import StringIO
6- from typing import Callable
6+ from typing import Callable , NamedTuple , cast
77from urllib .parse import urlparse
88
9- from pyinfra .api import Host , State
9+ from packaging .requirements import InvalidRequirement , Requirement
10+
11+ from pyinfra import logger
12+ from pyinfra .api import Host , OperationValueError , State
1013from pyinfra .facts .files import File
1114from pyinfra .facts .rpm import RpmPackage
1215from pyinfra .operations import files
1316
1417
15- def _package_name (package : list [str ] | str ) -> str :
16- if isinstance (package , list ):
17- return package [0 ]
18- return package
18+ class PkgInfo (NamedTuple ):
19+ name : str
20+ version : str
21+ operator : str
22+ url : str
23+ """
24+ The key packaging information needed: version, operator and url are optional.
25+ """
26+
27+ @property
28+ def lkup_name (self ) -> str | list [str ]:
29+ return self .name if self .version == "" else [self .name , self .version ]
30+
31+ @property
32+ def has_version (self ) -> bool :
33+ return self .version != ""
34+
35+ @property
36+ def inst_vers (self ) -> str :
37+ return (
38+ self .url
39+ if self .url != ""
40+ else (
41+ self .operator .join ([self .name , self .version ]) if self .version != "" else self .name
42+ )
43+ )
44+
45+ @classmethod
46+ def from_possible_pair (cls , s : str , join : str | None ) -> PkgInfo :
47+ if join is not None :
48+ pieces = s .rsplit (join , 1 )
49+ return cls (pieces [0 ], pieces [1 ] if len (pieces ) > 1 else "" , join , "" )
50+
51+ return cls (s , "" , "" , "" )
52+
53+ @classmethod
54+ def from_pep508 (cls , s : str ) -> PkgInfo | None :
55+ """
56+ Separate out the useful parts (name, url, operator, version) of a PEP-508 dependency.
57+ Note: only one specifier is allowed.
58+ PEP-0426 states that Python packages should be compared using lowercase; thus
59+ the name is lower-cased
60+ For backwards compatibility, invalid requirements are assumed to be package names with a
61+ warning that this will change in the next major release
62+ """
63+ pep_508 = "PEP 508 non-compliant "
64+ treatment = "requirement treated as package name"
65+ will_change = "4.x will make this an error" # pip and pipx already throw away None's
66+ try :
67+ reqt = Requirement (s )
68+ except InvalidRequirement as e :
69+ logger .warning (f"{ pep_508 } :{ e } \n { will_change } " )
70+ return cls (s , "" , "" , "" )
71+ else :
72+ if (len (reqt .specifier ) > 0 ) and (len (reqt .specifier ) > 1 ):
73+ logger .warning (f"{ pep_508 } /unsupported specifier ({ s } ) { treatment } \n { will_change } " )
74+ return cls (s , "" , "" , "" )
75+ else :
76+ spec = next (iter (reqt .specifier ), None )
77+ return cls (
78+ reqt .name .lower (),
79+ spec .version if spec is not None else "" ,
80+ spec .operator if spec is not None else "" ,
81+ reqt .url or "" ,
82+ )
1983
2084
2185def _has_package (
@@ -57,35 +121,35 @@ def in_packages(pkg_name, pkg_versions):
57121
58122def ensure_packages (
59123 host : Host ,
60- packages_to_ensure : str | list [str ] | None ,
124+ packages_to_ensure : str | list [str ] | list [ PkgInfo ] | None ,
61125 current_packages : dict [str , set [str ]],
62126 present : bool ,
63127 install_command : str ,
64128 uninstall_command : str ,
65- latest = False ,
129+ latest : bool = False ,
66130 upgrade_command : str | None = None ,
67131 version_join : str | None = None ,
68132 expand_package_fact : Callable [[str ], list [str | list [str ]]] | None = None ,
69133):
70134 """
71135 Handles this common scenario:
72136
73- + We have a list of packages(/versions) to ensure
137+ + We have a list of packages(/versions/urls ) to ensure
74138 + We have a map of existing package -> versions
75139 + We have the common command bits (install, uninstall, version "joiner")
76140 + Outputs commands to ensure our desired packages/versions
77141 + Optionally upgrades packages w/o specified version when present
78142
79143 Args:
80- packages_to_ensure (list): list of packages or package/versions
81- current_packages (fact): fact returning dict of package names -> version
144+ packages_to_ensure (list): list of packages or package/versions or PkgInfo's
145+ current_packages (dict): dict of package names -> version
82146 present (bool): whether packages should exist or not
83147 install_command (str): command to prefix to list of packages to install
84148 uninstall_command (str): as above for uninstalling packages
85149 latest (bool): whether to upgrade installed packages when present
86150 upgrade_command (str): as above for upgrading
87151 version_join (str): the package manager specific "joiner", ie ``=`` for \
88- ``<apt_pkg>=<version>``
152+ ``<apt_pkg>=<version>``. Not allowed if (pkg, ver, url) tuples are provided.
89153 expand_package_fact: fact returning packages providing a capability \
90154 (ie ``yum whatprovides``)
91155 """
@@ -95,12 +159,15 @@ def ensure_packages(
95159 if isinstance (packages_to_ensure , str ):
96160 packages_to_ensure = [packages_to_ensure ]
97161
98- packages : list [str | list [str ]] = packages_to_ensure # type: ignore[assignment]
99-
100- if version_join :
162+ packages : list [PkgInfo ] = []
163+ if isinstance (packages_to_ensure [0 ], PkgInfo ):
164+ packages = cast ("list[PkgInfo]" , packages_to_ensure )
165+ if version_join is not None :
166+ raise OperationValueError ("cannot specify version_join and provide list[PkgInfo]" )
167+ else :
101168 packages = [
102- package [ 0 ] if len (package ) == 1 else package
103- for package in [ package . rsplit ( version_join , 1 ) for package in packages ] # type: ignore[union-attr] # noqa
169+ PkgInfo . from_possible_pair (package , version_join )
170+ for package in cast ( "list[str]" , packages_to_ensure )
104171 ]
105172
106173 diff_packages = []
@@ -111,65 +178,41 @@ def ensure_packages(
111178 if present is True :
112179 for package in packages :
113180 has_package , expanded_packages = _has_package (
114- package ,
115- current_packages ,
116- expand_package_fact ,
181+ package .lkup_name , current_packages , expand_package_fact
117182 )
118183
119184 if not has_package :
120- diff_packages .append (package )
121- diff_expanded_packages [_package_name ( package ) ] = expanded_packages
185+ diff_packages .append (package . inst_vers )
186+ diff_expanded_packages [package . name ] = expanded_packages
122187 else :
123188 # Present packages w/o version specified - for upgrade if latest
124- if isinstance ( package , str ):
125- upgrade_packages .append (package )
189+ if not package . has_version : # don't try to upgrade if a specific version requested
190+ upgrade_packages .append (package . inst_vers )
126191
127192 if not latest :
128- pkg_name = _package_name (package )
129- if pkg_name in current_packages :
130- host .noop (
131- "package {0} is installed ({1})" .format (
132- package ,
133- ", " .join (current_packages [pkg_name ]),
134- ),
135- )
193+ if (pkg := package .name ) in current_packages :
194+ host .noop (f"package { pkg } is installed ({ ',' .join (current_packages [pkg ])} )" )
136195 else :
137- host .noop ("package {0 } is installed" . format ( package ) )
196+ host .noop (f "package { package . name } is installed" )
138197
139198 if present is False :
140199 for package in packages :
141- # String version, just check if existing
142200 has_package , expanded_packages = _has_package (
143- package ,
144- current_packages ,
145- expand_package_fact ,
146- match_any = True ,
201+ package .lkup_name , current_packages , expand_package_fact , match_any = True
147202 )
148203
149204 if has_package :
150- diff_packages .append (package )
151- diff_expanded_packages [_package_name ( package ) ] = expanded_packages
205+ diff_packages .append (package . inst_vers )
206+ diff_expanded_packages [package . name ] = expanded_packages
152207 else :
153- host .noop ("package {0 } is not installed" . format ( package ) )
208+ host .noop (f "package { package . name } is not installed" )
154209
155210 if diff_packages :
156211 command = install_command if present else uninstall_command
157-
158- joined_packages = [
159- version_join .join (package ) if isinstance (package , list ) else package # type: ignore[union-attr] # noqa
160- for package in diff_packages
161- ]
162-
163- yield "{0} {1}" .format (
164- command ,
165- " " .join ([shlex .quote (pkg ) for pkg in joined_packages ]),
166- )
212+ yield f"{ command } { ' ' .join ([shlex .quote (pkg ) for pkg in diff_packages ])} "
167213
168214 if latest and upgrade_command and upgrade_packages :
169- yield "{0} {1}" .format (
170- upgrade_command ,
171- " " .join ([shlex .quote (pkg ) for pkg in upgrade_packages ]),
172- )
215+ yield f"{ upgrade_command } { ' ' .join ([shlex .quote (pkg ) for pkg in upgrade_packages ])} "
173216
174217
175218def ensure_rpm (state : State , host : Host , source : str , present : bool , package_manager_command : str ):
0 commit comments