1
# Copyright 2019 Canonical Ltd
3
# Licensed under the Apache License, Version 2.0 (the "License");
4
# you may not use this file except in compliance with the License.
5
# You may obtain a copy of the License at
7
# http://www.apache.org/licenses/LICENSE-2.0
9
# Unless required by applicable law or agreed to in writing, software
10
# distributed under the License is distributed on an "AS IS" BASIS,
11
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
# See the License for the specific language governing permissions and
13
# limitations under the License.
15
"""Provide a subset of the ``python-apt`` module API.
17
Data collection is done through subprocess calls to ``apt-cache`` and
18
``dpkg-query`` commands.
20
The main purpose for this module is to avoid dependency on the
21
``python-apt`` python module.
23
The indicated python module is a wrapper around the ``apt`` C++ library
24
which is tightly connected to the version of the distribution it was
25
shipped on. It is not developed in a backward/forward compatible manner.
27
This in turn makes it incredibly hard to distribute as a wheel for a piece
28
of python software that supports a span of distro releases [0][1].
30
Upstream feedback like [2] does not give confidence in this ever changing,
31
so with this we get rid of the dependency.
33
0: https://github.com/juju-solutions/layer-basic/pull/135
34
1: https://bugs.launchpad.net/charm-octavia/+bug/1824112
35
2: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=845330#10
44
class _container(dict):
45
"""Simple container for attributes."""
46
__getattr__ = dict.__getitem__
47
__setattr__ = dict.__setitem__
50
class Package(_container):
51
"""Simple container for package attributes."""
54
class Version(_container):
55
"""Simple container for version attributes."""
59
"""Simulation of ``apt_pkg`` Cache object."""
60
def __init__(self, progress=None):
63
def __contains__(self, package):
65
pkg = self.__getitem__(package)
66
return pkg is not None
70
def __getitem__(self, package):
71
"""Get information about a package from apt and dpkg databases.
73
:param package: Name of package
75
:returns: Package object
77
:raises: KeyError, subprocess.CalledProcessError
79
apt_result = self._apt_cache_show([package])[package]
80
apt_result['name'] = apt_result.pop('package')
81
pkg = Package(apt_result)
82
dpkg_result = self._dpkg_list([package]).get(package, {})
84
installed_version = dpkg_result.get('version')
86
current_ver = Version({'ver_str': installed_version})
87
pkg.current_ver = current_ver
88
pkg.architecture = dpkg_result.get('architecture')
91
def _dpkg_list(self, packages):
92
"""Get data from system dpkg database for package.
94
:param packages: Packages to get data from
95
:type packages: List[str]
96
:returns: Structured data about installed packages, keys like
99
:raises: subprocess.CalledProcessError
102
cmd = ['dpkg-query', '--list']
104
if locale.getlocale() == (None, None):
105
# subprocess calls out to locale.getpreferredencoding(False) to
106
# determine encoding. Workaround for Trusty where the
107
# environment appears to not be set up correctly.
108
locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
110
output = subprocess.check_output(cmd,
111
stderr=subprocess.STDOUT,
112
universal_newlines=True)
113
except subprocess.CalledProcessError as cp:
114
# ``dpkg-query`` may return error and at the same time have
115
# produced useful output, for example when asked for multiple
116
# packages where some are not installed
117
if cp.returncode != 1:
121
for line in output.splitlines():
122
if line.startswith('||/'):
123
headings = line.split()
126
elif (line.startswith('|') or line.startswith('+') or
127
line.startswith('dpkg-query:')):
130
data = line.split(None, 4)
135
pkg.update({k.lower(): v for k, v in zip(headings, data)})
137
pkgs.update({pkg['name']: pkg})
140
def _apt_cache_show(self, packages):
141
"""Get data from system apt cache for package.
143
:param packages: Packages to get data from
144
:type packages: List[str]
145
:returns: Structured data about package, keys like
148
:raises: subprocess.CalledProcessError
151
cmd = ['apt-cache', 'show', '--no-all-versions']
153
if locale.getlocale() == (None, None):
154
# subprocess calls out to locale.getpreferredencoding(False) to
155
# determine encoding. Workaround for Trusty where the
156
# environment appears to not be set up correctly.
157
locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
159
output = subprocess.check_output(cmd,
160
stderr=subprocess.STDOUT,
161
universal_newlines=True)
164
for line in output.splitlines():
167
pkgs.update({pkg['package']: pkg})
170
if line.startswith(' '):
171
if previous and previous in pkg:
172
pkg[previous] += os.linesep + line.lstrip()
175
kv = line.split(':', 1)
180
pkg.update({key: kv[1].lstrip()})
181
except subprocess.CalledProcessError as cp:
182
# ``apt-cache`` returns 100 if none of the packages asked for
183
# exist in the apt cache.
184
if cp.returncode != 100:
189
class Config(_container):
191
super(Config, self).__init__(self._populate())
195
cmd = ['apt-config', 'dump']
196
output = subprocess.check_output(cmd,
197
stderr=subprocess.STDOUT,
198
universal_newlines=True)
199
for line in output.splitlines():
200
if not line.startswith("CommandLine"):
201
k, v = line.split(" ", 1)
202
cfgs[k] = v.strip(";").strip("\"")
207
# Backwards compatibility with old apt_pkg module
208
sys.modules[__name__].config = Config()
212
"""Compability shim that does nothing."""
216
def upstream_version(version):
217
"""Extracts upstream version from a version string.
219
Upstream reference: https://salsa.debian.org/apt-team/apt/blob/master/
220
apt-pkg/deb/debversion.cc#L259
222
:param version: Version string
224
:returns: Upstream version
228
version = version.split(':')[-1]
229
version = version.split('-')[0]
233
def version_compare(a, b):
234
"""Compare the given versions.
236
Call out to ``dpkg`` to make sure the code doing the comparison is
237
compatible with what the ``apt`` library would do. Mimic the return
241
https://apt-team.pages.debian.net/python-apt/library/apt_pkg.html
242
?highlight=version_compare#apt_pkg.version_compare
244
:param a: version string
246
:param b: version string
248
:returns: >0 if ``a`` is greater than ``b``, 0 if a equals b,
249
<0 if ``a`` is smaller than ``b``
251
:raises: subprocess.CalledProcessError, RuntimeError
253
for op in ('gt', 1), ('eq', 0), ('lt', -1):
255
subprocess.check_call(['dpkg', '--compare-versions',
257
stderr=subprocess.STDOUT,
258
universal_newlines=True)
260
except subprocess.CalledProcessError as cp:
261
if cp.returncode == 1:
265
raise RuntimeError('Unable to compare "{}" and "{}", according to '
266
'our logic they are neither greater, equal nor '
267
'less than each other.')