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