New plugin for generating catkin recipes

Currently the plugin only allow for processing a single
ROS package in a repository. If a repository contains more than
one package, or the package is not in the root of the repository,
then use the `--src-subdir=<dir>` option.

ROS repositories generally do not use `master` as their default
branch, so be sure to include the correct branch for the desired
distribution as part of the URI: `<URI>;branch=indigo-devel`
This commit is contained in:
Mark D Horn 2017-08-25 16:13:12 -07:00
parent 8dcc2ba0c5
commit f7dbc8bba0
3 changed files with 483 additions and 0 deletions

51
lib/recipetool/README.md Normal file
View File

@ -0,0 +1,51 @@
Extension to recipetool to enable automatic creation of
BitBake recipe files for ROS packages.
## REQUIREMENTS ##
Two changes to the recipetool are required to use this plugin:
Requires either:
From OE-Core rev: 1df60b09f7a60427795ec828c9c7180e4e52f98c
From OE-Core rev: 3bb979c13463705c4db6c59034661c4cd8100756
or
From poky rev: b1f237ebd0d4180c5d23a0ecd9aaf7193c63a48a
From poky rev: a7baa47c876c7895499909731aaa451c6009610a
These changes are currently in the master branch as of 2017-Aug-24.
## USAGE ##
Initialize the build environment:
source oe-init-build-env
Currently the plugin only allow for processing a single
ROS package in a repository. If a repository contains more than
one package, or the package is not in the root of the repository,
then use the `--src-subdir=<dir>` option.
ROS repositories generally do not use `master` as their default
branch, so be sure to include the correct branch for the desired
distribution as part of the URI: `<URI>;branch=indigo-devel`
## EXAMPLES ##
Build the Vector Nav package
```
devtool add https://github.com/dawonn/vectornav
```
Use the XBox Kinect Camera
```
devtool add --src-subdir=freenect_stack "https://github.com/ros-drivers/freenect_stack.git"
devtool add --src-subdir=freenect_launch "https://github.com/ros-drivers/freenect_stack.git"
devtool add --src-subdir=freenect_camera "https://github.com/ros-drivers/freenect_stack.git"
```
## TO DO ##
* Wrapper to generate recipes for each package in a repository.
* Add support for ament for ROS2 packages.
* Wrapper for using rosdistro data to generate recipes for various ROS distributions.

View File

@ -0,0 +1,3 @@
# Enable other layers to have modules in the same named directory
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)

View File

@ -0,0 +1,429 @@
#!/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))