Re: I/O uses default encoding argument

Guenter Milde via Docutils-develop <[email protected]>
Newsgroups gmane.text.docutils.devel
Message-ID <[email protected]>
On 2022-06-17, Guenter Milde via Docutils-develop wrote:
> On 2022-06-15, Adam Turner wrote:

...


>>> Why do you want to deprecate auto-detection of the input encoding?
>>> * ``encoding='locale'`` does not help if my input files are a mix of
>>>   UTF-8 and latin-1.

...

>> I'm not sure I understand the example you gave as Docutils works on a
>> single file basis. Could you add more context please?

The idea is to try another encoding when UTF-8 fails or when specified in
the document itself.

Use cases would be:

* lazy user with many different rST source files in different encodings
  compiling them on different occasions but not wanting to think about the
  encoding.

* different encodings in files compiled in one run via a Makefile or script
  (e.g. buildhtml.py or similar).


> What I want to keep/restore is the "auto-detect" default behaviour for
> reading/decoding input on Python2 (when opening files under Python 3,
> this only kicks in when the first try rises an UnicodeError):

The attache patches restore the Python2 behaviour 
(auto-detection if FileInput.encoding is None) on Python3 by
(internally) reading the file in binary mode and doing the decoding with
`io.Input.decode()`.

This allows decoding most input without the need to configure an encoding.

Günter
0001-Cosmetics.patch (text/x-diff, 3.5 KB)
From 23b0d3593e4d9dd07ee4b69f3f5f8cc60de5617d Mon Sep 17 00:00:00 2001
From: milde <[email protected]>
Date: Tue, 21 Jun 2022 11:07:02 +0200
Subject: [PATCH 1/4] Cosmetics.

Fix/expand help string.
Disambiguate name "encodings".
Use f-string instead of %-replacements.
---
 docutils/docutils/io.py | 29 ++++++++++++++++++-----------
 1 file changed, 18 insertions(+), 11 deletions(-)

diff --git a/docutils/docutils/io.py b/docutils/docutils/io.py
index 932ef2296..f5013d125 100644
--- a/docutils/docutils/io.py
+++ b/docutils/docutils/io.py
@@ -110,38 +110,45 @@ class Input(TransformSpec):
 
     def decode(self, data):
         """
-        Decode a string, `data`, heuristically.
-        Raise UnicodeError if unsuccessful.
+        Decode `data` if required.
+
+        Return Unicode `str` instances unchanged (nothing to decode).
 
+        If `self.encoding` is None, determine encoding from data
+        or try UTF-8, locale encoding, and (as last ressort) 'latin-1'.
         The client application should call ``locale.setlocale`` at the
         beginning of processing::
 
             locale.setlocale(locale.LC_ALL, '')
+
+        Raise UnicodeError if unsuccessful.
         """
         if self.encoding and self.encoding.lower() == 'unicode':
             assert isinstance(data, str), ('input encoding is "unicode" '
                                            'but input is not a `str` object')
         if isinstance(data, str):
-            # Accept unicode string even if self.encoding != 'unicode'.
+            # nothing to decode
             return data
         if self.encoding:
             # We believe the user/application when the encoding is
             # explicitly given.
-            encodings = [self.encoding]
+            encoding_candidates = [self.encoding]
         else:
             data_encoding = self.determine_encoding_from_data(data)
             if data_encoding:
                 # If the data declares its encoding (explicitly or via a BOM),
                 # we believe it.
-                encodings = [data_encoding]
+                encoding_candidates = [data_encoding]
             else:
                 # Apply heuristics only if no encoding is explicitly given and
                 # no BOM found.  Start with UTF-8, because that only matches
                 # data that *IS* UTF-8:
-                encodings = ['utf-8', 'latin-1']
-                if _locale_encoding:
-                    encodings.insert(1, _locale_encoding)
-        for enc in encodings:
+                encoding_candidates = ['utf-8']
+                if _locale_encoding and _locale_encoding != 'utf-8':
+                    encoding_candidates.append(_locale_encoding)
+                # TODO: fall back to 'latin-1' or report error? (API change)
+                encoding_candidates.append('latin-1')
+        for enc in encoding_candidates:
             try:
                 decoded = str(data, enc, self.error_handler)
                 self.successful_encoding = enc
@@ -152,8 +159,8 @@ class Input(TransformSpec):
                 error = err
         raise UnicodeError(
             'Unable to decode input data.  Tried the following encodings: '
-            '%s.\n(%s)' % (', '.join(repr(enc) for enc in encodings),
-                           error_string(error)))
+            f'{", ".join(repr(enc) for enc in encoding_candidates)}.\n'
+            f'({error_string(error)})')
 
     coding_slug = re.compile(br"coding[:=]\s*([-\w.]+)")
     """Encoding declaration pattern."""
