From 151c15ffda8e1f70e9cb0aa23112874cbd24fb8b Mon Sep 17 00:00:00 2001 From: oyeong011 Date: Tue, 8 Sep 2026 11:48:47 +0900 Subject: [PATCH 1/2] Preserve configuration when copying ConfigBox Tested: 163 tests passed; copy and copy.copy public API QA Confidence: high Scope-risk: moderate Directive: Copies retain the concrete Box subclass and detach their namespace. --- AUTHORS.rst | 2 ++ CHANGES.rst | 5 +++++ box/box.py | 2 +- box/config_box.py | 6 ------ test/test_config_box.py | 24 +++++++++++++++++++++++- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 6b44851..2f5261b 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -98,3 +98,5 @@ Suggestions and bug reporting: - d00m514y3r - Sébastien Weber (seb5g) - Ward Loos (wrdls) + +- oyeong011 diff --git a/CHANGES.rst b/CHANGES.rst index c4a347d..82dd88d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Unreleased +---------- + +* Preserve ConfigBox options when copying, including frozen_box and default_box. + Version 7.4.1 ------------- diff --git a/box/box.py b/box/box.py index 8252643..dea62c6 100644 --- a/box/box.py +++ b/box/box.py @@ -471,7 +471,7 @@ def get(self, key, default=NO_DEFAULT): def copy(self) -> Box: config = self.__box_config() config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again - return Box(super().copy(), **config) + return self.__class__(super().copy(), **config) def __copy__(self) -> Box: return self.copy() diff --git a/box/config_box.py b/box/config_box.py index 5f8ad55..bf98c88 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -125,9 +125,3 @@ def getfloat(self, item, default=None): def __repr__(self): return f"{self.__class__.__name__}({str(self.to_dict())})" - - def copy(self): - return ConfigBox(super().copy()) - - def __copy__(self): - return ConfigBox(super().copy()) diff --git a/test/test_config_box.py b/test/test_config_box.py index 345ed55..6aed8f4 100644 --- a/test/test_config_box.py +++ b/test/test_config_box.py @@ -1,9 +1,13 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +from copy import copy + +import pytest + from test.common import test_dict -from box import Box, ConfigBox +from box import Box, BoxError, ConfigBox class TestConfigBox: @@ -49,3 +53,21 @@ def test_dir(self): def test_config_default(self): bx4 = Box(default_box=True, default_box_attr=ConfigBox) assert isinstance(bx4.bbbbb, ConfigBox) + + +@pytest.mark.parametrize("copier", [copy, lambda value: value.copy()]) +def test_copy_preserves_frozen_config(copier): + original = ConfigBox(value=1, frozen_box=True) + copied = copier(original) + assert isinstance(copied, ConfigBox) + assert copied is not original + with pytest.raises(BoxError): + copied.value = 2 + + +@pytest.mark.parametrize("copier", [copy, lambda value: value.copy()]) +def test_copy_preserves_default_config(copier): + original = ConfigBox(default_box=True, default_box_attr=7) + copied = copier(original) + assert copied.missing == 7 + assert "missing" not in original From 41bb8b6a56fa353ad6e5141c24c91d4a8beaba48 Mon Sep 17 00:00:00 2001 From: oyeong011 Date: Tue, 8 Sep 2026 11:57:00 +0900 Subject: [PATCH 2/2] Limit copy configuration preservation to ConfigBox Constraint: Generic Box subclasses retain the existing Box return type, including subclasses with required constructor arguments. Rejected: Constructing type(self) in Box.copy | breaks incompatible subclass constructors Tested: 169 tests passed; public copy and copy.copy subclass/namespace QA Confidence: high Scope-risk: narrow --- box/box.py | 2 +- box/config_box.py | 11 +++++++++++ test/test_config_box.py | 24 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/box/box.py b/box/box.py index dea62c6..8252643 100644 --- a/box/box.py +++ b/box/box.py @@ -471,7 +471,7 @@ def get(self, key, default=NO_DEFAULT): def copy(self) -> Box: config = self.__box_config() config.pop("box_namespace") # Detach namespace; it will be reassigned if we nest again - return self.__class__(super().copy(), **config) + return Box(super().copy(), **config) def __copy__(self) -> Box: return self.copy() diff --git a/box/config_box.py b/box/config_box.py index bf98c88..b4e2fa8 100644 --- a/box/config_box.py +++ b/box/config_box.py @@ -125,3 +125,14 @@ def getfloat(self, item, default=None): def __repr__(self): return f"{self.__class__.__name__}({str(self.to_dict())})" + + def copy(self): + config = { + key: value + for key, value in self._box_config.items() + if not key.startswith("__") and key != "box_namespace" + } + return ConfigBox(super().copy(), **config) + + def __copy__(self): + return ConfigBox.copy(self) diff --git a/test/test_config_box.py b/test/test_config_box.py index 6aed8f4..3d5c352 100644 --- a/test/test_config_box.py +++ b/test/test_config_box.py @@ -71,3 +71,27 @@ def test_copy_preserves_default_config(copier): copied = copier(original) assert copied.missing == 7 assert "missing" not in original + + +@pytest.mark.parametrize("copier", [copy, lambda value: value.copy()]) +@pytest.mark.parametrize("base", [Box, ConfigBox]) +def test_copy_preserves_base_type_for_subclass_with_required_argument(copier, base): + class CustomBox(base): + def __init__(self, required, **kwargs): + super().__init__(value=required, **kwargs) + + original = CustomBox(1, frozen_box=True) + copied = copier(original) + assert type(copied) is base + assert copied.value == 1 + assert copied is not original + with pytest.raises(BoxError): + copied.value = 2 + + +@pytest.mark.parametrize("copier", [copy, lambda value: value.copy()]) +def test_config_copy_detaches_namespace_and_hides_internal_config(copier): + original = ConfigBox(value=1, box_namespace=("parent",)) + copied = copier(original) + assert copied == {"value": 1} + assert copied._box_config["box_namespace"] == ()