#!/usr/bin/python3 """Recipe creation tool - catkin support plugin.""" # Copyright (C) 2017 Intel Corporation # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import re import logging from html.parser import HTMLParser from lxml import etree import hashlib from recipetool.create import RecipeHandler from recipetool.create_buildsys import CmakeExtensionHandler LOGGER = logging.getLogger('recipetool') class RosHTMLParser(HTMLParser): """ROS HTML Parser class. Primarily for removing any XHTML from the tag. See: http://www.ros.org/reps/rep-0127.html#description (Format 1) See: http://www.ros.org/reps/rep-0140.html#description (Format 2) """ basic_text = "" def handle_data(self, data): """Override HTMLParser handle_data method.""" if len(self.basic_text) > 0: self.basic_text = self.basic_text + " " self.basic_text = self.basic_text + data.strip() class RosXmlParser: """ROS package.xml Parser class. Uses the etree class from lxml to parse the ROS package.xml file. This file is main source for information for constructing the BitBake recipe for the ROS package. See: http://www.ros.org/reps/rep-0127.html (Format 1) See: http://www.ros.org/reps/rep-0140.html (Format 2) """ package_format = 0 def __init__(self, xml_path): """Initialize the class by finding the package format version.""" # Default to ROS package format 1 # http://wiki.ros.org/catkin/package.xml#Format_1_.28Legacy.29 self.package_format = 1 self.xml_path = xml_path self.tree = etree.parse(self.xml_path) # Check the ROS package format # http://wiki.ros.org/catkin/package.xml#Format_1_.28Legacy.29 # or # http://wiki.ros.org/catkin/package.xml#Format_2_.28Recommended.29 package_format_list = self.tree.xpath("/package[@format]") for pkg_format in package_format_list: self.package_format = int(pkg_format.get('format', self.package_format)) if self.package_format > 2: self.package_format = 2 LOGGER.warning("FORCING ROS Package Format to version " + str(self.package_format)) elif self.package_format < 1: self.package_format = 1 LOGGER.warning("FORCING ROS Package Format to version " + str(self.package_format)) LOGGER.debug("ROS Package Format version " + str(self.package_format)) def get_format(self): """Return the package.xml format version.""" return str(self.package_format) def clean_string(self, raw_string): """Remove white space and sanitize the string. Replace double quotes with single quotes as bitbake recipes variables will be set with double quotes. """ return re.sub(r'\s+', ' ', raw_string.strip().replace('"', "'")) def get_single(self, xpath, required=True): """Return a single string value for the given xpath.""" xpath_list = self.tree.xpath(xpath) if len(xpath_list) < 1: if required: LOGGER.error("ROS package.xml missing element " + str(xpath)) return None elif len(xpath_list) > 1: LOGGER.warning("ROS package.xml has more than 1 match for " + str(xpath)) return self.clean_string(xpath_list[0].text) def get_multiple_with_type(self, xpath, required=False): """Return dict of type attributes and the matching urls from xpath.""" items = {} xpath_list = self.tree.xpath(xpath) if len(xpath_list) < 1: if required: LOGGER.error("ROS package.xml missing element " + str(xpath)) for item in xpath_list: url_string = self.clean_string(item.text) url_type = self.clean_string(item.get('type', '')).lower() items[url_type] = url_string return items def get_multiple_with_email(self, xpath, required=True): """Return list of string values and email attrib for given xpath.""" items = [] xpath_list = self.tree.xpath(xpath) if len(xpath_list) < 1: if required: LOGGER.error("ROS package.xml missing element " + str(xpath)) for item in xpath_list: fullstring = self.clean_string(item.text) email = self.clean_string(item.get('email', '')) if len(email) > 0: fullstring = fullstring + " <" + email + ">" items.append(fullstring) return items def get_multiple_with_version(self, xpath, required=False): """Return list of dependencies and version attribs for given xpath.""" def catkin_to_bitbake(version_type): """Map the Catkin version modifier to BitBake.""" mapper = { "version_lt": "<", "version_lte": "<=", "version_eq": "=", "version_gte": ">=", "version_gt": ">", } return mapper.get(version_type, "UNDEFINED") items = [] xpath_list = self.tree.xpath(xpath) if len(xpath_list) < 1: if required: LOGGER.error("ROS package.xml missing element " + str(xpath)) for item in xpath_list: fullstring = self.clean_string(item.text) if len(item.attrib) > 1: LOGGER.error("ROS package.xml element " + str(xpath) + " has too many attributes!") for version_type in item.attrib: c_version_type = catkin_to_bitbake(version_type) c_value = self.clean_string(item.attrib[version_type]) if len(c_value) > 1: c_version_type = c_version_type + " " + c_value if len(c_version_type) > 1: fullstring = fullstring + " (" + c_version_type + ")" items.append(fullstring) return items def get_multiple_with_linenumber(self, xpath, required=False): """Return dict of string and their line numbers for given xpath.""" items = {} xpath_list = self.tree.xpath(xpath) if len(xpath_list) < 1: if required: LOGGER.error("ROS package.xml missing element " + str(xpath)) for item in xpath_list: c_string = self.clean_string(item.text) items[c_string] = item.sourceline return items def get_name(self): """Return the Name of the ROS package.""" return self.get_single("/package/name") def get_version(self): """Return the Version of the ROS package.""" return self.get_single("/package/version") def get_description(self): """Return the Description of the ROS package. Remove the XHTML information, if present, and only return a simple text string description for the package. """ parser = RosHTMLParser() parser.feed(self.get_single("/package/description")) return self.clean_string(parser.basic_text) def get_authors(self): """Return list of Authors of the ROS package.""" return self.get_multiple_with_email("/package/author", required=False) def get_maintainers(self): """Return list of Maintainers of the ROS package.""" return self.get_multiple_with_email("/package/maintainer") def get_urls(self): """Return list of Website URLs for the ROS package.""" return self.get_multiple_with_type("/package/url", required=False) def get_licenses(self): """Return list of Licenses of the ROS package.""" return self.get_multiple_with_linenumber("/package/license") def get_build_dependencies(self): """Return list of package Build Dependencies of the ROS package.""" dependencies = [] # build_depend is both format 1 & 2 for dependency in self.get_multiple_with_version( "/package/build_depend"): dependencies.append(dependency.replace("_", "-")) if self.package_format > 1: for dependency in self.get_multiple_with_version( "/package/depend"): dependencies.append(dependency.replace("_", "-")) # remove any duplicates dependencies = list(set(dependencies)) return dependencies def get_runtime_dependencies(self): """Return list of package Run Dependencies of the ROS package.""" dependencies = [] # run_depend is format 1 only if self.package_format == 1: for dependency in self.get_multiple_with_version( "/package/run_depend"): dependencies.append(dependency.replace("_", "-")) if self.package_format == 2: for dependency in self.get_multiple_with_version( "/package/exec_depend"): dependencies.append(dependency.replace("_", "-")) for dependency in self.get_multiple_with_version( "/package/depend"): dependencies.append(dependency.replace("_", "-")) # remove any duplicates dependencies = list(set(dependencies)) return dependencies class CatkinRecipeHandler(RecipeHandler): """Catkin handler extension for recipetool.""" def process_license(self, srctree, classes, lines_before, lines_after, handled, extravalues, licenses, license_file): """Generate the Catkin license data. licenses: a dictionary of license:line_number license_file: the name of the license file """ all_lines = [] license_keys = list(licenses.keys()) def get_license_checksum(license): """Output the license details.""" line_number = licenses[license] m = hashlib.md5() try: lic_line = all_lines[line_number - 1] m.update(lic_line.encode('utf-8')) md5val = m.hexdigest() except UnicodeEncodeError: md5val = None LOGGER.debug("License: '" + license + "' on line " + str(line_number) + " with md5 " + md5val) checksum = "file://" + os.path.basename(license_file) + \ ";" + "beginline=" + str(line_number) + ";endline=" + \ str(line_number) + ";md5=" + md5val return checksum try: lic_file = open(license_file) all_lines = lic_file.readlines() lic_file.close() except: LOGGER.error("License file '" + license_file + "'not readable!") return False checksum_files = [] if len(license_keys) > 0: checksum_files.append(get_license_checksum(license_keys[0])) del license_keys[0] for license in license_keys: checksum_files.append(get_license_checksum(license)) # Commas are not valid in BitBake License name clean_keys = [re.sub(r',', '', lic) for lic in licenses.keys()] extravalues['LICENSE'] = " & ".join(clean_keys) extravalues['LIC_FILES_CHKSUM'] = " ".join(checksum_files) handled.append('license') def process(self, srctree, classes, lines_before, lines_after, handled, extravalues): """Process the Catkin recipe. Read the key tags from the package.xml ROS file and generate the corresponding recipe variables for the recipe file. """ package_list = RecipeHandler.checkfiles(srctree, ['package.xml'], recursive=False) if len(package_list) > 0: handled.append('buildsystem') for package_file in package_list: LOGGER.info("Found package_file: " + package_file) xml = RosXmlParser(package_file) classes.append('catkin') extravalues['PN'] = xml.get_name() # Ignored if set extravalues['PV'] = xml.get_version() licenses = xml.get_licenses() if len(licenses) < 1: LOGGER.error("package.xml missing required LICENSE field!") else: self.process_license(srctree, classes, lines_before, lines_after, handled, extravalues, licenses, package_file) lines_after.append('# This is a Catkin (ROS) based recipe') lines_after.append('# ROS package.xml format version ' + xml.get_format()) lines_after.append('') lines_after.append("SUMMARY = \"" + "ROS package " + xml.get_name() + "\"") lines_after.append("DESCRIPTION = \"" + xml.get_description() + "\"") # Map the Catkin URLs to BitBake urls = xml.get_urls() if 'website' in urls: lines_after.append("HOMEPAGE = \"" + urls['website'] + "\"") else: if '' in urls: lines_after.append("HOMEPAGE = \"" + urls[''] + "\"") if 'bugtracker' in urls: lines_after.append("# ROS_BUGTRACKER = \"" + urls['bugtracker'] + "\"") if 'repository' in urls: lines_after.append("# SRC_URI = \"" + urls['repository'] + "\"") authors = xml.get_authors() if len(authors) > 0: lines_after.append("# ROS_AUTHOR = \"" + authors[0] + "\"") del authors[0] for author in authors: lines_after.append("# ROS_AUTHOR += \"" + author + "\"") maintainers = xml.get_maintainers() if len(maintainers) > 0: lines_after.append("# ROS_MAINTAINER = \"" + maintainers[0] + "\"") del maintainers[0] for maintainer in maintainers: lines_after.append("# ROS_MAINTAINER += \"" + maintainer + "\"") lines_after.append("SECTION = \"devel\"") dependencies = xml.get_build_dependencies() if len(dependencies) > 0: lines_after.append("DEPENDS = \"" + dependencies[0] + "\"") del dependencies[0] for dependency in dependencies: lines_after.append("DEPENDS += \"" + dependency + "\"") dependencies = xml.get_runtime_dependencies() if len(dependencies) > 0: lines_after.append("RDEPENDS_${PN}-dev = \"" + dependencies[0] + "\"") del dependencies[0] for dependency in dependencies: lines_after.append("RDEPENDS_${PN}-dev += \"" + dependency + "\"") return True return False def register_recipe_handlers(handlers): """Register our recipe handler in front of default cmake handler. Catkin needs to be a higher priority than CMake (50). """ handlers.append((CatkinRecipeHandler(), 90))