Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
build/
_build/
_downloaded/
.idea/
.vscode/
__pycache__
Expand Down
66 changes: 65 additions & 1 deletion conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,21 @@
# documentation root, use os.path.abspath to make it absolute, like shown here.
#

import hashlib
import itertools
import os
from pathlib import Path
import re
import sys
import tempfile
import time
from typing import Dict
from typing import Text
import urllib.request

from docutils.parsers.rst import Directive
from sphinx.application import Sphinx
from sphinx.util import logging

sys.path.append(os.path.abspath('./sphinx-multiversion'))
sys.path.append(os.path.abspath('plugins'))
Expand Down Expand Up @@ -328,7 +334,8 @@ def expand_macros(app, docname, source):
result = expand_text_macros(result, app.config.macros)
source[0] = result

def setup(app):
def setup(app: Sphinx) -> None:
app.connect("builder-inited", download_files)
app.connect('config-inited', smv_rewrite_configs)
app.connect('html-page-context', github_link_rewrite_branch)
app.connect('source-read', expand_macros)
Expand Down Expand Up @@ -429,3 +436,60 @@ def expand_text_macros(text: Text, macros: Dict[Text, Text]) -> Text:
for key, value in macros.items():
text = text.replace(f'{{{key}}}', value)
return text


logger = logging.getLogger(__name__)


def _hash_file(path: Path) -> str:
"""Return SHA256 of a file."""
if not path.exists():
return ""
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()


def download_files(app: Sphinx) -> None:
distro: str = app.config.macros['DISTRO']

dl_dir = Path(app.srcdir) / "_downloaded" / distro
dl_dir.mkdir(parents=True, exist_ok=True)

files_to_download = {
"publisher_member_function.py": (
f"https://raw.githubusercontent.com/ros2/examples/{distro}/"
"rclpy/topics/minimal_publisher/examples_rclpy_minimal_publisher/"
"publisher_member_function.py"
),
"subscriber_member_function.py": (
f"https://raw.githubusercontent.com/ros2/examples/{distro}/"
"rclpy/topics/minimal_subscriber/examples_rclpy_minimal_subscriber/"
"subscriber_member_function.py"
),
}

for local_name, url in files_to_download.items():
local_path = dl_dir / local_name

with tempfile.NamedTemporaryFile(
delete=False, prefix=f"{local_name}_", suffix=".tmp"
) as tmp:
temp_path = Path(tmp.name)

logger.info(f"Checking for updates: {url}")
try:
urllib.request.urlretrieve(url, temp_path)
except Exception as e:
logger.warning(f"Failed to fetch {url}: {e}")
continue

# Compare hashes
old_hash = _hash_file(local_path)
new_hash = _hash_file(temp_path)

if old_hash != new_hash:
logger.info(f"Updating {local_name} (content changed)")
temp_path.replace(local_path)
else:
temp_path.unlink()
Original file line number Diff line number Diff line change
Expand Up @@ -92,71 +92,33 @@ Now there will be a new file named ``publisher_member_function.py`` adjacent to

Open the file using your preferred text editor.

.. code-block:: python

import rclpy
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node

from std_msgs.msg import String


class MinimalPublisher(Node):

def __init__(self):
super().__init__('minimal_publisher')
self.publisher_ = self.create_publisher(String, 'topic', 10)
timer_period = 0.5 # seconds
self.timer = self.create_timer(timer_period, self.timer_callback)
self.i = 0

def timer_callback(self):
msg = String()
msg.data = 'Hello World: %d' % self.i
self.publisher_.publish(msg)
self.get_logger().info('Publishing: "%s"' % msg.data)
self.i += 1


def main(args=None):
try:
with rclpy.init(args=args):
minimal_publisher = MinimalPublisher()

rclpy.spin(minimal_publisher)
except (KeyboardInterrupt, ExternalShutdownException):
pass


if __name__ == '__main__':
main()

.. literalinclude:: ../../_downloaded/{DISTRO}/publisher_member_function.py
:language: python
:lines: 15-50

2.1 Examine the code
~~~~~~~~~~~~~~~~~~~~

The first lines of code after the comments import ``rclpy`` so its ``Node`` class can be used.

.. code-block:: python

import rclpy
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node
.. literalinclude:: ../../_downloaded/{DISTRO}/publisher_member_function.py
:language: python
:lines: 15-17

The next statement imports the built-in string message type that the node uses to structure the data that it passes on the topic.

.. code-block:: python

from std_msgs.msg import String
.. literalinclude:: ../../_downloaded/{DISTRO}/publisher_member_function.py
:language: python
:lines: 19

These lines represent the node's dependencies.
Recall that dependencies have to be added to ``package.xml``, which you'll do in the next section.

Next, the ``MinimalPublisher`` class is created, which inherits from (or is a subclass of) ``Node``.

.. code-block:: python

class MinimalPublisher(Node):
.. literalinclude:: ../../_downloaded/{DISTRO}/publisher_member_function.py
:language: python
:lines: 22

