Repository: sdispater/tomlkit
Pull request #201 (2022-06-18 13:05:38)

Keep consistent line endings
Resolves: #200

With this change, when reading a toml file with consistent line endings, changing the content and writing the content into a file, the result is a file with the same consistent line endings as the input file.

Further, default line endings for Windows when creating a new toml file will be Windows line endings (`"\r\n"`) again.

This change is not perfect, because it only ensures that the written file has consistent line endings. Dumping the changed `TomlDocument` as string still has inconsistent line endings. However, keeping consistent line endings for `TomlDocument` seems to require way more effort since hard coded `"\n"` is used in many places.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd16788..0005356 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@
 ### Fixed
 
 - Fix `unwrap()` for String values to remove the quotes. ([#199](https://github.com/sdispater/tomlkit/issues/199))
+- Make `KeyAlreadyPresent` and `InvalidStringError` subclasses of `ParseError`. ([#202](https://github.com/sdispater/tomlkit/issues/202))
+- Remove empty table from `OutOfOrderTableProxy` when deleting items. ([#204](https://github.com/sdispater/tomlkit/issues/204))
+- Raise errors when trying to access unsupported methods on `OutOfOrderTableProxy`. ([#205](https://github.com/sdispater/tomlkit/issues/205))
 
 ## [0.11.0] - 2022-05-24
 
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index 6e80668..a3ce827 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -64,7 +64,7 @@ TOML Kit provides an intuitive API to modify TOML documents::
     """
 
     # Remove the newly added table
-    >>> doc.remove("table2")
+    >>> doc.pop("table2")
     # del doc["table2] is also possible
 
 Writing
diff --git a/tomlkit/container.py b/tomlkit/container.py
index cca9f04..070c583 100644
--- a/tomlkit/container.py
+++ b/tomlkit/container.py
@@ -192,7 +192,7 @@ class Container(_CustomDict):
             item.name = key.key
 
         prev = self._previous_item()
-        prev_ws = isinstance(prev, Whitespace) or ends_with_withespace(prev)
+        prev_ws = isinstance(prev, Whitespace) or ends_with_whitespace(prev)
         if isinstance(item, Table):
             if item.name != key.key:
                 item.invalidate_display_name()
@@ -310,7 +310,7 @@ class Container(_CustomDict):
                         previous_item = self._body[-1][1]
                         if not (
                             isinstance(previous_item, Whitespace)
-                            or ends_with_withespace(previous_item)
+                            or ends_with_whitespace(previous_item)
                             or is_table
                             or "\n" in previous_item.trivia.trail
                         ):
@@ -346,6 +346,25 @@ class Container(_CustomDict):
 
         return self
 
+    def _remove_at(self, idx: int) -> None:
+        key = self._body[idx][0]
+        index = self._map.get(key)
+        if index is None:
+            raise NonExistentKey(key)
+        self._body[idx] = (None, Null())
+
+        if isinstance(index, tuple):
+            index = list(index)
+            index.remove(idx)
+            if len(index) == 1:
+                index = index.pop()
+            else:
+                index = tuple(index)
+            self._map[key] = index
+        else:
+            dict.__delitem__(self, key.key)
+            self._map.pop(key)
+
     def remove(self, key: Union[Key, str]) -> "Container":
         """Remove a key from the container."""
         if not isinstance(key, Key):
@@ -425,7 +444,7 @@ class Container(_CustomDict):
             previous_item = self._body[idx - 1][1]
             if not (
                 isinstance(previous_item, Whitespace)
-                or ends_with_withespace(previous_item)
+                or ends_with_whitespace(previous_item)
                 or isinstance(item, (AoT, Table))
                 or "\n" in previous_item.trivia.trail
             ):
@@ -719,7 +738,7 @@ class Container(_CustomDict):
             # - it is not the last item
             last, _ = self._previous_item_with_index()
             idx = last if idx < 0 else idx
-            has_ws = ends_with_withespace(value)
+            has_ws = ends_with_whitespace(value)
             next_ws = idx < last and isinstance(self._body[idx + 1][1], Whitespace)
             if idx < last and not (next_ws or has_ws):
                 value.append(None, Whitespace("\n"))
@@ -804,7 +823,7 @@ class OutOfOrderTableProxy(_CustomDict):
         self._tables_map = {}
 
         for i in indices:
-            key, item = self._container._body[i]
+            _, item = self._container._body[i]
 
             if isinstance(item, Table):
                 self._tables.append(item)
@@ -842,10 +861,20 @@ class OutOfOrderTableProxy(_CustomDict):
         if key is not None:
             dict.__setitem__(self, key, item)
 
+    def _remove_table(self, table: Table) -> None:
+        """Remove table from the parent container"""
+        self._tables.remove(table)
+        for idx, item in enumerate(self._container._body):
+            if item[1] is table:
+                self._container._remove_at(idx)
+                break
+
     def __delitem__(self, key: Union[Key, str]) -> None:
         if key in self._tables_map:
             table = self._tables[self._tables_map[key]]
             del table[key]
+            if not table and len(self._tables) > 1:
+                self._remove_table(table)
             del self._tables_map[key]
         else:
             raise NonExistentKey(key)
@@ -860,15 +889,12 @@ class OutOfOrderTableProxy(_CustomDict):
     def __len__(self) -> int:
         return dict.__len__(self)
 
-    def __getattr__(self, attribute):
-        return getattr(self._internal_container, attribute)
-
     def setdefault(self, key: Union[Key, str], default: Any) -> Any:
         super().setdefault(key, default=default)
         return self[key]
 
 
-def ends_with_withespace(it: Any) -> bool:
+def ends_with_whitespace(it: Any) -> bool:
     """Returns ``True`` if the given item ``it`` is a ``Table`` or ``AoT`` object
     ending with a ``Whitespace``.
     """
diff --git a/tomlkit/parser.py b/tomlkit/parser.py
index dafc4b6..2f94daa 100644
--- a/tomlkit/parser.py
+++ b/tomlkit/parser.py
@@ -146,7 +146,10 @@ class Parser:
             key, value = item
             if (key is not None and key.is_multi()) or not self._merge_ws(value, body):
                 # We actually have a table
-                body.append(key, value)
+                try:
+                    body.append(key, value)
+                except Exception as e:
+                    raise self.parse_error(ParseError, str(e)) from e
 
             self.mark()
 
@@ -157,7 +160,10 @@ class Parser:
                 # along with it.
                 value = self._parse_aot(value, key)
 
-            body.append(key, value)
+            try:
+                body.append(key, value)
+            except Exception as e:
+                raise self.parse_error(ParseError, str(e)) from e
 
         body.parsing(False)
 
diff --git a/tomlkit/toml_file.py b/tomlkit/toml_file.py
index 47c9f2c..5b28cb0 100644
--- a/tomlkit/toml_file.py
+++ b/tomlkit/toml_file.py
@@ -1,3 +1,6 @@
+import os
+import re
+
 from .api import loads
 from .toml_document import TOMLDocument
 
@@ -11,13 +14,35 @@ class TOMLFile:
 
     def __init__(self, path: str) -> None:
         self._path = path
+        self._linesep = os.linesep
 
     def read(self) -> TOMLDocument:
         """Read the file content as a :class:`tomlkit.toml_document.TOMLDocument`."""
         with open(self._path, encoding="utf-8", newline="") as f:
-            return loads(f.read())
+            content = f.read()
+
+            # check if consistent line endings
+            num_newline = content.count("\n")
+            if num_newline > 0:
+                num_win_eol = content.count("\r\n")
+                if num_win_eol == num_newline:
+                    self._linesep = "\r\n"
+                elif num_win_eol == 0:
+                    self._linesep = "\n"
+                else:
+                    self._linesep = "mixed"
+
+            return loads(content)
 
     def write(self, data: TOMLDocument) -> None:
         """Write the TOMLDocument to the file."""
+        content = data.as_string()
+
+        # apply linesep
+        if self._linesep == "\n":
+            content = content.replace("\r\n", "\n")
+        elif self._linesep == "\r\n":
+            content = re.sub(r"(?<!\r)\n", "\r\n", content)
+
         with open(self._path, "w", encoding="utf-8", newline="") as f:
-            f.write(data.as_string())
+            f.write(content)


Test patch:
diff --git a/tests/test_toml_document.py b/tests/test_toml_document.py
index f2e57c9..f5a5e8c 100644
--- a/tests/test_toml_document.py
+++ b/tests/test_toml_document.py
@@ -14,10 +14,8 @@ from tomlkit import ws
 from tomlkit._utils import _utc
 from tomlkit.api import document
 from tomlkit.exceptions import NonExistentKey
-from tomlkit.toml_document import TOMLDocument
 
 from .util import assert_is_ppo
-from .util import elementary_test
 
 
 def test_document_is_a_dict(example):
@@ -654,6 +652,31 @@ a = "b"
     assert expected == doc.as_string()
 
 
+def test_remove_from_out_of_order_table():
+    content = """[a]
+x = 1
+
+[c]
+z = 3
+
+[a.b]
+y = 2
+"""
+    document = parse(content)
+    del document["a"]["b"]
+    assert (
+        document.as_string()
+        == """[a]
+x = 1
+
+[c]
+z = 3
+
+"""
+    )
+    assert json.dumps(document) == '{"a": {"x": 1}, "c": {"z": 3}}'
+
+
 def test_updating_nested_value_keeps_correct_indent():
     content = """
 [Key1]
diff --git a/tests/test_toml_file.py b/tests/test_toml_file.py
index e5b9b52..ae20656 100644
--- a/tests/test_toml_file.py
+++ b/tests/test_toml_file.py
@@ -62,3 +62,46 @@ def test_mixed_eol(tmpdir):
 
     with open(toml_path, "rb") as f:
         assert f.read() == b"a = 1\r\nrb = 2\n"
+
+
+def test_consistent_eol(tmpdir):
+    toml_path = str(tmpdir / "pyproject.toml")
+    with open(toml_path, "wb+") as f:
+        f.write(b"a = 1\r\nb = 2\r\n")
+
+    f = TOMLFile(toml_path)
+    content = f.read()
+    content["c"] = 3
+    f.write(content)
+
+    with open(toml_path, "rb") as f:
+        assert f.read() == b"a = 1\r\nb = 2\r\nc = 3\r\n"
+
+
+def test_consistent_eol_2(tmpdir):
+    toml_path = str(tmpdir / "pyproject.toml")
+    with open(toml_path, "wb+") as f:
+        f.write(b"a = 1\nb = 2\n")
+
+    f = TOMLFile(toml_path)
+    content = f.read()
+    content["c"] = 3
+    content["c"].trivia.trail = "\r\n"
+    f.write(content)
+
+    with open(toml_path, "rb") as f:
+        assert f.read() == b"a = 1\nb = 2\nc = 3\n"
+
+
+def test_default_eol_is_os_linesep(tmpdir):
+    toml_path = str(tmpdir / "pyproject.toml")
+    f = TOMLFile(toml_path)
+    content = TOMLDocument()
+    content.append("a", 1)
+    content["a"].trivia.trail = "\n"
+    content.append("b", 2)
+    content["b"].trivia.trail = "\r\n"
+    f.write(content)
+    linesep = os.linesep.encode()
+    with open(toml_path, "rb") as f:
+        assert f.read() == b"a = 1" + linesep + b"b = 2" + linesep