Repository: PrefectHQ/prefect

Issue:
DotDict doesn't accept non-string keys
Trying to set a non-string key results in `TypeError: hasattr(): attribute name must be string` because of [this line](https://github.com/PrefectHQ/prefect/blob/dcf0cdb2b27fc1434ad433afe9beb0720eb192db/src/prefect/utilities/collections.py#L62).




Discussion:

Resolving patch:
diff --git a/src/prefect/configuration.py b/src/prefect/configuration.py
--- a/src/prefect/configuration.py
+++ b/src/prefect/configuration.py
@@ -16,8 +16,7 @@
 
 
 class Config(collections.DotDict):
-    def __repr__(self) -> str:
-        return "<Config: {}>".format(", ".join(sorted(self.keys())))
+    pass
 
 
 def interpolate_env_var(env_var: str) -> str:
diff --git a/src/prefect/utilities/collections.py b/src/prefect/utilities/collections.py
--- a/src/prefect/utilities/collections.py
+++ b/src/prefect/utilities/collections.py
@@ -59,8 +59,9 @@ def __getitem__(self, key):
         return self.__dict__[key]
 
     def __setitem__(self, key, value):
-        if hasattr(MutableMapping, key):
-            raise ValueError("no sir")
+        # prevent overwriting any critical attributes
+        if isinstance(key, str) and hasattr(MutableMapping, key):
+            raise ValueError('Invalid key: "{}"'.format(key))
         self.__dict__[key] = value
 
     def __setattr__(self, attr, value):
@@ -75,6 +76,14 @@ def __delitem__(self, key):
     def __len__(self):
         return len(self.__dict__)
 
+    def __repr__(self) -> str:
+        if len(self) > 0:
+            return "<{}: {}>".format(
+                type(self).__name__, ", ".join(sorted(repr(k) for k in self.keys()))
+            )
+        else:
+            return "<{}>".format(type(self).__name__)
+
     def copy(self):
         "Returns a shallow copy of the current DotDict"
         return type(self)(self.__dict__.copy())


Test patch:
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
--- a/tests/test_configuration.py
+++ b/tests/test_configuration.py
@@ -49,6 +49,11 @@ def test_keys(config):
     assert "x" not in config
 
 
+def test_repr(config):
+    assert repr(config) == "<Config: 'env_vars', 'general', 'interpolation', 'logging'>"
+    assert repr(config.general) == "<Config: 'nested', 'x', 'y'>"
+
+
 def test_general(config):
     assert config.general.x == 1
     assert config.general.y == "hi"
diff --git a/tests/utilities/test_collections.py b/tests/utilities/test_collections.py
--- a/tests/utilities/test_collections.py
+++ b/tests/utilities/test_collections.py
@@ -142,6 +142,32 @@ def test_get(self):
         assert d.get("no_data") is None
         assert d.get("no_data", "fallback") == "fallback"
 
+    def test_setitem(self):
+        d = DotDict()
+        d["a"] = 1
+        assert d["a"] == 1
+        d["a"] = 2
+        assert d["a"] == 2
+
+    def test_setitem_nonstring_key(self):
+        d = DotDict()
+        d[1] = 1
+        assert d[1] == 1
+        d[1] = 2
+        assert d[1] == 2
+
+    def test_initialize_from_nonstring_keys(self):
+        d = DotDict({1: 1, "a": 2})
+        assert d[1] == 1 and d["a"] == 2
+
+    def test_repr_sorts_mixed_keys(self):
+        d = DotDict()
+        assert repr(d) == "<DotDict>"
+        d["a"] = 1
+        d[1] = 1
+        d["b"] = 1
+        assert repr(d) == "<DotDict: 'a', 'b', 1>"
+
     def test_keyerror_is_thrown_when_accessing_nonexistent_key(self):
         d = DotDict(data=5)
         with pytest.raises(KeyError):