Repository: streamlit/streamlit

Issue:
streamlit.runtime.secrets.AttrDict no longer returns True for isinstance() against dict in streamlit 1.14.0
### Checklist

- [X] I have searched the [existing issues](https://github.com/streamlit/streamlit/issues) for similar issues.
- [X] I added a very descriptive title to this issue.
- [X] I have provided sufficient information below to help reproduce this issue.

### Summary

Hi.

With the latest streamlit version `streamlit.runtime.secrets.AttrDict` seems to inherit from `UserDict` instead of `dict` in previous version.

Many libraries I use check that their configuration is `isinstance` against `dict` and are now failing when I pass my config from st.secrets to them.

I can't see any documentation changes related to this and am not familiar with `UserDict`, do I have to go through my code and wrap all st.secrets access in a `dict()` conversion call now?

### Reproducible Code Example

```Python
import streamlit as st

print(isinstance(st.secrets["my_secret_dict"], dict)) # False on streamlit 1.14.0 - was True prior to that
```


### Steps To Reproduce

_No response_

### Expected Behavior

_No response_

### Current Behavior

_No response_

### Is this a regression?

- [X] Yes, this used to work in a previous version.

### Debug info

- Streamlit version: 1.14.0
- Python version: 3.100
- Operating System: Linux
- Browser: Firefox
- Virtual environment: None


### Additional Information

_No response_

### Are you willing to submit a PR?

- [ ] Yes, I am willing to submit a PR!


Discussion:
@blipk Thanks for reporting this! Sorry to hear that we broke some code/libraries :( The recommended way to check the type would be via:

```python
isinstance(st.secrets["my_secret_dict"], collections.abc.Mapping)
```

`AttrDict` being an actual instance if `dict` wasn't the correct inheritance :( Thinking a bit more on this, also `UserDict` is probably not the correct superclass for this since it should be non-mutable. We will probably do some changes to this, but not sure if we will reverse it back to inherit from `dict`. But the `isinstance` check for `collections.abc. Mapping` should be the safest choice going forward.

Btw. what libraries do you know that got broken by this change?

Resolving patch:
diff --git a/lib/streamlit/runtime/secrets.py b/lib/streamlit/runtime/secrets.py
--- a/lib/streamlit/runtime/secrets.py
+++ b/lib/streamlit/runtime/secrets.py
@@ -14,8 +14,16 @@
 
 import os
 import threading
-from collections import UserDict
-from typing import Any, ItemsView, Iterator, KeysView, Mapping, Optional, ValuesView
+from typing import (
+    Any,
+    ItemsView,
+    Iterator,
+    KeysView,
+    Mapping,
+    NoReturn,
+    Optional,
+    ValuesView,
+)
 
 import toml
 from blinker import Signal
@@ -45,32 +53,50 @@ def _missing_key_error_message(key: str) -> str:
     )
 
 
-class AttrDict(UserDict):  # type: ignore[type-arg]
+class AttrDict(Mapping[str, Any]):
     """
     We use AttrDict to wrap up dictionary values from secrets
     to provide dot access to nested secrets
     """
 
+    def __init__(self, value):
+        self.__dict__["__nested_secrets__"] = dict(value)
+
     @staticmethod
     def _maybe_wrap_in_attr_dict(value) -> Any:
-        if not isinstance(value, dict):
+        if not isinstance(value, Mapping):
             return value
         else:
-            return AttrDict(**value)
+            return AttrDict(value)
 
-    def __getattr__(self, attr_name: str) -> Any:
+    def __len__(self) -> int:
+        return len(self.__nested_secrets__)
+
+    def __iter__(self) -> Iterator[str]:
+        return iter(self.__nested_secrets__)
+
+    def __getitem__(self, key: str) -> Any:
         try:
-            value = super().__getitem__(attr_name)
+            value = self.__nested_secrets__[key]
             return self._maybe_wrap_in_attr_dict(value)
         except KeyError:
-            raise AttributeError(_missing_attr_error_message(attr_name))
+            raise KeyError(_missing_key_error_message(key))
 
-    def __getitem__(self, key: str) -> Any:
+    def __getattr__(self, attr_name: str) -> Any:
         try:
-            value = super().__getitem__(key)
+            value = self.__nested_secrets__[attr_name]
             return self._maybe_wrap_in_attr_dict(value)
         except KeyError:
-            raise KeyError(_missing_key_error_message(key))
+            raise AttributeError(_missing_attr_error_message(attr_name))
+
+    def __repr__(self):
+        return repr(self.__nested_secrets__)
+
+    def __setitem__(self, key, value) -> NoReturn:
+        raise TypeError("Secrets does not support item assignment.")
+
+    def __setattr__(self, key, value) -> NoReturn:
+        raise TypeError("Secrets does not support attribute assignment.")
 
 
 class Secrets(Mapping[str, Any]):
@@ -218,10 +244,10 @@ def __getattr__(self, key: str) -> Any:
         """
         try:
             value = self._parse(True)[key]
-            if not isinstance(value, dict):
+            if not isinstance(value, Mapping):
                 return value
             else:
-                return AttrDict(**value)
+                return AttrDict(value)
         # We add FileNotFoundError since __getattr__ is expected to only raise
         # AttributeError. Without handling FileNotFoundError, unittests.mocks
         # fails during mock creation on Python3.9
@@ -236,10 +262,10 @@ def __getitem__(self, key: str) -> Any:
         """
         try:
             value = self._parse(True)[key]
-            if not isinstance(value, dict):
+            if not isinstance(value, Mapping):
                 return value
             else:
-                return AttrDict(**value)
+                return AttrDict(value)
         except KeyError:
             raise KeyError(_missing_key_error_message(key))
 


Test patch:
diff --git a/lib/tests/streamlit/runtime/secrets_test.py b/lib/tests/streamlit/runtime/secrets_test.py
--- a/lib/tests/streamlit/runtime/secrets_test.py
+++ b/lib/tests/streamlit/runtime/secrets_test.py
@@ -16,6 +16,9 @@
 
 import os
 import unittest
+from collections.abc import Mapping as MappingABC
+from collections.abc import MutableMapping as MutableMappingABC
+from typing import Mapping, MutableMapping
 from unittest.mock import MagicMock, mock_open, patch
 
 from toml import TomlDecodeError
@@ -116,6 +119,26 @@ def test_getattr_nonexistent(self, *mocks):
         with self.assertRaises(AttributeError):
             self.secrets.subsection.nonexistent_secret
 
+    @patch("streamlit.watcher.path_watcher.watch_file")
+    @patch("builtins.open", new_callable=mock_open, read_data=MOCK_TOML)
+    def test_getattr_raises_exception_on_attr_dict(self, *mocks):
+        """Verify that assignment to nested secrets raises TypeError."""
+        with self.assertRaises(TypeError):
+            self.secrets.subsection["new_secret"] = "123"
+
+        with self.assertRaises(TypeError):
+            self.secrets.subsection.new_secret = "123"
+
+    @patch("streamlit.watcher.path_watcher.watch_file")
+    @patch("builtins.open", new_callable=mock_open, read_data=MOCK_TOML)
+    def test_attr_dict_is_mapping_but_not_built_in_dict(self, *mocks):
+        """Verify that AttrDict implements Mapping, but not built-in Dict"""
+        self.assertTrue(isinstance(self.secrets.subsection, Mapping))
+        self.assertTrue(isinstance(self.secrets.subsection, MappingABC))
+        self.assertFalse(isinstance(self.secrets.subsection, MutableMapping))
+        self.assertFalse(isinstance(self.secrets.subsection, MutableMappingABC))
+        self.assertFalse(isinstance(self.secrets.subsection, dict))
+
     @patch("streamlit.watcher.path_watcher.watch_file")
     @patch("builtins.open", new_callable=mock_open, read_data=MOCK_TOML)
     def test_getitem_nonexistent(self, *mocks):