Skip to content

The JSON encoding

The same layout tree the .tosc holds, written as JSON. Reading and writing it is a load and a save away -- the extension chooses on the way out and the content decides on the way in -- and these are the functions underneath that, for when the text rather than the file is what you have.

The format itself is described in The .json format.

to_json

to_json(
    document: Document, *, indent: int | None = 2
) -> str

Serialize a document to JSON text.

Parameters:

Name Type Description Default
document Document

The layout to write.

required
indent int | None

Spaces per level, or None for the single-line form.

2

Returns:

Type Description
str

The complete document. The indented form ends in a newline, as the

str

pretty XML export does.

Raises:

Type Description
ValueError

If the layout holds a number JSON cannot represent. Only a non-finite one can be, and those are escaped rather than refused, so this is a guard rather than something a real layout meets.

Source code in src/py2tosc/json_codec.py
def to_json(document: Document, *, indent: int | None = 2) -> str:
    """Serialize a document to JSON text.

    Args:
        document: The layout to write.
        indent: Spaces per level, or `None` for the single-line form.

    Returns:
        The complete document. The indented form ends in a newline, as the
        pretty XML export does.

    Raises:
        ValueError: If the layout holds a number JSON cannot represent. Only a
            non-finite one can be, and those are escaped rather than refused,
            so this is a guard rather than something a real layout meets.
    """
    data = {
        "format": FORMAT,
        "schema": SCHEMA,
        "lexml": document.version,
        "root": encode(document.root),
    }
    if indent is None:
        return json.dumps(data, separators=(",", ":"), allow_nan=False)
    return _render(data, indent) + "\n"

from_json

from_json(source: str | bytes) -> Document

Parse a layout from JSON text.

Parameters:

Name Type Description Default
source str | bytes

The document, as text or UTF-8 bytes.

required

Returns:

Type Description
Document

The parsed document.

Raises:

Type Description
FormatError

If the source is not JSON, is not a py2tosc layout, or holds a node that cannot be read. The message names the node it gave up on.

SchemaError

If it declares a schema this release does not read.

Source code in src/py2tosc/json_codec.py
def from_json(source: str | bytes) -> Document:
    """Parse a layout from JSON text.

    Args:
        source: The document, as text or UTF-8 bytes.

    Returns:
        The parsed document.

    Raises:
        FormatError: If the source is not JSON, is not a py2tosc layout, or
            holds a node that cannot be read. The message names the node it
            gave up on.
        SchemaError: If it declares a schema this release does not read.
    """
    try:
        return build(json.loads(source))
    except json.JSONDecodeError as exc:
        raise FormatError(f"not valid JSON: {exc}") from exc

supports

supports(schema: int) -> bool

Whether this release reads a file declaring that schema.

The question a producer has before it writes one, and the reason to ask it rather than to build and catch: an answer here names its own remedy, where a SchemaError several steps into a file is about the file.

Parameters:

Name Type Description Default
schema int

The schema number a file declares or would declare.

required

Returns:

Type Description
bool

True if this release reads it.

Source code in src/py2tosc/json_codec.py
def supports(schema: int) -> bool:
    """Whether this release reads a file declaring that schema.

    The question a producer has before it writes one, and the reason to ask it
    rather than to build and catch: an answer here names its own remedy, where
    a `SchemaError` several steps into a file is about the file.

    Args:
        schema: The schema number a file declares or would declare.

    Returns:
        True if this release reads it.
    """
    return schema in SCHEMAS

encode

encode(control: Control) -> dict[str, Any]

Write one control and everything under it as JSON-ready data.

Parameters:

Name Type Description Default
control Control

The control to encode. Its children come with it.

required

Returns:

Type Description
dict[str, Any]

A dict of nothing but JSON types.

Source code in src/py2tosc/json_codec.py
def encode(control: Control) -> dict[str, Any]:
    """Write one control and everything under it as JSON-ready data.

    Args:
        control: The control to encode. Its children come with it.

    Returns:
        A dict of nothing but JSON types.
    """
    node: dict[str, Any] = {
        "type": control.control_type.value,
        "id": control.id,
    }
    # The root of a version 6 document carries an empty <includes/>, and so do
    # a handful of nodes beneath it. `codec` writes the root's from the format
    # version, but a child's exists only because the file said so.
    if getattr(control, "_has_includes", False):
        node["includes"] = True

    node["properties"] = {
        key: _property(control.properties[key]) for key in sorted(control.properties)
    }
    if control.values:
        node["values"] = [_thin(value) for value in control.values]
    if control.messages:
        node["messages"] = [_message(message) for message in control.messages]
    if control.children:
        node["children"] = [encode(child) for child in control.children]
    return node

decode

decode(node: Any, where: str = 'root') -> Control

Read one control and everything under it.

A node with no id is given one, as Control does. Nothing else is filled in: a node with no properties decodes to a control with none, rather than to the default set for its type.

Parameters:

Name Type Description Default
node Any

The decoded JSON for a single node.

required
where str

What to call this node when something in it will not read. The default names the root, and children extend it, so a message says root.children[2].properties.frame rather than just frame.

'root'

Returns:

Type Description
Control

The control, with its children.

Raises:

Type Description
FormatError

If the node is not an object, names no type or an unknown one, holds a key nothing reads, or holds a property, value or binding that cannot be read.

Source code in src/py2tosc/json_codec.py
def decode(node: Any, where: str = "root") -> Control:
    """Read one control and everything under it.

    A node with no `id` is given one, as `Control` does. Nothing else is filled
    in: a node with no properties decodes to a control with none, rather than
    to the default set for its type.

    Args:
        node: The decoded JSON for a single node.
        where: What to call this node when something in it will not read. The
            default names the root, and children extend it, so a message says
            `root.children[2].properties.frame` rather than just `frame`.

    Returns:
        The control, with its children.

    Raises:
        FormatError: If the node is not an object, names no type or an unknown
            one, holds a key nothing reads, or holds a property, value or
            binding that cannot be read.
    """
    entry = as_object(node, where)
    check_keys(entry, _NODE_KEYS, where)

    if "type" not in entry:
        raise FormatError(f"{where}: a node needs a type")
    try:
        control_type = ControlType(entry["type"])
    except ValueError:
        types = ", ".join(t.value for t in ControlType)
        raise FormatError(
            f"{where}: {entry['type']!r} is not a control type; expected one of {types}"
        ) from None

    identifier = entry.get("id")
    if identifier is not None and not isinstance(identifier, str):
        raise FormatError(
            f"{where}: id should be a string, found {describe(identifier)}"
        )

    control = Control(
        control_type,
        id=identifier,
        properties={},
        values=[],
        messages=[],
        children=[],
    )
    # Replace the type defaults wholesale, exactly as reading XML does: a
    # property the file leaves out is one the control does not have.
    control.properties.clear()

    properties = as_object(entry.get("properties", {}), f"{where}.properties")
    for key, value in properties.items():
        prop = _read_property(key, value, f"{where}.properties.{key}")
        control.properties[prop.key] = prop

    for index, value in enumerate(as_list(entry.get("values", []), f"{where}.values")):
        control.values.append(_read_value(value, f"{where}.values[{index}]"))

    for index, message in enumerate(
        as_list(entry.get("messages", []), f"{where}.messages")
    ):
        control.messages.append(_read_message(message, f"{where}.messages[{index}]"))

    for index, child in enumerate(
        as_list(entry.get("children", []), f"{where}.children")
    ):
        control.children.append(decode(child, f"{where}.children[{index}]"))

    if entry.get("includes"):
        object.__setattr__(control, "_has_includes", True)

    return control