Repository: matplotlib/matplotlib

Issue:
[Bug]: Backend name specified as module gets lowercased since 3.9
### Bug summary

`MPLBACKEND="module://foo.Bar"` now tries to import the `foo.bar` module as opposed to `foo.Bar`, as before.

### Code for reproduction

```Python
MPLBACKEND="module://foo.Bar"
```


### Actual outcome

`foo.bar` is imported

### Expected outcome

`foo.Bar` is imported

### Additional information

This probably regressed in https://github.com/matplotlib/matplotlib/pull/27948.

### Operating system

Fedora Linux 39

### Matplotlib Version

3.9.0

### Matplotlib Backend

_No response_

### Python version

_No response_

### Jupyter version

_No response_

### Installation

None


Discussion:
I agree third-party backends should be matched case-sensitively (or really, matching python's import system behavior, perhaps https://peps.python.org/pep-0235/ (which may have become obsolete since it was written)).
@torokati44 The change is intended. According to [PEP8](https://peps.python.org/pep-0008/#package-and-module-names) Python module names should be lowercase so your `backend_SWTAgg.py` should be `backend_swtagg.py`.

You can workaround this by adding a new `backend_swtagg.py` and importing (and hence effectively exporting) your `FigureCanvas`. But probably better is to rename your `backend_SWTAgg.py` to `backend_swtagg.py` and provide the import-export workaround in  `backend_SWTAgg.py` which you can deprecate and eventually remove.
I see. A warning would have been nice about it though...
Anyway, we have already renamed the module internally as a workaround - it may end up as a permanent solution then.
I'll add my opinion that I do not think we should have PEP8's style recommendations override the language implementation, forcing downstream projects to change capitalization. It may be best practice, but it is not universal practice.

Our own dependencies do not always have lower case names (looking at `PIL` and `IPython`) and while if it is only the last module name that is affected, that is workable. However, I would think this affects all parent modules, including the top level package names, which are much harder to change. 

Normalizing to lowercase for non `module://` backends I can absolutely get behind, but once you start getting to something that is directly imported, I think we should strive to handle all cases.
If someone wishes to change the current behaviour they are welcome to do so. It is not something that I have any interest in doing.
I think the point is that this is an unannounced regression.  Let's re-open to keep discussion going. 
I also think we should respect the casing of external user's requests. I think @ksunden's proposal about distinguishing between `module://` and built-ins is a good path forward.

> Normalizing to lowercase for non module:// backends I can absolutely get behind, but once you start getting to something that is directly imported, I think we should strive to handle all cases.



Patch:
diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py
index 47d5f65e350e..e08817bb089b 100644
--- a/lib/matplotlib/backends/registry.py
+++ b/lib/matplotlib/backends/registry.py
@@ -93,6 +93,9 @@ def __init__(self):
         }
 
     def _backend_module_name(self, backend):
+        if backend.startswith("module://"):
+            return backend[9:]
+
         # Return name of module containing the specified backend.
         # Does not check if the backend is valid, use is_valid_backend for that.
         backend = backend.lower()
@@ -224,7 +227,8 @@ def is_valid_backend(self, backend):
         bool
             True if backend is valid, False otherwise.
         """
-        backend = backend.lower()
+        if not backend.startswith("module://"):
+            backend = backend.lower()
 
         # For backward compatibility, convert ipympl and matplotlib-inline long
         # module:// names to their shortened forms.
@@ -342,7 +346,8 @@ def resolve_backend(self, backend):
             The GUI framework, which will be None for a backend that is non-interactive.
         """
         if isinstance(backend, str):
-            backend = backend.lower()
+            if not backend.startswith("module://"):
+                backend = backend.lower()
         else:  # Might be _auto_backend_sentinel or None
             # Use whatever is already running...
             from matplotlib import get_backend
@@ -395,7 +400,8 @@ def resolve_gui_or_backend(self, gui_or_backend):
         framework : str or None
             The GUI framework, which will be None for a backend that is non-interactive.
         """
-        gui_or_backend = gui_or_backend.lower()
+        if not gui_or_backend.startswith("module://"):
+            gui_or_backend = gui_or_backend.lower()
 
         # First check if it is a gui loop name.
         backend = self.backend_for_gui_framework(gui_or_backend)


Test patch:
diff --git a/lib/matplotlib/tests/test_backend_registry.py b/lib/matplotlib/tests/test_backend_registry.py
index 141ffd69c266..80c2ce4fc51a 100644
--- a/lib/matplotlib/tests/test_backend_registry.py
+++ b/lib/matplotlib/tests/test_backend_registry.py
@@ -86,6 +86,15 @@ def test_is_valid_backend(backend, is_valid):
     assert backend_registry.is_valid_backend(backend) == is_valid
 
 
+@pytest.mark.parametrize("backend, normalized", [
+    ("agg", "matplotlib.backends.backend_agg"),
+    ("QtAgg", "matplotlib.backends.backend_qtagg"),
+    ("module://Anything", "Anything"),
+])
+def test_backend_normalization(backend, normalized):
+    assert backend_registry._backend_module_name(backend) == normalized
+
+
 def test_deprecated_rcsetup_attributes():
     match = "was deprecated in Matplotlib 3.9"
     with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
diff --git a/lib/matplotlib/tests/test_backend_template.py b/lib/matplotlib/tests/test_backend_template.py
index d7e2a5cd1266..964d15c1559a 100644
--- a/lib/matplotlib/tests/test_backend_template.py
+++ b/lib/matplotlib/tests/test_backend_template.py
@@ -49,3 +49,14 @@ def test_show_old_global_api(monkeypatch):
     mpl.use("module://mpl_test_backend")
     plt.show()
     mock_show.assert_called_with()
+
+
+def test_load_case_sensitive(monkeypatch):
+    mpl_test_backend = SimpleNamespace(**vars(backend_template))
+    mock_show = MagicMock()
+    monkeypatch.setattr(
+        mpl_test_backend.FigureManagerTemplate, "pyplot_show", mock_show)
+    monkeypatch.setitem(sys.modules, "mpl_Test_Backend", mpl_test_backend)
+    mpl.use("module://mpl_Test_Backend")
+    plt.show()
+    mock_show.assert_called_with()