From 5d65776cc5e0e0c92d2f7c314220d0de62461b98 Mon Sep 17 00:00:00 2001 From: nimaniko Date: Fri, 27 Feb 2026 03:36:32 +0330 Subject: [PATCH 01/10] fix: optimize function_copy to avoid unnecessary deepcopy - Implement lazy deepcopy in function_copy() method - Only copy metadata when plugin_name actually changes - Reuse metadata reference when no modification needed - performance improvement in function_copy operations - Add unit tests to verify lazy copy behavior --- .../functions/kernel_function.py | 8 +- .../test_function_copy_optimization.py | 97 +++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 python/tests/unit/functions/test_function_copy_optimization.py diff --git a/python/semantic_kernel/functions/kernel_function.py b/python/semantic_kernel/functions/kernel_function.py index 15f2ab34c6b7..8928e7315fd8 100644 --- a/python/semantic_kernel/functions/kernel_function.py +++ b/python/semantic_kernel/functions/kernel_function.py @@ -388,9 +388,13 @@ def function_copy(self, plugin_name: str | None = None) -> "KernelFunction": KernelFunction: The copied function. """ cop: KernelFunction = copy(self) - cop.metadata = deepcopy(self.metadata) - if plugin_name: + # Only copy metadata if we need to modify plugin_name + if plugin_name and plugin_name != self.metadata.plugin_name: + cop.metadata = self.metadata.model_copy() # Shallow copy via Pydantic cop.metadata.plugin_name = plugin_name + else: + # Reuse reference when no modification needed (safe as metadata is immutable in practice) + cop.metadata = self.metadata return cop def _handle_exception(self, current_span: trace.Span, exception: Exception, attributes: dict[str, str]) -> None: diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py new file mode 100644 index 000000000000..8e7c93941580 --- /dev/null +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for function_copy optimization (Issue #1: Lazy deepcopy).""" + +import pytest +from unittest.mock import patch + +from semantic_kernel.functions.kernel_function import KernelFunction +from semantic_kernel.functions import kernel_function + + +@pytest.fixture +def sample_function(): + """Create a sample kernel function for testing.""" + @kernel_function + def test_func(input: str) -> str: + return f"Result: {input}" + + return test_func + + +class TestFunctionCopyOptimization: + """Test suite for function_copy lazy deepcopy optimization.""" + + def test_function_copy_same_plugin_no_deepcopy(self, sample_function): + """Test that function_copy doesn't deepcopy when plugin_name is same or None. + + This tests the optimization where metadata is reused when no plugin_name change. + """ + original_plugin = sample_function.plugin_name + + # Case 1: No plugin_name provided (should reuse reference) + copy1 = sample_function.function_copy() + assert copy1.metadata is sample_function.metadata # Should be same reference + assert copy1.plugin_name == original_plugin + + # Case 2: Same plugin_name provided (should reuse reference) + copy2 = sample_function.function_copy(original_plugin) + assert copy2.metadata is sample_function.metadata # Should be same reference + + def test_function_copy_different_plugin_creates_copy(self, sample_function): + """Test that function_copy does create a shallow copy when plugin_name changes. + + This tests that when we actually need to change the plugin_name, + a shallow copy is created. + """ + new_plugin_name = "new_plugin" + copy = sample_function.function_copy(new_plugin_name) + + # Metadata should be different object (copied) + assert copy.metadata is not sample_function.metadata + # But should have the new plugin_name + assert copy.metadata.plugin_name == new_plugin_name + # Original should be unchanged + assert sample_function.metadata.plugin_name != new_plugin_name + + def test_function_copy_preserves_function_behavior(self, sample_function): + """Test that copied function still works correctly.""" + copy = sample_function.function_copy() + + # Verify function metadata is preserved + assert copy.name == sample_function.name + assert copy.description == sample_function.description + # Verify function is callable (indirectly through having same underlying function) + assert hasattr(copy, 'invoke') + + @patch('semantic_kernel.functions.kernel_function.deepcopy', side_effect=AssertionError("deepcopy should not be called")) + def test_function_copy_no_unnecessary_deepcopy(self, mock_deepcopy, sample_function): + """Test that deepcopy is NOT called when plugin_name doesn't change. + + This is the key optimization test - it verifies that the old problematic + deepcopy is not being called anymore. + """ + # When plugin_name is None or same, deepcopy should not be called + try: + sample_function.function_copy() + # If we get here, deepcopy was not called (good!) + assert True + except AssertionError as e: + if "deepcopy should not be called" in str(e): + pytest.fail("function_copy still calls deepcopy unnecessarily") + raise + + def test_function_copy_multiple_calls_same_plugin(self, sample_function): + """Test that multiple copies with same plugin reuse metadata. + + This tests the performance benefit of reusing metadata references + when no change is needed. + """ + copy1 = sample_function.function_copy() + copy2 = sample_function.function_copy() + copy3 = sample_function.function_copy(sample_function.plugin_name) + + # All should reference the same original metadata + assert copy1.metadata is sample_function.metadata + assert copy2.metadata is sample_function.metadata + assert copy3.metadata is sample_function.metadata From 2e65593c0522ad7d9d3bec3d2c53a36eae6b61ad Mon Sep 17 00:00:00 2001 From: Nima Nikunazar Date: Tue, 24 Mar 2026 13:00:27 +0330 Subject: [PATCH 02/10] Update python/semantic_kernel/functions/kernel_function.py For enable deep copy Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- python/semantic_kernel/functions/kernel_function.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function.py b/python/semantic_kernel/functions/kernel_function.py index 8928e7315fd8..bdd0d372c953 100644 --- a/python/semantic_kernel/functions/kernel_function.py +++ b/python/semantic_kernel/functions/kernel_function.py @@ -388,9 +388,7 @@ def function_copy(self, plugin_name: str | None = None) -> "KernelFunction": KernelFunction: The copied function. """ cop: KernelFunction = copy(self) - # Only copy metadata if we need to modify plugin_name - if plugin_name and plugin_name != self.metadata.plugin_name: - cop.metadata = self.metadata.model_copy() # Shallow copy via Pydantic + cop.metadata = self.metadata.model_copy(update={"plugin_name": plugin_name}, deep=True) cop.metadata.plugin_name = plugin_name else: # Reuse reference when no modification needed (safe as metadata is immutable in practice) From e50fbd05c26dc9e969547f63fb8a3455124a3485 Mon Sep 17 00:00:00 2001 From: Nima Nikunazar Date: Tue, 24 Mar 2026 13:29:28 +0330 Subject: [PATCH 03/10] Update python/semantic_kernel/functions/kernel_function.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/semantic_kernel/functions/kernel_function.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function.py b/python/semantic_kernel/functions/kernel_function.py index bdd0d372c953..686815e72ad5 100644 --- a/python/semantic_kernel/functions/kernel_function.py +++ b/python/semantic_kernel/functions/kernel_function.py @@ -388,11 +388,12 @@ def function_copy(self, plugin_name: str | None = None) -> "KernelFunction": KernelFunction: The copied function. """ cop: KernelFunction = copy(self) - cop.metadata = self.metadata.model_copy(update={"plugin_name": plugin_name}, deep=True) - cop.metadata.plugin_name = plugin_name - else: - # Reuse reference when no modification needed (safe as metadata is immutable in practice) - cop.metadata = self.metadata + # Always deep-copy metadata to avoid shared mutable state between function copies. + new_plugin_name = plugin_name if plugin_name is not None else self.metadata.plugin_name + cop.metadata = self.metadata.model_copy( + update={"plugin_name": new_plugin_name}, + deep=True, + ) return cop def _handle_exception(self, current_span: trace.Span, exception: Exception, attributes: dict[str, str]) -> None: From 9fa1db6ff0515ceee11fe010e69527ba4cb589b6 Mon Sep 17 00:00:00 2001 From: Nima Nikunazar Date: Tue, 24 Mar 2026 13:29:42 +0330 Subject: [PATCH 04/10] Update python/tests/unit/functions/test_function_copy_optimization.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../tests/unit/functions/test_function_copy_optimization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py index 8e7c93941580..a04770b8899e 100644 --- a/python/tests/unit/functions/test_function_copy_optimization.py +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -11,12 +11,12 @@ @pytest.fixture def sample_function(): - """Create a sample kernel function for testing.""" @kernel_function def test_func(input: str) -> str: return f"Result: {input}" - - return test_func + + from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod + return KernelFunctionFromMethod(method=test_func, plugin_name="test_plugin") class TestFunctionCopyOptimization: From 4bb0c42d28d7c26919d225e34370b35b42cfc3a7 Mon Sep 17 00:00:00 2001 From: Nima Nikunazar Date: Tue, 24 Mar 2026 14:16:27 +0330 Subject: [PATCH 05/10] Update python/tests/unit/functions/test_function_copy_optimization.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/tests/unit/functions/test_function_copy_optimization.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py index a04770b8899e..91f35055fc7b 100644 --- a/python/tests/unit/functions/test_function_copy_optimization.py +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -5,7 +5,6 @@ import pytest from unittest.mock import patch -from semantic_kernel.functions.kernel_function import KernelFunction from semantic_kernel.functions import kernel_function From e84eb17826d65b277b6b2736f85ee738037503d4 Mon Sep 17 00:00:00 2001 From: Nima Nikunazar Date: Tue, 24 Mar 2026 14:16:41 +0330 Subject: [PATCH 06/10] Update python/tests/unit/functions/test_function_copy_optimization.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../unit/functions/test_function_copy_optimization.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py index 91f35055fc7b..9e4704e9c460 100644 --- a/python/tests/unit/functions/test_function_copy_optimization.py +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -71,10 +71,11 @@ def test_function_copy_no_unnecessary_deepcopy(self, mock_deepcopy, sample_funct deepcopy is not being called anymore. """ # When plugin_name is None or same, deepcopy should not be called + original_metadata = sample_function.metadata try: - sample_function.function_copy() - # If we get here, deepcopy was not called (good!) - assert True + copy = sample_function.function_copy() + # If we get here, deepcopy was not called and metadata reference was reused + assert copy.metadata is original_metadata except AssertionError as e: if "deepcopy should not be called" in str(e): pytest.fail("function_copy still calls deepcopy unnecessarily") From 8296e4283d803b902b5176fb68ac3402635a3214 Mon Sep 17 00:00:00 2001 From: nimaniko Date: Sun, 5 Apr 2026 22:53:46 +0330 Subject: [PATCH 07/10] Fixup errors on pre commit --- .../unit/functions/test_function_copy_optimization.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py index 9e4704e9c460..99f028d58b2e 100644 --- a/python/tests/unit/functions/test_function_copy_optimization.py +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -2,8 +2,8 @@ """Tests for function_copy optimization (Issue #1: Lazy deepcopy).""" -import pytest from unittest.mock import patch +import pytest from semantic_kernel.functions import kernel_function @@ -63,7 +63,10 @@ def test_function_copy_preserves_function_behavior(self, sample_function): # Verify function is callable (indirectly through having same underlying function) assert hasattr(copy, 'invoke') - @patch('semantic_kernel.functions.kernel_function.deepcopy', side_effect=AssertionError("deepcopy should not be called")) + @patch( + 'semantic_kernel.functions.kernel_function.deepcopy', + side_effect=AssertionError("deepcopy should not be called") + ) def test_function_copy_no_unnecessary_deepcopy(self, mock_deepcopy, sample_function): """Test that deepcopy is NOT called when plugin_name doesn't change. From 083b49f7d1baaacfc6d9a2bd9f13b63fbc0c8679 Mon Sep 17 00:00:00 2001 From: nimaniko Date: Thu, 23 Apr 2026 01:09:05 +0330 Subject: [PATCH 08/10] Apply ruff formatting fixes --- .../test_function_copy_optimization.py | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py index 99f028d58b2e..f24396bd22b7 100644 --- a/python/tests/unit/functions/test_function_copy_optimization.py +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -3,6 +3,7 @@ """Tests for function_copy optimization (Issue #1: Lazy deepcopy).""" from unittest.mock import patch + import pytest from semantic_kernel.functions import kernel_function @@ -15,61 +16,62 @@ def test_func(input: str) -> str: return f"Result: {input}" from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod + return KernelFunctionFromMethod(method=test_func, plugin_name="test_plugin") class TestFunctionCopyOptimization: """Test suite for function_copy lazy deepcopy optimization.""" - + def test_function_copy_same_plugin_no_deepcopy(self, sample_function): """Test that function_copy doesn't deepcopy when plugin_name is same or None. - + This tests the optimization where metadata is reused when no plugin_name change. """ original_plugin = sample_function.plugin_name - + # Case 1: No plugin_name provided (should reuse reference) copy1 = sample_function.function_copy() assert copy1.metadata is sample_function.metadata # Should be same reference assert copy1.plugin_name == original_plugin - + # Case 2: Same plugin_name provided (should reuse reference) copy2 = sample_function.function_copy(original_plugin) assert copy2.metadata is sample_function.metadata # Should be same reference - + def test_function_copy_different_plugin_creates_copy(self, sample_function): """Test that function_copy does create a shallow copy when plugin_name changes. - + This tests that when we actually need to change the plugin_name, a shallow copy is created. """ new_plugin_name = "new_plugin" copy = sample_function.function_copy(new_plugin_name) - + # Metadata should be different object (copied) assert copy.metadata is not sample_function.metadata # But should have the new plugin_name assert copy.metadata.plugin_name == new_plugin_name # Original should be unchanged assert sample_function.metadata.plugin_name != new_plugin_name - + def test_function_copy_preserves_function_behavior(self, sample_function): """Test that copied function still works correctly.""" copy = sample_function.function_copy() - + # Verify function metadata is preserved assert copy.name == sample_function.name assert copy.description == sample_function.description # Verify function is callable (indirectly through having same underlying function) - assert hasattr(copy, 'invoke') - + assert hasattr(copy, "invoke") + @patch( - 'semantic_kernel.functions.kernel_function.deepcopy', - side_effect=AssertionError("deepcopy should not be called") + "semantic_kernel.functions.kernel_function.deepcopy", + side_effect=AssertionError("deepcopy should not be called"), ) def test_function_copy_no_unnecessary_deepcopy(self, mock_deepcopy, sample_function): """Test that deepcopy is NOT called when plugin_name doesn't change. - + This is the key optimization test - it verifies that the old problematic deepcopy is not being called anymore. """ @@ -83,17 +85,17 @@ def test_function_copy_no_unnecessary_deepcopy(self, mock_deepcopy, sample_funct if "deepcopy should not be called" in str(e): pytest.fail("function_copy still calls deepcopy unnecessarily") raise - + def test_function_copy_multiple_calls_same_plugin(self, sample_function): """Test that multiple copies with same plugin reuse metadata. - + This tests the performance benefit of reusing metadata references when no change is needed. """ copy1 = sample_function.function_copy() copy2 = sample_function.function_copy() copy3 = sample_function.function_copy(sample_function.plugin_name) - + # All should reference the same original metadata assert copy1.metadata is sample_function.metadata assert copy2.metadata is sample_function.metadata From 1d829d6a71b8fac1b68865e183aea0878d4a11de Mon Sep 17 00:00:00 2001 From: nimaniko Date: Fri, 24 Apr 2026 16:56:45 +0330 Subject: [PATCH 09/10] Fixup --- .../functions/kernel_function.py | 15 +++-- .../test_function_copy_optimization.py | 61 ++++++++----------- 2 files changed, 33 insertions(+), 43 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function.py b/python/semantic_kernel/functions/kernel_function.py index 686815e72ad5..2cb1c4bbfe7f 100644 --- a/python/semantic_kernel/functions/kernel_function.py +++ b/python/semantic_kernel/functions/kernel_function.py @@ -379,22 +379,25 @@ async def invoke_stream( KernelFunctionLogMessages.log_function_streaming_completed(logger, duration) def function_copy(self, plugin_name: str | None = None) -> "KernelFunction": - """Copy the function, can also override the plugin_name. + """Copy the function and optionally override the plugin name. + + Metadata is always deep-copied so each function copy owns an independent + metadata instance, even when the plugin name does not change. Args: - plugin_name (str): The new plugin name. + plugin_name (str | None): The new plugin name, or None to preserve the current one. Returns: KernelFunction: The copied function. """ - cop: KernelFunction = copy(self) - # Always deep-copy metadata to avoid shared mutable state between function copies. + copied_function: KernelFunction = copy(self) + # Keep metadata isolated across copies to avoid shared mutable state. new_plugin_name = plugin_name if plugin_name is not None else self.metadata.plugin_name - cop.metadata = self.metadata.model_copy( + copied_function.metadata = self.metadata.model_copy( update={"plugin_name": new_plugin_name}, deep=True, ) - return cop + return copied_function def _handle_exception(self, current_span: trace.Span, exception: Exception, attributes: dict[str, str]) -> None: """Handle the exception. diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py index f24396bd22b7..fc4ae652f977 100644 --- a/python/tests/unit/functions/test_function_copy_optimization.py +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -"""Tests for function_copy optimization (Issue #1: Lazy deepcopy).""" +"""Tests for function_copy metadata copy behavior.""" from unittest.mock import patch @@ -21,23 +21,21 @@ def test_func(input: str) -> str: class TestFunctionCopyOptimization: - """Test suite for function_copy lazy deepcopy optimization.""" + """Test suite for function_copy metadata isolation.""" - def test_function_copy_same_plugin_no_deepcopy(self, sample_function): - """Test that function_copy doesn't deepcopy when plugin_name is same or None. - - This tests the optimization where metadata is reused when no plugin_name change. - """ + def test_function_copy_same_plugin_creates_independent_metadata(self, sample_function): + """Test that function_copy isolates metadata even when plugin_name is unchanged.""" original_plugin = sample_function.plugin_name - # Case 1: No plugin_name provided (should reuse reference) + # Case 1: No plugin_name provided (metadata should still be copied) copy1 = sample_function.function_copy() - assert copy1.metadata is sample_function.metadata # Should be same reference + assert copy1.metadata is not sample_function.metadata assert copy1.plugin_name == original_plugin - # Case 2: Same plugin_name provided (should reuse reference) + # Case 2: Same plugin_name explicitly provided (metadata should still be copied) copy2 = sample_function.function_copy(original_plugin) - assert copy2.metadata is sample_function.metadata # Should be same reference + assert copy2.metadata is not sample_function.metadata + assert copy2.plugin_name == original_plugin def test_function_copy_different_plugin_creates_copy(self, sample_function): """Test that function_copy does create a shallow copy when plugin_name changes. @@ -69,34 +67,23 @@ def test_function_copy_preserves_function_behavior(self, sample_function): "semantic_kernel.functions.kernel_function.deepcopy", side_effect=AssertionError("deepcopy should not be called"), ) - def test_function_copy_no_unnecessary_deepcopy(self, mock_deepcopy, sample_function): - """Test that deepcopy is NOT called when plugin_name doesn't change. - - This is the key optimization test - it verifies that the old problematic - deepcopy is not being called anymore. - """ - # When plugin_name is None or same, deepcopy should not be called + def test_function_copy_no_module_deepcopy_usage(self, mock_deepcopy, sample_function): + """Test that function_copy does not call the module-level deepcopy helper.""" original_metadata = sample_function.metadata - try: - copy = sample_function.function_copy() - # If we get here, deepcopy was not called and metadata reference was reused - assert copy.metadata is original_metadata - except AssertionError as e: - if "deepcopy should not be called" in str(e): - pytest.fail("function_copy still calls deepcopy unnecessarily") - raise - - def test_function_copy_multiple_calls_same_plugin(self, sample_function): - """Test that multiple copies with same plugin reuse metadata. - - This tests the performance benefit of reusing metadata references - when no change is needed. - """ + copy = sample_function.function_copy() + + assert copy.metadata is not original_metadata + mock_deepcopy.assert_not_called() + + def test_function_copy_multiple_calls_same_plugin_are_isolated(self, sample_function): + """Test that multiple copies with same plugin keep independent metadata.""" copy1 = sample_function.function_copy() copy2 = sample_function.function_copy() copy3 = sample_function.function_copy(sample_function.plugin_name) - # All should reference the same original metadata - assert copy1.metadata is sample_function.metadata - assert copy2.metadata is sample_function.metadata - assert copy3.metadata is sample_function.metadata + assert copy1.metadata is not sample_function.metadata + assert copy2.metadata is not sample_function.metadata + assert copy3.metadata is not sample_function.metadata + assert copy1.metadata is not copy2.metadata + assert copy2.metadata is not copy3.metadata + assert copy1.metadata is not copy3.metadata From c420c52adb5fcea657283519f1a88fa6e38fa25c Mon Sep 17 00:00:00 2001 From: nimaniko Date: Mon, 28 Sep 2026 20:22:41 +0330 Subject: [PATCH 10/10] fix: preserve plugin_name validation in function_copy model_copy(update=...) bypasses field validation, so it could silently create metadata with an invalid plugin_name (e.g. "bad-name" or ""). Deep-copy the metadata first, then assign the new plugin_name normally so validate_assignment=True enforces the plugin name pattern. Add regression tests covering invalid and empty plugin names. Co-Authored-By: Claude Sonnet 5 --- .../functions/kernel_function.py | 10 ++++----- .../test_function_copy_optimization.py | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/python/semantic_kernel/functions/kernel_function.py b/python/semantic_kernel/functions/kernel_function.py index 2cb1c4bbfe7f..8cb4582675f5 100644 --- a/python/semantic_kernel/functions/kernel_function.py +++ b/python/semantic_kernel/functions/kernel_function.py @@ -392,11 +392,11 @@ def function_copy(self, plugin_name: str | None = None) -> "KernelFunction": """ copied_function: KernelFunction = copy(self) # Keep metadata isolated across copies to avoid shared mutable state. - new_plugin_name = plugin_name if plugin_name is not None else self.metadata.plugin_name - copied_function.metadata = self.metadata.model_copy( - update={"plugin_name": new_plugin_name}, - deep=True, - ) + copied_function.metadata = self.metadata.model_copy(deep=True) + if plugin_name is not None: + # Assign normally (rather than via model_copy(update=...)) so that + # validate_assignment=True runs and rejects an invalid plugin name. + copied_function.metadata.plugin_name = plugin_name return copied_function def _handle_exception(self, current_span: trace.Span, exception: Exception, attributes: dict[str, str]) -> None: diff --git a/python/tests/unit/functions/test_function_copy_optimization.py b/python/tests/unit/functions/test_function_copy_optimization.py index fc4ae652f977..9d3892d5dad3 100644 --- a/python/tests/unit/functions/test_function_copy_optimization.py +++ b/python/tests/unit/functions/test_function_copy_optimization.py @@ -75,6 +75,27 @@ def test_function_copy_no_module_deepcopy_usage(self, mock_deepcopy, sample_func assert copy.metadata is not original_metadata mock_deepcopy.assert_not_called() + def test_function_copy_rejects_invalid_plugin_name(self, sample_function): + """Test that function_copy still validates the plugin name on assignment. + + model_copy(update=...) silently skips validation, so this guards against + that regression by asserting an invalid plugin name is rejected. + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError): + sample_function.function_copy("bad-name") + + # Original metadata must remain untouched by the failed copy attempt. + assert sample_function.metadata.plugin_name == "test_plugin" + + def test_function_copy_rejects_empty_plugin_name(self, sample_function): + """Test that an empty string plugin name is rejected, not silently accepted.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + sample_function.function_copy("") + def test_function_copy_multiple_calls_same_plugin_are_isolated(self, sample_function): """Test that multiple copies with same plugin keep independent metadata.""" copy1 = sample_function.function_copy()