Repository: streamlit/streamlit
Pull request #11095: Deexperimentalization of st.user
Author: login: kajarenc
Labels: name: change:chore
description: PR contains maintenance or housekeeping change
name: impact:users
description: PR changes affect end users
Created: 2025-04-14T13:25:40Z | Merged: 2025-04-16T11:39:32Z

## Describe your changes

Deexperimentalize `st.experimental_user` -> `st.user`. 
The `experimental_user` will continue to be present in streamlit codebase until at least `2025-11-06`.

## GitHub Issue Link (if applicable)

## Testing Plan

- Explanation of why no additional tests are needed
- Unit Tests (JS and/or Python) DONE!
- E2E Tests (Covered by existing tests)
- Any manual testing needed?

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.



Diff:
diff --git a/e2e_playwright/auth.py b/e2e_playwright/auth.py
index 0544741d6a1..409aad7da00 100644
--- a/e2e_playwright/auth.py
+++ b/e2e_playwright/auth.py
@@ -22,6 +22,6 @@
     st.login("testprovider")
 
 
-if st.experimental_user.get("is_logged_in"):
-    st.markdown(f"YOU ARE LOGGED IN: {st.experimental_user.email}")
-    st.markdown(st.experimental_user["name"])
+if st.user.get("is_logged_in"):
+    st.markdown(f"YOU ARE LOGGED IN: {st.user.email}")
+    st.markdown(st.user["name"])
diff --git a/e2e_playwright/st_write_objects.py b/e2e_playwright/st_write_objects.py
index 92041af63fe..2b600451d77 100644
--- a/e2e_playwright/st_write_objects.py
+++ b/e2e_playwright/st_write_objects.py
@@ -42,7 +42,7 @@
 st.write({"foo": "bar"})
 
 st.write(st.session_state)
-st.write(st.experimental_user)
+st.write(st.user)
 st.write(st.query_params)
 
 
diff --git a/lib/streamlit/__init__.py b/lib/streamlit/__init__.py
index 263c9c4a4aa..9e174d7dccb 100644
--- a/lib/streamlit/__init__.py
+++ b/lib/streamlit/__init__.py
@@ -117,6 +117,7 @@
 )
 from streamlit.user_info import (
     UserInfoProxy as _UserInfoProxy,
+    DeprecatedUserInfoProxy as _DeprecatedUserInfoProxy,
     login as _login,
     logout as _logout,
 )
@@ -277,11 +278,14 @@ def _update_logger() -> None:
 login = _login
 logout = _logout
 
+# User
+user = _UserInfoProxy()
+
 # Experimental APIs
 experimental_audio_input = _main.experimental_audio_input
 experimental_dialog = _experimental_dialog_decorator
 experimental_fragment = _experimental_fragment
-experimental_user = _UserInfoProxy()
+experimental_user = _DeprecatedUserInfoProxy()
 
 _EXPERIMENTAL_QUERY_PARAMS_DEPRECATE_MSG = "Refer to our [docs page](https://docs.streamlit.io/develop/api-reference/caching-and-state/st.query_params) for more information."
 
diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py
index c84ef23547a..7d0452f2e82 100644
--- a/lib/streamlit/type_util.py
+++ b/lib/streamlit/type_util.py
@@ -336,7 +336,6 @@ def _is_from_streamlit(obj: object) -> bool:
 
 def is_custom_dict(obj: object) -> TypeGuard[CustomDict]:
     """True if input looks like one of the Streamlit custom dictionaries."""
-
     return (
         isinstance(obj, Mapping)
         and _is_from_streamlit(obj)
diff --git a/lib/streamlit/user_info.py b/lib/streamlit/user_info.py
index 5393e83a42f..bda56d25513 100644
--- a/lib/streamlit/user_info.py
+++ b/lib/streamlit/user_info.py
@@ -29,6 +29,10 @@
     is_authlib_installed,
     validate_auth_credentials,
 )
