Skip to main content

encoder: add a better implementation of "compact encoding"

ID
98d1f96
date
2026-07-26 21:29:13+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
4da90a4
message
encoder: add a better implementation of "compact encoding"

Short lists and dicts will now be encoded on a single line, rather than
split across multiple lines.

Before:

```json
{
  "sides": 5,
  "colour": "red"
}
```

After:

```json
{"sides": 5, "colour": "red"}
```
changed files
6 files, 231 additions, 90 deletions

Changed files

CHANGELOG.md (4797 → 5397)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 159ecdd..b5e0195 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,29 @@
 # CHANGELOG
 
+## v1.5.0 - 2026-07-26
+
+Improve the way the JavaScript is encoded to make it more compact and readable -- in particular, short lists and dicts will now be encoded on a single line, rather than split across multiple lines.
+
+Before:
+
+```json
+{
+  "sides": 5,
+  "colour": "red"
+}
+```
+
+After:
+
+```json
+{"sides": 5, "colour": "red"}
+```
+
+Unlike previous attempts are compact encoding, this now applies to *nested* lists and dicts.
+
+The new formatting sets the target line length at 100 characters, and will wrap lists/dicts that exceed this length.
+The indent and target line length are not configurable.
+
 ## v1.4.1 - 2025-08-15
 
 Fix the yanked v1.4.0 release, and make sure it includes the correct code.

README.md (3235 → 3225)

diff --git a/README.md b/README.md
index 04eb257..3a33893 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ This is a collection of Python functions for manipulating JavaScript "data files
 Think of this as the Python `json` module, but built specifically for JavaScript files like this:
 
 ```javascript
-const shape = { "sides": 5, "colour": "red" };
+const shape = {"sides": 5, "colour": "red"};
 ```
 
 ## Why not use JSON files?
@@ -63,9 +63,9 @@ You have two options:
     ```console
     $ pip install javascript-data-files
     ```
-    
+
     To include type-checking capabilities:
-    
+
     ```console
     $ pip install javascript-data-files[typed]
     ```

src/javascript_data_files/__init__.py (6835 → 6835)

diff --git a/src/javascript_data_files/__init__.py b/src/javascript_data_files/__init__.py
index 230c3ca..094f934 100644
--- a/src/javascript_data_files/__init__.py
+++ b/src/javascript_data_files/__init__.py
@@ -21,7 +21,7 @@ from .decoder import decode_from_js
 from .encoder import encode_as_js, encode_as_json
 
 
