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.
+