-- 
2.30.2
0002-Test-input-encoding-auto-detection.patch (text/x-diff, 2.4 KB)
From 22da7ea2cd2fc7f65ce2cb31ee7c200e4f5040d2 Mon Sep 17 00:00:00 2001
From: milde <[email protected]>
Date: Tue, 21 Jun 2022 12:52:31 +0200
Subject: [PATCH 2/4] Test input encoding auto-detection.

Auto-detection should always kick in if io.FileInput.encoding is None.
---
 docutils/test/test_io.py | 42 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)

diff --git a/docutils/test/test_io.py b/docutils/test/test_io.py
index 8ccf567f0..1a3a7ac87 100755
--- a/docutils/test/test_io.py
+++ b/docutils/test/test_io.py
@@ -256,5 +256,47 @@ class ErrorOutputTests(unittest.TestCase):
         self.assertEqual(buf.getvalue(), 'b\ufffd u\xfc e\xfc b\xfc')
 
 
+class FileInputTests(unittest.TestCase):
+
+    def test_readlines(self):
+        source = io.FileInput(source_path='data/include.txt')
+        data = source.readlines()
+        self.assertEqual(data, ['Some include text.\n'])
+
+    # test input encoding auto-detection:
+
+    def test_bom(self):
+        source = io.FileInput(
+            source_path='test_parsers/test_rst/test_directives/utf-16.csv')
+        # Assert correct decoding, BOMs are gone.
+        self.assertTrue(source.read().startswith('"Treat", "Quantity"'))
+        self.assertEqual(source.successful_encoding, 'utf-16-be')
+
+    def test_coding_slug(self):
+        source = io.FileInput(source_path='data/latin2.txt')
+        # print(source.read())
+        self.assertTrue(source.read().endswith('škoda\n'))
+        self.assertEqual(source.successful_encoding, 'latin2')
+
+    def test_fallback_utf8(self):
+        # if encoding is not given to FileInput nor specified in the source
+        # try decoding with 'utf-8'
+        source = io.FileInput(source_path='data/include.txt')
+        self.assertEqual(source.read(), 'Some include text.\n')
+        self.assertEqual(source.successful_encoding, 'utf-8')
+
+    def test_fallback_no_utf8(self):
+        # if decoding with 'utf-8' fails,
+        # use either the locale encoding (if not None) or 'latin-1'
+        # TODO: don't fall back to latin1?
+        probed_encodings = (io._locale_encoding, 'latin-1')  # noqa
+
+        source = io.FileInput(source_path='data/latin1.txt')
+        data = source.read()
+        self.assertTrue(source.successful_encoding in probed_encodings)
+        if source.successful_encoding == 'latin-1':
+            self.assertEqual(data, 'Gr\xfc\xdfe\n')
+
+
 if __name__ == '__main__':
     unittest.main()
-- 
2.30.2
0003-Always-use-heuristics-if-input-encoding-is-not-speci.patch (text/x-diff, 4.2 KB)
From 1851f6abccef1a1419a2a6057b7dfc6280382c3d Mon Sep 17 00:00:00 2001
From: milde <[email protected]>
Date: Tue, 21 Jun 2022 12:22:04 +0200
Subject: [PATCH 3/4] Always use heuristics, if input encoding is not
 specified.

Don't rely on Python's default encoding when opening the input stream.

This restores the behaviour under Python2.
---
 docutils/docutils/io.py  | 34 +++++++++++-----------------------
 docutils/test/test_io.py | 22 ----------------------
 2 files changed, 11 insertions(+), 45 deletions(-)

diff --git a/docutils/docutils/io.py b/docutils/docutils/io.py
index f5013d125..a890fd4a5 100644
--- a/docutils/docutils/io.py
+++ b/docutils/docutils/io.py
@@ -342,7 +342,7 @@ class FileInput(Input):
         :Parameters:
             - `source`: either a file-like object (which is read directly), or
               `None` (which implies `sys.stdin` if no `source_path` given).
