conan-io/conan

View on GitHub
conan/tools/gnu/pkgconfigdeps.py

Summary

Maintainability
A
0 mins
Test Coverage
import os
import textwrap
from collections import namedtuple

from jinja2 import Template, StrictUndefined

from conan.tools.gnu.gnudeps_flags import GnuDepsFlags
from conans.errors import ConanException
from conans.util.files import save


def _get_name_with_namespace(namespace, name):
    """Build a name with a namespace, e.g., openssl-crypto"""
    return f"{namespace}-{name}"


def _get_package_reference_name(dep):
    """Get the reference name for the given package"""
    return dep.ref.name


def _get_package_aliases(dep):
    pkg_aliases = dep.cpp_info.get_property("pkg_config_aliases")
    return pkg_aliases or []


def _get_component_aliases(dep, comp_name):
    if comp_name not in dep.cpp_info.components:
        # foo::foo might be referencing the root cppinfo
        if _get_package_reference_name(dep) == comp_name:
            return _get_package_aliases(dep)
        raise ConanException("Component '{name}::{cname}' not found in '{name}' "
                             "package requirement".format(name=_get_package_reference_name(dep),
                                                          cname=comp_name))
    comp_aliases = dep.cpp_info.components[comp_name].get_property("pkg_config_aliases")
    return comp_aliases or []


def _get_package_name(dep, build_context_suffix=None):
    pkg_name = dep.cpp_info.get_property("pkg_config_name") or _get_package_reference_name(dep)
    suffix = _get_suffix(dep, build_context_suffix)
    return f"{pkg_name}{suffix}"


def _get_component_name(dep, comp_name, build_context_suffix=None):
    if comp_name not in dep.cpp_info.components:
        # foo::foo might be referencing the root cppinfo
        if _get_package_reference_name(dep) == comp_name:
            return _get_package_name(dep, build_context_suffix)
        raise ConanException("Component '{name}::{cname}' not found in '{name}' "
                             "package requirement".format(name=_get_package_reference_name(dep),
                                                          cname=comp_name))
    comp_name = dep.cpp_info.components[comp_name].get_property("pkg_config_name")
    suffix = _get_suffix(dep, build_context_suffix)
    return f"{comp_name}{suffix}" if comp_name else None


def _get_suffix(req, build_context_suffix=None):
    """
    Get the package name suffix coming from PkgConfigDeps.build_context_suffix attribute, but only
    for requirements declared as build requirement.

    :param req: requirement ConanFile instance
    :param build_context_suffix: `dict` with all the suffixes
    :return: `str` with the suffix
    """
    if not build_context_suffix or not req.is_build_context:
        return ""
    return build_context_suffix.get(req.ref.name, "")


def _get_formatted_dirs(folders, prefix_path_):
    ret = []
    for i, directory in enumerate(folders):
        directory = os.path.normpath(directory).replace("\\", "/")
        prefix = ""
        if not os.path.isabs(directory):
            prefix = "${prefix}/"
        elif directory.startswith(prefix_path_):
            prefix = "${prefix}/"
            directory = os.path.relpath(directory, prefix_path_).replace("\\", "/")
        ret.append("%s%s" % (prefix, directory))
    return ret


_PCInfo = namedtuple("PCInfo", ['name', 'requires', 'description', 'cpp_info', 'aliases'])


