Skip to content

Commit 879acbc

Browse files
committed
Add Python generator support for deprecated interface fields
Implements the Python generator side of field-level deprecation support. This PR is based on and should be merged as a companion to ros2/rosidl#945, which establishes the IDL annotation pipeline for C and C++ generators. When a message field is annotated with @deprecated(text=...) in an .idl file, the generated Python message class will: - Decorate the property getter and setter with @typing_extensions.deprecated(text) (PEP 702), enabling static analysis tools (mypy, pyright, IDEs) to flag deprecated field usage at development time - Emit a DeprecationWarning at runtime when the field is accessed or set The C extension (_msg_support.c.em) wraps internal accesses to deprecated fields with DISABLE_DEPRECATED_PUSH/POP to suppress warnings in generated conversion code. Changes: - resource/_msg.py.em: import and apply @_deprecated decorator on getter/setter when field has @deprecated annotation - resource/_msg_support.c.em: suppress deprecation warnings around internal PyObject_GetAttrString/SetAttrString calls - package.xml: add python3-typing-extensions exec_depend - CMakeLists.txt: add TestDeprecated.idl test message and test_deprecated.py regression test Signed-off-by: Pengkun-ZHU <q1091803103@gmail.com>
1 parent ac60288 commit 879acbc

File tree

6 files changed

+111
-0
lines changed

6 files changed

+111
-0
lines changed

rosidl_generator_py/CMakeLists.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ if(BUILD_TESTING)
4949
msg/BuiltinTypeSequencesIdl.idl
5050
msg/StringArrays.msg
5151
msg/Property.msg
52+
msg/TestDeprecated.idl
5253
ADD_LINTER_TESTS
5354
SKIP_INSTALL
5455
)
@@ -75,6 +76,12 @@ if(BUILD_TESTING)
7576
APPEND_LIBRARY_DIRS "${_append_library_dirs}"
7677
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_py"
7778
)
79+
80+
ament_add_pytest_test(test_deprecated_py test/test_deprecated.py
81+
APPEND_ENV "PYTHONPATH=${pythonpath}"
82+
APPEND_LIBRARY_DIRS "${_append_library_dirs}"
83+
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/rosidl_generator_py"
84+
)
7885
endif()
7986
endif()
8087

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module rosidl_generator_py {
2+
module msg {
3+
struct TestDeprecated {
4+
@deprecated ( text="Use distance_meters instead")
5+
uint8 distance_cm;
6+
double distance_meters;
7+
};
8+
};
9+
};

rosidl_generator_py/package.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
<exec_depend>ament_index_python</exec_depend>
4242
<exec_depend>python3-numpy</exec_depend>
43+
<exec_depend>python3-typing-extensions</exec_depend>
4344
<exec_depend>rosidl_cli</exec_depend>
4445
<exec_depend>rosidl_generator_c</exec_depend>
4546
<exec_depend>rosidl_parser</exec_depend>

rosidl_generator_py/resource/_msg.py.em

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ for member in message.structure.members:
4444
if member.name != EMPTY_STRUCTURE_REQUIRED_MEMBER_NAME:
4545
imports.setdefault(
4646
'import builtins', []) # used for @builtins.property
47+
if member.has_annotation('deprecated'):
48+
imports.setdefault(
49+
'from typing_extensions import deprecated as _deprecated', [])
4750
if isinstance(type_, BasicType) and type_.typename in FLOATING_POINT_TYPES:
4851
imports.setdefault(
4952
'import math', []) # used for math.isinf
@@ -429,12 +432,24 @@ noqa_string = ''
429432
if member.name in dict(inspect.getmembers(builtins)).keys():
430433
noqa_string = ' # noqa: A003'
431434
}@
435+
@[ if member.has_annotation('deprecated')]@
436+
@{
437+
deprecation_annotation = member.get_annotation_value('deprecated')
438+
deprecation_text = deprecation_annotation.get('text', '') if isinstance(deprecation_annotation, dict) else ''
439+
}@
440+
@[ end if]@
432441
@@builtins.property@(noqa_string)
442+
@[ if member.has_annotation('deprecated')]@
443+
@@_deprecated('@(deprecation_text)')@(noqa_string)
444+
@[ end if]@
433445
def @(member.name)(self):@(noqa_string)
434446
"""Message field '@(member.name)'."""
435447
return self._@(member.name)
436448