-            - `source_path`: a path to a file, which is opened and then read.
+            - `source_path`: a path to a file, which is opened for reading.
             - `encoding`: the expected text encoding of the input file.
             - `error_handler`: the encoding error handler to use.
             - `autoclose`: close automatically after read (except when
@@ -379,28 +379,16 @@ class FileInput(Input):
         """
         Read and decode a single file and return the data (Unicode string).
         """
-        try:
-            if self.source is sys.stdin:
-                # read as binary data to circumvent auto-decoding
-                data = self.source.buffer.read()
-                # normalize newlines
-                data = b'\n'.join(data.splitlines()+[b''])
-            else:
-                data = self.source.read()
-        except (UnicodeError, LookupError):
-            if not self.encoding and self.source_path:
-                # re-read in binary mode and decode with heuristics
-                b_source = open(self.source_path, 'rb')
-                data = b_source.read()
-                b_source.close()
-                # normalize newlines
-                data = b'\n'.join(data.splitlines()+[b''])
-            else:
-                raise
-        finally:
-            if self.autoclose:
-                self.close()
-        return self.decode(data)
+        if self.encoding is None:
+            # read as binary data to circumvent auto-decoding
+            data = self.source.buffer.read()
+            # normalize newlines and decode with heuristics
+            data = self.decode(b'\n'.join(data.splitlines()+[b'']))
+        else:
+            data = self.source.read()
+        if self.autoclose:
+            self.close()
+        return data
 
     def readlines(self):
         """
diff --git a/docutils/test/test_io.py b/docutils/test/test_io.py
index 1a3a7ac87..f9e2f483e 100755
--- a/docutils/test/test_io.py
+++ b/docutils/test/test_io.py
@@ -124,28 +124,6 @@ print("hello world")
         data = input.read()  # noqa: F841
         self.assertEqual(input.successful_encoding, 'utf-8')
 
-    def test_readlines(self):
-        input = io.FileInput(source_path='data/include.txt')
-        data = input.readlines()
-        self.assertEqual(data, ['Some include text.\n'])
-
-    def test_heuristics_no_utf8(self):
-        # if no encoding is given and decoding with 'utf-8' fails,
-        # use either the locale encoding (if specified) or 'latin-1':
-        if io._locale_encoding not in ('utf-8', 'utf8'):  # noqa
-            # in Py3k, the locale encoding is used without --input-encoding
-            # skipping the heuristic unless decoding fails.
-            return
-        probed_encodings = (io._locale_encoding, 'latin-1')  # noqa
-        input = io.FileInput(source_path='data/latin1.txt')
-        data = input.read()
-        if input.successful_encoding not in probed_encodings:
-            raise AssertionError(
-                "guessed encoding '%s' differs from probed encodings %r"
-                % (input.successful_encoding, probed_encodings))
-        if input.successful_encoding == 'latin-1':
-            self.assertEqual(data, 'Gr\xfc\xdfe\n')
-
     def test_decode_unicode(self):
         # With the special value "unicode" or "Unicode":
         uniinput = io.Input(encoding='unicode')
-- 
2.30.2
0004-Variant-open-source_path-in-binary-mode-if-encoding-.patch (text/x-diff, 2.1 KB)
From 536f9189627050c453ef311d07c090f234b009b5 Mon Sep 17 00:00:00 2001
From: milde <[email protected]>
Date: Tue, 21 Jun 2022 13:33:24 +0200
Subject: [PATCH 4/4] Variant: open `source_path` in binary mode if encoding is
 not specified.

Avoids warnings for unspecified encoding.
---
 docutils/docutils/io.py | 20 +++++++++++---------
 1 file changed, 11 insertions(+), 9 deletions(-)

diff --git a/docutils/docutils/io.py b/docutils/docutils/io.py
index a890fd4a5..7de31b143 100644
--- a/docutils/docutils/io.py
+++ b/docutils/docutils/io.py
@@ -357,9 +357,13 @@ class FileInput(Input):
         if source is None:
             if source_path:
                 try:
-                    self.source = open(source_path, mode,
-                                       encoding=self.encoding,
-                                       errors=self.error_handler)
+                    if self.encoding is None and mode == 'r':
+                        # read as binary data to allow encoding detection
+                        self.source = open(source_path, 'rb')
+                    else:
+                        self.source = open(source_path, mode,
+                                           encoding=self.encoding,
+                                           errors=self.error_handler)
                 except OSError as error:
                     raise InputError(error.errno, error.strerror, source_path)
             else:
@@ -377,15 +381,13 @@ class FileInput(Input):
 
     def read(self):
         """
-        Read and decode a single file and return the data (Unicode string).
+        Read a single file and return the data as Unicode string.
         """
-        if self.encoding is None:
-            # read as binary data to circumvent auto-decoding
-            data = self.source.buffer.read()
+        data = self.source.read()
+
+        if isinstance(data, bytes):
             # normalize newlines and decode with heuristics
             data = self.decode(b'\n'.join(data.splitlines()+[b'']))
-        else:
-            data = self.source.read()
         if self.autoclose:
             self.close()
         return data
-- 
2.30.2
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.