class _PCContentGenerator:

    template = textwrap.dedent("""\
        {%- macro get_libs(libdirs, cpp_info, gnudeps_flags) -%}
        {%- for _ in libdirs -%}
        {{ '-L"${libdir%s}"' % loop.index + " " }}
        {%- endfor -%}
        {%- for sys_lib in (cpp_info.libs + cpp_info.system_libs) -%}
        {{ "-l%s" % sys_lib + " " }}
        {%- endfor -%}
        {%- for shared_flag in (cpp_info.sharedlinkflags + cpp_info.exelinkflags) -%}
        {{  shared_flag + " " }}
        {%- endfor -%}
        {%- for framework in (gnudeps_flags.frameworks + gnudeps_flags.framework_paths) -%}
        {{ framework + " " }}
        {%- endfor -%}
        {%- endmacro -%}

        {%- macro get_cflags(includedirs, cxxflags, cflags, defines) -%}
        {%- for _ in includedirs -%}
        {{ '-I"${includedir%s}"' % loop.index + " " }}
        {%- endfor -%}
        {%- for cxxflag in cxxflags -%}
        {{ cxxflag + " " }}
        {%- endfor -%}
        {%- for cflag in cflags-%}
        {{ cflag + " " }}
        {%- endfor -%}
        {%- for define in defines-%}
        {{  "-D%s" % define + " " }}
        {%- endfor -%}
        {%- endmacro -%}

        prefix={{ prefix_path }}
        {% for path in libdirs %}
        {{ "libdir{}={}".format(loop.index, path) }}
        {% endfor %}
        {% for path in includedirs %}
        {{ "includedir%d=%s" % (loop.index, path) }}
        {% endfor %}
        {% if pkg_config_custom_content %}
        # Custom PC content
        {{ pkg_config_custom_content }}
        {% endif %}

        Name: {{ name }}
        Description: {{ description }}
        Version: {{ version }}
        Libs: {{ get_libs(libdirs, cpp_info, gnudeps_flags) }}
        Cflags: {{ get_cflags(includedirs, cxxflags, cflags, defines) }}
        {% if requires|length %}
        Requires: {{ requires|join(' ') }}
        {% endif %}
    """)

    shortened_template = textwrap.dedent("""\
        Name: {{ name }}
        Description: {{ description }}
        Version: {{ version }}
        {% if requires|length %}
        Requires: {{ requires|join(' ') }}
        {% endif %}
    """)

    def __init__(self, conanfile, dep):
        self._conanfile = conanfile
        self._dep = dep

    def content(self, info):
        assert isinstance(info, _PCInfo) and info.cpp_info is not None

        # If editable, package_folder can be None
        root_folder = self._dep.recipe_folder if self._dep.package_folder is None \
            else self._dep.package_folder
        version = info.cpp_info.get_property("component_version") or self._dep.ref.version

        prefix_path = root_folder.replace("\\", "/")
        libdirs = _get_formatted_dirs(info.cpp_info.libdirs, prefix_path)
        includedirs = _get_formatted_dirs(info.cpp_info.includedirs, prefix_path)
        custom_content = info.cpp_info.get_property("pkg_config_custom_content")

        context = {
            "prefix_path": prefix_path,
            "libdirs": libdirs,
            "includedirs": includedirs,
            "pkg_config_custom_content": custom_content,
            "name": info.name,
            "description": info.description,
            "version": version,
            "requires": info.requires,
            "cpp_info": info.cpp_info,
            "cxxflags": [var.replace('"', '\\"') for var in info.cpp_info.cxxflags],
            "cflags": [var.replace('"', '\\"') for var in info.cpp_info.cflags],
            "defines": [var.replace('"', '\\"') for var in info.cpp_info.defines],
            "gnudeps_flags": GnuDepsFlags(self._conanfile, info.cpp_info)
        }
        template = Template(self.template, trim_blocks=True, lstrip_blocks=True,
                            undefined=StrictUndefined)
        return template.render(context)

    def shortened_content(self, info):
        assert isinstance(info, _PCInfo)

        context = {
            "name": info.name,
            "description": info.description,
            "version": self._dep.ref.version,
            "requires": info.requires
        }
        template = Template(self.shortened_template, trim_blocks=True,
                            lstrip_blocks=True, undefined=StrictUndefined)
        return template.render(context)


