Repository: asottile/babi

Issue:
saving over a non-utf-8 file causes a crash
I typed `hello world`, then <kbd>^O</kbd>, then `f`, then <kbd>Enter</kbd>

```console
$ echo $'\x98\xea\xf2' > f
$ babi
Traceback (most recent call last):
  File "/home/asottile/bin/babi", line 8, in <module>
    sys.exit(main())
  File "/home/asottile/opt/venv/lib/python3.8/site-packages/babi/main.py", line 176, in main
    return c_main(stdscr, file_infos, stdin, perf)
  File "/home/asottile/opt/venv/lib/python3.8/site-packages/babi/main.py", line 63, in c_main
    res = _edit(screen, stdin)
  File "/home/asottile/opt/venv/lib/python3.8/site-packages/babi/main.py", line 36, in _edit
    ret = Screen.DISPATCH[key.keyname](screen)
  File "/home/asottile/opt/venv/lib/python3.8/site-packages/babi/screen.py", line 770, in save_filename
    return self.save()
  File "/home/asottile/opt/venv/lib/python3.8/site-packages/babi/screen.py", line 735, in save
    *_, sha256 = get_lines(f)
  File "/home/asottile/opt/venv/lib/python3.8/site-packages/babi/file.py", line 47, in get_lines
    for line in sio:
  File "/usr/lib/python3.8/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x98 in position 0: invalid start byte
```

Discussion:

Patch:
diff --git a/babi/file.py b/babi/file.py
index 3fa5184..7a0d353 100644
--- a/babi/file.py
+++ b/babi/file.py
@@ -14,10 +14,12 @@ from typing import Callable
 from typing import cast
 from typing import Generator
 from typing import IO
+from typing import Literal
 from typing import Match
 from typing import NamedTuple
 from typing import Pattern
 from typing import TYPE_CHECKING
+from typing import TypedDict
 from typing import TypeVar
 
 from babi.buf import Buf
@@ -40,6 +42,14 @@ TCallable = TypeVar('TCallable', bound=Callable[..., Any])
 WS_RE = re.compile(r'^\s*')
 
 
+class OpenSettings(TypedDict):
+    encoding: Literal['UTF-8']
+    newline: Literal['']
+
+
+OPEN_SETTINGS = OpenSettings(encoding='UTF-8', newline='')
+
+
 class NullByteError(ValueError):
     pass
 
diff --git a/babi/screen.py b/babi/screen.py
index 213758c..e26c702 100644
--- a/babi/screen.py
+++ b/babi/screen.py
@@ -23,6 +23,7 @@ from babi.dim import Dim
 from babi.file import Action
 from babi.file import File
 from babi.file import get_lines
+from babi.file import OPEN_SETTINGS
 from babi.history import History
 from babi.hl.syntax import Syntax
 from babi.linters.flake8 import Flake8
@@ -728,8 +729,12 @@ class Screen:
         if not os.path.isfile(self.file.filename):
             sha256: str | None = None
         else:
-            with open(self.file.filename, encoding='UTF-8', newline='') as f:
-                *_, sha256 = get_lines(f)
+            try:
+                with open(self.file.filename, **OPEN_SETTINGS) as f:
+                    *_, sha256 = get_lines(f)
+            except UnicodeDecodeError:
+                # instead of crashing, show "changed on disk" error
+                sha256 = 'error'
 
         contents = self.file.nl.join(self.file.buf)
         sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
@@ -742,9 +747,7 @@ class Screen:
         try:
             dir_path = os.path.dirname(os.path.abspath(self.file.filename))
             os.makedirs(dir_path, exist_ok=True)
-            with open(
-                self.file.filename, 'w', encoding='UTF-8', newline='',
-            ) as f:
+            with open(self.file.filename, 'w', **OPEN_SETTINGS) as f:
                 f.write(contents)
         except OSError as e:
             self.status.update(f'cannot save file: {e}')


Test patch:
diff --git a/tests/features/save_test.py b/tests/features/save_test.py
index 2463d66..a960bda 100644
--- a/tests/features/save_test.py
+++ b/tests/features/save_test.py
@@ -103,6 +103,16 @@ def test_saving_file_on_disk_changes(run, tmpdir):
         h.await_text('file changed on disk, not implemented')
 
 
+def test_save_over_non_utf8_file(run, tmp_path):
+    f = tmp_path.joinpath('f')
+
+    with run(str(f)) as h, and_exit(h):
+        h.run(lambda: f.write_bytes(b'\x98\xef\xa0\x12'))
+
+        h.press('^S')
+        h.await_text('file changed on disk, not implemented')
+
+
 def test_allows_saving_same_contents_as_modified_contents(run, tmpdir):
     f = tmpdir.join('f')