From a19dbfc238010bce42bdcb44159ebd85fbc53517 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:49:24 -0400 Subject: [PATCH] Add example for migrating a Python package from ROS 1 to 2 (#4780) (#4817) * Add tutorial for migrating a Python package from ROS 1 to 2 Signed-off-by: Shane Loretz Co-authored-by: Chris Lalancette (cherry picked from commit d9391f3d13720488f35908349618e187ed8bd720) Co-authored-by: Shane Loretz --- source/How-To-Guides/Migrating-from-ROS1.rst | 1 + .../Migrating-CPP-Package-Example.rst | 4 +- .../Migrating-Python-Package-Example.rst | 863 ++++++++++++++++++ .../Migrating-Python-Packages.rst | 7 +- 4 files changed, 871 insertions(+), 4 deletions(-) create mode 100644 source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Package-Example.rst diff --git a/source/How-To-Guides/Migrating-from-ROS1.rst b/source/How-To-Guides/Migrating-from-ROS1.rst index 91e298d1af..6b095ca63f 100644 --- a/source/How-To-Guides/Migrating-from-ROS1.rst +++ b/source/How-To-Guides/Migrating-from-ROS1.rst @@ -12,6 +12,7 @@ If you are new to porting between ROS 1 and ROS 2, it is recommended to read thr Migrating-from-ROS1/Migrating-Interfaces Migrating-from-ROS1/Migrating-CPP-Package-Example Migrating-from-ROS1/Migrating-CPP-Packages + Migrating-from-ROS1/Migrating-Python-Package-Example Migrating-from-ROS1/Migrating-Python-Packages Migrating-from-ROS1/Migrating-Launch-Files Migrating-from-ROS1/Migrating-Parameters diff --git a/source/How-To-Guides/Migrating-from-ROS1/Migrating-CPP-Package-Example.rst b/source/How-To-Guides/Migrating-from-ROS1/Migrating-CPP-Package-Example.rst index 68038da8e0..5cdb8ff0cc 100644 --- a/source/How-To-Guides/Migrating-from-ROS1/Migrating-CPP-Package-Example.rst +++ b/source/How-To-Guides/Migrating-from-ROS1/Migrating-CPP-Package-Example.rst @@ -43,7 +43,7 @@ The files have the following content: talker 0.0.0 talker - Brian Gerkey + Brian Gerkey Apache-2.0 catkin roscpp @@ -304,7 +304,7 @@ Your ``package.xml`` now looks like this: talker 0.0.0 talker - Brian Gerkey + Brian Gerkey Apache-2.0 ament_cmake rclcpp diff --git a/source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Package-Example.rst b/source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Package-Example.rst new file mode 100644 index 0000000000..d4230c615c --- /dev/null +++ b/source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Package-Example.rst @@ -0,0 +1,863 @@ +Migrating a Python Package Example +================================== + +This guide shows how to migrate an example Python package from ROS 1 to ROS 2. + +.. contents:: Table of Contents + :depth: 2 + :local: + +Prerequisites +------------- + +You need a working ROS 2 installation, such as :doc:`ROS {DISTRO} <../../Installation>`. + +The ROS 1 code +-------------- + +You won't be using `catkin `__ in this guide, so you don't need a working ROS 1 installation. +You are going to use ROS 2's build tool `Colcon `__ instead. + +This section gives you the code for a ROS 1 Python package. +The package is called ``talker_py``, and it has one node called ``talker_py_node``. +To make it easier to run Colcon later, these instructions make you create the package inside a `Colcon workspace `__, + +First, create a folder at ``~/ros2_talker_py`` to be the root of the Colcon workspace. + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + mkdir -p ~/ros2_talker_py/src + + .. group-tab:: macOS + + .. code-block:: bash + + mkdir -p ~/ros2_talker_py/src + + .. group-tab:: Windows + + .. code-block:: bash + + md \ros2_talker_py\src + +Next, create the files for the ROS 1 package. + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + cd ~/ros2_talker_py + mkdir -p src/talker_py/src/talker_py + mkdir -p src/talker_py/scripts + touch src/talker_py/package.xml + touch src/talker_py/CMakeLists.txt + touch src/talker_py/src/talker_py/__init__.py + touch src/talker_py/scripts/talker_py_node + touch src/talker_py/setup.py + + .. group-tab:: macOS + + .. code-block:: bash + + cd ~/ros2_talker_py + mkdir -p src/talker_py/src/talker_py + mkdir -p src/talker_py/scripts + touch src/talker_py/package.xml + touch src/talker_py/CMakeLists.txt + touch src/talker_py/src/talker_py/__init__.py + touch src/talker_py/scripts/talker_py_node + touch src/talker_py/setup.py + + .. group-tab:: Windows + + .. code-block:: bash + + cd \ros2_talker_py + md src\talker_py\src\talker_py + md src\talker_py\scripts + type nul > src\talker_py\package.xml + type nul > src\talker_py\CMakeLists.txt + type nul > src\talker_py\src\talker_py\__init__.py + type nul > src\talker_py\scripts/talker_py_node + type nul > src\talker_py\setup.py + +Put the following content into each file. + +``src/talker_py/package.xml``: + +.. code-block:: xml + + + + + talker_py + 1.0.0 + The talker_py package + Brian Gerkey + BSD + + catkin + + rospy + std_msgs + + +``src/talker_py/CMakeLists.txt``: + +.. code-block:: cmake + + cmake_minimum_required(VERSION 3.0.2) + project(talker_py) + + find_package(catkin REQUIRED) + + catkin_python_setup() + + catkin_package() + + catkin_install_python(PROGRAMS + scripts/talker_py_node + DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION} + ) + +``src/talker/src/talker_py/__init__.py``: + +.. code-block:: Python + + import rospy + from std_msgs.msg import String + + def main(): + rospy.init_node('talker') + pub = rospy.Publisher('chatter', String, queue_size=10) + rate = rospy.Rate(10) # 10hz + while not rospy.is_shutdown(): + hello_str = "hello world %s" % rospy.get_time() + rospy.loginfo(hello_str) + pub.publish(hello_str) + rate.sleep() + +``src/talker_py/scripts/talker_py_node``: + +.. code-block:: Python + + #!/usr/bin/env python + + import talker_py + + if __name__ == '__main__': + talker_py.main() + +``src/talker_py/setup.py``: + +.. code-block:: Python + + from setuptools import setup + from catkin_pkg.python_setup import generate_distutils_setup + + setup_args = generate_distutils_setup( + packages=['talker_py'], + package_dir={'': 'src'} + ) + + setup(**setup_args) + +This is the complete ROS 1 Python package. + +Migrate the ``package.xml`` +--------------------------- + +When migrating packages to ROS 2, migrate the build system files first so that you can check your work by building and running code as you go. +Always start by migrating your ``package.xml``. + +First, ROS 2 does not use ``catkin``. +Delete the ```` on it. + +.. code-block:: + + + catkin + + +Next, ROS 2 uses ``rclpy`` instead of ``rospy``. +Delete the dependency on ``rospy``. + +.. code-block:: + + + rospy + + +Replace it with a new dependency on ``rclpy``. + +.. code-block:: xml + + rclpy + +Add an ```` section to tell ROS 2's build tool `Colcon `__ that this is an ``ament_python`` package instead of a ``catkin`` package. + +.. code-block:: xml + + + ament_python + + + +Your ``package.xml`` is fully migrated. +It should now look like this: + +.. code-block:: xml + + + + + talker_py + 1.0.0 + The talker_py package + Brian Gerkey + BSD + + rclpy + std_msgs + + + ament_python + + + +Delete the ``CMakeLists.txt`` +----------------------------- + +Python packages in ROS 2 do not use CMake, so delete the ``CMakeLists.txt``. + +Migrate the ``setup.py`` +------------------------ + +The arguments to ``setup()`` in the ``setup.py`` can no longer be automatically generated with ``catkin_pkg``. +You must pass these arguments manually, which means there will be some duplication with your ``package.xml``. + +Start by deleting the import from ``catkin_pkg``. + +.. code-block:: + + # Delete this + from catkin_pkg.python_setup import generate_distutils_setup + +Move all arguments given to ``generate_distutils_setup()`` to the call to ``setup()``, and then add the ``install_requires`` and ``zip_safe`` arguments. +Your call to ``setup()`` should look like this: + +.. code-block:: Python + + setup( + packages=['talker_py'], + package_dir={'': 'src'}, + install_requires=['setuptools'], + zip_safe=True, + ) + +Delete the call to ``generate_distutils_setup()``. + +.. code-block:: + + # Delete this + setup_args = generate_distutils_setup( + packages=['talker_py'], + package_dir={'': 'src'} + ) + +The call to ``setup()`` needs some `additional metadata `__ copied from the ``package.xml``: + +* package name via the ``name`` argument +* package version via the ``version`` argument +* maintainer via the ``maintainer`` and ``maintainer_email`` arguments +* description via the ``description`` argument +* license via the ``license`` argument + +The package name will be used multiple times. +Create a variable called ``package_name`` above the call to ``setup()``. + +.. code-block:: Python + + package_name = 'talker_py' + +Copy all of the remaining information into the arguments of ``setup()`` in ``setup.py``. +Your call to ``setup()`` should look like this: + +.. code-block:: Python + + setup( + name=package_name, + version='1.0.0', + install_requires=['setuptools'], + zip_safe=True, + packages=['talker_py'], + package_dir={'': 'src'}, + maintainer='Brian Gerkey', + maintainer_email='gerkey@example.com', + description='The talker_py package', + license='BSD', + ) + + +ROS 2 packages must install two data files: + +* a ``package.xml`` +* a package marker file + +Your package already has a ``package.xml``. +It describes your package's dependencies. +A package marker file tells tools like ``ros2 run`` where to find your package. + +Create a directory next to the ``package.xml`` called ``resource``. +Create an empty file in the ``resource`` directory with the same name as the package. + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + mkdir resource + touch resource/talker_py + + .. group-tab:: macOS + + .. code-block:: bash + + mkdir resource + touch resource/talker_py + + .. group-tab:: Windows + + .. code-block:: bash + + md resource + type nul > resource\talker_py + +The ``setup()`` call in ``setup.py`` must tell ``setuptools`` how to install these files. +Add the following ``data_files`` argument to the call to ``setup()``. + +.. code-block:: Python + + data_files=[ + ('share/ament_index/resource_index/packages', + ['resource/' + package_name]), + ('share/' + package_name, ['package.xml']), + ], + +Your ``setup.py`` is almost complete. + +Migrate Python scripts and create ``setup.cfg`` +----------------------------------------------- + +ROS 2 Python packages uses ``console_scripts`` `entry points `__ to install Python scripts as executables. +The `configuration file `__ ``setup.cfg`` tells ``setuptools`` to install those executables in a package specific directory so that tools like ``ros2 run`` can find them. +Create a ``setup.cfg`` file next to the ``package.xml``. + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + touch setup.cfg + + .. group-tab:: macOS + + .. code-block:: bash + + touch setup.cfg + + .. group-tab:: Windows + + .. code-block:: bash + + type nul > touch setup.cfg + +Put the following content into it: + +.. code-block:: + + [develop] + script_dir=$base/lib/talker_py + [install] + install_scripts=$base/lib/talker_py + +You'll need to use the ``console_scripts`` entry point to define the executables to be installed. +Each entry has the format ``executable_name = some.module:function``. +The first part specifies the name of the executable to create. +The second part specifies the function that should be run when the executable starts. +This package needs to create an executable called ``talker_py_node``, and the executable needs to call the function ``main`` in the ``talker_py`` module. +Add the following entry point specification as another argument to ``setup()`` in your ``setup.py``. + +.. code-block:: Python + + entry_points={ + 'console_scripts': [ + 'talker_py_node = talker_py:main', + ], + }, + +The ``talker_py_node`` file is no longer necessary. +Delete the file ``talker_py_node`` and delete the ``scripts/`` directory. + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + rm scripts/talker_py_node + rmdir scripts + + .. group-tab:: macOS + + .. code-block:: bash + + rm scripts/talker_py_node + rmdir scripts + + .. group-tab:: Windows + + .. code-block:: bash + + del scripts/talker_py_node + rd scripts + +The addition of ``console_scripts`` is the last change to your ``setup.py``. +Your final ``setup.py`` should look like this: + +.. code-block:: Python + + from setuptools import setup + + package_name = 'talker_py' + + setup( + name=package_name, + version='1.0.0', + packages=['talker_py'], + package_dir={'': 'src'}, + install_requires=['setuptools'], + zip_safe=True, + data_files=[ + ('share/ament_index/resource_index/packages', + ['resource/' + package_name]), + ('share/' + package_name, ['package.xml']), + ], + maintainer='Brian Gerkey', + maintainer_email='gerkey@example.com', + description='The talker_py package', + license='BSD', + entry_points={ + 'console_scripts': [ + 'talker_py_node = talker_py:main', + ], + }, + ) + +Migrate Python code in ``src/talker_py/__init__.py`` +---------------------------------------------------- + +ROS 2 changed a lot of the best practices for Python code. +Start by migrating the code as-is. +It will be easier to refactor code later after you have something working. + +Use ``rclpy`` instead of ``rospy`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +ROS 2 packages use `rclpy `__ instead of ``rospy``. +You must do two things to use ``rclpy``: + + 1. Import ``rclpy`` + 2. Initialize ``rclpy`` + +Remove the statement that imports ``rospy``. + +.. code-block:: Python + + # Remove this + import rospy + +Rplace it with a statement that imports ``rclpy``. + +.. code-block:: Python + + import rclpy + +Add a call to ``rclpy.init()`` as the very first statement in the ``main()`` function. + +.. code-block:: Python + + def main(): + # Add this line + rclpy.init() + +Execute callbacks in the background +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Both ROS 1 and ROS 2 use `callbacks `__. +In ROS 1, callbacks are always executed in background threads, and users are free to block the main thread with calls like ``rate.sleep()``. +In ROS 2, ``rclpy`` uses :doc:`Executors <../../Concepts/Intermediate/About-Executors>` to give users more control over where callbacks are called. +When porting code that uses blocking calls like ``rate.sleep()``, you must make sure that those calls won't interfere with the executor. +One way to do this is to create a dedicated thread for the executor. + +First, add these two import statements. + +.. code-block:: Python + + import threading + + from rclpy.executors import ExternalShutdownException + +Next, add top-level function called ``spin_in_background()``. +This function asks the default executor to execute callbacks until something shuts it down. + +.. code-block:: Python + + def spin_in_background(): + executor = rclpy.get_global_executor() + try: + executor.spin() + except ExternalShutdownException: + pass + +Add the following code in the ``main()`` function just after the call to ``rclpy.init()`` to start a thread that calls ``spin_in_background()``. + +.. code-block:: Python + + # In rospy callbacks are always called in background threads. + # Spin the executor in another thread for similar behavior in ROS 2. + t = threading.Thread(target=spin_in_background) + t.start() + + +Finally, join the thread when the program ends by putting this statement at the bottom of the ``main()`` function. + +.. code-block:: Python + + t.join() + + +Create a node +~~~~~~~~~~~~~ + +In ROS 1, Python scripts can only create a single node per process, and the API ``init_node()`` creates it. +In ROS 2, a single Python script may create multiple nodes, and the API to create a node is named ``create_node``. + +Remove the call to ``rospy.init_node()``: + +.. code-block:: + + rospy.init_node('talker') + +Add a new call to ``rclpy.create_node()`` and store the result in a variable named ``node``: + +.. code-block:: Python + + node = rclpy.create_node('talker') + +We must tell the executor about this node. +Add the following line just below the creation of the node: + +.. code-block:: Python + + rclpy.get_global_executor().add_node(node) + +Create a publisher +~~~~~~~~~~~~~~~~~~ + +In ROS 1, users create publishers by instantiating the ``Publisher`` class. +In ROS 2, users create publishers through a node's ``create_publisher()`` API. +The ``create_publisher()`` API has an unfortunate difference with ROS 1: the topic name and topic type arguments are swapped. + +Remove the creation of the ``rospy.Publisher`` instance. + +.. code-block:: + + pub = rospy.Publisher('chatter', String, queue_size=10) + +Replace it with a call to ``node.create_publisher()``. + +.. code-block:: Python + + pub = node.create_publisher(String, 'chatter', 10) + + +Create a rate +~~~~~~~~~~~~~ + +In ROS 1, users create ``Rate`` instances directly, while in ROS 2 users create them through a node's ``create_rate()`` API. + +Remove the creation of the ``rospy.Rate`` instance. + +.. code-block:: + + rate = rospy.Rate(10) # 10hz + +Replace it with a call to ``node.create_rate()``. + +.. code-block:: Python + + rate = node.create_rate(10) # 10hz + +Loop on ``rclpy.ok()`` +~~~~~~~~~~~~~~~~~~~~~~ + +In ROS 1, the ``rospy.is_shutdown()`` API indicates if the process has been asked to shutdown. +In ROS 2, the ``rclpy.ok()`` API does this. + +Remove the statement ``not rospy.is_shutdown()`` + +.. code-block:: + + while not rospy.is_shutdown(): + +Replace it with a call to ``rclpy.ok()``. + +.. code-block:: Python + + while rclpy.ok(): + + +Create a ``String`` message with the current time +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You must make a few changes to this line + +.. code-block:: + + hello_str = "hello world %s" % rospy.get_time() + +In ROS 2 you: + +* Must get the time from a ``Clock`` instance +* Should format the ``str`` data using `f-strings `__ since `% is discouraged in active Python versions `__ +* Must instantiate a ``std_msgs.msg.String`` instance + +Start with getting the time. +ROS 2 nodes have a ``Clock`` instance. +Replace the call to ``rospy.get_time()`` with ``node.get_clock().now()`` to get the current time from the node's clock. + +Next, replace the use of ``%`` with an f-string: ``f'hello world {node.get_clock().now()}'``. + +Finally, instantiate a ``std_msgs.msg.String()`` instance and assign the above to the ``data`` attribute of that instance. +Your final code should look like this: + +.. code-block:: Python + + hello_str = String() + hello_str.data = f'hello world {node.get_clock().now()}' + +Log an informational message +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In ROS 2, you must send log messages through a ``Logger`` instance, and the node has one. + +Remove the call to ``rospy.loginfo()``. + +.. code-block:: + + rospy.loginfo(hello_str) + +Replace it with a call to ``info()`` on the node's ``Logger`` instance. + +.. code-block:: Python + + node.get_logger().info(hello_str.data) + +This is the last change to ``src/talker_py/__init__.py``. +Your file should look like the following: + +.. code-block:: Python + + import threading + + import rclpy + from rclpy.executors import ExternalShutdownException + from std_msgs.msg import String + + + def spin_in_background(): + executor = rclpy.get_global_executor() + try: + executor.spin() + except ExternalShutdownException: + pass + + + def main(): + rclpy.init() + # In rospy callbacks are always called in background threads. + # Spin the executor in another thread for similar behavior in ROS 2. + t = threading.Thread(target=spin_in_background) + t.start() + + node = rclpy.create_node('talker') + rclpy.get_global_executor().add_node(node) + pub = node.create_publisher(String, 'chatter', 10) + rate = node.create_rate(10) # 10hz + + while rclpy.ok(): + hello_str = String() + hello_str.data = f'hello world {node.get_clock().now()}' + node.get_logger().info(hello_str.data) + pub.publish(hello_str) + rate.sleep() + + t.join() + + +Build and run ``talker_py_node`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create three terminals: + +1. One to build ``talker_py`` +2. One to run ``talker_py_node`` +3. One to echo the message published by ``talker_py_node`` + +Build the workspace in the first terminal. + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + cd ~/ros2_talker_py + . /opt/ros/{DISTRO}/setup.bash + colcon build + + .. group-tab:: macOS + + .. code-block:: bash + + cd ~/ros2_talker_py + . /opt/ros/{DISTRO}/setup.bash + colcon build + + .. group-tab:: Windows + + .. code-block:: bash + + cd \ros2_talker_py + call C:\dev\ros2\local_setup.bat + colcon build + +Source your workspace in the second terminal, and run the ``talker_py_node``. + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + cd ~/ros2_talker_py + . install/setup.bash + ros2 run talker_py talker_py_node + + .. group-tab:: macOS + + .. code-block:: bash + + cd ~/ros2_talker_py + . install/setup.bash + ros2 run talker_py talker_py_node + + .. group-tab:: Windows + + .. code-block:: bash + + cd \ros2_talker_py + call install\setup.bat + ros2 run talker_py talker_py_node + +Echo the message published by the node in the third terminal: + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + . /opt/ros/{DISTRO}/setup.bash + ros2 topic echo /chatter + + .. group-tab:: macOS + + .. code-block:: bash + + . /opt/ros/{DISTRO}/setup.bash + ros2 topic echo /chatter + + .. group-tab:: Windows + + .. code-block:: bash + + call C:\dev\ros2\local_setup.bat + ros2 topic echo /chatter + + +You should see messages with the current time being published in the second terminal, and those same messages received in the third. + +Refactor code to use ROS 2 convensions +-------------------------------------- + +You have successfully migrated a ROS 1 Python package to ROS 2! +Now that you have something working, consider refactoring it to align better with ROS 2's Python APIs. +Follow these two principles. + +* Create a class that inherits from ``Node``. +* Do all work in callbacks, and never block those callbacks. + +For example, create a ``Talker`` class that inherits from ``Node``. +As for doing work in callbacks, use a ``Timer`` with a callback instead of ``rate.sleep()``. +Make the timer callback publish the message and return. +Make ``main()`` create a ``Talker`` instance rather than using ``rclpy.create_node()``, and give the executor the main thread to execute in. + +Your refactored code might look like this: + +.. code-block:: Python + + import rclpy + from rclpy.node import Node + from rclpy.executors import ExternalShutdownException + from std_msgs.msg import String + + + class Talker(Node): + + def __init__(self, **kwargs): + super().__init__('talker', **kwargs) + + self._pub = self.create_publisher(String, 'chatter', 10) + self._timer = self.create_timer(1 / 10, self.do_publish) + + def do_publish(self): + hello_str = String() + hello_str.data = f'hello world {self.get_clock().now()}' + self.get_logger().info(hello_str.data) + self._pub.publish(hello_str) + + + def main(): + rclpy.init() + try: + rclpy.spin(Talker()) + except (ExternalShutdownException, KeyboardInterrupt): + pass + finally: + rclpy.try_shutdown() + +Conclusion +---------- + +You have learned how to migrate an example Python ROS 1 package to ROS 2. +From now on, refer to the :doc:`Migrating Python Packages reference page <./Migrating-Python-Packages>` as you migrate your own Python packages. diff --git a/source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Packages.rst b/source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Packages.rst index c280e6b845..8e2d20780b 100644 --- a/source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Packages.rst +++ b/source/How-To-Guides/Migrating-from-ROS1/Migrating-Python-Packages.rst @@ -3,8 +3,11 @@ Migration-Guide-Python The-ROS2-Project/Contributing/Migration-Guide-Python -Migrating Python Packages -========================= +Migrating Python Packages Reference +=================================== + +This page is a reference on how to migrate Python packages from ROS 1 to ROS 2. +If this is your first time migrating a Python package, then follow :doc:`this guide to migrate an example Python package <./Migrating-Python-Package-Example>` first. .. contents:: Table of Contents :depth: 2