meta-ros/lib/recipetool/create_catkin.py

430 lines
16 KiB
Python
Raw Normal View History

#!/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 <description> 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))