Repository: dynaconf/dynaconf

Issue:
[bug] SQLAlchemy URL object replaced with BoxList object
Using dynaconf with Flask and Flask-SQLAlchemy. If I initialize dynaconf, then assign a sqlalchemy `URL` object to a config key, the object becomes a `BoxList`, which causes sqlalchemy to fail later. Dynaconf should not replace arbitrary objects.

```python
app = Flask(__name__)
dynaconf.init_app(app)
app.config["SQLALCHEMY_DATABASE_URI"] = sa_url(
    "postgresql", None, None, None, None, "example"
)

print(type(app.config["SQLALCHEMY_DATABASE_URI"]))
```

```
<class 'dynaconf.vendor.box.box_list.BoxList'>
```

This is a problem when using SQLAlchemy 1.4, which treats the URL as an object with attributes instead of a tuple.
 
cc @davidism
[bug] SQLAlchemy URL object replaced with BoxList object
Using dynaconf with Flask and Flask-SQLAlchemy. If I initialize dynaconf, then assign a sqlalchemy `URL` object to a config key, the object becomes a `BoxList`, which causes sqlalchemy to fail later. Dynaconf should not replace arbitrary objects.

```python
app = Flask(__name__)
dynaconf.init_app(app)
app.config["SQLALCHEMY_DATABASE_URI"] = sa_url(
    "postgresql", None, None, None, None, "example"
)

print(type(app.config["SQLALCHEMY_DATABASE_URI"]))
```

```
<class 'dynaconf.vendor.box.box_list.BoxList'>
```

This is a problem when using SQLAlchemy 1.4, which treats the URL as an object with attributes instead of a tuple.
 
cc @davidism


Discussion:
I think that happens because `sa_url` returns an instance of `list/tuple` and dynaconf needs to encapsulate that data type in to a Box to enable the **dynamic** features, such as templates etc..

I will think in a solution for this problem, maybe we need a way to prevend some specific keys from being transformed to a Box with the drawback of missing the dynamism.
Maybe checking exact list type instead of subclass. Or making the box type proxy more methods such as getattr, although that's probably going to get out of hand.
I think that happens because `sa_url` returns an instance of `list/tuple` and dynaconf needs to encapsulate that data type in to a Box to enable the **dynamic** features, such as templates etc..

I will think in a solution for this problem, maybe we need a way to prevend some specific keys from being transformed to a Box with the drawback of missing the dynamism.
Maybe checking exact list type instead of subclass. Or making the box type proxy more methods such as getattr, although that's probably going to get out of hand.

Resolving patch:
diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py
--- a/dynaconf/utils/__init__.py
+++ b/dynaconf/utils/__init__.py
@@ -366,3 +366,20 @@ def recursively_evaluate_lazy_format(value, settings):
         )
 
     return value
+
+
+def isnamedtupleinstance(value):
+    """Check if value is a namedtuple instance
+
+    stackoverflow.com/questions/2166818/
+    how-to-check-if-an-object-is-an-instance-of-a-namedtuple
+    """
+
+    t = type(value)
+    b = t.__bases__
+    if len(b) != 1 or b[0] != tuple:
+        return False
+    f = getattr(t, "_fields", None)
+    if not isinstance(f, tuple):
+        return False
+    return all(type(n) == str for n in f)
diff --git a/dynaconf/utils/parse_conf.py b/dynaconf/utils/parse_conf.py
--- a/dynaconf/utils/parse_conf.py
+++ b/dynaconf/utils/parse_conf.py
@@ -5,6 +5,7 @@
 from functools import wraps
 
 from dynaconf.utils import extract_json_objects
+from dynaconf.utils import isnamedtupleinstance
 from dynaconf.utils import multi_replace
 from dynaconf.utils import recursively_evaluate_lazy_format
 from dynaconf.utils.boxing import DynaBox
@@ -288,10 +289,15 @@ def _parse_conf_data(data, tomlfy=False, box_settings=None):
 
 def parse_conf_data(data, tomlfy=False, box_settings=None):
 
