diff --git a/rosidl_generator_py/CMakeLists.txt b/rosidl_generator_py/CMakeLists.txt index e5ff834f..69f874c2 100644 --- a/rosidl_generator_py/CMakeLists.txt +++ b/rosidl_generator_py/CMakeLists.txt @@ -49,6 +49,7 @@ if(BUILD_TESTING) msg/BuiltinTypeSequencesIdl.idl msg/StringArrays.msg msg/Property.msg + msg/TestDeprecated.idl ADD_LINTER_TESTS SKIP_INSTALL ) @@ -75,6 +76,12 @@ if(BUILD_TESTING) APPEND_LIBRARY_DIRS "${_append_library_dirs}" WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_py" ) + + ament_add_pytest_test(test_deprecated_py test/test_deprecated.py + APPEND_ENV "PYTHONPATH=${pythonpath}" + APPEND_LIBRARY_DIRS "${_append_library_dirs}" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_py" + ) endif() endif() diff --git a/rosidl_generator_py/msg/TestDeprecated.idl b/rosidl_generator_py/msg/TestDeprecated.idl new file mode 100644 index 00000000..6b5ed29f --- /dev/null +++ b/rosidl_generator_py/msg/TestDeprecated.idl @@ -0,0 +1,9 @@ +module rosidl_generator_py { + module msg { + struct TestDeprecated { + @deprecated ( text="Use distance_meters instead") + uint8 distance_cm; + double distance_meters; + }; + }; +}; diff --git a/rosidl_generator_py/package.xml b/rosidl_generator_py/package.xml index 8d67c041..68a4a209 100644 --- a/rosidl_generator_py/package.xml +++ b/rosidl_generator_py/package.xml @@ -40,6 +40,7 @@ ament_index_python python3-numpy + python3-typing-extensions rosidl_cli rosidl_generator_c rosidl_parser diff --git a/rosidl_generator_py/resource/_msg.py.em b/rosidl_generator_py/resource/_msg.py.em index 6271e52d..44106789 100644 --- a/rosidl_generator_py/resource/_msg.py.em +++ b/rosidl_generator_py/resource/_msg.py.em @@ -44,6 +44,9 @@ for member in message.structure.members: if member.name != EMPTY_STRUCTURE_REQUIRED_MEMBER_NAME: imports.setdefault( 'import builtins', []) # used for @builtins.property + if member.has_annotation('deprecated'): + imports.setdefault( + 'from typing_extensions import deprecated as _deprecated', []) if isinstance(type_, BasicType) and type_.typename in FLOATING_POINT_TYPES: imports.setdefault( 'import math', []) # used for math.isinf @@ -429,12 +432,24 @@ noqa_string = '' if member.name in dict(inspect.getmembers(builtins)).keys(): noqa_string = ' # noqa: A003' }@ +@[ if member.has_annotation('deprecated')]@ +@{ +deprecation_annotation = member.get_annotation_value('deprecated') +deprecation_text = deprecation_annotation.get('text', '') if isinstance(deprecation_annotation, dict) else '' +}@ +@[ end if]@ @@builtins.property@(noqa_string) +@[ if member.has_annotation('deprecated')]@ + @@_deprecated('@(deprecation_text)')@(noqa_string) +@[ end if]@ def @(member.name)(self):@(noqa_string) """Message field '@(member.name)'.""" return self._@(member.name) @@@(member.name).setter@(noqa_string) +@[ if member.has_annotation('deprecated')]@ + @@_deprecated('@(deprecation_text)')@(noqa_string) +@[ end if]@ def @(member.name)(self, value):@(noqa_string) if self._check_fields: @[ if isinstance(member.type, AbstractNestedType) and isinstance(member.type.value_type, BasicType) and member.type.value_type.typename in SPECIAL_NESTED_BASIC_TYPES]@ diff --git a/rosidl_generator_py/resource/_msg_support.c.em b/rosidl_generator_py/resource/_msg_support.c.em index 36a29454..d62b6ceb 100644 --- a/rosidl_generator_py/resource/_msg_support.c.em +++ b/rosidl_generator_py/resource/_msg_support.c.em @@ -134,6 +134,9 @@ nested_header += '__functions.h' @[ end for]@ // end nested array functions include @[end if]@ +@[if any(member.has_annotation('deprecated') for member in message.structure.members)]@ +#include "rosidl_runtime_c/deprecation.h" +@[end if]@ @{ msg_typename = '__'.join(message.structure.namespaced_type.namespaced_name()) }@ @@ -202,6 +205,9 @@ if isinstance(type_, AbstractNestedType): type_ = type_.value_type }@ { // @(member.name) +@[ if member.has_annotation('deprecated')]@ + DISABLE_DEPRECATED_PUSH +@[ end if]@ PyObject * field = PyObject_GetAttrString(_pymsg, "@(member.name)"); if (!field) { return false; @@ -512,6 +518,9 @@ nested_type = '__'.join(type_.namespaced_name()) assert(false); @[ end if]@ Py_DECREF(field); +@[ if member.has_annotation('deprecated')]@ + DISABLE_DEPRECATED_POP +@[ end if]@ } @[end for]@ @@ -550,6 +559,9 @@ if isinstance(type_, AbstractNestedType): type_ = type_.value_type }@ { // @(member.name) +@[ if member.has_annotation('deprecated')]@ + DISABLE_DEPRECATED_PUSH +@[ end if]@ PyObject * field = NULL; @[ if isinstance(member.type, AbstractNestedType) and isinstance(member.type.value_type, BasicType) and member.type.value_type.typename in SPECIAL_NESTED_BASIC_TYPES]@ @[ if isinstance(member.type, Array)]@ @@ -795,6 +807,9 @@ nested_type = '__'.join(type_.namespaced_name()) } } @[ end if]@ +@[ if member.has_annotation('deprecated')]@ + DISABLE_DEPRECATED_POP +@[ end if]@ } @[end for]@ diff --git a/rosidl_generator_py/test/test_deprecated.py b/rosidl_generator_py/test/test_deprecated.py new file mode 100644 index 00000000..65198978 --- /dev/null +++ b/rosidl_generator_py/test/test_deprecated.py @@ -0,0 +1,64 @@ +# Copyright 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. + +import warnings + +import pytest + +from rosidl_generator_py.msg import TestDeprecated + + +def test_deprecated_field_getter_emits_warning(): + """Test that accessing a deprecated field emits a DeprecationWarning.""" + msg = TestDeprecated() + + with pytest.warns(DeprecationWarning, match='Use distance_meters instead'): + _ = msg.distance_cm + + +def test_deprecated_field_setter_emits_warning(): + """Test that setting a deprecated field emits a DeprecationWarning.""" + msg = TestDeprecated() + + with pytest.warns(DeprecationWarning, match='Use distance_meters instead'): + msg.distance_cm = 42 + + +def test_non_deprecated_field_no_warning(): + """Test that accessing non-deprecated fields does not emit a warning.""" + msg = TestDeprecated() + + with warnings.catch_warnings(): + warnings.simplefilter('error', DeprecationWarning) + # Should not raise - distance_meters is not deprecated + _ = msg.distance_meters + + +def test_deprecated_field_values(): + """Test that deprecated fields still work correctly for values.""" + msg = TestDeprecated() + + # Suppress the deprecation warnings for value testing + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + + # Default value + assert msg.distance_cm == 0 + assert msg.distance_meters == 0.0 + + # Set and get + msg.distance_cm = 10 + msg.distance_meters = 1.5 + assert msg.distance_cm == 10 + assert msg.distance_meters == 1.5