Repository: matplotlib/matplotlib (Python)

Issue:
[MNT]: Require that matplotlibrc/style files use utf-8 (or have an encoding cookie)
### Summary

Currently, matplotlibrc and style files are read with the locale encoding, since #3575.  There's even a test for it in test_rcparams.py, which reads
```python
def test_Issue_1713(tmpdir):
    rcpath = Path(tmpdir) / 'test_rcparams.rc'
    rcpath.write_text('timezone: UTC', encoding='UTF-32-BE')
    with mock.patch('locale.getpreferredencoding', return_value='UTF-32-BE'):
        rc = mpl.rc_params_from_file(rcpath, True, False)
    assert rc.get('timezone') == 'UTC'
```

But actually, we probably never really supported non-ascii encodings (such as utf-32-be), because if you try to import matplotlib in such a context, we will fail much earlier, when trying to read the default matplotlibrc file:
```python
from unittest import mock
with mock.patch("locale.getpreferredencoding", return_value="utf-32-be"):
    import matplotlib
```
gives
```
Traceback (most recent call last):
  File "/tmp/test.py", line 3, in <module>
    import matplotlib
  File ".../matplotlib/__init__.py", line 883, in <module>
    rcParamsDefault = _rc_params_in_file(
  File ".../matplotlib/__init__.py", line 785, in _rc_params_in_file
    for line_no, line in enumerate(fd, 1):
  File "/usr/lib/python3.10/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-32-be' codec can't decode bytes in position 0-3: code point not in range(0x110000)
```
(the test doesn't see that because the default matplotlibrc file has already been imported at this point...).  This behavior also means that style files are actually not shareable between systems that use incompatible encodings.

Given that #3575 was implemented in response to #1713, which is about the Py2/Py3 unicode transition and not any user actually requesting support for non-standard encodings, I think we should just drop any intent of reading matplotlibrc/style files using the user locale, and instead spec them as being utf-8 (or, if we want to be super-flexible, support encoding cookies as in https://docs.python.org/3/library/tokenize.html#tokenize.detect_encoding / https://peps.python.org/pep-0263/ -- but I'd say it's probably not worth it?).

### Proposed fix

_No response_


Discussion:
I agree.



Patch:
diff --git a/doc/api/next_api_changes/behavior/23031-AL.rst b/doc/api/next_api_changes/behavior/23031-AL.rst
new file mode 100644
index 000000000000..bdb1554fe759
--- /dev/null
+++ b/doc/api/next_api_changes/behavior/23031-AL.rst
@@ -0,0 +1,5 @@
+The encoding of style file is now specified to be utf-8
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+It has been impossible to import Matplotlib with a non UTF-8 compatible locale
+encoding because we read the style library at import time.  This change is
+formalizing and documenting the status quo so there is no deprecation period.
diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py
index 7e8f6efa9af4..7df511af1e16 100644
--- a/lib/matplotlib/__init__.py
+++ b/lib/matplotlib/__init__.py
@@ -754,10 +754,7 @@ def _open_file_or_url(fname):
             yield (line.decode('utf-8') for line in f)
     else:
         fname = os.path.expanduser(fname)
-        encoding = locale.getpreferredencoding(do_setlocale=False)
-        if encoding is None:
-            encoding = "utf-8"
-        with open(fname, encoding=encoding) as f:
+        with open(fname, encoding='utf-8') as f:
             yield f
 
 
@@ -802,11 +799,8 @@ def _rc_params_in_file(fname, transform=lambda x: x, fail_on_error=False):
                                  fname, line_no, line.rstrip('\n'))
                 rc_temp[key] = (val, line, line_no)
         except UnicodeDecodeError:
-            _log.warning('Cannot decode configuration file %s with encoding '
-                         '%s, check LANG and LC_* variables.',
-                         fname,
-                         locale.getpreferredencoding(do_setlocale=False)
-                         or 'utf-8 (default)')
+            _log.warning('Cannot decode configuration file %r as utf-8.',
+                         fname)
             raise
 
     config = RcParams()
diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc
index 749689a6370c..85557f128d32 100644
--- a/lib/matplotlib/mpl-data/matplotlibrc
+++ b/lib/matplotlib/mpl-data/matplotlibrc
@@ -42,6 +42,8 @@
 ## String values may optionally be enclosed in double quotes, which allows
 ## using the comment character # in the string.
 ##