+    # fix for https://github.com/rochacbruno/dynaconf/issues/595
+    if isnamedtupleinstance(data):
+        return data
+
     # not enforced to not break backwards compatibility with custom loaders
     box_settings = box_settings or {}
 
     if isinstance(data, (tuple, list)):
+
         # recursively parse each sequence item
         return [
             parse_conf_data(item, tomlfy=tomlfy, box_settings=box_settings)


Test patch:
diff --git a/tests/test_dynabox.py b/tests/test_dynabox.py
--- a/tests/test_dynabox.py
+++ b/tests/test_dynabox.py
@@ -1,3 +1,5 @@
+from collections import namedtuple
+
 import pytest
 
 from dynaconf.utils.boxing import DynaBox
@@ -6,6 +8,9 @@
 from dynaconf.vendor.box import BoxList
 
 
+DBDATA = namedtuple("DbData", ["server", "port"])
+
+
 box = DynaBox(
     {
         "server": {
@@ -16,11 +21,18 @@
                 "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/rochacbruno/dynaconf/issues/595"""
+    assert isinstance(box.database, DBDATA)
+    assert isinstance(box.database, tuple)
+
+
 def test_datatypes():
     assert isinstance(box.server, dict)
     assert isinstance(box.server, DynaBox)
diff --git a/tests/test_flask.py b/tests/test_flask.py
--- a/tests/test_flask.py
+++ b/tests/test_flask.py
@@ -1,3 +1,5 @@
+from collections import namedtuple
+
 import pytest
 from flask import Flask
 
@@ -5,6 +7,27 @@
 from example.flask_with_dotenv.app import app as flask_app
 
 
+DBDATA = namedtuple("DbData", ["server", "port"])
+
+
+def test_named_tuple_config():
+    app = Flask(__name__)
+    app.config["DBDATA"] = DBDATA(server="localhost", port=5432)
+    FlaskDynaconf(app)
+    assert app.config["DBDATA"].server == "localhost"
+    assert app.config["DBDATA"].port == 5432
+    assert isinstance(app.config["DBDATA"], DBDATA)
+
+
+def test_named_tuple_config_using_initapp():
+    app = Flask(__name__)
+    FlaskDynaconf(app)
+    app.config["DBDATA"] = DBDATA(server="localhost", port=5432)
+    assert app.config["DBDATA"].server == "localhost"
+    assert app.config["DBDATA"].port == 5432
+    assert isinstance(app.config["DBDATA"], DBDATA)
+
+
 def test_dynamic_load_exts(settings):
     """Assert that a config based extensions are loaded"""
     app = Flask(__name__)
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,6 +1,7 @@
 import io
 import json
 import os
+from collections import namedtuple
 
 import pytest
 
@@ -8,6 +9,7 @@
 from dynaconf.loaders.json_loader import DynaconfEncoder
 from dynaconf.utils import ensure_a_list
 from dynaconf.utils import extract_json_objects
+from dynaconf.utils import isnamedtupleinstance
 from dynaconf.utils import Missing
 from dynaconf.utils import missing
 from dynaconf.utils import object_merge
@@ -23,6 +25,20 @@
 from dynaconf.utils.parse_conf import unparse_conf_data
 
 
+def test_isnamedtupleinstance():
+    """Assert that isnamedtupleinstance works as expected"""
+    Db = namedtuple("Db", ["host", "port"])
+    assert isnamedtupleinstance(Db(host="localhost", port=3306))
+    assert not isnamedtupleinstance(dict(host="localhost", port=3306.0))
+    assert not isnamedtupleinstance(("localhost", "3306"))
+
+    class Foo(tuple):
+        def _fields(self):
+            return ["a", "b"]
+
+    assert not isnamedtupleinstance(Foo())
+
+
 def test_unparse():
     """Assert bare types are reversed cast"""
     assert unparse_conf_data("teste") == "teste"