This is an automated email from the ASF dual-hosted git repository.

pvillard pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 1e3f3ad2ad0 NIFI-14368 Fix Python Processor cannot use parent class 
properties as PropertyDependency (#11095)
1e3f3ad2ad0 is described below

commit 1e3f3ad2ad053432f5562356dfcd3ff7de33df82
Author: vishal-firgan-ksolves <[email protected]>
AuthorDate: Thu Apr 9 00:28:21 2026 +0530

    NIFI-14368 Fix Python Processor cannot use parent class properties as 
PropertyDependency (#11095)
---
 .../main/python/framework/ProcessorInspection.py   |  42 ++++++-
 .../python/framework/TestProcessorInspection.py    | 128 +++++++++++++++++++++
 .../ChildProcessor.py                              |  68 +++++++++++
 .../ParentProcessorClass.py                        |  37 ++++++
 .../parent_class_property_dependency/__init__.py   |  14 +++
 5 files changed, 283 insertions(+), 6 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/main/python/framework/ProcessorInspection.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/main/python/framework/ProcessorInspection.py
index 19ff0e549a4..0eddaad0a7b 100644
--- 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/main/python/framework/ProcessorInspection.py
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/main/python/framework/ProcessorInspection.py
@@ -53,17 +53,29 @@ class CollectPropertyDescriptorVisitors(ast.NodeVisitor):
         self.module_string_constants = module_string_constants
         self.discovered_property_descriptors = {}
         self.imported_property_descriptors = imported_property_descriptors if 
imported_property_descriptors else {}
+        self.used_imported_descriptors = set()
         self.processor_name = processor_name
         self.logger = 
logging.getLogger("python.CollectPropertyDescriptorVisitors")
 
     def resolve_dependencies(self, node: ast.AST):
         resolved_dependencies = []
         for dependency in node.elts:
-            variable_name = dependency.args[0].id
+            arg = dependency.args[0]
+            if isinstance(arg, ast.Name):
+                # Simple local reference: PropertyDependency(MY_PROP, ...)
+                variable_name = arg.id
+            elif isinstance(arg, ast.Attribute):
+                # Parent-class attribute reference: 
PropertyDependency(ParentClass.MY_PROP, ...)
+                variable_name = arg.attr
+            else:
+                self.logger.warning(f"Unable to resolve dependency variable 
name in {self.processor_name}, skipping.")
+                continue
             # First check locally discovered descriptors, then check imported 
ones
             actual_property = 
self.discovered_property_descriptors.get(variable_name)
             if actual_property is None:
                 actual_property = 
self.imported_property_descriptors.get(variable_name)
+                if actual_property is not None:
+                    self.used_imported_descriptors.add(variable_name)
 
             if actual_property is None:
                 self.logger.warning(f"Not able to find actual property 
descriptor for {variable_name}, so not able to resolve property dependencies in 
{self.processor_name}.")
@@ -258,16 +270,28 @@ def get_imported_property_descriptors_from_ast(root_node: 
ast.AST, module_file:
             try:
                 source_ast = parse_module_file(source_file)
                 source_constants = 
get_module_string_constants_from_ast(source_ast)
-                parsed_modules[source_file] = 
get_property_descriptors_from_ast(source_ast, source_constants, source_file)
+                parsed_modules[source_file] = {
+                    "ast": source_ast,
+                    "descriptors": 
get_property_descriptors_from_ast(source_ast, source_constants, source_file),
+                    "classes": {n.name for n in ast.walk(source_ast) if 
isinstance(n, ast.ClassDef)}
+                }
             except Exception as e:
                 logger.warning(f"Failed to parse module {source_file} for 
PropertyDescriptors: {e}")
-                parsed_modules[source_file] = {}
+                parsed_modules[source_file] = {"ast": None, "descriptors": {}, 
"classes": set()}
 
         # Check if the imported name is a PropertyDescriptor in the source 
module
-        source_descriptors = parsed_modules[source_file]
+        source_descriptors = parsed_modules[source_file]["descriptors"]
+        source_classes = parsed_modules[source_file]["classes"]
         if imported_name in source_descriptors:
             imported_descriptors[imported_name] = 
source_descriptors[imported_name]
             logger.debug(f"Resolved imported PropertyDescriptor 
'{imported_name}' from {source_file}")
+        elif imported_name in source_classes:
+            # The imported name is a class in the source module. Expose its 
PropertyDescriptors so that
+            # attribute-style references like ParentClass.MY_PROP can be 
resolved without pulling unrelated symbols.
+            for desc_name, desc in source_descriptors.items():
+                if desc_name not in imported_descriptors:
+                    imported_descriptors[desc_name] = desc
+                    logger.debug(f"Resolved PropertyDescriptor '{desc_name}' 
from class '{imported_name}' in {source_file}")
 
     return imported_descriptors
 
@@ -459,8 +483,14 @@ def get_property_descriptions(class_node, 
module_string_constants, module_file,
 
     visitor = CollectPropertyDescriptorVisitors(module_string_constants, 
class_node.name, imported_property_descriptors)
     visitor.visit(class_node)
-    return visitor.discovered_property_descriptors.values()
-
+    # Merge only imported descriptors actually referenced (e.g., via 
dependencies) and not already defined locally.
+    merged = dict(visitor.discovered_property_descriptors)
+    for name in visitor.used_imported_descriptors:
+        desc = imported_property_descriptors.get(name)
+        if desc and name not in merged:
+            merged[name] = desc
+
+    return merged.values()
 
 def replace_null(val: any, replacement: any):
     return val if val else replacement
diff --git 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/python/framework/TestProcessorInspection.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/python/framework/TestProcessorInspection.py
index aa850930e3e..d3ddc28be69 100644
--- 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/python/framework/TestProcessorInspection.py
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/python/framework/TestProcessorInspection.py
@@ -44,6 +44,11 @@ _TEST_RESOURCES_DIR = os.path.join(
 IMPORTED_DEPENDENCY_TEST_DIR = os.path.join(_TEST_RESOURCES_DIR, 
'imported_property_dependency')
 IMPORTED_DEPENDENCY_TEST_FILE = os.path.join(IMPORTED_DEPENDENCY_TEST_DIR, 
'ProcessorWithImportedDependency.py')
 
+# Path to the test processor that uses a parent class property as a 
PropertyDependency
+# via the attribute-style reference: PropertyDependency(ParentClass.MY_PROP, 
...)
+PARENT_CLASS_DEPENDENCY_TEST_DIR = os.path.join(_TEST_RESOURCES_DIR, 
'parent_class_property_dependency')
+PARENT_CLASS_DEPENDENCY_TEST_FILE = 
os.path.join(PARENT_CLASS_DEPENDENCY_TEST_DIR, 'ChildProcessor.py')
+
 # Path to the existing ConditionalProcessor which uses local dependencies 
(should work)
 # Navigate from test/python/framework up to nifi root
 # _SCRIPT_DIR is .../nifi-python-framework/src/test/python/framework
@@ -219,6 +224,129 @@ class TestPropertyDependencyResolution(unittest.TestCase):
         self.assertEqual(result, [])
 
 
+class TestParentClassPropertyDependency(unittest.TestCase):
+    """
+    Tests for the pattern where a processor uses multiple inheritance with a 
Python
+    parent class and references a parent class PropertyDescriptor as a 
PropertyDependency
+    using the attribute-style syntax: PropertyDependency(ParentClass.MY_PROP, 
...).
+
+    This covers two bugs that affected NiFi 2.1.0+:
+      1. AttributeError crash: 'Attribute' object has no attribute 'id'
+         (ast.Attribute was not handled in resolve_dependencies)
+      2. Silent dependency drop: property found by attr name but not in 
imported_descriptors
+         because the import was of the class, not the property directly
+    """
+
+    def setUp(self):
+        set_up_env()
+
+    def test_child_processor_class_nodes_found(self):
+        """Verify the child processor fixture is correctly identified as a 
processor."""
+        class_nodes = 
ProcessorInspection.get_processor_class_nodes(PARENT_CLASS_DEPENDENCY_TEST_FILE)
+        self.assertIsNotNone(class_nodes)
+        self.assertEqual(len(class_nodes), 1)
+        self.assertEqual(class_nodes[0].name, 'ChildProcessor')
+
+    def test_parent_class_attribute_dependency_does_not_crash(self):
+        """
+        Using PropertyDependency(ParentClass.MY_PROP, ...) must NOT raise
+        AttributeError ('Attribute' object has no attribute 'id').
+
+        This was the crash introduced in NiFi 2.1.0 when the AST node for the
+        first argument of PropertyDependency is ast.Attribute instead of 
ast.Name.
+        """
+        details = get_processor_details(
+            self,
+            'ChildProcessor',
+            PARENT_CLASS_DEPENDENCY_TEST_FILE,
+            PARENT_CLASS_DEPENDENCY_TEST_DIR
+        )
+        self.assertIsNotNone(details)
+
+    def test_parent_class_attribute_dependency_resolves_correctly(self):
+        """
+        When PropertyDependency(ParentClass.MY_PROP, ...) is used, the 
dependency
+        must be resolved to the correct PropertyDescription (not silently 
dropped).
+
+        This covers the warning: 'Not able to find actual property descriptor 
for
+        MY_PROP, so not able to resolve property dependencies'.
+        """
+        details = get_processor_details(
+            self,
+            'ChildProcessor',
+            PARENT_CLASS_DEPENDENCY_TEST_FILE,
+            PARENT_CLASS_DEPENDENCY_TEST_DIR
+        )
+
+        property_descriptions = list(details.property_descriptions)
+        self.assertTrue(len(property_descriptions) > 0)
+
+        property_map = {p.name: p for p in property_descriptions}
+        self.assertIn('Child Only Setting', property_map)
+
+        child_prop = property_map['Child Only Setting']
+        self.assertIsNotNone(child_prop.dependencies)
+        self.assertEqual(len(child_prop.dependencies), 1)
+
+        dep = child_prop.dependencies[0]
+        self.assertEqual(dep.name, 'Enable Feature')
+        self.assertEqual(dep.dependent_values, ['true'])
+
+    def test_parent_class_property_is_included_in_descriptions(self):
+        """
+        The parent class PropertyDescriptor itself must appear in the 
processor's
+        property descriptions so that it can be used as a dependency target.
+        """
+        details = get_processor_details(
+            self,
+            'ChildProcessor',
+            PARENT_CLASS_DEPENDENCY_TEST_FILE,
+            PARENT_CLASS_DEPENDENCY_TEST_DIR
+        )
+
+        property_names = [p.name for p in details.property_descriptions]
+        self.assertIn('Child Only Setting', property_names)
+        self.assertIn('Enable Feature', property_names)
+
+    def 
test_ast_attribute_node_extracted_correctly_in_resolve_dependencies(self):
+        """
+        Unit test for resolve_dependencies: verifies that an ast.Attribute node
+        (ParentClass.MY_PROP) correctly extracts the attribute name (MY_PROP)
+        without crashing, and matches against discovered_property_descriptors.
+        """
+        import ast
+        from nifiapi.documentation import PropertyDescription
+
+        # Simulate: [PropertyDependency(ParentClass.PARENT_ENABLE_FEATURE, 
"true")]
+        code = '[PropertyDependency(ParentClass.PARENT_ENABLE_FEATURE, 
"true")]'
+        tree = ast.parse(code, mode='eval')
+        dependency_list_node = tree.body
+
+        visitor = ProcessorInspection.CollectPropertyDescriptorVisitors(
+            module_string_constants={},
+            processor_name='ChildProcessor'
+        )
+
+        # Manually inject the parent property so resolution can succeed
+        visitor.discovered_property_descriptors['PARENT_ENABLE_FEATURE'] = 
PropertyDescription(
+            name='Enable Feature',
+            description='Whether to enable the optional feature',
+            display_name='Enable Feature',
+            required=True,
+            sensitive=False,
+            default_value='false',
+            expression_language_scope='NONE',
+            controller_service_definition=None,
+            allowable_values=['true', 'false'],
+            dependencies=None
+        )
+
+        result = visitor.resolve_dependencies(dependency_list_node)
+
+        self.assertEqual(len(result), 1)
+        self.assertEqual(result[0].name, 'Enable Feature')
+        self.assertEqual(result[0].dependent_values, ['true'])
+
 if __name__ == '__main__':
     unittest.main()
 
diff --git 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/ChildProcessor.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/ChildProcessor.py
new file mode 100644
index 00000000000..22a6dc00527
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/ChildProcessor.py
@@ -0,0 +1,68 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+"""
+A child processor that uses multiple inheritance with a Python parent class.
+
+This fixture tests the pattern that caused failures in NiFi 2.1.0+:
+  - ParentProcessorClass is imported as a class (not its individual properties)
+  - CHILD_ONLY_PROPERTY references ParentProcessorClass.PARENT_ENABLE_FEATURE
+    via PropertyDependency(ParentProcessorClass.PARENT_ENABLE_FEATURE, "true")
+  - The AST for that argument is ast.Attribute (not ast.Name), which previously
+    caused AttributeError: 'Attribute' object has no attribute 'id'
+  - After the crash fix, the property still could not be resolved, causing a
+    warning and the dependency being silently dropped
+"""
+
+from ParentProcessorClass import ParentProcessorClass
+
+from nifiapi.flowfiletransform import FlowFileTransform, 
FlowFileTransformResult
+from nifiapi.properties import PropertyDescriptor, PropertyDependency, 
StandardValidators
+
+
+class ChildProcessor(ParentProcessorClass, FlowFileTransform):
+
+    class Java:
+        implements = ['org.apache.nifi.python.processor.FlowFileTransform']
+
+    class ProcessorDetails:
+        version = '0.0.1-SNAPSHOT'
+        description = 'Test processor for parent-class PropertyDependency 
resolution'
+        tags = ['test', 'inheritance', 'dependency']
+
+    # This property depends on a property defined in ParentProcessorClass.
+    # The dependency is written as ParentProcessorClass.PARENT_ENABLE_FEATURE,
+    # which produces an ast.Attribute node — the pattern that caused the crash.
+    CHILD_ONLY_PROPERTY = PropertyDescriptor(
+        name="Child Only Setting",
+        description="A setting only visible when Enable Feature is true",
+        required=False,
+        validators=[StandardValidators.NON_EMPTY_VALIDATOR],
+        
dependencies=[PropertyDependency(ParentProcessorClass.PARENT_ENABLE_FEATURE, 
"true")]
+    )
+
+    property_descriptors = [
+        ParentProcessorClass.PARENT_ENABLE_FEATURE,
+        CHILD_ONLY_PROPERTY,
+    ]
+
+    def __init__(self, **kwargs):
+        super().__init__()
+
+    def getPropertyDescriptors(self):
+        return self.property_descriptors
+
+    def transform(self, context, flowfile):
+        return FlowFileTransformResult(relationship='success')
diff --git 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/ParentProcessorClass.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/ParentProcessorClass.py
new file mode 100644
index 00000000000..677c13e3422
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/ParentProcessorClass.py
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+"""
+A parent class that defines a PropertyDescriptor as a class-level attribute.
+
+This is the pattern that caused a crash in NiFi 2.1.0+:
+  - The parent class is imported as a class (not its individual properties)
+  - Child processors reference the property as ParentClass.MY_PROP in 
PropertyDependency
+  - ProcessorInspection must resolve that attribute-style reference correctly
+"""
+
+from nifiapi.properties import PropertyDescriptor, StandardValidators
+
+
+class ParentProcessorClass:
+
+    PARENT_ENABLE_FEATURE = PropertyDescriptor(
+        name="Enable Feature",
+        description="Whether to enable the optional feature",
+        allowable_values=["true", "false"],
+        default_value="false",
+        required=True,
+        validators=[StandardValidators.BOOLEAN_VALIDATOR]
+    )
diff --git 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/__init__.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/__init__.py
new file mode 100644
index 00000000000..ae1e83eeb3d
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/parent_class_property_dependency/__init__.py
@@ -0,0 +1,14 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.

Reply via email to