From 28cf699b479d379ad94f5505e44c049f1cdf7284 Mon Sep 17 00:00:00 2001 From: Michael Carlstrom Date: Tue, 2 Sep 2025 20:33:19 -0700 Subject: [PATCH 1/5] download examples Signed-off-by: Michael Carlstrom --- .gitignore | 1 + conf.py | 64 ++++++- ...g-A-Simple-Py-Publisher-And-Subscriber.rst | 162 ++++-------------- test/test_hash_file.py | 37 ++++ 4 files changed, 134 insertions(+), 130 deletions(-) create mode 100644 test/test_hash_file.py diff --git a/.gitignore b/.gitignore index 55a64e39764..4e76cb4355c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/ _build/ +_downloaded/ .idea/ .vscode/ __pycache__ diff --git a/conf.py b/conf.py index 1754ea079e9..7a1e2680c37 100644 --- a/conf.py +++ b/conf.py @@ -17,15 +17,20 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # +import hashlib import itertools import os 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')) @@ -328,7 +333,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) @@ -429,3 +435,59 @@ 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: str) -> str: + """Return SHA256 of a file.""" + if not os.path.exists(path): + return "" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +def download_files(app: Sphinx) -> None: + distro = app.config.macros['DISTRO'] + + srcdir = app.srcdir + dl_dir = os.path.join(srcdir, "_downloaded", distro) + os.makedirs(dl_dir, 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 = os.path.join(dl_dir, local_name) + temp_path = os.path.join(tempfile.gettempdir(), f"{local_name}.tmp") + + 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)") + os.replace(temp_path, local_path) + else: + os.remove(temp_path) \ No newline at end of file diff --git a/source/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Py-Publisher-And-Subscriber.rst b/source/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Py-Publisher-And-Subscriber.rst index 83f01ab0f50..a4d5f986930 100644 --- a/source/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Py-Publisher-And-Subscriber.rst +++ b/source/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Py-Publisher-And-Subscriber.rst @@ -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``. @@ -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. @@ -315,54 +259,17 @@ 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. @@ -370,18 +277,15 @@ 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. diff --git a/test/test_hash_file.py b/test/test_hash_file.py new file mode 100644 index 00000000000..860799be610 --- /dev/null +++ b/test/test_hash_file.py @@ -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(str(non_existent)) == "" + + empty_file = tmp_path / "empty.txt" + empty_file.write_text("") + expected_empty_hash = hashlib.sha256(b"").hexdigest() + assert _hash_file(str(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(str(known_file)) == expected_known_hash From f8881386ebe817197afe4d98ad2cebe1e357dec7 Mon Sep 17 00:00:00 2001 From: Michael Carlstrom Date: Tue, 2 Sep 2025 22:58:16 -0700 Subject: [PATCH 2/5] Fix lint Signed-off-by: Michael Carlstrom --- conf.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/conf.py b/conf.py index 7a1e2680c37..ba8d74054ef 100644 --- a/conf.py +++ b/conf.py @@ -473,7 +473,11 @@ def download_files(app: Sphinx) -> None: for local_name, url in files_to_download.items(): local_path = os.path.join(dl_dir, local_name) - temp_path = os.path.join(tempfile.gettempdir(), f"{local_name}.tmp") + + with tempfile.NamedTemporaryFile( + delete=False, prefix=f"{local_name}_", suffix=".tmp" + ) as tmp: + temp_path = tmp.name logger.info(f"Checking for updates: {url}") try: @@ -490,4 +494,4 @@ def download_files(app: Sphinx) -> None: logger.info(f"Updating {local_name} (content changed)") os.replace(temp_path, local_path) else: - os.remove(temp_path) \ No newline at end of file + os.remove(temp_path) From 20b7d366d64592469f8a14f5ac2fade7cae3fb1a Mon Sep 17 00:00:00 2001 From: Michael Carlstrom Date: Tue, 16 Sep 2025 21:53:23 -0700 Subject: [PATCH 3/5] Use Path objects like sloretz recommended Signed-off-by: Michael Carlstrom --- conf.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/conf.py b/conf.py index ba8d74054ef..aaf8db646b9 100644 --- a/conf.py +++ b/conf.py @@ -20,6 +20,7 @@ import hashlib import itertools import os +from pathlib import Path import re import sys import tempfile @@ -440,23 +441,20 @@ def expand_text_macros(text: Text, macros: Dict[Text, Text]) -> Text: logger = logging.getLogger(__name__) -def _hash_file(path: str) -> str: +def _hash_file(path: Path) -> str: """Return SHA256 of a file.""" - if not os.path.exists(path): + if not path.exists(): return "" h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - h.update(chunk) + h.update(path.read_bytes()) return h.hexdigest() def download_files(app: Sphinx) -> None: - distro = app.config.macros['DISTRO'] + distro: str = app.config.macros['DISTRO'] - srcdir = app.srcdir - dl_dir = os.path.join(srcdir, "_downloaded", distro) - os.makedirs(dl_dir, exist_ok=True) + dl_dir = Path(app.srcdir) / "_downloaded" / distro + dl_dir.mkdir(exist_ok=True) files_to_download = { "publisher_member_function.py": ( @@ -472,12 +470,12 @@ def download_files(app: Sphinx) -> None: } for local_name, url in files_to_download.items(): - local_path = os.path.join(dl_dir, local_name) + local_path = dl_dir / local_name with tempfile.NamedTemporaryFile( delete=False, prefix=f"{local_name}_", suffix=".tmp" ) as tmp: - temp_path = tmp.name + temp_path = Path(tmp.name) logger.info(f"Checking for updates: {url}") try: @@ -492,6 +490,6 @@ def download_files(app: Sphinx) -> None: if old_hash != new_hash: logger.info(f"Updating {local_name} (content changed)") - os.replace(temp_path, local_path) + temp_path.replace(local_path) else: - os.remove(temp_path) + temp_path.unlink() From 525008cc8acc73b979113c01817a26fa98a7f107 Mon Sep 17 00:00:00 2001 From: Michael Carlstrom Date: Tue, 16 Sep 2025 21:57:08 -0700 Subject: [PATCH 4/5] fix unit test Signed-off-by: Michael Carlstrom --- test/test_hash_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_hash_file.py b/test/test_hash_file.py index 860799be610..a34f14aeed3 100644 --- a/test/test_hash_file.py +++ b/test/test_hash_file.py @@ -23,15 +23,15 @@ def test_hash_file(tmp_path: pathlib.Path) -> None: non_existent = tmp_path / "does_not_exist.txt" - assert _hash_file(str(non_existent)) == "" + 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(str(empty_file)) == expected_empty_hash + 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(str(known_file)) == expected_known_hash + assert _hash_file(known_file) == expected_known_hash From c5328fe7c531e65f5367b868b0d18d878f88701a Mon Sep 17 00:00:00 2001 From: Michael Carlstrom Date: Tue, 16 Sep 2025 22:00:04 -0700 Subject: [PATCH 5/5] allow parents Signed-off-by: Michael Carlstrom --- conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf.py b/conf.py index aaf8db646b9..8d6390c3cbf 100644 --- a/conf.py +++ b/conf.py @@ -454,7 +454,7 @@ def download_files(app: Sphinx) -> None: distro: str = app.config.macros['DISTRO'] dl_dir = Path(app.srcdir) / "_downloaded" / distro - dl_dir.mkdir(exist_ok=True) + dl_dir.mkdir(parents=True, exist_ok=True) files_to_download = { "publisher_member_function.py": (