437449
@@@(member.name).setter@(noqa_string)
450+
@[ if member.has_annotation('deprecated')]@
451+
@@_deprecated('@(deprecation_text)')@(noqa_string)
452+
@[ end if]@
438453
def @(member.name)(self, value):@(noqa_string)
439454
if self._check_fields:
440455
@[ if isinstance(member.type, AbstractNestedType) and isinstance(member.type.value_type, BasicType) and member.type.value_type.typename in SPECIAL_NESTED_BASIC_TYPES]@

rosidl_generator_py/resource/_msg_support.c.em

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ nested_header += '__functions.h'
134134
@[ end for]@
135135
// end nested array functions include
136136
@[end if]@
137+
@[if any(member.has_annotation('deprecated') for member in message.structure.members)]@
138+
#include "rosidl_runtime_c/deprecation.h"
139+
@[end if]@
137140
@{
138141
msg_typename = '__'.join(message.structure.namespaced_type.namespaced_name())
139142
}@
@@ -202,6 +205,9 @@ if isinstance(type_, AbstractNestedType):
202205
type_ = type_.value_type
203206
}@
204207
{ // @(member.name)
208+
@[ if member.has_annotation('deprecated')]@
209+
DISABLE_DEPRECATED_PUSH
210+
@[ end if]@
205211
PyObject * field = PyObject_GetAttrString(_pymsg, "@(member.name)");
206212
if (!field) {
207213
return false;
@@ -512,6 +518,9 @@ nested_type = '__'.join(type_.namespaced_name())
512518
assert(false);
513519
@[ end if]@
514520
Py_DECREF(field);
521+
@[ if member.has_annotation('deprecated')]@
522+
DISABLE_DEPRECATED_POP
523+
@[ end if]@
515524
}
516525
@[end for]@
517526

@@ -550,6 +559,9 @@ if isinstance(type_, AbstractNestedType):
550559
type_ = type_.value_type
551560
}@
552561
{ // @(member.name)
562+
@[ if member.has_annotation('deprecated')]@
563+
DISABLE_DEPRECATED_PUSH
564+
@[ end if]@
553565
PyObject * field = NULL;
554566
@[ if isinstance(member.type, AbstractNestedType) and isinstance(member.type.value_type, BasicType) and member.type.value_type.typename in SPECIAL_NESTED_BASIC_TYPES]@
555567
@[ if isinstance(member.type, Array)]@
@@ -795,6 +807,9 @@ nested_type = '__'.join(type_.namespaced_name())
795807
}
796808
}
797809
@[ end if]@
810+
@[ if member.has_annotation('deprecated')]@
811+
DISABLE_DEPRECATED_POP
812+
@[ end if]@
798813
}
799814
@[end for]@
800815

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2026 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import warnings
16+
17+
import pytest
18+
19+
from rosidl_generator_py.msg import TestDeprecated
20+
21+
22+
def test_deprecated_field_getter_emits_warning():
23+
"""Test that accessing a deprecated field emits a DeprecationWarning."""
24+
msg = TestDeprecated()
25+
26+
with pytest.warns(DeprecationWarning, match='Use distance_meters instead'):
27+
_ = msg.distance_cm
28+
29+
30+
def test_deprecated_field_setter_emits_warning():
31+
"""Test that setting a deprecated field emits a DeprecationWarning."""
32+
msg = TestDeprecated()
33+
34+
with pytest.warns(DeprecationWarning, match='Use distance_meters instead'):
35+
msg.distance_cm = 42
36+
37+
38+
def test_non_deprecated_field_no_warning():
39+
"""Test that accessing non-deprecated fields does not emit a warning."""
40+
msg = TestDeprecated()
41+
42+
with warnings.catch_warnings():
43+
warnings.simplefilter('error', DeprecationWarning)
44+
# Should not raise - distance_meters is not deprecated
45+
_ = msg.distance_meters
46+
47+
48+
def test_deprecated_field_values():
49+
"""Test that deprecated fields still work correctly for values."""
50+
msg = TestDeprecated()
51+
52+
# Suppress the deprecation warnings for value testing
53+
with warnings.catch_warnings():
54+
warnings.simplefilter('ignore', DeprecationWarning)
55+
56+
# Default value
57+
assert msg.distance_cm == 0
58+
assert msg.distance_meters == 0.0
59+
60+
# Set and get
61+
msg.distance_cm = 10
62+
msg.distance_meters = 1.5
63+
assert msg.distance_cm == 10
64+
assert msg.distance_meters == 1.5

0 commit comments

Comments
 (0)