class _PCGenerator:

    def __init__(self, conanfile, dep, build_context_suffix=None):
        self._conanfile = conanfile
        self._build_context_suffix = build_context_suffix or {}
        self._dep = dep
        self._content_generator = _PCContentGenerator(self._conanfile, self._dep)

    def _get_cpp_info_requires_names(self, cpp_info):
        """
        Get all the pkg-config valid names from the requires ones given a CppInfo object.

        For instance, those requires could be coming from:

        ```python
        from conan import ConanFile
        class PkgConfigConan(ConanFile):
            requires = "other/1.0"

            def package_info(self):
                self.cpp_info.requires = ["other::cmp1"]
        ```
        Or:

        ```python
        from conan import ConanFile
        class PkgConfigConan(ConanFile):
            requires = "other/1.0"

            def package_info(self):
                self.cpp_info.components["cmp"].requires = ["other::cmp1"]
        ```
        """
        dep_ref_name = _get_package_reference_name(self._dep)
        ret = []
        for req in cpp_info.requires:
            pkg_ref_name, comp_ref_name = req.split("::") if "::" in req else (dep_ref_name, req)
            # For instance, dep == "hello/1.0" and req == "other::cmp1" -> hello != other
            if dep_ref_name != pkg_ref_name:
                req_conanfile = self._dep.dependencies.host[pkg_ref_name]
            else:  # For instance, dep == "hello/1.0" and req == "hello::cmp1" -> hello == hello
                req_conanfile = self._dep
            comp_name = _get_component_name(req_conanfile, comp_ref_name, self._build_context_suffix)
            if not comp_name:
                pkg_name = _get_package_name(req_conanfile, self._build_context_suffix)
                # Creating a component name with namespace, e.g., dep-comp1
                comp_name = _get_name_with_namespace(pkg_name, comp_ref_name)
            ret.append(comp_name)
        return ret

    @property
    def components_info(self):
        """
        Get the whole package and its components information like their own requires, names and even
        the cpp_info for each component.

        :return: `list` of `_PCInfo` objects with all the components information
        """
        pkg_name = _get_package_name(self._dep, self._build_context_suffix)
        components_info = []
        # Loop through all the package's components
        for comp_ref_name, cpp_info in self._dep.cpp_info.get_sorted_components().items():
            # At first, let's check if we have defined some components requires, e.g., "dep::cmp1"
            comp_requires_names = self._get_cpp_info_requires_names(cpp_info)
            comp_name = _get_component_name(self._dep, comp_ref_name, self._build_context_suffix)
            if not comp_name:
                comp_name = _get_name_with_namespace(pkg_name, comp_ref_name)
                comp_description = f"Conan component: {comp_name}"
            else:
                comp_description = f"Conan component: {pkg_name}-{comp_name}"
            comp_aliases = _get_component_aliases(self._dep, comp_ref_name)
            # Save each component information
            components_info.append(_PCInfo(comp_name, comp_requires_names, comp_description,
                                           cpp_info, comp_aliases))
        return components_info

    @property
    def package_info(self):
        """
        Get the whole package information

        :return: `_PCInfo` object with the package information
        """
        pkg_name = _get_package_name(self._dep, self._build_context_suffix)
        # At first, let's check if we have defined some global requires, e.g., "other::cmp1"
        requires = self._get_cpp_info_requires_names(self._dep.cpp_info)
        # If we have found some component requires it would be enough
        if not requires:
            # If no requires were found, let's try to get all the direct visible dependencies,
            # e.g., requires = "other_pkg/1.0"
            requires = [_get_package_name(req, self._build_context_suffix)
                        for dep, req in self._dep.dependencies.direct_host.items() if dep.visible]
        description = "Conan package: %s" % pkg_name
        aliases = _get_package_aliases(self._dep)
        cpp_info = self._dep.cpp_info
        return _PCInfo(pkg_name, requires, description, cpp_info, aliases)

    @property
    def pc_files(self):
        """
        Get all the PC files and contents for any dependency:

        * If the given dependency does not have components:
            The PC file will be the depency one.

        * If the given dependency has components:
            The PC files will be saved in this order:
                1- Package components.
                2- Root component.

            Note: If the root-package PC name matches with any other of the components one, the first one
            is not going to be created. Components have more priority than root package.

        * Apart from those PC files, if there are any aliases declared, they will be created too.
        """
        def _update_pc_files(info):
            pc_files[f"{info.name}.pc"] = self._content_generator.content(info)
            for alias in info.aliases:
                alias_info = _PCInfo(alias, [info.name], f"Alias {alias} for {info.name}", None, [])
                pc_files[f"{alias}.pc"] = self._content_generator.shortened_content(alias_info)

        pc_files = {}
        # If the package has no components, then we have to calculate only the root pc file
        if not self._dep.cpp_info.has_components:
            package_info = self.package_info
            _update_pc_files(package_info)
            return pc_files

        # First, let's load all the components PC files
        # Loop through all the package's components
        pkg_requires = []
        for component_info in self.components_info:
            _update_pc_files(component_info)
            # Saving components name as the package requires
            pkg_requires.append(component_info.name)

        # Second, let's load the root package's PC file ONLY
        # if it does not already exist in components one
        # Issue related: https://github.com/conan-io/conan/issues/10341
        pkg_name = _get_package_name(self._dep, self._build_context_suffix)
        if f"{pkg_name}.pc" not in pc_files:
            package_info = _PCInfo(pkg_name, pkg_requires, f"Conan package: {pkg_name}", None,
                                   _get_package_aliases(self._dep))
            # It'll be enough creating a shortened PC file. This file will be like an alias
            pc_files[f"{package_info.name}.pc"] = self._content_generator.shortened_content(package_info)
            for alias in package_info.aliases:
                alias_info = _PCInfo(alias, [package_info.name],
                                     f"Alias {alias} for {package_info.name}", None, [])
                pc_files[f"{alias}.pc"] = self._content_generator.shortened_content(alias_info)

        return pc_files


