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

exceptionfactory 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 2c55656e3d NIFI-14233 Fixed Python Processor imported Property 
Dependencies (#10616)
2c55656e3d is described below

commit 2c55656e3d8e55d0281667c1e058d40af1ba31bb
Author: Pierre Villard <[email protected]>
AuthorDate: Mon Feb 2 16:57:47 2026 +0100

    NIFI-14233 Fixed Python Processor imported Property Dependencies (#10616)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../main/python/framework/ProcessorInspection.py   | 193 +++++++++++++++++-
 .../python/framework/TestProcessorInspection.py    | 224 +++++++++++++++++++++
 .../ProcessorWithImportedDependency.py             |  84 ++++++++
 .../SharedProperties.py                            |  44 ++++
 .../imported_property_dependency/__init__.py       |  15 ++
 5 files changed, 550 insertions(+), 10 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 d9907a39d7..19ff0e549a 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
@@ -49,9 +49,10 @@ class StringConstantVisitor(ast.NodeVisitor):
 
 class CollectPropertyDescriptorVisitors(ast.NodeVisitor):
 
-    def __init__(self, module_string_constants, processor_name):
+    def __init__(self, module_string_constants, processor_name, 
imported_property_descriptors=None):
         self.module_string_constants = module_string_constants
         self.discovered_property_descriptors = {}
+        self.imported_property_descriptors = imported_property_descriptors if 
imported_property_descriptors else {}
         self.processor_name = processor_name
         self.logger = 
logging.getLogger("python.CollectPropertyDescriptorVisitors")
 
@@ -59,10 +60,14 @@ class CollectPropertyDescriptorVisitors(ast.NodeVisitor):
         resolved_dependencies = []
         for dependency in node.elts:
             variable_name = dependency.args[0].id
-            if not self.discovered_property_descriptors[variable_name]:
-                self.logger.error(f"Not able to find actual property 
descriptor for {variable_name}, so not able to resolve property dependencies in 
{self.processor_name}.")
+            # 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 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}.")
             else:
-                actual_property = 
self.discovered_property_descriptors[variable_name]
                 dependent_values = []
                 for dependent_value in dependency.args[1:]:
                     
dependent_values.append(get_constant_values(dependent_value, 
self.module_string_constants))
@@ -113,14 +118,160 @@ class CollectPropertyDescriptorVisitors(ast.NodeVisitor):
         return isinstance(node.value, ast.Call) and 
isinstance(node.value.func, ast.Name) and node.value.func.id == 
'PropertyDescriptor'
 
 
-def get_module_string_constants(module_file: str) -> dict:
+def parse_module_file(module_file: str) -> ast.AST:
+    """
+    Parse a Python module file and return the AST root node.
+
+    This function reads and parses a module file once, allowing the AST
+    to be reused by multiple inspection functions without re-parsing.
+
+    :param module_file: Path to the Python module file to parse
+    :return: The root AST node of the parsed module
+    """
     with open(module_file) as file:
-        root_node = ast.parse(file.read())
+        return ast.parse(file.read())
+
+
+def get_module_string_constants_from_ast(root_node: ast.AST) -> dict:
+    """
+    Extract string constant assignments from a pre-parsed AST.
+
+    :param root_node: The root AST node of a parsed module
+    :return: Dictionary mapping variable names to string values
+    """
     visitor = StringConstantVisitor()
     visitor.visit(root_node)
     return visitor.string_assignments
 
 
+def get_imports_from_ast(root_node: ast.AST, module_dir: str) -> dict:
+    """
+    Extract import information from a pre-parsed AST.
+
+    This function extracts import statements from the AST and resolves
+    them to actual file paths. It handles:
+    - 'from module import name' style imports
+    - Relative imports within the same directory
+
+    :param root_node: The root AST node of a parsed module
+    :param module_dir: Directory containing the module (for resolving relative 
imports)
+    :return: Dictionary mapping imported names to their source file paths
+             e.g., {'SHARED_PROPERTY': '/path/to/SharedModule.py'}
+    """
+    imports = {}
+
+    for node in ast.walk(root_node):
+        if isinstance(node, ast.ImportFrom):
+            # Handle: from ModuleName import name1, name2
+            source_module = node.module
+            if source_module is None:
+                # Relative import without module name (e.g., from . import x)
+                continue
+
+            # Try to resolve the module to a file in the same directory
+            source_file = os.path.join(module_dir, f"{source_module}.py")
+            if os.path.exists(source_file):
+                for alias in node.names:
+                    # Use the alias name if provided, otherwise use the 
original name
+                    imported_name = alias.asname if alias.asname else 
alias.name
+                    imports[imported_name] = source_file
+
+    return imports
+
+
+def get_property_descriptors_from_ast(root_node: ast.AST, 
module_string_constants: dict, module_file: str = None) -> dict:
+    """
+    Extract all PropertyDescriptor assignments from a pre-parsed AST.
+
+    This function is used to discover PropertyDescriptors defined in 
shared/utility modules
+    that are imported by processor classes.
+
+    :param root_node: The root AST node of a parsed module
+    :param module_string_constants: Dictionary of string constants from the 
module
+    :param module_file: Optional path to the module file (used for logging 
only)
+    :return: Dictionary mapping variable names to PropertyDescription objects
+    """
+    property_descriptors = {}
+
+    for node in ast.walk(root_node):
+        if isinstance(node, ast.Assign):
+            # Check if this is a PropertyDescriptor assignment
+            if isinstance(node.value, ast.Call) and 
isinstance(node.value.func, ast.Name):
+                if node.value.func.id == 'PropertyDescriptor':
+                    # Extract the variable name
+                    for target in node.targets:
+                        if isinstance(target, ast.Name):
+                            variable_name = target.id
+                            # Parse the PropertyDescriptor keywords
+                            if node.value.keywords:
+                                descriptor_info = {}
+                                for keyword in node.value.keywords:
+                                    key = keyword.arg
+                                    # Skip dependencies for now - they would 
create circular issues
+                                    if key != 'dependencies':
+                                        value = 
get_constant_values(keyword.value, module_string_constants)
+                                        descriptor_info[key] = value
+
+                                property_descriptors[variable_name] = 
PropertyDescription(
+                                    name=descriptor_info.get('name'),
+                                    
description=descriptor_info.get('description'),
+                                    
display_name=replace_null(descriptor_info.get('display_name'), 
descriptor_info.get('name')),
+                                    
required=replace_null(descriptor_info.get('required'), False),
+                                    
sensitive=replace_null(descriptor_info.get('sensitive'), False),
+                                    
default_value=descriptor_info.get('default_value'),
+                                    
expression_language_scope=replace_null(descriptor_info.get('expression_language_scope'),
 'NONE'),
+                                    
controller_service_definition=descriptor_info.get('controller_service_definition'),
+                                    
allowable_values=descriptor_info.get('allowable_values'),
+                                    dependencies=None  # Dependencies from 
imported modules are not resolved
+                                )
+                                if module_file:
+                                    logger.debug(f"Found PropertyDescriptor 
'{variable_name}' in module {module_file}")
+
+    return property_descriptors
+
+
+def get_imported_property_descriptors_from_ast(root_node: ast.AST, 
module_file: str) -> dict:
+    """
+    Get all PropertyDescriptors that are imported into the given module, using 
a pre-parsed AST.
+
+    This function:
+    1. Extracts import statements from the pre-parsed AST
+    2. For each imported name, checks if it's a PropertyDescriptor in the 
source module
+    3. Returns a dictionary of imported PropertyDescriptors
+
+    :param root_node: The root AST node of a parsed module
+    :param module_file: Path to the module file (used for resolving relative 
imports)
+    :return: Dictionary mapping imported variable names to PropertyDescription 
objects
+    """
+    imported_descriptors = {}
+
+    # Get all imports from the pre-parsed AST
+    module_dir = os.path.dirname(module_file)
+    imports = get_imports_from_ast(root_node, module_dir)
+
+    # Cache of already-parsed modules to avoid re-parsing
+    parsed_modules = {}
+
+    for imported_name, source_file in imports.items():
+        # Parse the source module if we haven't already (parse once, extract 
both constants and descriptors)
+        if source_file not in parsed_modules:
+            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)
+            except Exception as e:
+                logger.warning(f"Failed to parse module {source_file} for 
PropertyDescriptors: {e}")
+                parsed_modules[source_file] = {}
+
+        # Check if the imported name is a PropertyDescriptor in the source 
module
+        source_descriptors = parsed_modules[source_file]
+        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}")
+
+    return imported_descriptors
+
+
 def get_processor_class_nodes(module_file: str) -> list:
     with open(module_file) as file:
         root_node = ast.parse(file.read())
@@ -137,7 +288,10 @@ def get_processor_class_nodes(module_file: str) -> list:
 def get_processor_details(class_node, module_file, extension_home, 
dependencies_bundled):
     # Look for a 'ProcessorDetails' class
     child_class_nodes = get_class_nodes(class_node)
-    module_string_constants = get_module_string_constants(module_file)
+
+    # Parse the module file once and reuse the AST for all operations
+    module_ast = parse_module_file(module_file)
+    module_string_constants = get_module_string_constants_from_ast(module_ast)
 
     # Get the Java interfaces that it implements
     interfaces = get_java_interfaces(class_node)
@@ -151,7 +305,7 @@ def get_processor_details(class_node, module_file, 
extension_home, dependencies_
             tags = __get_processor_tags(child_class_node)
             use_cases = get_use_cases(class_node)
             multi_processor_use_cases = 
get_multi_processor_use_cases(class_node)
-            property_descriptions = get_property_descriptions(class_node, 
module_string_constants)
+            property_descriptions = get_property_descriptions(class_node, 
module_string_constants, module_file, module_ast)
             bundle_coordinate = __get_bundle_coordinate(extension_home)
 
             return ExtensionDetails.ExtensionDetails(interfaces=interfaces,
@@ -283,8 +437,27 @@ def get_processor_configurations(constructor_calls: 
ast.List) -> list:
     return configurations
 
 
-def get_property_descriptions(class_node, module_string_constants):
-    visitor = CollectPropertyDescriptorVisitors(module_string_constants, 
class_node.name)
+def get_property_descriptions(class_node, module_string_constants, 
module_file, module_ast):
+    """
+    Extract PropertyDescriptions from a processor class node.
+
+    This function discovers all PropertyDescriptors defined in the class and 
resolves
+    any dependencies, including those that reference imported 
PropertyDescriptors.
+
+    :param class_node: The AST node representing the processor class
+    :param module_string_constants: Dictionary of string constants defined in 
the module
+    :param module_file: Path to the module file, used to resolve imported 
PropertyDescriptors
+    :param module_ast: Pre-parsed AST of the module file
+    :return: Collection of PropertyDescription objects
+    """
+    # Get imported PropertyDescriptors using the pre-parsed AST
+    imported_property_descriptors = {}
+    try:
+        imported_property_descriptors = 
get_imported_property_descriptors_from_ast(module_ast, module_file)
+    except Exception as e:
+        logger.warning(f"Failed to resolve imported PropertyDescriptors for 
{class_node.name}: {e}")
+
+    visitor = CollectPropertyDescriptorVisitors(module_string_constants, 
class_node.name, imported_property_descriptors)
     visitor.visit(class_node)
     return visitor.discovered_property_descriptors.values()
 
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
new file mode 100644
index 0000000000..aa850930e3
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/python/framework/TestProcessorInspection.py
@@ -0,0 +1,224 @@
+# 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.
+
+"""
+Tests for ProcessorInspection module.
+
+This test module includes tests for NIFI-14233: Python Processor can not use
+imported properties as PropertyDependency.
+
+The issue is that when a PropertyDescriptor is imported from another module
+and used as a PropertyDependency, the AST-based inspection fails with a 
KeyError
+because it can only discover PropertyDescriptors defined within the current 
class.
+"""
+
+import os
+import unittest
+
+import ProcessorInspection
+from testutils import set_up_env, get_processor_details
+
+
+# Use absolute path resolution based on the script location
+# _SCRIPT_DIR: .../nifi-python-framework/src/test/python/framework
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+# _TEST_RESOURCES_DIR: 
.../nifi-python-framework/src/test/resources/python/framework
+_TEST_RESOURCES_DIR = os.path.join(
+    os.path.dirname(os.path.dirname(_SCRIPT_DIR)),  # Go up from 
python/framework/ to test/
+    'resources/python/framework'
+)
+
+# Path to the test processor that uses imported properties as dependencies
+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 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
+# We need to go up 8 levels to reach the nifi root:
+# framework -> python -> test -> src -> nifi-python-framework -> 
nifi-py4j-framework-bundle -> 
+# nifi-framework-extensions -> nifi-framework-bundle -> nifi
+_NIFI_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
+    
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_SCRIPT_DIR))))
+))))
+CONDITIONAL_PROCESSOR_FILE = os.path.join(
+    _NIFI_ROOT,
+    
'nifi-extension-bundles/nifi-py4j-extension-bundle/nifi-python-test-extensions/src/main/resources/extensions/ConditionalProcessor.py'
+)
+
+
+class TestProcessorInspection(unittest.TestCase):
+    """Tests for the ProcessorInspection module."""
+
+    def setUp(self):
+        set_up_env()
+
+    def test_get_processor_class_nodes_finds_processor(self):
+        """Test that get_processor_class_nodes correctly identifies processor 
classes."""
+        class_nodes = 
ProcessorInspection.get_processor_class_nodes(IMPORTED_DEPENDENCY_TEST_FILE)
+        self.assertIsNotNone(class_nodes)
+        self.assertEqual(len(class_nodes), 1)
+        self.assertEqual(class_nodes[0].name, 
'ProcessorWithImportedDependency')
+
+    def test_local_property_dependency_works(self):
+        """
+        Test that PropertyDependency with locally-defined properties works 
correctly.
+        
+        This test uses ConditionalProcessor which defines all properties 
locally
+        and uses them as dependencies. This should work without issues.
+        """
+        # Skip if the file doesn't exist (might be in a different test 
environment)
+        if not os.path.exists(CONDITIONAL_PROCESSOR_FILE):
+            self.skipTest(f"ConditionalProcessor.py not found at 
{CONDITIONAL_PROCESSOR_FILE}")
+
+        class_nodes = 
ProcessorInspection.get_processor_class_nodes(CONDITIONAL_PROCESSOR_FILE)
+        self.assertIsNotNone(class_nodes)
+        self.assertEqual(len(class_nodes), 1)
+        
+        class_node = class_nodes[0]
+        self.assertEqual(class_node.name, 'ConditionalProcessor')
+        
+        # This should work without raising an exception
+        details = ProcessorInspection.get_processor_details(
+            class_node, 
+            CONDITIONAL_PROCESSOR_FILE, 
+            '/extensions/conditional', 
+            False
+        )
+        self.assertIsNotNone(details)
+
+    def test_imported_property_dependency_does_not_raise_key_error(self):
+        """
+        Test that verifies the fix for NIFI-14233: PropertyDependency with 
imported 
+        properties should NOT cause a KeyError during processor inspection.
+        
+        After the fix:
+        1. ProcessorInspection resolves imported PropertyDescriptors from 
source modules
+        2. The resolve_dependencies() method uses .get() to avoid KeyError
+        3. Imported properties are correctly resolved and dependencies work
+        """
+        class_nodes = 
ProcessorInspection.get_processor_class_nodes(IMPORTED_DEPENDENCY_TEST_FILE)
+        self.assertIsNotNone(class_nodes)
+        self.assertEqual(len(class_nodes), 1)
+        
+        class_node = class_nodes[0]
+        self.assertEqual(class_node.name, 'ProcessorWithImportedDependency')
+        
+        # This should NOT raise a KeyError after the fix
+        # Instead, it should successfully process the imported property 
dependencies
+        try:
+            details = ProcessorInspection.get_processor_details(
+                class_node, 
+                IMPORTED_DEPENDENCY_TEST_FILE, 
+                IMPORTED_DEPENDENCY_TEST_DIR, 
+                False
+            )
+            self.assertIsNotNone(details)
+        except KeyError as e:
+            self.fail(f"KeyError should not be raised after NIFI-14233 fix: 
{e}")
+
+    def test_imported_property_dependency_works_correctly(self):
+        """
+        Test that imported property dependencies work correctly after the 
NIFI-14233 fix.
+        
+        This test verifies that:
+        1. No exception is raised during processor inspection
+        2. Property descriptions are correctly extracted
+        3. Dependencies on imported properties are properly resolved
+        """
+        class_nodes = 
ProcessorInspection.get_processor_class_nodes(IMPORTED_DEPENDENCY_TEST_FILE)
+        self.assertIsNotNone(class_nodes)
+        self.assertEqual(len(class_nodes), 1)
+        
+        class_node = class_nodes[0]
+        
+        # Get processor details - this should work without raising any 
exception
+        details = ProcessorInspection.get_processor_details(
+            class_node, 
+            IMPORTED_DEPENDENCY_TEST_FILE, 
+            IMPORTED_DEPENDENCY_TEST_DIR, 
+            False
+        )
+        
+        # Verify basic details
+        self.assertIsNotNone(details)
+        self.assertEqual(details.type, 'ProcessorWithImportedDependency')
+        self.assertEqual(details.version, '0.0.1-SNAPSHOT')
+        
+        # Verify property descriptions were extracted
+        property_descriptions = list(details.property_descriptions) if 
details.property_descriptions else []
+        
+        # We should have at least the two properties with dependencies
+        # (JSON_PRETTY_PRINT and FEATURE_CONFIG)
+        property_names = [p.name for p in property_descriptions]
+        self.assertIn('Pretty Print JSON', property_names)
+        self.assertIn('Feature Configuration', property_names)
+        
+        # Verify that dependencies were correctly resolved
+        for prop in property_descriptions:
+            if prop.name == 'Pretty Print JSON':
+                self.assertIsNotNone(prop.dependencies)
+                self.assertTrue(len(prop.dependencies) > 0)
+                # The dependency should reference "Output Format" (the name of 
SHARED_OUTPUT_FORMAT)
+                dep_names = [d.name for d in prop.dependencies]
+                self.assertIn('Output Format', dep_names)
+
+
+class TestPropertyDependencyResolution(unittest.TestCase):
+    """
+    Focused tests for property dependency resolution in ProcessorInspection.
+    
+    These tests specifically target the resolve_dependencies() method and
+    the CollectPropertyDescriptorVisitors class.
+    """
+
+    def setUp(self):
+        set_up_env()
+
+    def 
test_resolve_dependencies_with_missing_property_handles_gracefully(self):
+        """
+        Test that resolve_dependencies handles missing properties gracefully
+        without raising KeyError.
+        
+        After the fix for NIFI-14233, when a dependent property is not found,
+        the code uses .get() which returns None instead of raising KeyError.
+        The dependency is logged as a warning and skipped.
+        """
+        import ast
+        
+        # Create a minimal AST node that simulates a dependency list
+        # This simulates: [PropertyDependency(MISSING_PROPERTY, "value")]
+        code = '[PropertyDependency(MISSING_PROPERTY, "value")]'
+        tree = ast.parse(code, mode='eval')
+        dependency_list_node = tree.body  # This is the List node
+        
+        module_string_constants = {}
+        visitor = ProcessorInspection.CollectPropertyDescriptorVisitors(
+            module_string_constants, 
+            'TestProcessor'
+        )
+        
+        # The visitor has no discovered_property_descriptors, so 
MISSING_PROPERTY won't be found
+        # After the fix, this should NOT raise KeyError - it should return an 
empty list
+        # and log a warning instead
+        result = visitor.resolve_dependencies(dependency_list_node)
+        
+        # The result should be an empty list since the property couldn't be 
resolved
+        self.assertEqual(result, [])
+
+
+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/imported_property_dependency/ProcessorWithImportedDependency.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/imported_property_dependency/ProcessorWithImportedDependency.py
new file mode 100644
index 0000000000..2d35ef11cf
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/imported_property_dependency/ProcessorWithImportedDependency.py
@@ -0,0 +1,84 @@
+# 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.
+
+"""
+This processor demonstrates the bug described in NIFI-14233:
+When a PropertyDescriptor is imported from another module and used
+as a PropertyDependency, NiFi fails to load the processor with a KeyError.
+
+The issue is that the AST-based ProcessorInspection only discovers
+PropertyDescriptors defined within the current class, not imported ones.
+"""
+
+from SharedProperties import SHARED_OUTPUT_FORMAT, SHARED_FEATURE_ENABLED
+from nifiapi.flowfiletransform import FlowFileTransform, 
FlowFileTransformResult
+from nifiapi.properties import PropertyDescriptor, PropertyDependency, 
StandardValidators, ExpressionLanguageScope
+
+
+class ProcessorWithImportedDependency(FlowFileTransform):
+    """
+    A test processor that imports properties from a shared module
+    and uses them as dependencies for other properties.
+    """
+    
+    class Java:
+        implements = ['org.apache.nifi.python.processor.FlowFileTransform']
+
+    class ProcessorDetails:
+        version = '0.0.1-SNAPSHOT'
+        description = 'Test processor for NIFI-14233 - uses imported 
properties as dependencies'
+        tags = ['test', 'dependency', 'import']
+
+    # This property depends on an IMPORTED property (SHARED_OUTPUT_FORMAT)
+    # This is where the bug manifests - the AST inspection can't find
+    # SHARED_OUTPUT_FORMAT in discovered_property_descriptors because
+    # it's defined in a different module
+    JSON_PRETTY_PRINT = PropertyDescriptor(
+        name="Pretty Print JSON",
+        description="Whether to pretty-print JSON output (only applies when 
Output Format is 'json')",
+        allowable_values=["true", "false"],
+        default_value="false",
+        required=False,
+        dependencies=[PropertyDependency(SHARED_OUTPUT_FORMAT, "json")],
+        validators=[StandardValidators.BOOLEAN_VALIDATOR]
+    )
+
+    # Another property that depends on the imported SHARED_FEATURE_ENABLED
+    FEATURE_CONFIG = PropertyDescriptor(
+        name="Feature Configuration",
+        description="Configuration for the optional feature (only shown when 
Feature Enabled is 'true')",
+        required=False,
+        expression_language_scope=ExpressionLanguageScope.FLOWFILE_ATTRIBUTES,
+        dependencies=[PropertyDependency(SHARED_FEATURE_ENABLED, "true")],
+        validators=[StandardValidators.NON_EMPTY_VALIDATOR]
+    )
+
+    def __init__(self, **kwargs):
+        super().__init__()
+        self.descriptors = [
+            SHARED_OUTPUT_FORMAT,
+            SHARED_FEATURE_ENABLED,
+            self.JSON_PRETTY_PRINT,
+            self.FEATURE_CONFIG
+        ]
+
+    def getPropertyDescriptors(self):
+        return self.descriptors
+
+    def transform(self, context, flowfile):
+        output_format = 
context.getProperty(SHARED_OUTPUT_FORMAT.name).getValue()
+        contents = f"Output format: {output_format}"
+        return FlowFileTransformResult(relationship='success', 
contents=contents)
+
diff --git 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/imported_property_dependency/SharedProperties.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/imported_property_dependency/SharedProperties.py
new file mode 100644
index 0000000000..5cafb2566a
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/imported_property_dependency/SharedProperties.py
@@ -0,0 +1,44 @@
+# 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.
+
+"""
+This module defines shared PropertyDescriptors that can be imported
+by multiple processors. This is a common pattern for reusing property
+definitions across processors.
+"""
+
+from nifiapi.properties import PropertyDescriptor, StandardValidators
+
+# A shared property that controls the output format
+# This property is intended to be imported and used by other processors
+SHARED_OUTPUT_FORMAT = PropertyDescriptor(
+    name="Output Format",
+    description="The format of the output data",
+    allowable_values=["json", "xml", "csv"],
+    default_value="json",
+    required=True,
+    validators=[StandardValidators.NON_EMPTY_VALIDATOR]
+)
+
+# Another shared property for enabling/disabling features
+SHARED_FEATURE_ENABLED = PropertyDescriptor(
+    name="Feature Enabled",
+    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/imported_property_dependency/__init__.py
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/imported_property_dependency/__init__.py
new file mode 100644
index 0000000000..09697dce6e
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-py4j-framework-bundle/nifi-python-framework/src/test/resources/python/framework/imported_property_dependency/__init__.py
@@ -0,0 +1,15 @@
+# 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