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/resource/_msg.py.em b/rosidl_generator_py/resource/_msg.py.em index f53a000d..3b1b8c39 100644 --- a/rosidl_generator_py/resource/_msg.py.em +++ b/rosidl_generator_py/resource/_msg.py.em @@ -83,6 +83,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 @@ -417,7 +420,7 @@ if isinstance(type_, AbstractNestedType): typename.append(self.__class__.__name__) args: list[str] = [] for s, t in zip(self.get_fields_and_field_types().keys(), self.SLOT_TYPES): - field = getattr(self, s) + field = getattr(self, '_' + s) fieldstr = repr(field) # We use Python array type for fields that can be directly stored # in them, and "normal" sequences for everything else. If it is @@ -446,9 +449,9 @@ if isinstance(type_, AbstractNestedType): @[ continue]@ @[ end if]@ @[ if isinstance(member.type, Array) and isinstance(member.type.value_type, BasicType) and member.type.value_type.typename in SPECIAL_NESTED_BASIC_TYPES]@ - if any(self.@(member.name) != other.@(member.name)): + if any(self._@(member.name) != other._@(member.name)): @[ else]@ - if self.@(member.name) != other.@(member.name): + if self._@(member.name) != other._@(member.name): @[ end if]@ return False @[end for]@ @@ -476,12 +479,24 @@ array_type_commment = '' if isinstance(member.type, (Array, AbstractSequence)): array_type_commment = ' # typing.Annotated can be remove after mypy 1.16+ see mypy#3004' }@ +@[ 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) -> @(type_annotations_getter[member.name]):@(noqa_string)@(array_type_commment) """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: @(type_annotations_setter[member.name])) -> None:@(noqa_string) @[ if isinstance(member.type, AbstractNestedType)]@ if isinstance(value, collections.abc.Set): diff --git a/rosidl_generator_py/resource/_msg_support.c.em b/rosidl_generator_py/resource/_msg_support.c.em index 36a29454..f41d04a4 100644 --- a/rosidl_generator_py/resource/_msg_support.c.em +++ b/rosidl_generator_py/resource/_msg_support.c.em @@ -202,6 +202,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 +515,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 +556,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 +804,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