Repository: cdgriffith/Box

Issue:
Support for tracking the box "namespace"
I have some nested Boxes using an overridden `__convert_and_store` in a subclass to convert values to an expected type (eg: I want all values to be an `int` or some other custom class). For that conversion, I would like to know the "namespace" of the Box/value in order to reference it in the conversion and  show nicer error messages.

For example, when handling the conversion of `5` in the example below:
```python
box = MyBox({"a": {"b": {"c": 5}}})
```
I would like to know that I'm assigning to `("a", "b", "c")`.

This is quite hard to setup only in a subclass because of the recursive value conversion (and copying) during `__init__` and conversion doesn't give a good hook to intercept _after_ the box is created, but _before_ sub-boxes are created.

I think the easiest way to support this is by adding a `box_path` or `box_namespace` param to `__init__` (defaulting to `()`) and then into `_box_config`, which can be passed through (and extended) in `__get_default` and `__convert_and_store` when creating sub-boxes.

Is this something you'd be open to? I'd be happy to make a PR for this if so!

Discussion:
JacobHayes: Pushed a fix for `None` keys and the dup imports!

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 9fbd58f..1760874 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,10 +1,12 @@
 Changelog
 =========
 
-Version 6.2.0
+Version 7.0.0
 -------------
 
 * Adding support for properties and setters in subclasses (thanks to Serge Lu)
+* Adding way to track changes to the box (thanks to Jacob Hayes)
+* Fixing how `|` and `+` updates were performed for right operations (thanks to aviveh21)
 * Fixing typos (thanks to Martin Schorfmann)
 * Fixing no implicit optionals with type hinting
 
diff --git a/box/__init__.py b/box/__init__.py
index 1f095b7..12a9aa1 100644
--- a/box/__init__.py
+++ b/box/__init__.py
@@ -2,7 +2,7 @@
 # -*- coding: utf-8 -*-
 
 __author__ = "Chris Griffith"
-__version__ = "6.2.0"
+__version__ = "7.0.0"
 
 from box.box import Box
 from box.box_list import BoxList
diff --git a/box/box.py b/box/box.py
index c84508d..9d5790d 100644
--- a/box/box.py
+++ b/box/box.py
@@ -8,9 +8,10 @@ Improved dictionary access through dot notation with additional tools.
 import copy
 import re
 import warnings
+import sys
 from keyword import iskeyword
 from os import PathLike
-from typing import Optional, Any, Dict, Generator, List, Tuple, Union, Type
+from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union
 
 try:
     from typing import Callable, Iterable, Mapping
@@ -44,6 +45,8 @@ _list_pos_re = re.compile(r"\[(\d+)\]")
 # a sentinel object for indicating no default, in order to allow users
 # to pass `None` as a valid default value
 NO_DEFAULT = object()
+# a sentinel object for indicating when to skip adding a new namespace, allowing `None` keys
+NO_NAMESPACE = object()
 
 
 def _exception_cause(e):
@@ -158,6 +161,7 @@ class Box(dict):
     :param box_recast: cast certain keys to a specified type
     :param box_dots: access nested Boxes by period separated keys in string
     :param box_class: change what type of class sub-boxes will be created as
+    :param box_namespace: the namespace this (possibly nested) Box lives within
     """
 
     _box_config: Dict[str, Any]
@@ -190,6 +194,7 @@ class Box(dict):
         box_recast: Optional[Dict] = None,
         box_dots: bool = False,
         box_class: Optional[Union[Dict, Type["Box"]]] = None,
+        box_namespace: Tuple[str, ...] = (),
         **kwargs: Any,
     ):
         """
@@ -214,6 +219,7 @@ class Box(dict):
                 "box_recast": box_recast,
                 "box_dots": box_dots,
                 "box_class": box_class if box_class is not None else Box,
+                "box_namespace": box_namespace,
             }
         )
         return obj
@@ -235,6 +241,7 @@ class Box(dict):
         box_recast: Optional[Dict] = None,
         box_dots: bool = False,
         box_class: Optional[Union[Dict, Type["Box"]]] = None,
+        box_namespace: Tuple[str, ...] = (),
         **kwargs: Any,
     ):
         super().__init__()
@@ -255,6 +262,7 @@ class Box(dict):
                 "box_recast": box_recast,
                 "box_dots": box_dots,
                 "box_class": box_class if box_class is not None else self.__class__,
+                "box_namespace": box_namespace,
             }
         )
         if not self._box_config["conversion_box"] and self._box_config["box_duplicates"] != "ignore":
@@ -294,8 +302,10 @@ class Box(dict):
     def __radd__(self, other: Mapping[Any, Any]):
         if not isinstance(other, dict):
             raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.")
