Skip to content

Commit

Permalink
Refactor launch.frontend file loading (ros2#271)
Browse files Browse the repository at this point in the history
Signed-off-by: ivanpauno <[email protected]>
  • Loading branch information
ivanpauno authored and piraka9011 committed Aug 16, 2019
1 parent 2ee8d44 commit fd321ce
Show file tree
Hide file tree
Showing 10 changed files with 224 additions and 36 deletions.
2 changes: 2 additions & 0 deletions launch/launch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .condition import Condition
from .event import Event
from .event_handler import EventHandler
from .invalid_launch_file_error import InvalidLaunchFileError
from .launch_context import LaunchContext
from .launch_description import LaunchDescription
from .launch_description_entity import LaunchDescriptionEntity
Expand All @@ -47,6 +48,7 @@
'Condition',
'Event',
'EventHandler',
'InvalidLaunchFileError',
'LaunchContext',
'LaunchDescription',
'LaunchDescriptionEntity',
Expand Down
3 changes: 2 additions & 1 deletion launch/launch/frontend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
from . import type_utils
from .entity import Entity
from .expose import expose_action, expose_substitution
from .parser import Parser
from .parser import InvalidFrontendLaunchFileError, Parser


__all__ = [
'Entity',
'InvalidFrontendLaunchFileError',
'Parser',
'expose_action',
'expose_substitution',
Expand Down
82 changes: 68 additions & 14 deletions launch/launch/frontend/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
"""Module for Parser class and parsing methods."""

import io
import os
from typing import Any
from typing import List
from typing import Optional
from typing import Text
from typing import Tuple
from typing import Type
from typing import Union

from pkg_resources import iter_entry_points
Expand All @@ -27,6 +31,7 @@
from .parse_substitution import parse_substitution
from .parse_substitution import replace_escaped_characters
from ..action import Action
from ..invalid_launch_file_error import InvalidLaunchFileError
from ..some_substitutions_type import SomeSubstitutionsType
from ..utilities import is_a

Expand All @@ -39,6 +44,12 @@
from ..launch_description import LaunchDescription # noqa: F401


class InvalidFrontendLaunchFileError(InvalidLaunchFileError):
"""Exception raised when the given frontend launch file is not valid."""

...


class Parser:
"""
Abstract class for parsing launch actions, substitutions and descriptions.
Expand Down Expand Up @@ -90,28 +101,71 @@ def parse_description(self, entity: Entity) -> 'LaunchDescription':
actions = [self.parse_action(child) for child in entity.children]
return LaunchDescription(actions)

@classmethod
def get_available_extensions(cls) -> List[Text]:
"""Return the registered extensions."""
cls.load_parser_implementations()
return cls.frontend_parsers.keys()

@classmethod
def is_extension_valid(
cls,
extension: Text,
) -> bool:
"""Return an entity loaded with a markup file."""
cls.load_parser_implementations()
return extension in cls.frontend_parsers

@classmethod
def get_parser_from_extension(
cls,
extension: Text,
) -> Optional[Type['Parser']]:
"""Return an entity loaded with a markup file."""
cls.load_parser_implementations()
try:
return cls.frontend_parsers[extension]
except KeyError:
raise RuntimeError('Not recognized frontend implementation')

@classmethod
def load(
cls,
file: Union[str, io.TextIOBase],
) -> (Entity, 'Parser'):
"""Return an entity loaded with a markup file."""
"""
Parse an Entity from a markup language-based launch file.
Parsers are exposed and provided by available frontend implementations.
To choose the right parser, it'll first attempt to infer the launch
description format based on the filename extension, if any.
If format inference fails, it'll try all available parsers one after the other.
"""
# Imported here, to avoid recursive import.
cls.load_parser_implementations()

def get_key(extension):
def key(x):
return x[0] != extension
return key
exceptions = []
extension = ''
if is_a(file, str):
# This automatically recognizes the launch frontend markup
# from the extension.
frontend_name = file.rsplit('.', 1)[1]
if frontend_name in cls.frontend_parsers:
return cls.frontend_parsers[frontend_name].load(file)
# If not, apply brute force.
# TODO(ivanpauno): Maybe, we want to force correct file naming.
# In that case, we should raise an error here.
# TODO(ivanpauno): Recognize a wrong formatted file error from
# unknown front-end implementation error.
for implementation in cls.frontend_parsers.values():
extension = file
elif hasattr(file, 'name'):
extension = file.name
extension = os.path.splitext(extension)[1]
if extension:
extension = extension[1:]
for (frontend_name, implementation) in sorted(
cls.frontend_parsers.items(), key=get_key(extension)
):
try:
return implementation.load(file)
except Exception:
except Exception as ex:
if is_a(file, io.TextIOBase):
file.seek(0)
raise RuntimeError('Not recognized front-end implementation.')
else:
exceptions.append(ex)
extension = '' if not cls.is_extension_valid(extension) else extension
raise InvalidFrontendLaunchFileError(extension, likely_errors=exceptions)
41 changes: 41 additions & 0 deletions launch/launch/invalid_launch_file_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright 2019 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Implementation of `InvalidLaunchFileError` class."""


class InvalidLaunchFileError(Exception):
"""Exception raised when the given launch file is not valid."""

def __init__(self, extension='', *, likely_errors=None):
"""Constructor."""
self._extension = extension
self._likely_errors = likely_errors

def __str__(self):
"""Pretty print."""
if self._extension == '' or not self._likely_errors:
return 'The launch file may have a syntax error, or its format is unknown'
else:
# If `self.likely_errors[0]` is `RuntimeError('asd')`, the following will be printed:
# ```
# InvalidLaunchFileError: Failed to load file of format [<extension>]:
# RuntimeError: asd
# ```
return 'Failed to load file of format [{}]:\n\t{}: {}'.format(
self._extension,
# Convert `<class 'ERROR_NAME'>` to `ERROR_NAME`
str(self._likely_errors[0].__class__)[8:-2],
str(self._likely_errors[0])
)
8 changes: 8 additions & 0 deletions launch/launch/launch_description_sources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@
"""Package for launch_description_sources."""

from .any_launch_description_source import AnyLaunchDescriptionSource
from .any_launch_file_utilities import get_launch_description_from_any_launch_file
from .frontend_launch_description_source import FrontendLaunchDescriptionSource
from .frontend_launch_file_utilities import get_launch_description_from_frontend_launch_file
from .frontend_launch_file_utilities import InvalidFrontendLaunchFileError
from .python_launch_description_source import PythonLaunchDescriptionSource
from .python_launch_file_utilities import get_launch_description_from_python_launch_file
from .python_launch_file_utilities import InvalidPythonLaunchFileError
from .python_launch_file_utilities import load_python_launch_file_as_module
from ..invalid_launch_file_error import InvalidLaunchFileError

__all__ = [
'get_launch_description_from_any_launch_file',
'get_launch_description_from_python_launch_file',
'get_launch_description_from_frontend_launch_file',
'InvalidFrontendLaunchFileError',
'InvalidLaunchFileError',
'InvalidPythonLaunchFileError',
'load_python_launch_file_as_module',
'AnyLaunchDescriptionSource',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@

"""Module for the AnyLaunchDescriptionSource class."""

from .python_launch_file_utilities import get_launch_description_from_python_launch_file
from .python_launch_file_utilities import InvalidPythonLaunchFileError
from ..frontend import Parser
from .any_launch_file_utilities import get_launch_description_from_any_launch_file
from ..launch_description_source import LaunchDescriptionSource
from ..some_substitutions_type import SomeSubstitutionsType

Expand All @@ -25,8 +23,9 @@ class AnyLaunchDescriptionSource(LaunchDescriptionSource):
"""
Encapsulation of a launch file, which can be loaded during launch.
This launch description source will attempt to load the file at the given location as a python
launch file first, and as a declarative (markup based) launch file if the former fails.
This launch description source will attempt to load a launch file based on its extension
first, then it will try to load the file as a python launch file, and then as a declarative
(markup based) launch file.
It is recommended to use specific `LaunchDescriptionSource` subclasses when possible.
"""

Expand All @@ -51,16 +50,4 @@ def __init__(

def _get_launch_description(self, location):
"""Get the LaunchDescription from location."""
launch_description = None
try:
launch_description = get_launch_description_from_python_launch_file(location)
except (InvalidPythonLaunchFileError, SyntaxError):
pass
try:
root_entity, parser = Parser.load(location)
launch_description = parser.parse_description(root_entity)
except RuntimeError:
pass
if launch_description is None:
raise RuntimeError('Cannot load launch file')
return launch_description
return get_launch_description_from_any_launch_file(location)
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright 2019 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Python package utility functions related to loading Frontend Launch Files."""

import os
from typing import Text
from typing import Type

from .frontend_launch_file_utilities import get_launch_description_from_frontend_launch_file
from .python_launch_file_utilities import get_launch_description_from_python_launch_file
from ..frontend import Parser
from ..invalid_launch_file_error import InvalidLaunchFileError
from ..launch_description import LaunchDescription


def get_launch_description_from_any_launch_file(
launch_file_path: Text,
*,
parser: Type[Parser] = Parser
) -> LaunchDescription:
"""
Load a given launch file (by path), and return the launch description from it.
:raise `InvalidLaunchFileError`: Failed to load launch file.
It's only showed with launch files without extension (or not recognized extensions).
:raise `SyntaxError`: Invalid file. The file may have a syntax error in it.
:raise `ValueError`: Invalid file. The file may not be a text file.
"""
loaders = [get_launch_description_from_frontend_launch_file]
extension = os.path.splitext(launch_file_path)[1]
if extension:
extension = extension[1:]
if extension == 'py':
loaders.insert(0, get_launch_description_from_python_launch_file)
else:
loaders.append(get_launch_description_from_python_launch_file)
extension = '' if not Parser.is_extension_valid(extension) else extension
exceptions = []
for loader in loaders:
try:
return loader(launch_file_path)
except Exception as ex:
exceptions.append(ex)
raise InvalidLaunchFileError(extension, likely_errors=exceptions)
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from typing import Type

from .frontend_launch_file_utilities import get_launch_description_from_frontend_launch_file
from ..frontend import Parser
from ..launch_description_source import LaunchDescriptionSource
from ..some_substitutions_type import SomeSubstitutionsType
Expand Down Expand Up @@ -51,8 +52,8 @@ def __init__(
launch_file_path,
method
)
self._parser = Parser

def _get_launch_description(self, location):
"""Get the LaunchDescription from location."""
root_entity, parser = Parser.load(location)
return parser.parse_description(root_entity)
return get_launch_description_from_frontend_launch_file(location, parser=self._parser)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2019 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Python package utility functions related to loading Frontend Launch Files."""

from typing import Text
from typing import Type

from ..frontend import InvalidFrontendLaunchFileError
from ..frontend import Parser
from ..launch_description import LaunchDescription

# Re-export name
InvalidFrontendLaunchFileError = InvalidFrontendLaunchFileError


def get_launch_description_from_frontend_launch_file(
frontend_launch_file_path: Text,
*,
parser: Type[Parser] = Parser
) -> LaunchDescription:
"""Load a `LaunchDescription` from a declarative (markup based) launch file."""
root_entity, parser = parser.load(frontend_launch_file_path)
return parser.parse_description(root_entity)
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@ def __init__(
launch_file_path,
'interpreted python launch file'
)
self._get_launch_description = get_launch_description_from_python_launch_file

def _get_launch_description(self, location):
"""Get the LaunchDescription from location."""
return get_launch_description_from_python_launch_file(location)

0 comments on commit fd321ce

Please sign in to comment.