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.