-__version__ = "1.4.1"
+__version__ = "1.5.0"
 __all__ = [
     "read_js",
     "read_typed_js",

src/javascript_data_files/encoder.py (1408 → 3372)

diff --git a/src/javascript_data_files/encoder.py b/src/javascript_data_files/encoder.py
index 364eef5..86a4e50 100644
--- a/src/javascript_data_files/encoder.py
+++ b/src/javascript_data_files/encoder.py
@@ -10,36 +10,104 @@ import json
 from typing import Any
 
 
-class HumanReadableEncoder(json.JSONEncoder):
-    """
-    A custom JSON encoder with a few niceties for human-readability.
-    """
-
-    def encode(self, o: Any) -> str:
-        """
-        Return a JSON string representation of a Python data structure, o.
-        """
-        if isinstance(o, list) and len(o) < 7 and len(json.dumps(o)) < 60:
-            return json.dumps(o)
-
-        return super().encode(o)
-
-
 def encode_as_json(
     value: Any, *, ensure_ascii: bool = False, sort_keys: bool = False
 ) -> str:
     """
     Convert a Python value to a JSON-encoded string.
     """
-    return json.dumps(
+    return _smart_json_dumps(
         value,
-        indent=2,
-        sort_keys=sort_keys,
         ensure_ascii=ensure_ascii,
-        cls=HumanReadableEncoder,
+        sort_keys=sort_keys,
+        indent=2,
+        max_width=100,
+        current_depth=0,
     )
 
 
+def _smart_json_dumps(
+    value: Any,
+    *,
+    ensure_ascii: bool,
+    sort_keys: bool,
+    indent: int,
+    max_width: int,
+    current_depth: int,
+) -> str:
+    """
+    Convert a Python value to a JSON-encoded string.
+
+    Arrays and objects will only be split across multiple lines if
+    their individual entries are too long to fit on a single line.
+    """
+    if not isinstance(value, (list, dict)):
+        return json.dumps(value, ensure_ascii=ensure_ascii)
+
+    indent_str = " " * (current_depth * indent)
+    child_indent_str = " " * ((current_depth + 1) * indent)
+
+    # 1. Try rendering the entire object on a single line
+    flat = json.dumps(value, ensure_ascii=ensure_ascii, sort_keys=sort_keys)
+    if len(flat + indent_str) <= max_width:
+        return flat
+
+    # 2. If it's a list and too long, split elements across lines.
+    if isinstance(value, list):
+        if not value:
+            return "[]"
+
+        parts = [
+            _smart_json_dumps(
+                item,
+                ensure_ascii=ensure_ascii,
+                sort_keys=False,
+                indent=indent,
+                max_width=max_width,
+                current_depth=current_depth + 1,
+            )
+            for item in value
+        ]
+        return (
+            "[\n"
+            + ",\n".join(child_indent_str + p for p in parts)
+            + "\n"
+            + indent_str
+            + "]"
+        )
+
+    # 3. If it's a dict and too long, split key-value pairs across lines.
+    if isinstance(value, dict):
+        if not value:
+            return "{}"
+
+        parts = []
+        kv_pairs = list(value.items())
+        if sort_keys:
+            kv_pairs = sorted(kv_pairs)
+        for key, val in kv_pairs:
+            key_str = json.dumps(key, ensure_ascii=ensure_ascii)
+            val_str = _smart_json_dumps(
+                val,
+                ensure_ascii=ensure_ascii,
+                sort_keys=False,
+                indent=indent,
+                max_width=max_width,
+                current_depth=current_depth + 1,
+            )
+            parts.append(f"{key_str}: {val_str}")
+
+        return (
+            "{\n"
+            + ",\n".join(child_indent_str + p for p in parts)
+            + "\n"
+            + indent_str
+            + "}"
+        )
+
+    raise TypeError(f"unrecognised type: {type(value)}")  # pragma: no cover
+
+
 def encode_as_js(
     value: Any,
     varname: str,

tests/test_encoder.py (3942 → 6616)

diff --git a/tests/test_encoder.py b/tests/test_encoder.py
index a3e0ae6..f3f7dd5 100644
--- a/tests/test_encoder.py
+++ b/tests/test_encoder.py
@@ -2,33 +2,125 @@
 Tests for ``javascript_data_files.encoder``.
 """
 
+import json
 import string
+from typing import Any
+
+import pytest
 
 from javascript_data_files.encoder import encode_as_json, encode_as_js
 
 
-def test_it_pretty_prints_json() -> None:
+@pytest.mark.parametrize(
+    "value, json_string",
+    [
+        # Test primitive values -- bools, null, strings, empty containers
+        (True, "true"),
+        (False, "false"),
+        (None, "null"),
+        ("hello world", '"hello world"'),
+        ([], "[]"),
+        ({}, "{}"),
+        #
+        # Containers which fit on a single line
+        ([1, 2, 3], "[1, 2, 3]"),
+        ({"colour": "red", "sides": 5}, '{"colour": "red", "sides": 5}'),
+        #
+        # A large array is split across multiple lines
+        (
+            ["a" * 25, "b" * 25, "c" * 25, "d" * 25],
+            "[\n"
+            '  "aaaaaaaaaaaaaaaaaaaaaaaaa",\n'
+            '  "bbbbbbbbbbbbbbbbbbbbbbbbb",\n'
+            '  "ccccccccccccccccccccccccc",\n'
+            '  "ddddddddddddddddddddddddd"\n'
+            "]",
+        ),
+        #
+        # A large dict is split across multiple lines
+        (
+            {"a": "a" * 25, "b": "b" * 25, "c": "c" * 25, "d": "d" * 25},
+            "{\n"
+            '  "a": "aaaaaaaaaaaaaaaaaaaaaaaaa",\n'
+            '  "b": "bbbbbbbbbbbbbbbbbbbbbbbbb",\n'
+            '  "c": "ccccccccccccccccccccccccc",\n'
+            '  "d": "ddddddddddddddddddddddddd"\n'
+            "}",
+        ),
+    ],
+)
+def test_json_encoding(value: Any, json_string: str) -> None:
     """
-    JSON strings are pretty-printed with indentation.
+    Check the exact encoding of values.
     """
-    assert (
-        encode_as_json({"sides": 5, "colour": "red"})
-        == '{\n  "sides": 5,\n  "colour": "red"\n}'
-    )
+    assert encode_as_json(value) == json_string
+    assert json.loads(encode_as_json(value)) == value
+
+
+def test_nested_empty_list() -> None:
+    """
+    A heavily nested empty list will be returned on a single line.
+    """
+    value = {"list": [], "level": 0}
+    for level in range(1, 51):
+        value = {"list": value, "level": level}
+
+    json_string = encode_as_json(value)
+    assert '"list": [],\n' in json_string
+
+
+def test_nested_empty_dict() -> None:
+    """
+    A heavily nested empty dict will be returned on a single line.
+    """
+    value = {"dict": {}, "level": 0}
+    for level in range(1, 51):
+        value = {"dict": value, "level": level}
+
+    json_string = encode_as_json(value)
+    assert '"dict": {},\n' in json_string
 
 
-def test_it_sorts_keys() -> None:
+def test_sort_keys() -> None:
     """
     If you pass `sort_keys=True`, it sorts the keys in JSON objects.
     """
-    assert (
-        encode_as_json({"sides": 5, "colour": "red"}, sort_keys=False)
-        == '{\n  "sides": 5,\n  "colour": "red"\n}'
-    )
+    value = {"sides": 5, "colour": "red"}
+
+    assert encode_as_json(value, sort_keys=False) == '{"sides": 5, "colour": "red"}'
+    assert encode_as_json(value, sort_keys=True) == '{"colour": "red", "sides": 5}'
 
-    assert (
-        encode_as_json({"sides": 5, "colour": "red"}, sort_keys=True)
-        == '{\n  "colour": "red",\n  "sides": 5\n}'
+
+def test_sort_multiline_keys() -> None:
+    """
+    If you pass `sort_keys=True` and the object is split across
+    multiple lines, the keys are sorted.
+    """
+    value = {
+        "sides": 5,
+        "colour": "red",
+        "name": "pentagon",
+        "fill_pattern": "stripey",
+        "border_pattern": "dashed",
+    }
+
+    assert encode_as_json(value, sort_keys=False) == (
+        "{\n"
+        '  "sides": 5,\n'
+        '  "colour": "red",\n'
+        '  "name": "pentagon",\n'
+        '  "fill_pattern": "stripey",\n'
+        '  "border_pattern": "dashed"\n'
+        "}"
+    )
+    assert encode_as_json(value, sort_keys=True) == (
+        "{\n"
+        '  "border_pattern": "dashed",\n'
+        '  "colour": "red",\n'
+        '  "fill_pattern": "stripey",\n'
+        '  "name": "pentagon",\n'
+        '  "sides": 5\n'
+        "}"
     )
 
 

tests/test_javascript_data_files.py (19158 → 17497)

diff --git a/tests/test_javascript_data_files.py b/tests/test_javascript_data_files.py
index 666dd74..8ec2af1 100644
--- a/tests/test_javascript_data_files.py
+++ b/tests/test_javascript_data_files.py
@@ -164,7 +164,7 @@ class TestWriteJs:
 
         assert (
             js_path.read_text()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
     def test_write_to_str(self, tmp_path: pathlib.Path) -> None:
@@ -179,7 +179,7 @@ class TestWriteJs:
 
         assert (
             js_path.read_text()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
     def test_write_to_path(self, tmp_path: pathlib.Path) -> None:
@@ -194,7 +194,7 @@ class TestWriteJs:
 
         assert (
             js_path.read_text()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
     def test_write_to_file(self, tmp_path: pathlib.Path) -> None:
@@ -210,7 +210,7 @@ class TestWriteJs:
 
         assert (
             js_path.read_text()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
     def test_write_to_binary_file(self, tmp_path: pathlib.Path) -> None:
@@ -226,7 +226,7 @@ class TestWriteJs:
 
         assert (
             js_path.read_text()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
     def test_write_to_string_buffer(self) -> None:
@@ -241,7 +241,7 @@ class TestWriteJs:
 
         assert (
             string_buffer.getvalue()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
     def test_write_to_bytes_buffer(self) -> None:
@@ -256,7 +256,7 @@ class TestWriteJs:
 
         assert (
             bytes_buffer.getvalue()
-            == b'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == b'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
     def test_write_with_sort_keys(self, tmp_path: pathlib.Path) -> None:
@@ -276,13 +276,13 @@ class TestWriteJs:
         )
         assert (
             unsorted_path.read_text()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
         write_js(sorted_path, value=red_pentagon, varname="redPentagon", sort_keys=True)
         assert (
             sorted_path.read_text()
-            == 'const redPentagon = {\n  "colour": "red",\n  "sides": 5\n};\n'
+            == 'const redPentagon = {"colour": "red", "sides": 5};\n'
         )
 
     @pytest.mark.parametrize(
@@ -363,7 +363,7 @@ class TestWriteJs:
         assert js_path.exists()
         assert (
             js_path.read_text()
-            == 'const redPentagon = {\n  "sides": 5,\n  "colour": "red"\n};\n'
+            == 'const redPentagon = {"sides": 5, "colour": "red"};\n'
         )
 
 
@@ -439,26 +439,6 @@ class TestAppendToArray:
         with pytest.raises(IsADirectoryError):
             append_to_js_array(tmp_path, value="alex")
 
-    def test_indentation_is_consistent(self, tmp_path: pathlib.Path) -> None:
-        """
-        If you append to an array, the file looks as if you'd read and rewritten
-        the whole thing with ``write_js()``.
-        """
-        js_path1 = tmp_path / "data1.js"
-        js_path2 = tmp_path / "data2.js"
-
-        # We use deliberately large value, so they won't be compressed
-        # by the custom encoder.
-        value = ["1" * 10, "2" * 20, "3" * 30]
-        appended_value = ["4" * 40, "5" * 50, "6" * 60]
-
-        write_js(js_path1, varname="numbers", value=value)
-        append_to_js_array(js_path1, value=appended_value)
-
-        write_js(js_path2, varname="numbers", value=value + [appended_value])
-
-        assert js_path1.read_text() == js_path2.read_text()
-
 
 class TestAppendToObject:
     """
@@ -494,29 +474,6 @@ class TestAppendToObject:
             "sideLengths": [1, 2, 3, 4, 5],
         }
 
-    def test_indentation_is_consistent(self, tmp_path: pathlib.Path) -> None:
-        """
-        If you append to an object, the file looks as if you'd read and
-        rewritten the whole thing with ``write_js()``.
-        """
-        js_path1 = tmp_path / "data1.js"
-        js_path2 = tmp_path / "data2.js"
-
-        # We pick a deliberately large value, so it won't be compressed
-        # by the custom encoder.
-        value = ["1" * 10, "2" * 20, "3" * 30]
-
-        write_js(js_path1, varname="shape", value={"colour": "red"})
-        append_to_js_object(js_path1, key="sides", value=value)
-
-        write_js(
-            js_path2,
-            varname="shape",
-            value={"colour": "red", "sides": value},
-        )
-
-        assert js_path1.read_text() == js_path2.read_text()
-
     def test_error_if_file_doesnt_look_like_object(self, js_path: pathlib.Path) -> None:
         """
         Appending to a file which doesn't contain a JSON object throws