class PkgConfigDeps:

    def __init__(self, conanfile):
        self._conanfile = conanfile
        # Activate the build *.pc files for the specified libraries
        self.build_context_activated = []
        # If specified, the files/requires/names for the build context will be renamed appending
        # a suffix. It is necessary in case of same require and build_require and will cause an error
        self.build_context_suffix = {}

    def _validate_build_requires(self, host_req, build_req):
        """
        Check if any package exists at host and build context at the same time, and
        it doesn't have any suffix to avoid any name collisions

        :param host_req: list of host requires
        :param build_req: list of build requires
        """
        activated_br = {r.ref.name for r in build_req.values()
                        if r.ref.name in self.build_context_activated}
        common_names = {r.ref.name for r in host_req.values()}.intersection(activated_br)
        without_suffixes = [common_name for common_name in common_names
                            if self.build_context_suffix.get(common_name) is None]
        if without_suffixes:
            raise ConanException(f"The packages {without_suffixes} exist both as 'require' and as"
                                 f" 'build require'. You need to specify a suffix using the "
                                 f"'build_context_suffix' attribute at the PkgConfigDeps generator.")

    @property
    def content(self):
        """Get all the *.pc files content"""
        pc_files = {}
        # Get all the dependencies
        host_req = self._conanfile.dependencies.host
        build_req = self._conanfile.dependencies.build  # tool_requires
        test_req = self._conanfile.dependencies.test

        # Check if it exists both as require and as build require without a suffix
        self._validate_build_requires(host_req, build_req)

        for require, dep in list(host_req.items()) + list(build_req.items()) + list(test_req.items()):
            # Require is not used at the moment, but its information could be used,
            # and will be used in Conan 2.0
            # Filter the build_requires not activated with PkgConfigDeps.build_context_activated
            if require.build and dep.ref.name not in self.build_context_activated:
                continue

            pc_generator = _PCGenerator(self._conanfile, dep, build_context_suffix=self.build_context_suffix)
            pc_files.update(pc_generator.pc_files)
        return pc_files

    def generate(self):
        """Save all the *.pc files"""
        # FIXME: Remove this in 2.0
        if not hasattr(self._conanfile, "settings_build") and \
                      (self.build_context_activated or self.build_context_suffix):
            raise ConanException("The 'build_context_activated' and 'build_context_build_modules' of"
                                 " the PkgConfigDeps generator cannot be used without specifying"
                                 " a build profile. e.g: -pr:b=default")

        # Current directory is the generators_folder
        generator_files = self.content
        for generator_file, content in generator_files.items():
            save(generator_file, content)