From e04eb44b772ffc8909a5f8d89f782bde6454e092 Mon Sep 17 00:00:00 2001 From: Chen Bainian Date: Tue, 9 Jun 2026 17:31:28 +0800 Subject: [PATCH] feat(cli): add initial CLI package Signed-off-by: Chen Bainian --- rmfcli/colcon.pkg | 7 +++ rmfcli/completion/rmf-argcomplete.bash | 19 ++++++ rmfcli/completion/rmf-argcomplete.fish | 20 ++++++ rmfcli/completion/rmf-argcomplete.zsh | 25 ++++++++ rmfcli/package.xml | 27 ++++++++ rmfcli/resource/package.dsv | 3 + rmfcli/resource/rmfcli | 0 rmfcli/rmfcli/__init__.py | 0 rmfcli/rmfcli/cli.py | 61 +++++++++++++++++++ rmfcli/rmfcli/command/__init__.py | 22 +++++++ rmfcli/rmfcli/command/destination.py | 40 ++++++++++++ rmfcli/rmfcli/extensions/__init__.py | 17 ++++++ rmfcli/rmfcli/extensions/command_extension.py | 8 +++ rmfcli/rmfcli/extensions/extension_base.py | 43 +++++++++++++ rmfcli/rmfcli/extensions/utils.py | 49 +++++++++++++++ rmfcli/rmfcli/extensions/verb_extension.py | 29 +++++++++ rmfcli/rmfcli/py.typed | 0 rmfcli/rmfcli/verb/__init__.py | 0 rmfcli/rmfcli/verb/destination/__init__.py | 10 +++ rmfcli/rmfcli/verb/destination/dummy.py | 27 ++++++++ rmfcli/rmfcli/verb/destination/send.py | 28 +++++++++ rmfcli/setup.py | 50 +++++++++++++++ 22 files changed, 485 insertions(+) create mode 100644 rmfcli/colcon.pkg create mode 100644 rmfcli/completion/rmf-argcomplete.bash create mode 100644 rmfcli/completion/rmf-argcomplete.fish create mode 100644 rmfcli/completion/rmf-argcomplete.zsh create mode 100644 rmfcli/package.xml create mode 100644 rmfcli/resource/package.dsv create mode 100644 rmfcli/resource/rmfcli create mode 100644 rmfcli/rmfcli/__init__.py create mode 100644 rmfcli/rmfcli/cli.py create mode 100644 rmfcli/rmfcli/command/__init__.py create mode 100644 rmfcli/rmfcli/command/destination.py create mode 100644 rmfcli/rmfcli/extensions/__init__.py create mode 100644 rmfcli/rmfcli/extensions/command_extension.py create mode 100644 rmfcli/rmfcli/extensions/extension_base.py create mode 100644 rmfcli/rmfcli/extensions/utils.py create mode 100644 rmfcli/rmfcli/extensions/verb_extension.py create mode 100644 rmfcli/rmfcli/py.typed create mode 100644 rmfcli/rmfcli/verb/__init__.py create mode 100644 rmfcli/rmfcli/verb/destination/__init__.py create mode 100644 rmfcli/rmfcli/verb/destination/dummy.py create mode 100644 rmfcli/rmfcli/verb/destination/send.py create mode 100644 rmfcli/setup.py diff --git a/rmfcli/colcon.pkg b/rmfcli/colcon.pkg new file mode 100644 index 0000000..a860870 --- /dev/null +++ b/rmfcli/colcon.pkg @@ -0,0 +1,7 @@ +{ + "hooks": [ + "share/rmfcli/environment/rmf-argcomplete.bash", + "share/rmfcli/environment/rmf-argcomplete.fish", + "share/rmfcli/environment/rmf-argcomplete.zsh" + ] +} diff --git a/rmfcli/completion/rmf-argcomplete.bash b/rmfcli/completion/rmf-argcomplete.bash new file mode 100644 index 0000000..ec622ff --- /dev/null +++ b/rmfcli/completion/rmf-argcomplete.bash @@ -0,0 +1,19 @@ +# Copyright 2017-2026 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. + +if type register-python-argcomplete3 > /dev/null 2>&1; then + eval "$(register-python-argcomplete3 rmf)" +elif type register-python-argcomplete > /dev/null 2>&1; then + eval "$(register-python-argcomplete rmf)" +fi diff --git a/rmfcli/completion/rmf-argcomplete.fish b/rmfcli/completion/rmf-argcomplete.fish new file mode 100644 index 0000000..7947aa2 --- /dev/null +++ b/rmfcli/completion/rmf-argcomplete.fish @@ -0,0 +1,20 @@ +# Copyright 2017-2026 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. + +# Register rmf tab-completion for fish shell via argcomplete. +if type -q register-python-argcomplete + register-python-argcomplete --shell fish rmf | source +else if type -q register-python-argcomplete3 + register-python-argcomplete3 --shell fish rmf | source +end diff --git a/rmfcli/completion/rmf-argcomplete.zsh b/rmfcli/completion/rmf-argcomplete.zsh new file mode 100644 index 0000000..217f13c --- /dev/null +++ b/rmfcli/completion/rmf-argcomplete.zsh @@ -0,0 +1,25 @@ +# Copyright 2017-2018 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. + +if (( ! ${+_comps} )); then + autoload -U +X compinit && compinit +fi +autoload -U +X bashcompinit && bashcompinit + +# Get this scripts directory +__rmfcli_completion_dir=${0:a:h} +# Just source the bash version, it works in zsh too +source "$__rmfcli_completion_dir/rmf-argcomplete.bash" +# Cleanup +unset __rmfcli_completion_dir diff --git a/rmfcli/package.xml b/rmfcli/package.xml new file mode 100644 index 0000000..7201df4 --- /dev/null +++ b/rmfcli/package.xml @@ -0,0 +1,27 @@ + + + + rmfcli + 0.0.0 + + RMF command line tools. + + Chen Bainian + Apache License 2.0 + + Chen Bainian + + python3-argcomplete + rclpy + rmf_prototype_msgs + + ament_copyright + ament_flake8 + ament_pep257 + ament_xmllint + python3-pytest + + + ament_python + + diff --git a/rmfcli/resource/package.dsv b/rmfcli/resource/package.dsv new file mode 100644 index 0000000..d78e835 --- /dev/null +++ b/rmfcli/resource/package.dsv @@ -0,0 +1,3 @@ +source;share/rmfcli/environment/rmf-argcomplete.bash +source;share/rmfcli/environment/rmf-argcomplete.fish +source;share/rmfcli/environment/rmf-argcomplete.zsh diff --git a/rmfcli/resource/rmfcli b/rmfcli/resource/rmfcli new file mode 100644 index 0000000..e69de29 diff --git a/rmfcli/rmfcli/__init__.py b/rmfcli/rmfcli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rmfcli/rmfcli/cli.py b/rmfcli/rmfcli/cli.py new file mode 100644 index 0000000..8958105 --- /dev/null +++ b/rmfcli/rmfcli/cli.py @@ -0,0 +1,61 @@ +# Copyright 2026 Chen Bainian +# +# 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. + +from argparse import ArgumentParser, RawDescriptionHelpFormatter +from signal import SIGINT +from typing import Any, Optional + +from .command import extensions as command_extensions +from .extensions import ExtensionBase +from .extensions.utils import add_subparsers + + +def main( + *, script_name: str = "rmf", argv: Any = None, description: Optional[str] = None +) -> int | str | None: + if description is None: + description = f"{script_name} is an extensible command-line tool for RMF" + + # top level parser + parser = ArgumentParser(description=description, formatter_class=RawDescriptionHelpFormatter) + extension_key = "_command" + add_subparsers( + parser, script_name, extension_key, command_extensions, required=False, argv=argv + ) + + # register argcomplete hook if available + try: + from argcomplete import autocomplete + except ImportError: + pass + else: + autocomplete(parser, exclude=["-h", "--help"], always_complete_options=False) + + args = parser.parse_args(args=argv) + + extension: Optional[ExtensionBase] = getattr(args, extension_key, None) + + # handle the case that no command was passed + if extension is None: + parser.print_help() + return 0 + + # call the main method of the extension + try: + rc = extension.main(parser=parser, args=args) + except KeyboardInterrupt: + rc = SIGINT + except RuntimeError as e: + rc = str(e) + return rc diff --git a/rmfcli/rmfcli/command/__init__.py b/rmfcli/rmfcli/command/__init__.py new file mode 100644 index 0000000..b656c14 --- /dev/null +++ b/rmfcli/rmfcli/command/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2026 Chen Bainian +# +# 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. + +from collections import OrderedDict + +from rmfcli.extensions import ExtensionBase + +from .destination import DestinationCommand + +extensions: OrderedDict[str, ExtensionBase] = OrderedDict() +extensions["destination"] = DestinationCommand() diff --git a/rmfcli/rmfcli/command/destination.py b/rmfcli/rmfcli/command/destination.py new file mode 100644 index 0000000..74ede88 --- /dev/null +++ b/rmfcli/rmfcli/command/destination.py @@ -0,0 +1,40 @@ +# Copyright 2026 Chen Bainian +# +# 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. + +from argparse import ArgumentParser +from typing import Any + +from rmfcli.extensions import CommandExtension, VerbExtension +from rmfcli.extensions.utils import add_subparsers +from rmfcli.verb.destination import extensions as verb_extensions + + +class DestinationCommand(CommandExtension): + """Run Destination CLI sub-commands""" + + def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None: + self._subparser = parser + add_subparsers(parser, cli_name, "_verb", verb_extensions, required=False) + + def main(self, *, parser: ArgumentParser, args: Any) -> None | int: + if not hasattr(args, "_verb"): + # in case no verb was passed + self._subparser.print_help() + return 0 + + extension: VerbExtension = args._verb + + # call the verb's main method + rc: None | int = extension.main(parser=parser, args=args) + return rc diff --git a/rmfcli/rmfcli/extensions/__init__.py b/rmfcli/rmfcli/extensions/__init__.py new file mode 100644 index 0000000..ecc336b --- /dev/null +++ b/rmfcli/rmfcli/extensions/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Chen Bainian +# +# 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. + +from .command_extension import CommandExtension +from .extension_base import ExtensionBase +from .verb_extension import VerbExtension diff --git a/rmfcli/rmfcli/extensions/command_extension.py b/rmfcli/rmfcli/extensions/command_extension.py new file mode 100644 index 0000000..a969d19 --- /dev/null +++ b/rmfcli/rmfcli/extensions/command_extension.py @@ -0,0 +1,8 @@ +from .extension_base import ExtensionBase + + +class CommandExtension(ExtensionBase): + NAME = None + + def __init__(self) -> None: + super(CommandExtension, self).__init__() diff --git a/rmfcli/rmfcli/extensions/extension_base.py b/rmfcli/rmfcli/extensions/extension_base.py new file mode 100644 index 0000000..0f89b55 --- /dev/null +++ b/rmfcli/rmfcli/extensions/extension_base.py @@ -0,0 +1,43 @@ +# Copyright 2026 Chen Bainian +# +# 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.from argparse import ArgumentParser + +from argparse import ArgumentParser +from typing import Any + + +class ExtensionBase: + """ + The extension point for 'command' extensions. + + The following properties must be defined: + * `NAME` (will be set to the entry point name) + + The following methods must be defined: + * `main` + + The following methods can be defined: + * `add_arguments` + """ + + NAME = None + + def __init__(self) -> None: + super(ExtensionBase, self).__init__() + + def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None: + pass + + def main(self, *, parser: ArgumentParser, args: Any) -> int | None: + raise NotImplementedError() + pass diff --git a/rmfcli/rmfcli/extensions/utils.py b/rmfcli/rmfcli/extensions/utils.py new file mode 100644 index 0000000..c153668 --- /dev/null +++ b/rmfcli/rmfcli/extensions/utils.py @@ -0,0 +1,49 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter, _SubParsersAction +from collections import OrderedDict +from typing import Any, List + +from .extension_base import ExtensionBase + + +def get_first_line_doc(any_type: Any) -> str: + if not any_type.__doc__: + return "" + lines: List[str] = any_type.__doc__.splitlines() + if not lines: + return "" + if lines[0]: + line = lines[0] + elif len(lines) > 1: + line = lines[1] + return line.strip().rstrip(".") + + +def add_subparsers( + parser: ArgumentParser, + cli_name: str, + dest: str, + extensions: OrderedDict[str, ExtensionBase], + required: bool = True, + argv: Any = None, +) -> _SubParsersAction: + # Generate description + description = "" + max_length = max(len(name) for name in extensions.keys()) + for name, extension in extensions.items(): + description += "%s %s\n" % (name.ljust(max_length), get_first_line_doc(extension)) + + # Create subparser + subparsers = parser.add_subparsers( + title="Commands", + description=description, + metavar=f"Call `{cli_name} -h` for more detailed usage.", + ) + + subparsers.dest = " " + dest.lstrip("_") + subparsers.required = required + for name, extension in extensions.items(): + command_parser = subparsers.add_parser(name, formatter_class=RawDescriptionHelpFormatter) + extension.add_arguments(command_parser, name, argv=argv) + command_parser.set_defaults(**{dest: extension}) + + return subparsers diff --git a/rmfcli/rmfcli/extensions/verb_extension.py b/rmfcli/rmfcli/extensions/verb_extension.py new file mode 100644 index 0000000..636b8d0 --- /dev/null +++ b/rmfcli/rmfcli/extensions/verb_extension.py @@ -0,0 +1,29 @@ +# Copyright 2026 Chen Bainian +# +# 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. + +from argparse import ArgumentParser +from typing import Any + +from .extension_base import ExtensionBase + + +class VerbExtension(ExtensionBase): + NAME = None + + def __init__(self) -> None: + super(VerbExtension, self).__init__() + + def main(self, *, parser: ArgumentParser, args: Any) -> int | None: + raise NotImplementedError() + pass diff --git a/rmfcli/rmfcli/py.typed b/rmfcli/rmfcli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/rmfcli/rmfcli/verb/__init__.py b/rmfcli/rmfcli/verb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rmfcli/rmfcli/verb/destination/__init__.py b/rmfcli/rmfcli/verb/destination/__init__.py new file mode 100644 index 0000000..395b3fe --- /dev/null +++ b/rmfcli/rmfcli/verb/destination/__init__.py @@ -0,0 +1,10 @@ +from typing import OrderedDict + +from rmfcli.extensions import ExtensionBase + +from .send import SendVerb +from .dummy import DummyVerb + +extensions: OrderedDict[str, ExtensionBase] = OrderedDict() +extensions["send"] = SendVerb() +extensions["dummy"] = DummyVerb() diff --git a/rmfcli/rmfcli/verb/destination/dummy.py b/rmfcli/rmfcli/verb/destination/dummy.py new file mode 100644 index 0000000..4b56bdd --- /dev/null +++ b/rmfcli/rmfcli/verb/destination/dummy.py @@ -0,0 +1,27 @@ +# Copyright 2026 Chen Bainian +# +# 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. + +from argparse import ArgumentParser +from typing import Any + +from rmfcli.extensions import VerbExtension + +class DummyVerb(VerbExtension): + """Run dummy print script (example)""" + + def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None: + parser.add_argument("message", type=str, help="Message to print") + + def main(self, *, parser: ArgumentParser, args: Any) -> int | None: + print(args.message) diff --git a/rmfcli/rmfcli/verb/destination/send.py b/rmfcli/rmfcli/verb/destination/send.py new file mode 100644 index 0000000..ccfd3c7 --- /dev/null +++ b/rmfcli/rmfcli/verb/destination/send.py @@ -0,0 +1,28 @@ +# Copyright 2026 Chen Bainian +# +# 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. + +from argparse import ArgumentParser +from collections import OrderedDict +from typing import Any + +from rmfcli.extensions import VerbExtension + +class SendVerb(VerbExtension): + """Send Destination""" + + def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None: + pass + + def main(self, *, parser: ArgumentParser, args: Any) -> int | None: + pass diff --git a/rmfcli/setup.py b/rmfcli/setup.py new file mode 100644 index 0000000..3645751 --- /dev/null +++ b/rmfcli/setup.py @@ -0,0 +1,50 @@ +from setuptools import find_packages +from setuptools import setup + +setup( + name='rmfcli', + version='0.0.0', + packages=find_packages(exclude=['test']), + extras_require={ + 'completion': ['argcomplete'], + 'test': [ + 'pytest', + ], + }, + data_files=[ + ('share/ament_index/resource_index/packages', [ + 'resource/rmfcli', + ]), + ('share/rmfcli', [ + 'package.xml', + 'resource/package.dsv', + ]), + ('share/rmfcli/environment', [ + 'completion/rmf-argcomplete.bash', + 'completion/rmf-argcomplete.fish', + 'completion/rmf-argcomplete.zsh', + ]), + ], + package_data={'': ['py.typed']}, + zip_safe=False, + author='Chen Bainian', + author_email='chenbn@a-star.edu.sg', + maintainer='Chen Bainian', + maintainer_email='chenbn@a-star.edu.sg', + keywords=[], + classifiers=[ + 'Environment :: Console', + 'Intended Audience :: Developers', + 'Programming Language :: Python', + ], + description='RMF command line tools.', + long_description="""\ +The framework provides a single command line script which can be extended with +commands and verbs.""", + license='Apache License, Version 2.0', + entry_points={ + 'console_scripts': [ + 'rmf = rmfcli.cli:main', + ], + } +)