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
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