+## This file (and other style files) must be encoded as utf-8.
+##
 ## Matplotlib configuration are currently divided into following parts:
 ##     - BACKENDS
 ##     - LINES
diff --git a/setup.py b/setup.py
index c64069af69b3..9b7a55fc9144 100644
--- a/setup.py
+++ b/setup.py
@@ -194,7 +194,7 @@ def update_matplotlibrc(path):
     # line.  Otherwise, use the default `##backend: Agg` which has no effect
     # even after decommenting, which allows _auto_backend_sentinel to be filled
     # in at import time.
-    template_lines = path.read_text().splitlines(True)
+    template_lines = path.read_text(encoding="utf-8").splitlines(True)
     backend_line_idx, = [  # Also asserts that there is a single such line.
         idx for idx, line in enumerate(template_lines)
         if "#backend:" in line]
@@ -202,7 +202,7 @@ def update_matplotlibrc(path):
         "#backend: {}\n".format(setupext.options["backend"])
         if setupext.options["backend"]
         else "##backend: Agg\n")
-    path.write_text("".join(template_lines))
+    path.write_text("".join(template_lines), encoding="utf-8")
 
 
 class BuildPy(setuptools.command.build_py.build_py):


Test patch:
diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py
index 6f0edf3ae1f3..99856b344255 100644
--- a/lib/matplotlib/tests/test_rcparams.py
+++ b/lib/matplotlib/tests/test_rcparams.py
@@ -39,7 +39,7 @@ def test_rcparams(tmpdir):
     linewidth = mpl.rcParams['lines.linewidth']
 
     rcpath = Path(tmpdir) / 'test_rcparams.rc'
-    rcpath.write_text('lines.linewidth: 33')
+    rcpath.write_text('lines.linewidth: 33', encoding='utf-8')
 
     # test context given dictionary
     with mpl.rc_context(rc={'text.usetex': not usetex}):
@@ -191,7 +191,7 @@ def test_axes_titlecolor_rcparams():
 
 def test_Issue_1713(tmpdir):
     rcpath = Path(tmpdir) / 'test_rcparams.rc'
-    rcpath.write_text('timezone: UTC', encoding='UTF-32-BE')
+    rcpath.write_text('timezone: UTC', encoding='utf-8')
     with mock.patch('locale.getpreferredencoding', return_value='UTF-32-BE'):
         rc = mpl.rc_params_from_file(rcpath, True, False)
     assert rc.get('timezone') == 'UTC'
diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py
index 7f0780bf8f54..fe54e7c5a06f 100644
--- a/lib/matplotlib/tests/test_style.py
+++ b/lib/matplotlib/tests/test_style.py
@@ -26,7 +26,8 @@ def temp_style(style_name, settings=None):
         with TemporaryDirectory() as tmpdir:
             # Write style settings to file in the tmpdir.
             Path(tmpdir, temp_file).write_text(
-                "\n".join("{}: {}".format(k, v) for k, v in settings.items()))
+                "\n".join("{}: {}".format(k, v) for k, v in settings.items()),
+                encoding="utf-8")
             # Add tmpdir to style path and reload so we can access this style.
             USER_LIBRARY_PATHS.append(tmpdir)
             style.reload_library()
@@ -59,7 +60,7 @@ def test_use():
 
 def test_use_url(tmpdir):
     path = Path(tmpdir, 'file')
-    path.write_text('axes.facecolor: adeade')
+    path.write_text('axes.facecolor: adeade', encoding='utf-8')
     with temp_style('test', DUMMY_SETTINGS):
         url = ('file:'
                + ('///' if sys.platform == 'win32' else '')
@@ -72,7 +73,7 @@ def test_single_path(tmpdir):
     mpl.rcParams[PARAM] = 'gray'
     temp_file = f'text.{STYLE_EXTENSION}'
     path = Path(tmpdir, temp_file)
-    path.write_text(f'{PARAM} : {VALUE}')
+    path.write_text(f'{PARAM} : {VALUE}', encoding='utf-8')
     with style.context(path):
         assert mpl.rcParams[PARAM] == VALUE
     assert mpl.rcParams[PARAM] == 'gray'