Repository: dynaconf/dynaconf
Pull request #1309 (2025-07-10 13:02:19)

[PULP-647] Add and test alternative Box (#1298/part 1)
Replaces https://github.com/dynaconf/dynaconf/pull/1306
Experimenting with the plan [here](https://github.com/dynaconf/dynaconf/issues/1298#issuecomment-3046242845).
Taking https://github.com/dynaconf/dynaconf/pull/1162/ in consideration.

This first part should ensure the new implementation complies with most Box usage, without yet replacing it.
Also, it should add some unit test hardening to the desired behavior of those classes.

Summary:
- [x] Create functional DataDict and DataList
- [x] Tests:
    - [x] Adapt test from DynaBox to DataDict
    - [x] Move some test from experimental dynaconf 4 branch
    - [x] Create more unit tests
- [x] Add deprecation messages for Box compatible methods


Patch:
diff --git a/dynaconf/nodes.py b/dynaconf/nodes.py
new file mode 100644
index 0000000..24bdd2e
--- /dev/null
+++ b/dynaconf/nodes.py
@@ -0,0 +1,676 @@
+"""The nodes model for dynaconf.
+
+Internally, the user facing settings data are special data nodes that can store
+metadata about itself. They are designed to have compatible API's with it's corresponding
+data type (E.g, dict, list).
+
+Besides storing it's own internal state (e.g, it's schema, hooks, lazy values),
+the nodes also store a shared state which is called `DynaconfCore`.
+This is designed to store legacy `Settings` attributes such as `._fresh, ._store, ._deleted, etc`.
+"""
+
+from __future__ import annotations
+
+import copy
+import warnings
+from dataclasses import dataclass
+from typing import Optional
+from typing import Union
+
+import dynaconf.utils as ut
+from dynaconf.vendor.box import converters
+
+
+class DynaconfNotInitialized(BaseException): ...
+
+
+def box_deprecation_warning(
+    method_name: str, class_name: str, alternative: Optional[str] = None
+):
+    """Issue a deprecation warning for Box compatibility methods."""
+    message = f"{class_name}.{method_name}() is deprecated and will be removed in v4.0."
+    if alternative:
+        message += f" {alternative}"
+    warnings.warn(message, DeprecationWarning, stacklevel=3)
+
+
+class DynaconfCore:
+    def __init__(self, id: str):
+        self.id = id
+
+
+@dataclass
+class NodeMetadata:
+    """
+    The namespace for any internal state of the data-node.
+    """
+
+    core: Optional[DynaconfCore] = None
+    data_env: str = "default"
+
+
+# Utilities for manipulating Data nodes.
+
+# Adding those directly to the data nodes causes some overhead and clutters
+# the class namespace. It should be as close as possible to 'dict'.
+
+
+def init_core(node, dynaconf_core: DynaconfCore):
+    if not node.__meta__.core:
+        node.__meta__.core = dynaconf_core
+
+
+def get_core(node, raises=True) -> DynaconfCore:
+    dynaconf_core = node.__meta__.core
+    if not dynaconf_core and raises is True:
+        raise DynaconfNotInitialized("Dynaconf not initialized.")
+    return dynaconf_core
+
+
+def convert_containers(data: dict | list | DataNode, iter, core):
+    for key, value in iter:
+        if value.__class__ is dict:
+            data[key] = DataDict(value, core=core)
+        if value.__class__ is list:
+            data[key] = DataList(value, core=core)
+
+
+def ensure_containers(data, core):
+    if data.__class__ is dict:
+        return DataDict(data, core=core)
+    elif data.__class__ is list:
+        return DataList(data, core=core)
+    return data
+
+
+class DataDict(dict):
+    __slots__ = ("__meta__",)
+
+    def __init__(self, *args, **kwargs):
+        core = kwargs.pop("box_settings", kwargs.pop("core", None))
+        super().__init__(*args, **kwargs)
+        self.__meta__ = NodeMetadata(core=core)
+        convert_containers(self, self.items(), core)
+
+    def update(self, data):
+        super().update(ensure_containers(data, self.__meta__.core))
+
+    def setdefault(self, k, v):
+        return super().setdefault(k, ensure_containers(v, self.__meta__.core))
+
+    def copy(self):
+        return self.__class__(super().copy())
+
+    def get(self, k, default=None):
+        resolved = ut.find_the_correct_casing(key=k, data=self)
+        return super().get(resolved, default)
+
+    def __copy__(self):
+        return self.__class__(super().copy())
+
+    def __getitem__(self, k):
+        resolved = ut.find_the_correct_casing(key=k, data=self)
+        return super().__getitem__(resolved)
+
+    def __getattr__(self, attr):
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError(attr) from None
+
+    def __setitem__(self, k, v):
+        super().__setitem__(k, ensure_containers(v, self.__meta__.core))
+
+    def __setattr__(self, k, v):
+        if k == "__meta__":
+            return super().__setattr__(k, v)
+        self[k] = v
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({dict(self)})"
+
+    # Box compatibility. Remove in 3.3.0
+    def to_dict(self):
+        """
+        Turn the DataDict and sub DataDicts back into a native python dictionary.
+
+        :return: python dictionary of this DataDict
+        """
+        box_deprecation_warning(
+            "to_dict", "DataDict", "Use dict(data_dict) instead."
+        )  # pragma: nocover
+        out_dict = dict(self)
+        for k, v in out_dict.items():
+            if v is self:
+                out_dict[k] = out_dict
+            elif isinstance(v, DataDict):
+                out_dict[k] = v.to_dict()
+            elif isinstance(v, DataList):
+                out_dict[k] = v.to_list()
+        return out_dict
+
+    def merge_update(self, __m=None, **kwargs):  # pragma: nocover
+        """Merge update with another dict"""
+        box_deprecation_warning("merge_update", "DataDict")
+
+        def convert_and_set(k, v):
+            if isinstance(v, dict):
+                v = self.__class__(v, core=self.__meta__.core)
+                if k in self and isinstance(self[k], dict):
+                    if isinstance(self[k], DataDict):
+                        self[k].merge_update(v)
+                    else:
+                        self[k].update(v)
+                    return
+            if isinstance(v, list):
+                v = DataList(v, core=self.__meta__.core)
+            self.__setitem__(k, v)
+
+        if __m:
+            if hasattr(__m, "keys"):
+                for key in __m:
+                    convert_and_set(key, __m[key])
+            else:
+                for key, value in __m:
+                    convert_and_set(key, value)
+        for key in kwargs:
+            convert_and_set(key, kwargs[key])
+
+    def to_json(
+        self, filename=None, encoding="utf-8", errors="strict", **json_kwargs
+    ):  # pragma: nocover
+        """
+        Transform the DataDict object into a JSON string.
+
+        :param filename: If provided will save to file
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param json_kwargs: additional arguments to pass to json.dump(s)
+        :return: string of JSON (if no filename provided)
+        """
+        box_deprecation_warning(
+            "to_json", "DataDict", "Use json.dumps(dict(data_dict))"
+        )
+        return converters._to_json(
+            self.to_dict(),
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+            **json_kwargs,
+        )
+
+    def to_yaml(
+        self,
+        filename=None,
+        default_flow_style=False,
+        encoding="utf-8",
+        errors="strict",
+        **yaml_kwargs,
+    ):
+        """
+        Transform the DataDict object into a YAML string.
+
+        :param filename:  If provided will save to file
+        :param default_flow_style: False will recursively dump dicts
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param yaml_kwargs: additional arguments to pass to yaml.dump
+        :return: string of YAML (if no filename provided)
+        """
+        box_deprecation_warning("to_yaml", "DataDict")
+        return converters._to_yaml(
+            self.to_dict(),
+            filename=filename,
+            default_flow_style=default_flow_style,
+            encoding=encoding,
+            errors=errors,
+            **yaml_kwargs,
+        )
+
+    def to_toml(
+        self, filename=None, encoding="utf-8", errors="strict"
+    ):  # pragma: nocover
+        """
+        Transform the DataDict object into a toml string.
+
+        :param filename: File to write toml object too
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :return: string of TOML (if no filename provided)
+        """
+        box_deprecation_warning("to_toml", "DataDict")
+        return converters._to_toml(
+            self.to_dict(), filename=filename, encoding=encoding, errors=errors
+        )
+
+    @classmethod
+    def from_json(
+        cls,
+        json_string=None,
+        filename=None,
+        encoding="utf-8",
+        errors="strict",
+        **kwargs,
+    ):  # pragma: nocover
+        """
+        Transform a json object string into a DataDict object. If the incoming
+        json is a list, you must use DataList.from_json.
+
+        :param json_string: string to pass to `json.loads`
+        :param filename: filename to open and pass to `json.load`
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param kwargs: parameters to pass to `DataDict()` or `json.loads`
+        :return: DataDict object from json data
+        """
+        box_deprecation_warning("from_json", "DataDict")
+
+        data_args = {}
+        for arg in kwargs.copy():
+            if arg in ("core", "box_settings"):
+                data_args[arg] = kwargs.pop(arg)
+
+        data = converters._from_json(
+            json_string,
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+            **kwargs,
+        )
+
+        if not isinstance(data, dict):
+            raise ValueError(
+                f"json data not returned as a dictionary, but rather a {type(data).__name__}"
+            )
+        return cls(data, **data_args)
+
+    @classmethod
+    def from_yaml(
+        cls,
+        yaml_string=None,
+        filename=None,
+        encoding="utf-8",
+        errors="strict",
+        **kwargs,
+    ):  # pragma: nocover
+        """
+        Transform a yaml object string into a DataDict object. By default will use SafeLoader.
+
+        :param yaml_string: string to pass to `yaml.load`
+        :param filename: filename to open and pass to `yaml.load`
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param kwargs: parameters to pass to `DataDict()` or `yaml.load`
+        :return: DataDict object from yaml data
+        """
+        box_deprecation_warning("from_yaml", "DataDict")
+
+        data_args = {}
+        for arg in kwargs.copy():
+            if arg in ("core", "box_settings"):
+                data_args[arg] = kwargs.pop(arg)
+
+        data = converters._from_yaml(
+            yaml_string=yaml_string,
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+            **kwargs,
+        )
+        if not isinstance(data, dict):
+            raise ValueError(
+                f"yaml data not returned as a dictionary but rather a {type(data).__name__}"
+            )
+        return cls(data, **data_args)
+
+    @classmethod
+    def from_toml(
+        cls,
+        toml_string=None,
+        filename=None,
+        encoding="utf-8",
+        errors="strict",
+        **kwargs,
+    ):  # pragma: nocover
+        """
+        Transforms a toml string or file into a DataDict object
+
+        :param toml_string: string to pass to `toml.load`
+        :param filename: filename to open and pass to `toml.load`
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param kwargs: parameters to pass to `DataDict()`
+        :return: DataDict object from toml data
+        """
+        box_deprecation_warning("from_toml", "DataDict")
+
+        data_args = {}
+        for arg in kwargs.copy():
+            if arg in ("core", "box_settings"):
+                data_args[arg] = kwargs.pop(arg)
+
+        data = converters._from_toml(
+            toml_string=toml_string,
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+        )
+        return cls(data, **data_args)
+
+
+class DataList(list):
+    __slots__ = ("__meta__",)
+
+    def __init__(self, *args, **kwargs):
+        core = kwargs.pop("box_settings", kwargs.pop("core", None))
+        super().__init__(*args, **kwargs)
+        self.__meta__ = NodeMetadata(core=core)
+        convert_containers(self, enumerate(self), core)
+
+    def copy(self):
+        return self.__class__(super().copy())
+
+    def append(self, v):
+        super().append(ensure_containers(v, self.__meta__.core))
+
+    def insert(self, i, v):
+        super().insert(i, ensure_containers(v, self.__meta__.core))
+
+    def extend(self, data):
+        super().extend(ensure_containers(data, self.__meta__.core))
+
+    def __setitem__(self, k, v):
+        super().__setitem__(k, ensure_containers(v, self.__meta__.core))
+
+    def __add__(self, v):
+        super().__add__(ensure_containers(v, self.__meta__.core))
+
+    def __iadd__(self, v):
+        return super().__iadd__(ensure_containers(v, self.__meta__.core))
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({list(self)!r})"
+
+    # Box compatibility. Remove in 3.3.0
+    def __copy__(self):  # pragma: nocover
+        box_deprecation_warning("__copy__", "DataList")
+        return DataList((x for x in self), core=self.__meta__.core)
+
+    def __deepcopy__(self, memo=None):  # pragma: nocover
+        box_deprecation_warning("__deepcopy__", "DataList")
+        out = self.__class__(core=self.__meta__.core)
+        memo = memo or {}
+        memo[id(self)] = out
+        for k in self:
+            out.append(copy.deepcopy(k, memo=memo))
+        return out
+
+    def to_list(self):  # pragma: nocover
+        """
+        Turn the DataList and sub DataLists back into a native python list.
+
+        :return: python list of this DataList
+        """
+        box_deprecation_warning(
+            "to_list", "DataList", "Use list(data_list) instead."
+        )
+        new_list = []
+        for x in self:
+            if x is self:
+                new_list.append(new_list)
+            elif isinstance(x, DataDict):
+                new_list.append(x.to_dict())
+            elif isinstance(x, DataList):
+                new_list.append(x.to_list())
+            else:
+                new_list.append(x)
+        return new_list
+
+    def to_json(
+        self,
+        filename=None,
+        encoding="utf-8",
+        errors="strict",
+        multiline=False,
+        **json_kwargs,
+    ):  # pragma: nocover
+        """
+        Transform the DataList object into a JSON string.
+
+        :param filename: If provided will save to file
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param multiline: Put each item in list onto it's own line
+        :param json_kwargs: additional arguments to pass to json.dump(s)
+        :return: string of JSON or return of `json.dump`
+        """
+        box_deprecation_warning("to_json", "DataList")
+        if filename and multiline:
+            lines = [
+                converters._to_json(
+                    item,
+                    filename=False,
+                    encoding=encoding,
+                    errors=errors,
+                    **json_kwargs,
+                )
+                for item in self
+            ]
+            with open(filename, "w", encoding=encoding, errors=errors) as f:
+                f.write("\n".join(lines))
+        else:
+            return converters._to_json(
+                self.to_list(),
+                filename=filename,
+                encoding=encoding,
+                errors=errors,
+                **json_kwargs,
+            )
+
+    def to_yaml(
+        self,
+        filename=None,
+        default_flow_style=False,
+        encoding="utf-8",
+        errors="strict",
+        **yaml_kwargs,
+    ):  # pragma: nocover
+        """
+        Transform the DataList object into a YAML string.
+
+        :param filename:  If provided will save to file
+        :param default_flow_style: False will recursively dump dicts
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param yaml_kwargs: additional arguments to pass to yaml.dump
+        :return: string of YAML or return of `yaml.dump`
+        """
+        box_deprecation_warning("to_yaml", "DataList")
+        return converters._to_yaml(
+            self.to_list(),
+            filename=filename,
+            default_flow_style=default_flow_style,
+            encoding=encoding,
+            errors=errors,
+            **yaml_kwargs,
+        )
+
+    def to_toml(
+        self, filename=None, key_name="toml", encoding="utf-8", errors="strict"
+    ):  # pragma: nocover
+        """
+        Transform the DataList object into a toml string.
+
+        :param filename: File to write toml object too
+        :param key_name: Specify the name of the key to store the string under
+            (cannot directly convert to toml)
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :return: string of TOML (if no filename provided)
+        """
+        box_deprecation_warning("to_toml", "DataList")
+        return converters._to_toml(
+            {key_name: self.to_list()},
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+        )
+
+    def to_csv(
+        self, filename, encoding="utf-8", errors="strict"
+    ):  # pragma: nocover
+        """
+        Transform the DataList object into a CSV file.
+
+        :param filename: File to write CSV data to
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        """
+        box_deprecation_warning("to_csv", "DataList")
+        converters._to_csv(
+            self, filename=filename, encoding=encoding, errors=errors
+        )
+
+    @classmethod
+    def from_json(
+        cls,
+        json_string=None,
+        filename=None,
+        encoding="utf-8",
+        errors="strict",
+        multiline=False,
+        **kwargs,
+    ):  # pragma: nocover
+        """
+        Transform a json object string into a DataList object. If the incoming
+        json is a dict, you must use DataDict.from_json.
+
+        :param json_string: string to pass to `json.loads`
+        :param filename: filename to open and pass to `json.load`
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param multiline: One object per line
+        :param kwargs: parameters to pass to `DataList()` or `json.loads`
+        :return: DataList object from json data
+        """
+        box_deprecation_warning("from_json", "DataList")
+
+        data_args = {}
+        for arg in kwargs.copy():
+            if arg in ("core", "box_settings"):
+                data_args[arg] = kwargs.pop(arg)
+
+        data = converters._from_json(
+            json_string,
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+            multiline=multiline,
+            **kwargs,
+        )
+
+        if not isinstance(data, list):
+            raise ValueError(
+                f"json data not returned as a list, but rather a {type(data).__name__}"
+            )
+        return cls(data, **data_args)
+
+    @classmethod
+    def from_yaml(
+        cls,
+        yaml_string=None,
+        filename=None,
+        encoding="utf-8",
+        errors="strict",
+        **kwargs,
+    ):  # pragma: nocover
+        """
+        Transform a yaml object string into a DataList object.
+
+        :param yaml_string: string to pass to `yaml.load`
+        :param filename: filename to open and pass to `yaml.load`
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param kwargs: parameters to pass to `DataList()` or `yaml.load`
+        :return: DataList object from yaml data
+        """
+        box_deprecation_warning("from_yaml", "DataList")
+
+        data_args = {}
+        for arg in kwargs.copy():
+            if arg in ("core", "box_settings"):
+                data_args[arg] = kwargs.pop(arg)
+
+        data = converters._from_yaml(
+            yaml_string=yaml_string,
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+            **kwargs,
+        )
+        if not isinstance(data, list):
+            raise ValueError(
+                f"yaml data not returned as a list but rather a {type(data).__name__}"
+            )
+        return cls(data, **data_args)
+
+    @classmethod
+    def from_toml(
+        cls,
+        toml_string=None,
+        filename=None,
+        key_name="toml",
+        encoding="utf-8",
+        errors="strict",
+        **kwargs,
+    ):  # pragma: nocover
+        """
+        Transforms a toml string or file into a DataList object
+
+        :param toml_string: string to pass to `toml.load`
+        :param filename: filename to open and pass to `toml.load`
+        :param key_name: Specify the name of the key to pull the list from
+            (cannot directly convert from toml)
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :param kwargs: parameters to pass to `DataList()`
+        :return: DataList object from toml data
+        """
+        box_deprecation_warning("from_toml", "DataList")
+
+        data_args = {}
+        for arg in kwargs.copy():
+            if arg in ("core", "box_settings"):
+                data_args[arg] = kwargs.pop(arg)
+
+        data = converters._from_toml(
+            toml_string=toml_string,
+            filename=filename,
+            encoding=encoding,
+            errors=errors,
+        )
+        if key_name not in data:
+            raise ValueError(f"{key_name} was not found.")
+        return cls(data[key_name], **data_args)
+
+    @classmethod
+    def from_csv(
+        cls, filename, encoding="utf-8", errors="strict"
+    ):  # pragma: nocover
+        """
+        Transform a CSV file into a DataList object.
+
+        :param filename: CSV file to read from
+        :param encoding: File encoding
+        :param errors: How to handle encoding errors
+        :return: DataList object from CSV data
+        """
+        box_deprecation_warning("from_csv", "DataList")
+        return cls(
+            converters._from_csv(
+                filename=filename, encoding=encoding, errors=errors
+            )
+        )
+
+
+DataNode = Union[DataDict, DataList]
diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py
index 46abef0..9066ef1 100644
--- a/dynaconf/utils/__init__.py
+++ b/dynaconf/utils/__init__.py
@@ -1,19 +1,21 @@
 from __future__ import annotations
 
+import json
 import os
 import warnings
 from collections import defaultdict
 from collections.abc import Iterator
-from json import JSONDecoder
 from pathlib import Path
 from typing import Any
 from typing import Literal
 from typing import TYPE_CHECKING
+from typing import TypedDict
 from typing import TypeVar
 
 if TYPE_CHECKING:  # pragma: no cover
     from dynaconf.base import LazySettings
     from dynaconf.base import Settings
+    from dynaconf.nodes import DataNode
     from dynaconf.utils.boxing import DynaBox
 
 
@@ -462,7 +464,7 @@ def multi_replace(text: str, patterns: dict[str, str]) -> str:
 
 
 def extract_json_objects(
-    text: str, decoder: JSONDecoder = JSONDecoder()
+    text: str, decoder=json.JSONDecoder()
 ) -> Iterator[dict[str, int | dict[Any, Any]]]:
     """Find JSON objects in text, and yield the decoded JSON data
 
@@ -570,3 +572,48 @@ def prepare_json(data: Any) -> Any:
             return_data.append(value)
         return return_data
     return data
+
+
+def container_items(container: dict | list):
+    if isinstance(container, dict):
+        return container.items()
+    elif isinstance(container, list):
+        return enumerate(container)
+    else:
+        raise TypeError(f"Unsupported container type: {type(container)}")
+
+
+def data_print(data: DataNode, debug=False):
+    """Data print utilities.
+
+    Params:
+        data: The data to be displayed.
+        debug: Whether internal info should be displayed for debugging
+    """
+
+    if not debug:
+        print(json.dumps(data, indent=4))  # noqa
+        return
+
+    class Node(TypedDict):
+        metadata: dict
+        children: list
+
+    def walk(data):
+        children = []
+        for k, v in container_items(data):
+            if isinstance(v, (dict, list)):
+                node = Node(
+                    metadata=v.__meta__.__dict__,
+                    children=walk(v),
+                )
+                children.append({k: node})
+            else:
+                children.append({k: v})
+        return children
+
+    root = Node(
+        metadata=data.__meta__.__dict__,
+        children=walk(data),
+    )
+    print(json.dumps(root, indent=4))  # noqa
diff --git a/pyproject.toml b/pyproject.toml
index 13078de..878fee2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -170,7 +170,9 @@ extend-select = [
     "I",
     "N",
 ]
-ignore = []
+ignore = [
+    "UP007", # Use `X | Y` for type annotations
+]
 
 [tool.ruff.lint.per-file-ignores]
 "*/__init__.py" = [
diff --git a/scripts/timing_compare.py b/scripts/timing_compare.py
old mode 100644
new mode 100755
index d19323a..f936b8d
--- a/scripts/timing_compare.py
+++ b/scripts/timing_compare.py
@@ -1,39 +1,110 @@
+#!/usr/bin/env python3
 from __future__ import annotations
 
+import os
 import timeit
 from textwrap import dedent
 
+import click
+
 setup_code = """\
 from dynaconf import Dynaconf
+from dynaconf.utils.boxing import DynaBox
+from dynaconf.data_nodes import DataDict
+
 data = {"common": {"mode": 123}}
 settings = Dynaconf(
     **data,
 )
+dynabox = DynaBox(data)
+datadict = DataDict(data)
 """
 
-repeats = 40_000
-code = {
-    "baseline": f"""\
+scenarios = {
+    "baseline": """\
         for i in range({repeats}):
             x = data["common"]["mode"]
         """,
-    "dot_access": f"""\
+    # "dot_access": """\
+    #     for i in range({repeats}):
+    #         x = settings.common.mode
+    #     """,
+    "subs_access": """\
         for i in range({repeats}):
-            x = settings.common.mode
+            x = settings["common"]["mode"]
         """,
-    "subs_access": f"""\
+    "subs_access_pure_dynabox": """\
         for i in range({repeats}):
-            x = settings["common"]["mode"]
+            x = dynabox["common"]["mode"]
+        """,
+    "dot_access_pure_datadict": """\
+        for i in range({repeats}):
+            x = datadict.common.mode
+        """,
+    "subs_access_pure_datadict": """\
+        for i in range({repeats}):
+            x = datadict["common"]["mode"]
         """,
 }
 
 
-def main():
-    print(f"repeats: {repeats}")  # noqa
-    for k, v in code.items():
-        result = timeit.timeit(stmt=dedent(v), setup=setup_code, number=1)
-        print(f"{k}: {result}")  # noqa
+@click.group()
+def cli():
+    """Benchmark access patterns for dynaconf data structures."""
+    pass
+
+
+@cli.command()
+def list():
+    """List available scenarios."""
+    for scenario_name in scenarios.keys():
+        click.echo(scenario_name)
+
+
+@cli.command()
+@click.option(
+    "--repeat-set",
+    "-r",
+    default="1,10,100,1000,5000,10000,50000,100000,250000,500000",
+    help="Comma-separated list of repeat counts",
+)
+@click.option(
+    "--include",
+    "-i",
+    help="Comma-separated list of scenarios to include (default: all)",
+)
+@click.option(
+    "--output-dir", "-o", help="Output directory (default: current directory)"
+)
+def run(repeat_set, include, output_dir):
+    """Run benchmark."""
+    global scenarios
+
+    repeat_list = [int(x.strip()) for x in repeat_set.split(",")]
+    include_list = []
+    if include:
+        include_list = [x.strip() for x in include.split(",")]
+
+    scenarios_items = scenarios.items()
+    if include_list:
+        scenarios_items = [
+            (k, v) for k, v in scenarios_items if k in include_list
+        ]
+
+    for scenario_name, scenario_code in scenarios_items:
+        print(scenario_name)  # noqa
+        filename = scenario_name + ".dat"
+        if output_dir:
+            os.makedirs(output_dir, exist_ok=True)
+            filename = os.path.join(output_dir, filename)
+        with open(filename, "w") as fd:
+            for repeat in repeat_list:
+                code = dedent(scenario_code).format(repeats=repeat)
+                result = timeit.timeit(stmt=code, setup=setup_code, number=1)
+                display = f"{repeat} {result}"
+                print(display, file=fd)
+                print(display)  # noqa
 
 
 if __name__ == "__main__":
-    main()
+    cli()


Test patch:
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
new file mode 100644
index 0000000..b5958a4
--- /dev/null
+++ b/tests/test_nodes.py
@@ -0,0 +1,447 @@
+from __future__ import annotations
+
+import copy
+from collections import namedtuple
+
+import pytest
+
+from dynaconf.nodes import DataDict
+from dynaconf.nodes import DataList
+from dynaconf.nodes import DynaconfCore
+from dynaconf.nodes import DynaconfNotInitialized
+from dynaconf.nodes import get_core
+from dynaconf.nodes import init_core
+from dynaconf.utils import container_items
+from dynaconf.utils import data_print
+from dynaconf.utils.boxing import DynaBox
+from dynaconf.vendor.box import BoxList
+
+# DynaBox compatibility tests
+# Copied from tests/test_dynabox.py
+
+DBDATA = namedtuple("DbData", ["server", "port"])
+
+
+ddict = DataDict(
+    {
+        "server": {
+            "HOST": "server.com",
+            "port": 8080,
+            "PARAMS": {
+                "username": "admin",
+                "PASSWORD": "secret",
+                "token": {"TYPE": 1, "value": 2},
+            },
+        },
+        "database": DBDATA(server="db.com", port=3306),
+    },
+)
+
+
+def test_named_tuple_is_not_transformed():
+    """Issue: https://github.com/dynaconf/dynaconf/issues/595"""
+    assert isinstance(ddict.database, DBDATA)
+    assert isinstance(ddict.database, tuple)
+
+
+def test_datatypes():
+    assert isinstance(ddict.server, dict)
+    assert isinstance(ddict.server, DataDict)
+    assert isinstance(ddict.server.HOST, str)
+    assert isinstance(ddict.server.port, int)
+
+
+def test_access_lowercase():
+    assert ddict.server.host == "server.com"
+    assert ddict.server.port == 8080
+    assert ddict.server.params.username == "admin"
+    assert ddict.server.params.password == "secret"
+    assert ddict.server.params.token.type == 1
+    assert ddict.server.params.token.value == 2
+
+
+def test_access_uppercase():
+    assert ddict.SERVER.HOST == "server.com"
+    assert ddict.SERVER.PORT == 8080
+    assert ddict.SERVER.PARAMS.USERNAME == "admin"
+    assert ddict.SERVER.PARAMS.PASSWORD == "secret"
+    assert ddict.SERVER.PARAMS.TOKEN.TYPE == 1
+    assert ddict.SERVER.PARAMS.TOKEN.VALUE == 2
+
+
+def test_access_items():
+    assert ddict["SERVER"]["HOST"] == "server.com"
+    assert ddict["SERVER"]["PORT"] == 8080
+    assert ddict["SERVER"]["PARAMS"]["USERNAME"] == "admin"
+    assert ddict["SERVER"]["PARAMS"]["PASSWORD"] == "secret"
+    assert ddict["SERVER"]["PARAMS"]["TOKEN"]["TYPE"] == 1
+    assert ddict["SERVER"]["PARAMS"]["TOKEN"]["VALUE"] == 2
+
+
+def test_access_items_lower():
+    assert ddict["server"]["HOST"] == "server.com"
+    assert ddict["server"]["PORT"] == 8080
+    assert ddict["server"]["params"]["USERNAME"] == "admin"
+    assert ddict["server"]["params"]["PASSWORD"] == "secret"
+    assert ddict["server"]["params"]["TOKEN"]["TYPE"] == 1
+    assert ddict["server"]["params"]["TOKEN"]["VALUE"] == 2
+
+
+def test_get():
+    assert ddict.get("server").get("host") == "server.com"
+    assert ddict.get("server").get("port") == 8080
+    assert ddict.get("server").get("params").username == "admin"
+    assert ddict.get("server").get("params").password == "secret"
+    assert ddict.get("server").get("params").token.type == 1
+    assert ddict.get("server").get("params").token.value == 2
+    assert ddict.get("server").get("blabla") is None
+    assert ddict.get("server").get("blabla", "foo") == "foo"
+
+
+def test_copy_no_cause_inf_recursion():
+    ddict.__copy__()
+    ddict.copy()
+
+
+def test_accessing_datadict_inside_datalist_inside_datadict():
+    data = DataDict({"nested": [{"deeper": "nest"}]})
+    assert data.nested[0].deeper == "nest"
+    assert data.NESTED[0].deeper == "nest"
+    assert data.NESTED[0].DEEPER == "nest"
+
+    data = DataDict({"nested": DataList([DataDict({"deeper": "nest"})])})
+    assert data.nested[0].deeper == "nest"
+    assert data.NESTED[0].deeper == "nest"
+    assert isinstance(data.NESTED, DataList)
+    assert isinstance(data.NESTED[0], DataDict)
+    assert data.NESTED[0].DEEPER == "nest"
+
+
+# Additional Tests
+
+
+def test_core_initialized():
+    core = DynaconfCore("test")
+
+    # Immediate-initialize
+    data = DataDict(core=core)
+    assert get_core(data) == core
+
+    data = DataDict(box_settings=core)  # compat
+    assert get_core(data) == core
+
+    # Post-initialize
+    data = DataDict()
+    with pytest.raises(DynaconfNotInitialized, match="not initialized"):
+        get_core(data)
+    init_core(data, core)
+    assert get_core(data) == core
+
+
+data_set = (
+    [
+        {"a": 1},
+        {"a": {"b": 1}},
+        {"a": {"b": {"c": 1}}},
+        [1],
+        [1, [2]],
+        [1, [2, [3]]],
+        {"a": {"b": [1, {"c": 2}, 3]}},
+        [1, [2, {"a": [3]}]],
+    ],
+)
+
+
+def test_data_repr():
+    data = DataDict({"a": 1, "b": {"c": [1, 2, 3]}})
+    print()  # noqa
+    print(data)  # noqa
+    data_print(data)
+    data_print(data, debug=True)
+
+
+def recursive_walk(container: dict | list, assert_fn):
+    """Recursively walk a data tree and run assert_fn on each container."""
+    assert_fn(container)
+    for k, v in container_items(container):
+        if isinstance(v, (dict, list)):
+            recursive_walk(v, assert_fn)
+
+
+@pytest.mark.parametrize("input", data_set)
+def test_data_containers_init(input):
+    """All the ingested dict/list via init should be converted to DataDict/DataList."""
+    DataType = DataDict if isinstance(input, dict) else DataList
+    core = DynaconfCore("test")
+    data = DataType(input, core=core)
+
+    def assert_fn(container):
+        assert container.__class__ in (DataDict, DataList)
+        assert get_core(container) == core
+
+    recursive_walk(data, assert_fn)
+
+
+def test_dict_methods():
+    core = DynaconfCore("test")
+    di = DataDict(core=core)
+
+    data0 = {"a": [1, {"b": [2]}]}
+    data1 = {"c": [3, {"d": [4]}]}
+
+    def assert_fn(container):
+        assert container.__class__ in (DataDict, DataList)
+        assert get_core(container) == core
+
+    di["a"] = data0["a"]
+    recursive_walk(di, assert_fn)
+
+    di.update(data1)
+    recursive_walk(di, assert_fn)
+
+    popped = di.pop("a")
+    assert isinstance(popped, DataList)
+
+    d_copy = di.copy()
+    assert isinstance(d_copy, DataDict)
+    di.clear()
+    assert len(di) == 0
+
+
+def test_list_methods():
+    li = DataList()
+
+    li.append({"x": 1})
+    assert isinstance(li[0], DataDict)
+
+    li.extend([{"y": 2}, [1, 2]])
+    assert isinstance(li[1], DataDict)
+    assert isinstance(li[2], DataList)
+
+    li.insert(0, {"z": 3})
+    assert isinstance(li[0], DataDict)
+
+    popped = li.pop()
+    assert isinstance(popped, DataList)
+
+    li[1:1] = [{"w": 4}]
+    assert isinstance(li[1], DataDict)
+
+    d_copy = li.copy()
+    assert isinstance(d_copy, DataList)
+    li.clear()
+    assert len(li) == 0
+
+
+def test_nested_structures():
+    di = DataDict({"a": [{"x": 1}, {"y": 2}], "b": {"c": [3, 4]}})
+
+    assert isinstance(di["a"], DataList)
+    assert isinstance(di["a"][0], DataDict)
+    assert isinstance(di["b"], DataDict)
+    assert isinstance(di["b"]["c"], DataList)
+
+
+def test_method_preservation():
+    di = DataDict()
+    di["a"] = {"x": 1}
+
+    # Dict methods
+    assert list(di.keys()) == ["a"]
+    assert list(di.values())[0]["x"] == 1
+    assert list(di.items())[0][1]["x"] == 1
+
+    li = DataList([1, {"x": 2}])
+    # List methods
+    assert li.count(1) == 1
+    assert li.index({"x": 2}) == 1
+
+    # Sort, reverse
+    li = DataList([{"x": 2}, {"x": 1}])
+    li.sort(key=lambda x: x["x"])
+    assert li[0]["x"] == 1
+
+
+def test_mutable_operations():
+    di = DataDict()
+    # Test setdefault
+    item = di.setdefault("a", {"x": 1})
+    assert isinstance(item, DataDict)
+
+    # Test dict comprehension conversion
+    di = DataDict({k: {"val": v} for k, v in [("a", 1), ("b", 2)]})
+    assert all(isinstance(v, DataDict) for v in di.values())
+    assert all(isinstance(v, DataDict) for k, v in di.items())
+
+    # Test list concatenation
+    li = DataList()
+    li += [{"x": 1}]
+    assert isinstance(li[0], DataDict)
+
+    # Test multiply
+    li = DataList([{"x": 1}]) * 2
+    assert isinstance(li[0], DataDict) and isinstance(li[1], DataDict)
+
+
+def test_repr():
+    """Test that no unexpected internal attributes shows up."""
+    di = DataDict({"foo": 123})
+    dl = DataList([1, 2, 3])
+    assert repr(di) == "DataDict({'foo': 123})"
+    assert repr(dl) == "DataList([1, 2, 3])"
+
+
+@pytest.fixture
+def deprecated_context():
+    msg = "is deprecated and will be removed in v4.0"
+    with pytest.warns(DeprecationWarning, match=msg):
+        yield
+
+
+class TestBoxCompatibility:
+    """Test compatibility with Box library API. Remove in v3.3.0."""
+
+    def test_interface(self):
+        data_dict_dir = DataDict().__dir__()
+        data_list_dir = DataList().__dir__()
+        expected_dynabox_methods = [
+            "merge_update",
+            "to_dict",
+            "to_json",
+            "to_yaml",
+            "to_toml",
+            "from_json",
+            "from_yaml",
+            "from_toml",
+        ]
+        expected_box_list_methods = [
+            "__copy__",
+            "__deepcopy__",
+            "to_list",
+            "to_json",
+            "to_yaml",
+            "to_toml",
+            "to_csv",
+            "from_json",
+            "from_yaml",
+            "from_toml",
+            "from_csv",
+        ]
+
+        for method in expected_dynabox_methods:
+            assert method in data_dict_dir
+
+        for method in expected_box_list_methods:
+            assert method in data_list_dir
+
+    def test_dynabox_merge_update(self, deprecated_context, tmp_path):
+        """
+        Test that DataDict keeps compatibility with Box public API.
+        """
+        test_data = {"name": "test", "nested": {"value": 42}}
+        data_dict = DataDict(test_data.copy())
+
+        dyna_box = DynaBox(test_data.copy())
+        # Test merge_update method works the same
+        data_dict.merge_update({"new_key": "new_value"})
+        dyna_box.merge_update({"new_key": "new_value"})
+        assert data_dict == dyna_box
+
+    def test_dynabox(self, deprecated_context, tmp_path):
+        """
+        Test that DataDict keeps compatibility with Box public API.
+        """
+        test_data = {"name": "test", "nested": {"value": 42}}
+        data_dict = DataDict(test_data.copy())
+        dyna_box = DynaBox(test_data.copy())
+
+        # Test to_yaml, to_json, etc
+        contents_of = {}
+        for method_name in ("to_dict", "to_json", "to_yaml", "to_toml"):
+            datadict_method = getattr(data_dict, method_name)
+            dynabox_method = getattr(dyna_box, method_name)
+            result = datadict_method()
+            assert result == dynabox_method()
+            contents_of[method_name] = result
+
+        # Test from_yaml, json, etc
+        for method_name_to, content in contents_of.items():
+            method_name = method_name_to.replace("to_", "from_")
+            if method_name in ("from_dict", "from_toml"):
+                # vendored toml breaks with Box too
+                continue
+            file = tmp_path / method_name.replace("_", ".")
+            assert isinstance(content, str)
+            file.write_text(content)
+            filename = str(file.absolute())
+            box_method = getattr(DynaBox, method_name)
+            assert box_method(filename=filename) == test_data
+            method = getattr(DataDict, method_name)
+            assert method(filename=filename) == test_data
+
+    def test_box_list(self, tmp_path, deprecated_context):
+        """
+        Test that DataList keeps compatibility with BoxList API.
+        """
+        test_data = [{"name": "item1"}, {"name": "item2"}]
+        data_list = DataList(test_data.copy())
+        box_list = BoxList(test_data.copy())
+
+        # Test to_yaml, to_json, etc
+        contents_of = {}
+        for method_name in (
+            "to_list",
+            "to_json",
+            "to_yaml",
+            "to_toml",
+        ):
+            datadict_method = getattr(data_list, method_name)
+            boxlist_method = getattr(box_list, method_name)
+            result = datadict_method()
+            assert result == boxlist_method()
+            contents_of[method_name] = result
+
+        # unlike the others, to_csv requires filename...
+        box_file = tmp_path / "box.csv"
+        box_list.to_csv(str(box_file))
+        datalist_file = tmp_path / "datalist.csv"
+        data_list.to_csv(str(datalist_file))
+        assert box_file.read_text() == datalist_file.read_text()
+        contents_of["to_csv"] = datalist_file.read_text()
+
+        # Test from_yaml, json, etc
+        for method_name_to, content in contents_of.items():
+            method_name = method_name_to.replace("to_", "from_")
+            if method_name in ("from_list", "from_toml"):
+                # vendored toml breaks with Box too
+                continue
+            file = tmp_path / method_name.replace("_", ".")
+            assert isinstance(content, str)
+            file.write_text(content)
+            filename = str(file.absolute())
+            box_method = getattr(BoxList, method_name)
+            assert box_method(filename=filename) == test_data
+            method = getattr(DataList, method_name)
+            assert method(filename=filename) == test_data
+
+    def test_box_list_copying(self, deprecated_context):
+        test_data = [{"a": [1, 2, 3]}, {"a": [4, 5, 6]}]
+        datalist_origin = DataList(test_data)
+        boxlist_origin = BoxList(test_data)
+
+        datalist_shallow = copy.copy(datalist_origin)
+        boxlist_shallow = copy.copy(boxlist_origin)
+        assert datalist_shallow == datalist_origin
+        assert datalist_shallow is not datalist_origin
+
+        assert boxlist_shallow == boxlist_origin
+        assert boxlist_shallow is not boxlist_origin
+
+        data_deep = copy.deepcopy(datalist_origin)
+        box_deep = copy.deepcopy(boxlist_origin)
+        assert isinstance(data_deep, DataList)
+        assert isinstance(box_deep, BoxList)
+        assert data_deep == datalist_origin
+        assert box_deep == boxlist_origin
+        assert data_deep is not datalist_origin
+        assert box_deep is not boxlist_origin