-        new_box = self.copy()
-        new_box.merge_update(other)
+        new_box = other.copy()
+        if not isinstance(other, Box):
+            new_box = self._box_config["box_class"](new_box)
+        new_box.merge_update(self)  # type: ignore[attr-defined]
         return new_box
 
     def __iadd__(self, other: Mapping[Any, Any]):
@@ -307,6 +317,8 @@ class Box(dict):
     def __or__(self, other: Mapping[Any, Any]):
         if not isinstance(other, dict):
             raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.")
+        if sys.version_info >= (3, 9):
+            return super().__or__(other)
         new_box = self.copy()
         new_box.update(other)
         return new_box
@@ -314,13 +326,19 @@ class Box(dict):
     def __ror__(self, other: Mapping[Any, Any]):
         if not isinstance(other, dict):
             raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.")
-        new_box = self.copy()
-        new_box.update(other)
+        if sys.version_info >= (3, 9) and isinstance(other, Box):
+            return super().__ror__(other)
+        new_box = other.copy()
+        if not isinstance(other, Box):
+            new_box = self._box_config["box_class"](new_box)
+        new_box.update(self)
         return new_box
 
     def __ior__(self, other: Mapping[Any, Any]):  # type: ignore[override]
         if not isinstance(other, dict):
             raise BoxTypeError("Box can only merge two boxes or a box and a dictionary.")
+        if sys.version_info >= (3, 9):
+            return super().__ior__(other)
         self.update(other)
         return self
 
@@ -328,6 +346,7 @@ class Box(dict):
         frozen = self._box_config["frozen_box"]
         config = self.__box_config()
         config["frozen_box"] = False
+        config.pop("box_namespace")  # Detach namespace; it will be reassigned if we nest again
         output = self._box_config["box_class"](**config)
         if not isinstance(other, dict):
             raise BoxError("Box can only compare two boxes or a box and a dictionary.")
@@ -430,10 +449,12 @@ class Box(dict):
         return self[key]
 
     def copy(self) -> "Box":
-        return Box(super().copy(), **self.__box_config())
+        config = self.__box_config()
+        config.pop("box_namespace")  # Detach namespace; it will be reassigned if we nest again
+        return Box(super().copy(), **config)
 
     def __copy__(self) -> "Box":
-        return Box(super().copy(), **self.__box_config())
+        return self.copy()
 
     def __deepcopy__(self, memodict=None) -> "Box":
         frozen = self._box_config["frozen_box"]
@@ -454,11 +475,11 @@ class Box(dict):
     def __get_default(self, item, attr=False):
         default_value = self._box_config["default_box_attr"]
         if default_value in (self._box_config["box_class"], dict):
-            value = self._box_config["box_class"](**self.__box_config())
+            value = self._box_config["box_class"](**self.__box_config(extra_namespace=item))
         elif isinstance(default_value, dict):
-            value = self._box_config["box_class"](**self.__box_config(), **default_value)
+            value = self._box_config["box_class"](**self.__box_config(extra_namespace=item), **default_value)
         elif isinstance(default_value, list):
-            value = box.BoxList(**self.__box_config())
+            value = box.BoxList(**self.__box_config(extra_namespace=item))
         elif isinstance(default_value, Callable):
             value = default_value()
         elif hasattr(default_value, "copy"):
@@ -470,11 +491,13 @@ class Box(dict):
                 super().__setitem__(item, value)
         return value
 
-    def __box_config(self) -> Dict:
+    def __box_config(self, extra_namespace: Any = NO_NAMESPACE) -> Dict:
         out = {}
         for k, v in self._box_config.copy().items():
             if not k.startswith("__"):
                 out[k] = v
+        if extra_namespace is not NO_NAMESPACE:
+            out["box_namespace"] = (*out["box_namespace"], extra_namespace)
         return out
 
     def __recast(self, item, value):
@@ -501,18 +524,20 @@ class Box(dict):
         # This is the magic sauce that makes sub dictionaries into new box objects
         if isinstance(value, dict):
             # We always re-create even if it was already a Box object to pass down configurations correctly
-            value = self._box_config["box_class"](value, **self.__box_config())
+            value = self._box_config["box_class"](value, **self.__box_config(extra_namespace=item))
         elif isinstance(value, list) and not isinstance(value, box.BoxList):
             if self._box_config["frozen_box"]:
                 value = _recursive_tuples(
-                    value, recreate_tuples=self._box_config["modify_tuples_box"], **self.__box_config()
+                    value,
+                    recreate_tuples=self._box_config["modify_tuples_box"],
+                    **self.__box_config(extra_namespace=item),
                 )
             else:
-                value = box.BoxList(value, **self.__box_config())
+                value = box.BoxList(value, **self.__box_config(extra_namespace=item))
         elif isinstance(value, box.BoxList):
-            value.box_options.update(self.__box_config())
+            value.box_options.update(self.__box_config(extra_namespace=item))
         elif self._box_config["modify_tuples_box"] and isinstance(value, tuple):
-            value = _recursive_tuples(value, recreate_tuples=True, **self.__box_config())
+            value = _recursive_tuples(value, recreate_tuples=True, **self.__box_config(extra_namespace=item))
         super().__setitem__(item, value)
 
     def __getitem__(self, item, _ignore_default=False):
@@ -747,12 +772,12 @@ class Box(dict):
             if isinstance(v, dict) and not intact_type:
                 # Box objects must be created in case they are already
                 # in the `converted` box_config set
-                v = self._box_config["box_class"](v, **self.__box_config())
+                v = self._box_config["box_class"](v, **self.__box_config(extra_namespace=k))
                 if k in self and isinstance(self[k], dict):
                     self[k].merge_update(v)
                     return
             if isinstance(v, list) and not intact_type:
-                v = box.BoxList(v, **self.__box_config())
+                v = box.BoxList(v, **self.__box_config(extra_namespace=k))
                 if merge_type == "extend" and k in self and isinstance(self[k], list):
                     self[k].extend(v)
                     return
@@ -786,9 +811,9 @@ class Box(dict):
                 return self[item]
 
         if isinstance(default, dict):
-            default = self._box_config["box_class"](default, **self.__box_config())
+            default = self._box_config["box_class"](default, **self.__box_config(extra_namespace=item))
         if isinstance(default, list):
-            default = box.BoxList(default, **self.__box_config())
+            default = box.BoxList(default, **self.__box_config(extra_namespace=item))
         self[item] = default
         return self[item]
 


Test patch:
diff --git a/test/test_box.py b/test/test_box.py
index 96e415d..9fe4b2a 100644
--- a/test/test_box.py
+++ b/test/test_box.py
@@ -560,7 +560,9 @@ class TestBox:
             data.widget._bad_value
 
         base_config = data._Box__box_config()
+        assert base_config.pop("box_namespace") == ()
         widget_config = data.widget._Box__box_config()
+        assert widget_config.pop("box_namespace") == ("widget",)
 
         assert base_config == widget_config, "{} != {}".format(base_config, widget_config)
 
@@ -1005,8 +1007,9 @@ class TestBox:
         d | a
         b = dict(c=1, d={"sub": 1}, e=1)
         c = Box(d={"val": 2}, e=4)
-        assert c.__radd__(b) == Box(c=1, d={"sub": 1, "val": 2}, e=1)
+        assert (b + c) == Box(c=1, d={"sub": 1, "val": 2}, e=4)
         assert c + b == Box(c=1, d={"sub": 1, "val": 2}, e=1)
+        assert isinstance(b | c, Box)
         with pytest.raises(BoxError):
             BoxList() + Box()
 
@@ -1029,8 +1032,9 @@ class TestBox:
     def test_ror_boxes(self):
         b = dict(c=1, d={"sub": 1}, e=1)
         c = Box(d={"val": 2}, e=4)
-        assert c.__ror__(b) == Box(c=1, d={"sub": 1}, e=1)
+        assert c.__ror__(b) == Box(c=1, d={"val": 2}, e=4)
         assert c | b == Box(c=1, d={"sub": 1}, e=1)
+        assert isinstance(b | c, Box)
         with pytest.raises(BoxError):
             BoxList() | Box()
 
@@ -1385,3 +1389,33 @@ class TestBox:
         assert box._field == 5
         del box.field
         assert not "_field" in box
+
+    def test_box_namespace(self):
+        box = Box(default_box=True)
+        assert box._box_config["box_namespace"] == ()
+        box.a.b.c = 5
+        assert box.a._box_config["box_namespace"] == ("a",)
+        assert box.a.b._box_config["box_namespace"] == ("a", "b")
+        box.x = {"y": {"z": 5}}
+        assert box.x._box_config["box_namespace"] == ("x",)
+        assert box.x.y._box_config["box_namespace"] == ("x", "y")
+        box[None][1][2] = 3
+        assert box[None][1]._box_config["box_namespace"] == (None, 1)
+
+        for modified_box in [
+            box.a + box.x,
+            box.a - box.x,
+            box.a | box.x,
+        ]:
+            assert modified_box._box_config["box_namespace"] == ()
+            assert modified_box.b._box_config["box_namespace"] == ("b",)
+            assert modified_box.y._box_config["box_namespace"] == ("y",)
+
+        box.modified = {}
+        assert box.modified._box_config["box_namespace"] == ("modified",)
+        box.modified += box.a
+        assert box.modified.b._box_config["box_namespace"] == ("modified", "b")
+        box.modified |= box.x
+        assert box.modified.y._box_config["box_namespace"] == ("modified", "y")
+        box.modified -= box.a
+        assert box.modified._box_config["box_namespace"] == ("modified",)