+from streamlit.deprecation_util import (
+    make_deprecated_name_warning,
+    show_deprecation_warning,
+)
 from streamlit.errors import StreamlitAPIException, StreamlitAuthError
 from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
 from streamlit.runtime.metrics_util import gather_metrics
@@ -55,14 +59,14 @@ def login(provider: str | None = None) -> None:
     the user authenticates their identity, they are redirected back to the
     home page of your app. Streamlit stores a cookie with the user's identity
     information in the user's browser . You can access the identity information
-    through |st.experimental_user|_. Call ``st.logout()`` to remove the cookie
+    through |st.user|_. Call ``st.logout()`` to remove the cookie
     and start a new session.
 
     You can use any OIDC provider, including Google, Microsoft, Okta, and more.
     You must configure the provider through secrets management. Although OIDC
     is an extension of OAuth 2.0, you can't use generic OAuth providers.
     Streamlit parses the user's identity token and surfaces its attributes in
-    ``st.experimental_user``. If the provider returns an access token, that
+    ``st.user``. If the provider returns an access token, that
     token is ignored. Therefore, this command will not allow your app to act on
     behalf of a user in a secure system.
 
@@ -491,10 +495,10 @@ def __getattr__(self, key: str) -> str | bool | None:
             raise AttributeError(f'st.experimental_user has no attribute "{key}".')
 
     def __setattr__(self, name: str, value: str | None) -> NoReturn:
-        raise StreamlitAPIException("st.experimental_user cannot be modified")
+        raise StreamlitAPIException("st.user cannot be modified")
 
     def __setitem__(self, name: str, value: str | None) -> NoReturn:
-        raise StreamlitAPIException("st.experimental_user cannot be modified")
+        raise StreamlitAPIException("st.user cannot be modified")
 
     def __iter__(self) -> Iterator[str]:
         return iter(_get_user_info())
@@ -507,7 +511,7 @@ def to_dict(self) -> UserInfo:
         Get user info as a dictionary.
 
         This method primarily exists for internal use and is not needed for
-        most cases. ``st.experimental_user`` returns an object that inherits from
+        most cases. ``st.user`` returns an object that inherits from
         ``dict`` by default.
 
         Returns
