Skip to content

Weird indentation when using custom Encoder class #94

@Hirnmoder

Description

@Hirnmoder

First of all, thank you for providing the json5 package. It really does improve the configuration file readability. So far, I have not encountered any issues while parsing / loading and un-parsing / dumping.
However, I did notice a strange behavior when using the indent setting during dumps(...). Please see this MWE below:

import json5

class Foo:
    def __init__(self):
        self.bar = Bar()
        self.baz = "hello"
        self.foobar = None
        self.arr = [1, 2, 3]
        self.qux = {"a": 1, "b": 2}
        self.obj = Bar()

class Bar:
    def __init__(self):
        self.answer = 42

class Encoder(json5.JSON5Encoder):
    def default(self, obj):
        if isinstance(obj, Foo):
            return {"__type": "Foo", **vars(obj)}
        if isinstance(obj, Bar):
            return {"__type": "Bar", "answer": obj.answer}
        return super().default(obj)

print(
    json5.dumps(
        Foo(),
        indent=4,
        cls=Encoder,
    ),
)

It gives this semantically correct output:

{
        __type: "Foo",
        bar: {
                __type: "Bar",
                answer: 42,
            },
        baz: "hello",
        foobar: null,
        arr: [
            1,
            2,
            3,
        ],
        qux: {
            a: 1,
            b: 2,
        },
        obj: {
                __type: "Bar",
                answer: 42,
            },
    }

As you can see, the indentation gets applied twice for my custom objects (Foo and Bar) and their closing braces do not line up with the (semantically) corresponding opening braces.

This is due to an additional level + 1 in _encode_non_basic_type.

In fact, overriding the method _encode_non_basic_type in the custom Encoder class will fix this issue:

# ...
class Encoder(json5.JSON5Encoder):
    def default(self, obj):
        if isinstance(obj, Foo):
            return {"__type": "Foo", **vars(obj)}
        if isinstance(obj, Bar):
            return {"__type": "Bar", "answer": obj.answer}
        return super().default(obj)

    def _encode_non_basic_type(self, obj, seen: Set, level: int) -> str:
        # Basic types can't be recursive so we only check for circularity
        # on non-basic types. If for some reason the caller was using a
        # subclass of a basic type and wanted to check circularity on it,
        # it'd have to do so directly in a subclass of JSON5Encoder.
        if self.check_circular:
            i = id(obj)
            if i in seen:
                raise ValueError("Circular reference detected.")
            seen.add(i)

        # Ideally we'd use collections.abc.Mapping and collections.abc.Sequence
        # here, but for backwards-compatibility with potential old callers,
        # we only check for the two attributes we need in each case.
        if hasattr(obj, "keys") and hasattr(obj, "__getitem__"):
            s = self._encode_dict(obj, seen, level + 1)
        elif hasattr(obj, "__getitem__") and hasattr(obj, "__iter__"):
            s = self._encode_array(obj, seen, level + 1)
        else:
            s = self.encode(self.default(obj), seen, level, as_key=False)    # <---- this line is the culprit
            assert s is not None

        if self.check_circular:
            seen.remove(i)  # type: ignore
        return s
# ...

Output:

{
    __type: "Foo",
    bar: {
        __type: "Bar",
        answer: 42,
    },
    baz: "hello",
    foobar: null,
    arr: [
        1,
        2,
        3,
    ],
    qux: {
        a: 1,
        b: 2,
    },
    obj: {
        __type: "Bar",
        answer: 42,
    },
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions