Skip to content

Commit 765ea7c

Browse files
authored
operations/pip: support PEP-508 package versions
1 parent 70f0450 commit 765ea7c

File tree

7 files changed

+163
-69
lines changed

7 files changed

+163
-69
lines changed

pyinfra/operations/pip.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pyinfra.facts.pip import PipPackages
1212

1313
from . import files
14-
from .util.packaging import ensure_packages
14+
from .util.packaging import PkgInfo, ensure_packages
1515

1616

1717
@operation()
@@ -186,21 +186,20 @@ def packages(
186186

187187
# Handle passed in packages
188188
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
189193
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]
194194
current_packages = {pkg.lower(): versions for pkg, versions in current_packages.items()}
195195

196196
yield from ensure_packages(
197197
host,
198-
packages,
198+
list(filter(None, (PkgInfo.from_pep508(package) for package in packages))),
199199
current_packages,
200200
present,
201201
install_command=install_command,
202202
uninstall_command=uninstall_command,
203203
upgrade_command=upgrade_command,
204-
version_join="==",
205204
latest=latest,
206205
)

pyinfra/operations/pipx.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,27 @@
22
Manage pipx (python) applications.
33
"""
44

5+
from typing import Optional, Union
6+
57
from pyinfra import host
68
from pyinfra.api import operation
79
from pyinfra.facts.pipx import PipxEnvironment, PipxPackages
810
from pyinfra.facts.server import Path
911

10-
from .util.packaging import ensure_packages
12+
from .util.packaging import PkgInfo, ensure_packages
1113

1214

1315
@operation()
1416
def packages(
15-
packages=None,
17+
packages: Optional[Union[str, list[str]]] = None,
1618
present=True,
1719
latest=False,
18-
extra_args=None,
20+
extra_args: Optional[str] = None,
1921
):
2022
"""
2123
Install/remove/update pipx packages.
2224
23-
+ packages: list of packages to ensure
25+
+ packages: list of packages (PEP-508 format) to ensure
2426
+ present: whether the packages should be installed
2527
+ latest: whether to upgrade packages without a specified version
2628
+ extra_args: additional arguments to the pipx command
@@ -37,6 +39,9 @@ def packages(
3739
packages=["pyinfra"],
3840
)
3941
"""
42+
if packages is None:
43+
host.noop("no package list provided to pipx.packages")
44+
return
4045

4146
prep_install_command = ["pipx", "install"]
4247

@@ -47,19 +52,26 @@ def packages(
4752
uninstall_command = "pipx uninstall"
4853
upgrade_command = "pipx upgrade"
4954

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]
5162

5263
# pipx support only one package name at a time
5364
for package in packages:
65+
if (pkg_info := PkgInfo.from_pep508(package)) is None:
66+
continue # from_pep508 logged a warning
5467
yield from ensure_packages(
5568
host,
56-
[package],
69+
[pkg_info],
5770
current_packages,
5871
present,
5972
install_command=install_command,
6073
uninstall_command=uninstall_command,
6174
upgrade_command=upgrade_command,
62-
version_join="==",
6375
latest=latest,
6476
)
6577

pyinfra/operations/util/packaging.py

Lines changed: 98 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,83 @@
33
import shlex
44
from collections import defaultdict
55
from io import StringIO
6-
from typing import Callable
6+
from typing import Callable, NamedTuple, cast
77
from 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
1013
from pyinfra.facts.files import File
1114
from pyinfra.facts.rpm import RpmPackage
1215
from 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

2185
def _has_package(
@@ -57,35 +121,35 @@ def in_packages(pkg_name, pkg_versions):
57121

58122
def 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

175218
def ensure_rpm(state: State, host: Host, source: str, present: bool, package_manager_command: str):
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"args": ["copier @ [email protected]:copier-org/[email protected]"],
3+
"facts": {
4+
"pip.PipPackages": {
5+
"pip=pip": {
6+
"copier": ["9.9.0"]
7+
}
8+
}
9+
},
10+
"commands": [
11+
],
12+
"noop_description": "package copier is installed (9.9.0)"
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"args": ["copier @ [email protected]:copier-org/[email protected]"],
3+
"facts": {
4+
"pipx.PipxPackages": {"copier": ["9.9.0"]}
5+
},
6+
"commands": [
7+
],
8+
"noop_description": "package copier is installed (9.9.0)"
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"args": [null],
3+
"facts": {
4+
"pipx.PipxPackages": {"copier": ["9.9.0"]}
5+
},
6+
"commands": [
7+
],
8+
"noop_description": "no package list provided to pipx.packages"
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"args": ["copier==0.9.1"],
3+
"facts": {
4+
"pipx.PipxPackages": {"ensurepath": ["0.1.1"]}
5+
},
6+
"commands": [
7+
"pipx install copier==0.9.1"
8+
]
9+
}

0 commit comments

Comments
 (0)