@@ -516,3 +520,38 @@ def to_dict(self) -> UserInfo:
             A dictionary of the current user's information.
         """
         return _get_user_info()
+
+
+has_shown_experimental_user_warning = False
+
+
+def maybe_show_deprecated_user_warning() -> None:
+    """Show a deprecation warning for the experimental_user alias."""
+    global has_shown_experimental_user_warning  # noqa: PLW0603
+
+    if not has_shown_experimental_user_warning:
+        has_shown_experimental_user_warning = True
+        show_deprecation_warning(
+            make_deprecated_name_warning(
+                "experimental_user",
+                "user",
+                "2025-11-06",
+            )
+        )
+
+
+class DeprecatedUserInfoProxy(UserInfoProxy):
+    """
+    A deprecated alias for UserInfoProxy.
+
+    This class is deprecated and will be removed in a future version of
+    Streamlit.
+    """
+
+    def __getattribute__(self, name: str):
+        maybe_show_deprecated_user_warning()
+        return super().__getattribute__(name)
+
+    def __getitem__(self, key: str):
+        maybe_show_deprecated_user_warning()
+        return super().__getitem__(key)
diff --git a/lib/tests/streamlit/streamlit_test.py b/lib/tests/streamlit/streamlit_test.py
index c4bd4958367..9c0dcff8f07 100644
--- a/lib/tests/streamlit/streamlit_test.py
+++ b/lib/tests/streamlit/streamlit_test.py
@@ -73,6 +73,7 @@ def get_version():
     "sidebar",
     "stop",
     "switch_page",
+    "user",
 }
 
 # Element commands that are exposed on the DeltaGenerator
diff --git a/lib/tests/streamlit/user_info_test.py b/lib/tests/streamlit/user_info_test.py
index 354e83ea04d..5d8af112fe2 100644
--- a/lib/tests/streamlit/user_info_test.py
+++ b/lib/tests/streamlit/user_info_test.py
@@ -60,59 +60,59 @@ class UserInfoProxyTest(DeltaGeneratorTestCase):
 
     def test_user_email_attr(self):
         """Test that `st.user.email` returns user info from ScriptRunContext"""
-        self.assertEqual(st.experimental_user.email, "test@example.com")
+        self.assertEqual(st.user.email, "test@example.com")
 
     def test_user_email_key(self):
-        self.assertEqual(st.experimental_user["email"], "test@example.com")
+        self.assertEqual(st.user["email"], "test@example.com")
 
     def test_user_non_existing_attr(self):
         """Test that an error is raised when called non existed attr."""
         with self.assertRaises(AttributeError):
-            st.write(st.experimental_user.attribute)
+            st.write(st.user.attribute)
 
     def test_user_non_existing_key(self):
         """Test that an error is raised when called non existed key."""
         with self.assertRaises(KeyError):
-            st.write(st.experimental_user["key"])
+            st.write(st.user["key"])
 
     def test_user_cannot_be_modified_existing_key(self):
         """
         Test that an error is raised when try to assign new value to existing key.
         """
         with self.assertRaises(StreamlitAPIException) as e:
-            st.experimental_user["email"] = "NEW_VALUE"
+            st.user["email"] = "NEW_VALUE"
 
-        self.assertEqual(str(e.exception), "st.experimental_user cannot be modified")
+        self.assertEqual(str(e.exception), "st.user cannot be modified")
 
     def test_user_cannot_be_modified_new_key(self):
         """
         Test that an error is raised when try to assign new value to new key.
         """
         with self.assertRaises(StreamlitAPIException) as e:
-            st.experimental_user["foo"] = "bar"
+            st.user["foo"] = "bar"
 
-        self.assertEqual(str(e.exception), "st.experimental_user cannot be modified")
+        self.assertEqual(str(e.exception), "st.user cannot be modified")
 
     def test_user_cannot_be_modified_existing_attr(self):
         """
         Test that an error is raised when try to assign new value to existing attr.
         """
         with self.assertRaises(StreamlitAPIException) as e:
-            st.experimental_user.email = "bar"
+            st.user.email = "bar"
 
-        self.assertEqual(str(e.exception), "st.experimental_user cannot be modified")
+        self.assertEqual(str(e.exception), "st.user cannot be modified")
 
     def test_user_cannot_be_modified_new_attr(self):
         """
         Test that an error is raised when try to assign new value to new attr.
         """
         with self.assertRaises(StreamlitAPIException) as e:
-            st.experimental_user.foo = "bar"
+            st.user.foo = "bar"
 
-        self.assertEqual(str(e.exception), "st.experimental_user cannot be modified")
+        self.assertEqual(str(e.exception), "st.user cannot be modified")
 
     def test_user_len(self):
-        self.assertEqual(len(st.experimental_user), 1)
+        self.assertEqual(len(st.user), 1)
 
     def test_st_user_reads_from_context_(self):
         """Test that st.user reads information from current ScriptRunContext
@@ -138,12 +138,30 @@ def test_st_user_reads_from_context_(self):
                 ),
             )
 
-            self.assertEqual(st.experimental_user.email, "something@else.com")
+            self.assertEqual(st.user.email, "something@else.com")
         except Exception as e:
             raise e
         finally:
             add_script_run_ctx(threading.current_thread(), orig_report_ctx)
 