Following is the definition of the class's constructor.
``super().__init__`` calls the ``Node`` class's constructor and gives it your node name, in this case ``minimal_publisher``.
Expand All @@ -167,39 +129,21 @@ Queue size is a required QoS (quality of service) setting that limits the amount
Next, a timer is created with a callback to execute every 0.5 seconds.
``self.i`` is a counter used in the callback.

.. code-block:: python

def __init__(self):
super().__init__('minimal_publisher')
self.publisher_ = self.create_publisher(String, 'topic', 10)
timer_period = 0.5 # seconds
self.timer = self.create_timer(timer_period, self.timer_callback)
self.i = 0
.. literalinclude:: ../../_downloaded/{DISTRO}/publisher_member_function.py
:language: python
:lines: 24-29

``timer_callback`` creates a message with the counter value appended, and publishes it to the console with ``get_logger().info``.

.. code-block:: python

def timer_callback(self):
msg = String()
msg.data = 'Hello World: %d' % self.i
self.publisher_.publish(msg)
self.get_logger().info('Publishing: "%s"' % msg.data)
self.i += 1
.. literalinclude:: ../../_downloaded/{DISTRO}/publisher_member_function.py
:language: python
:lines: 31-36

Lastly, the main function is defined.

.. code-block:: python

def main(args=None):
try:
with rclpy.init(args=args):
minimal_publisher = MinimalPublisher()

rclpy.spin(minimal_publisher)
except (KeyboardInterrupt, ExternalShutdownException):
pass

.. literalinclude:: ../../_downloaded/{DISTRO}/publisher_member_function.py
:language: python
:lines: 39-46

First the ``rclpy`` library is initialized, then the node is created, and then it "spins" the node so its callbacks are called.

Expand Down Expand Up @@ -315,73 +259,33 @@ Now the directory should have these files:

Open the ``subscriber_member_function.py`` with your text editor.

.. code-block:: python

import rclpy
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node

from std_msgs.msg import String


class MinimalSubscriber(Node):

def __init__(self):
super().__init__('minimal_subscriber')
self.subscription = self.create_subscription(
String,
'topic',
self.listener_callback,
10)
self.subscription # prevent unused variable warning

def listener_callback(self, msg):
self.get_logger().info('I heard: "%s"' % msg.data)


def main(args=None):
try:
with rclpy.init(args=args):
minimal_subscriber = MinimalSubscriber()

rclpy.spin(minimal_subscriber)
except (KeyboardInterrupt, ExternalShutdownException):
pass


if __name__ == '__main__':
main()
.. literalinclude:: ../../_downloaded/{DISTRO}/subscriber_member_function.py
:language: python
:lines: 15-48

The subscriber node's code is nearly identical to the publisher's.
The constructor creates a subscriber with the same arguments as the publisher.
Recall from the :doc:`topics tutorial <../Beginner-CLI-Tools/Understanding-ROS2-Topics/Understanding-ROS2-Topics>` that the topic name and message type used by the publisher and subscriber must match to allow them to communicate.

.. code-block:: python

self.subscription = self.create_subscription(
String,
'topic',
self.listener_callback,
10)
.. literalinclude:: ../../_downloaded/{DISTRO}/subscriber_member_function.py
:language: python
:lines: 26-31

The subscriber's constructor and callback don't include any timer definition, because it doesn't need one.
Its callback gets called as soon as it receives a message.

The callback definition simply prints an info message to the console, along with the data it received.
Recall that the publisher defines ``msg.data = 'Hello World: %d' % self.i``

.. code-block:: python

def listener_callback(self, msg):
self.get_logger().info('I heard: "%s"' % msg.data)
.. literalinclude:: ../../_downloaded/{DISTRO}/subscriber_member_function.py
:language: python
:lines: 33-34

The ``main`` definition is almost exactly the same, replacing the creation and spinning of the publisher with the subscriber.

.. code-block:: python

minimal_subscriber = MinimalSubscriber()

rclpy.spin(minimal_subscriber)
.. literalinclude:: ../../_downloaded/{DISTRO}/subscriber_member_function.py
:language: python
:lines: 40-42

Since this node has the same dependencies as the publisher, there's nothing new to add to ``package.xml``.
The ``setup.cfg`` file can also remain untouched.
Expand Down
37 changes: 37 additions & 0 deletions test/test_hash_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Copyright 2025 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.

import hashlib
import sys
import pathlib

# Workaround to be able to import conf without it being a proper module
sys.path.append('..')

from conf import _hash_file

def test_hash_file(tmp_path: pathlib.Path) -> None:
non_existent = tmp_path / "does_not_exist.txt"
assert _hash_file(non_existent) == ""

empty_file = tmp_path / "empty.txt"
empty_file.write_text("")
expected_empty_hash = hashlib.sha256(b"").hexdigest()
assert _hash_file(empty_file) == expected_empty_hash

content = b"Hello, World!"
known_file = tmp_path / "known.txt"
known_file.write_bytes(content)
expected_known_hash = hashlib.sha256(content).hexdigest()
assert _hash_file(known_file) == expected_known_hash