Repository: plotly/dash

Issue:
The stringcase package can no longer be installed with setuptools version >=78.
              The [https://pypi.org/project/stringcase/](stringcase) package, that is hard dependacy of Dash since this PR can no longer be installed using setuptools >=78, because of the following PR [https://github.com/pypa/setuptools/pull/4870](#4870). As a result, Dash can no longer be installed with setuptools version >=78.

_Originally posted by @ivan-mingolov-blue-technologies in https://github.com/plotly/dash/issues/3220#issuecomment-2748637195_

Hints from issue comments:
@T4rk1n: Should just remove "stringcase" and handle PascalCase in the codebase.@ndrezn: Plotly's Enterprise libraries are experiencing this in CI.@ndrezn: Setuptools diff causing this: https://github.com/pypa/setuptools/pull/4870

A traceback might look like:
```
      setuptools.errors.InvalidConfigError: Invalid dash-separated key 'description-file' in 'metadata' (setup.cfg), please use the underscore name 'description_file' instead.
      [end of output]
  
  note: This error originates from a subprocess, and is likely not a problem with pip.
error: subprocess-exited-with-error

× Getting requirements to build wheel did not run successfully.
│ exit code: 1
╰─> See above for output.
```

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf289c5b2..688a5c7ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
 All notable changes to `dash` will be documented in this file.
 This project adheres to [Semantic Versioning](https://semver.org/).
 
+## [UNRELEASED]
+
+## Fixed
+
+- [#3239](https://github.com/plotly/dash/pull/3239) Remove stringcase dependency, fix [#3238](https://github.com/plotly/dash/issues/3238)
+
 ## [3.0.0] - 2025-03-17
 
 ## Added
diff --git a/dash/_utils.py b/dash/_utils.py
index 38ff0c39a..f1056d013 100644
--- a/dash/_utils.py
+++ b/dash/_utils.py
@@ -11,6 +11,8 @@ import json
 import secrets
 import string
 import inspect
+import re
+
 from html import escape
 from functools import wraps
 from typing import Union
@@ -302,3 +304,14 @@ def get_caller_name():
             return s.frame.f_locals.get("__name__", "__main__")
 
     return "__main__"
+
+
+def pascal_case(name: Union[str, None]):
+    s = re.sub(r"\s", "_", str(name))
+    # Replace leading `_`
+    s = re.sub("^[_]+", "", s)
+    if not s:
+        return s
+    return s[0].upper() + re.sub(
+        r"[\-_\.]+([a-z])", lambda match: match.group(1).upper(), s[1:]
+    )
diff --git a/dash/development/_py_prop_typing.py b/dash/development/_py_prop_typing.py
index fcd4c5896..1c3f67397 100644
--- a/dash/development/_py_prop_typing.py
+++ b/dash/development/_py_prop_typing.py
@@ -4,7 +4,7 @@ import string
 import textwrap
 import importlib
 
-import stringcase
+from .._utils import pascal_case
 
 
 shapes = {}
@@ -54,7 +54,7 @@ def generate_any(*_):
 
 def generate_shape(type_info, component_name: str, prop_name: str):
     props = []
-    name = stringcase.pascalcase(prop_name)
+    name = pascal_case(prop_name)
 
     for prop_key, prop_type in type_info["value"].items():
         typed = get_prop_typing(
diff --git a/dash/py.typed b/dash/py.typed
index e69de29bb..b648ac923 100644
--- a/dash/py.typed
+++ b/dash/py.typed
@@ -0,0 +1 @@
+partial
diff --git a/requirements/install.txt b/requirements/install.txt
index 8a02fa781..65fccc279 100644
--- a/requirements/install.txt
+++ b/requirements/install.txt
@@ -7,4 +7,3 @@ requests
 retrying
 nest-asyncio
 setuptools
-stringcase>=1.2.0


Test patch:
diff --git a/tests/unit/library/test_utils.py b/tests/unit/library/test_utils.py
index f643442dd..cb677e835 100644
--- a/tests/unit/library/test_utils.py
+++ b/tests/unit/library/test_utils.py
@@ -58,3 +58,19 @@ def test_ddut001_attribute_dict():
         a.x = 4
     assert err.value.args == ("Object is final: No new keys may be added.", "x")
     assert "x" not in a
+
+
+@pytest.mark.parametrize(
+    "value,expected",
+    [
+        ("foo_bar", "FooBar"),
+        ("", ""),
+        ("fooBarFoo", "FooBarFoo"),
+        ("foo bar", "FooBar"),
+        ("foo-bar", "FooBar"),
+        ("__private_prop", "PrivateProp"),
+        ("double__middle___triple", "DoubleMiddleTriple"),
+    ],
+)
+def test_ddut002_pascal_case(value, expected):
+    assert utils.pascal_case(value) == expected