1# -*- coding: utf-8 -*-
2# Copyright (c) 2013 Matthias Vogelgesang <matthias.vogelgesang@gmail.com>
3
4# Permission is hereby granted, free of charge, to any person obtaining a copy
5# of this software and associated documentation files (the "Software"), to deal
6# in the Software without restriction, including without limitation the rights
7# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8# copies of the Software, and to permit persons to whom the Software is
9# furnished to do so, subject to the following conditions:
10
11# The above copyright notice and this permission notice shall be included in
12# all copies or substantial portions of the Software.
13
14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20# SOFTWARE.
21
22"""pkgconfig is a Python module to interface with the pkg-config command line
23tool."""
24
25import subprocess
26import re
27import collections
28
29
30def _compare_versions(v1, v2):
31    """
32    Compare two version strings and return -1, 0 or 1 depending on the equality
33    of the subset of matching version numbers.
34
35    The implementation is taken from the top answer at
36    http://stackoverflow.com/a/1714190/997768.
37    """
38    def normalize(v):
39        return [int(x) for x in re.sub(r'(\.0+)*$', '', v).split(".")]
40
41    n1 = normalize(v1)
42    n2 = normalize(v2)
43
44    return (n1 > n2) - (n1 < n2)
45
46
47def _split_version_specifier(spec):
48    """Splits version specifiers in the form ">= 0.1.2" into ('0.1.2', '>=')"""
49    m = re.search(r'([<>=]?=?)?\s*((\d*\.)*\d*)', spec)
50    return m.group(2), m.group(1)
51
52
53def _convert_error(func):
54    def _wrapper(*args, **kwargs):
55        try:
56            return func(*args, **kwargs)
57        except OSError:
58            raise EnvironmentError("pkg-config is not installed")
59
60    return _wrapper
61
62
63@_convert_error
64def _query(package, option):
65    cmd = 'pkg-config {0} {1}'.format(option, package).split()
66    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
67                            stderr=subprocess.PIPE)
68    out, err = proc.communicate()
69
70    return out.rstrip().decode('utf-8')
71
72
73@_convert_error
74def exists(package):
75    """Return True if package information is available."""
76    cmd = 'pkg-config --exists {0}'.format(package).split()
77    return subprocess.call(cmd) == 0
78
79
80@_convert_error
81def requires(package):
82    """Return a list of package names that is required by the package"""
83    return _query(package, '--print-requires').split('\n')
84
85
86def cflags(package):
87    """Return the CFLAGS string returned by pkg-config."""
88    return _query(package, '--cflags')
89
90
91def libs(package):
92    """Return the LDFLAGS string returned by pkg-config."""
93    return _query(package, '--libs')
94
95
96def installed(package, version):
97    """
98    Check if the package meets the required version.
99
100    The version specifier consists of an optional comparator (one of =, ==, >,
101    <, >=, <=) and an arbitrarily long version number separated by dots. The
102    should be as you would expect, e.g. for an installed version '0.1.2' of
103    package 'foo':
104
105    >>> installed('foo', '==0.1.2')
106    True
107    >>> installed('foo', '<0.1')
108    False
109    >>> installed('foo', '>= 0.0.4')
110    True
111    """
112    if not exists(package):
113        return False
114
115    number, comparator = _split_version_specifier(version)
116    modversion = _query(package, '--modversion')
117
118    try:
119        result = _compare_versions(modversion, number)
120    except ValueError:
121        msg = "{0} is not a correct version specifier".format(version)
122        raise ValueError(msg)
123
124    if comparator in ('', '=', '=='):
125        return result == 0
126
127    if comparator == '>':
128        return result > 0
129
130    if comparator == '>=':
131        return result >= 0
132
133    if comparator == '<':
134        return result < 0
135
136    if comparator == '<=':
137        return result <= 0
138
139
140_PARSE_MAP = {
141    '-D': 'define_macros',
142    '-I': 'include_dirs',
143    '-L': 'library_dirs',
144    '-l': 'libraries'
145}
146
147
148def parse(packages):
149    """
150    Parse the output from pkg-config about the passed package or packages.
151
152    Builds a dictionary containing the 'libraries', the 'library_dirs',
153    the 'include_dirs', and the 'define_macros' that are presented by
154    pkg-config. *package* is a string with space-delimited package names.
155    """
156    def parse_package(package):
157        result = collections.defaultdict(set)
158
159        # Execute the query to pkg-config and clean the result.
160        out = _query(package, '--cflags --libs')
161        out = out.replace('\\"', '')
162
163        # Iterate through each token in the output.
164        for token in out.split():
165            key = _PARSE_MAP.get(token[:2])
166            if key:
167                result[key].add(token[2:].strip())
168
169        # Iterate and clean define macros.
170        macros = set()
171        for declaration in result['define_macros']:
172            macro = tuple(declaration.split('='))
173            if len(macro) == 1:
174                macro += '',
175
176            macros.add(macro)
177
178        result['define_macros'] = macros
179
180        # Return parsed configuration.
181        return result
182
183    # Go through all package names and update the result dict accordingly.
184    result = collections.defaultdict(set)
185
186    for package in packages.split():
187        for k, v in parse_package(package).items():
188            result[k].update(v)
189
190    return result
191