+    @patch("streamlit.user_info.show_deprecation_warning")
+    @patch("streamlit.user_info.has_shown_experimental_user_warning", False)
+    def test_deprecate_st_experimental_user(self, mock_show_warning: MagicMock):
+        """Test that we show deprecation warning only once."""
+        st.write(st.experimental_user)
+
+        expected_warning = (
+            "Please replace `st.experimental_user` with `st.user`.\n\n"
+            "`st.experimental_user` will be removed after 2025-11-06."
+        )
+
+        # We only show the warning a single time for a given object.
+        mock_show_warning.assert_called_once_with(expected_warning)
+        mock_show_warning.reset_mock()
+
+        st.write(st.experimental_user)
+        mock_show_warning.assert_not_called()
+
 
 @patch(
     "streamlit.auth_util.secrets_singleton",


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:43:12Z
user: login: kmcgrady
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T20:17:31Z
user: login: kajarenc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T22:23:33Z
user: login: kmcgrady
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-15T22:41:43Z

Review comments:
diff_hunk: @@ -516,3 +520,38 @@ def to_dict(self) -> UserInfo:
             A dictionary of the current user's information.
         """
         return _get_user_info()
+
+
+has_shown_experimental_user_warning = False
+
+
+def maybe_show_deprecated_user_warning() -> None:
+    """Show a deprecation warning for the experimental_user alias."""
+    global has_shown_experimental_user_warning  # noqa: PLW0603
path: lib/streamlit/user_info.py
user: login: kmcgrady
body: I understand this is a way to only show this once. Does this mean that the deprecation notice will disappear after the first run (and is that our standard)?
created_at: 2025-04-15T20:17:30Z
author_association: COLLABORATOR
diff_hunk: @@ -516,3 +520,38 @@ def to_dict(self) -> UserInfo:
             A dictionary of the current user's information.
         """
         return _get_user_info()
+
+
+has_shown_experimental_user_warning = False
+
+
+def maybe_show_deprecated_user_warning() -> None:
+    """Show a deprecation warning for the experimental_user alias."""
+    global has_shown_experimental_user_warning  # noqa: PLW0603
path: lib/streamlit/user_info.py
user: login: kajarenc
body: Correct, this is a way to show it only once, and it is our standard for other cases too

Similar to `deprecate_func_name` function that havily used in `__init__.py` we have `deprecate_obj_name` function in our `deprecation_utils.py`
But in this case it is inapplicable, bause the `deprecate_obj_name` wrapper functionality breaks the `st.experimental_user` functionality (e.g. the behaviour of `st.write`).

The reason why the "global" used it here instead of method used in e.g. `_create_deprecated_obj_wrapper` is in that case it would be possible to mock it and write test for it. (See [this test](https://github.com/streamlit/streamlit/pull/11095/files#diff-c9e7505993b4c1d59e38061ec771fbf5578ad8ee75afb9c9f40c083cda8ded40R148) )


created_at: 2025-04-15T22:23:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: snyk-io[bot]
created_at: 2025-04-14T13:25:52Z
body: ![](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### :tada: **Snyk checks have passed. No issues have been found so far.**

:white_check_mark: **security/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/streamlit-open-source/pr-checks/cc8d7dcb-f7d1-479d-bed7-22f1f75e4bee?refs=01ddf944338fb6d6c89c385b35ce8c0ae09d9488&source=prChecksComment)

:white_check_mark: **license/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/streamlit-open-source/pr-checks/cc8d7dcb-f7d1-479d-bed7-22f1f75e4bee/license?refs=01ddf944338fb6d6c89c385b35ce8c0ae09d9488&source=prChecksComment)



author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-14T13:26:04Z
body: ### ✅ PR preview is ready!

| Name | Link |
| :----: | ---- |
| 📦 Wheel file | https://core-previews.s3-us-west-2.amazonaws.com/pr-11095/streamlit-1.44.1-py3-none-any.whl |
| 🕹️ Preview app | [pr-11095.streamlit.app](https://pr-11095.streamlit.app) (☁️ Deploy [here](https://share.streamlit.io/deploy?repository=streamlit/core-previews&branch=pr-11095&mainModule=E2E_Tester_🧪.py&subdomain=pr-11095) if not accessible)| 
author_association: CONTRIBUTOR