Source code for jsonyx

"""Customizable JSON library for Python."""
from __future__ import annotations

__all__: list[str] = [
    "Decoder",
    "Encoder",
    "JSONSyntaxError",
    "Manipulator",
    "TruncatedSyntaxError",
    "apply_filter",
    "apply_patch",
    "detect_encoding",
    "dump",
    "dumps",
    "format_syntax_error",
    "load",
    "load_query_value",
    "loads",
    "make_patch",
    "read",
    "select_nodes",
    "write",
]
__version__: str = "2.3.0"

from typing import TYPE_CHECKING, Any, Protocol, TypeVar

from jsonyx._decoder import (
    Decoder, JSONSyntaxError, TruncatedSyntaxError, detect_encoding,
)
from jsonyx._differ import make_patch
from jsonyx._encoder import Encoder
from jsonyx._manipulator import Manipulator
from jsonyx.allow import NOTHING

if TYPE_CHECKING:
    from collections.abc import Callable, Container
    from os import PathLike

    _T_co = TypeVar("_T_co", covariant=True)
    _T_contra = TypeVar("_T_contra", contravariant=True)

    # pylint: disable-next=R0903
    class _SupportsRead(Protocol[_T_co]):
        def read(self, length: int = ..., /) -> _T_co:  # type: ignore
            """Read string."""

    # pylint: disable-next=R0903
    class _SupportsWrite(Protocol[_T_contra]):
        def write(self, s: _T_contra, /) -> object:
            """Write string."""

    _Node = tuple[dict[Any, Any] | list[Any], Any]
    _Operation = dict[str, Any]
    _StrPath = PathLike[str] | str
    _Hook = Callable[[Any], Any]


[docs] def format_syntax_error(exc: TruncatedSyntaxError) -> list[str]: """Format a truncated syntax error. :param exc: a truncated syntax error :return: a list of strings, each ending in a newline Example: >>> import jsonyx as json >>> try: ... json.loads("[,]") ... except json.JSONSyntaxError as exc: ... print("Traceback (most recent call last):") ... print(end="".join(json.format_syntax_error(exc))) ... Traceback (most recent call last): File "<string>", line 1, column 2 [,] ^ jsonyx.JSONSyntaxError: Expecting value .. note:: Don't use :func:`traceback.format_exception_only`, it displays less information. """ if exc.end_lineno == exc.lineno: line_range: str = f"{exc.lineno:d}" else: line_range = f"{exc.lineno:d}-{exc.end_lineno:d}" if exc.end_colno == exc.colno: column_range: str = f"{exc.colno:d}" else: column_range = f"{exc.colno:d}-{exc.end_colno:d}" indent: str = " " * (exc.offset - 1) # type: ignore if exc.end_lineno == exc.lineno: selection: str = "^" * (exc.end_offset - exc.offset) # type: ignore else: selection = "^" * (len(exc.text) - exc.offset + 1) # type: ignore return [ f' File "{exc.filename}", line {line_range}, column {column_range}\n', f" {exc.text}\n", f" {indent}{selection}\n", f"{exc.__module__}.{type(exc).__qualname__}: {exc.msg}\n", ]
[docs] def read( filename: _StrPath, *, allow: Container[str] = NOTHING, cache_keys: bool = False, hooks: dict[str, _Hook] | None = None, ) -> Any: """Deserialize a JSON file to a Python object. .. versionchanged:: 2.0 Replaced ``use_decimal`` with ``hooks``. .. versionchanged:: 2.2 - Added ``cache_keys``. - Disabled caching keys by default. :param filename: the path to the JSON file :param allow: the JSON deviations from :mod:`jsonyx.allow` :param cache_keys: re-use the keys of objects :param hooks: the :ref:`hooks <decoding_hooks>` used for transforming data :raises OSError: if the file can't be opened :raises TruncatedSyntaxError: when failing to decode the file :return: a Python object. Example: >>> import jsonyx as json >>> from pathlib import Path >>> from tempfile import TemporaryDirectory >>> with TemporaryDirectory() as tmpdir: ... filename = Path(tmpdir) / "file.json" ... _ = filename.write_text('["filesystem API"]', "utf-8") ... json.read(filename) ... ['filesystem API'] """ return Decoder( allow=allow, cache_keys=cache_keys, hooks=hooks, ).read(filename)
[docs] def load( fp: _SupportsRead[bytes | str], *, allow: Container[str] = NOTHING, cache_keys: bool = False, hooks: dict[str, _Hook] | None = None, root: _StrPath = ".", ) -> Any: """Deserialize an open JSON file to a Python object. .. versionchanged:: 2.0 Replaced ``use_decimal`` with ``hooks``. .. versionchanged:: 2.2 - Added ``cache_keys``. - Disabled caching keys by default. :param fp: an open JSON file :param allow: the JSON deviations from :mod:`jsonyx.allow` :param cache_keys: re-use the keys of objects :param hooks: the :ref:`hooks <decoding_hooks>` used for transforming data :param root: the path to the archive containing this JSON file :raises TruncatedSyntaxError: when failing to decode the file :return: a Python object Example: >>> import jsonyx as json >>> from io import StringIO >>> io = StringIO('["streaming API"]') >>> json.load(io) ['streaming API'] .. tip:: Specify ``root`` to display the zip filename in error messages. """ return Decoder( allow=allow, cache_keys=cache_keys, hooks=hooks, ).load(fp, root=root)
[docs] def loads( s: bytearray | bytes | str, *, allow: Container[str] = NOTHING, cache_keys: bool = False, filename: _StrPath = "<string>", hooks: dict[str, _Hook] | None = None, ) -> Any: r"""Deserialize a JSON string to a Python object. .. versionchanged:: 2.0 Replaced ``use_decimal`` with ``hooks``. .. versionchanged:: 2.2 - Added ``cache_keys``. - Disabled caching keys by default. :param s: a JSON string :param allow: the JSON deviations from :mod:`jsonyx.allow` :param cache_keys: re-use the keys of objects :param filename: the path to the JSON file :param hooks: the :ref:`hooks <decoding_hooks>` used for transforming data :raises TruncatedSyntaxError: when failing to decode the string :return: a Python object Example: >>> import jsonyx as json >>> json.loads('{"foo": ["bar", null, 1.0, 2]}') {'foo': ['bar', None, 1.0, 2]} >>> json.loads(r'"\"foo\bar"') '"foo\x08ar' .. tip:: Specify ``filename`` to display the filename in error messages. """ return Decoder( allow=allow, cache_keys=cache_keys, hooks=hooks, ).loads(s, filename=filename)
[docs] def write( obj: object, filename: _StrPath, encoding: str = "utf-8", *, allow: Container[str] = NOTHING, check_circular: bool = True, commas: bool = True, end: str = "\n", ensure_ascii: bool = False, hook: _Hook | None = None, indent: int | str | None = None, indent_leaves: bool = True, max_indent_level: int | None = None, quoted_keys: bool = True, separators: tuple[str, str] = (", ", ": "), skipkeys: bool = False, sort_keys: bool = False, trailing_comma: bool = False, types: dict[str, type | tuple[type, ...]] | None = None, ) -> None: r"""Serialize a Python object to a JSON file. .. versionchanged:: 2.0 - Added ``commas``, ``encoding``, ``indent_leaves``, ``max_indent_level``, ``quoted_keys`` and ``types``. - Made :class:`tuple` serializable by default instead of :class:`enum.Enum` and :class:`decimal.Decimal`. - Replaced ``item_separator`` and ``key_separator`` with ``separators``. .. versionchanged:: 2.1 Added ``check_circular``, ``hook`` and ``skipkeys``. :param obj: a Python object :param filename: the path to the JSON file :param encoding: the JSON encoding :param allow: the JSON deviations from :mod:`jsonyx.allow` :param check_circular: check for circular references :param commas: separate items by commas when indented :param end: the string to append at the end :param ensure_ascii: escape non-ASCII characters :param hook: the :ref:`hook <encoding_hook>` used for transforming data :param indent: the number of spaces or string to indent with :param indent_leaves: indent leaf objects and arrays :param max_indent_level: the level up to which to indent :param quoted_keys: quote keys which are :ref:`identifiers <identifiers>` :param separators: the item and key separator :param skipkeys: skip non-string keys :param sort_keys: sort the keys of objects :param trailing_comma: add a trailing comma when indented :param types: a dictionary of :ref:`additional types <protocol_types>` :raises OSError: if the file can't be opened :raises RecursionError: if the object is too deeply nested :raises TypeError: for unserializable values :raises TruncatedSyntaxError: when failing to encode the file :raises ValueError: for invalid values Example: >>> import jsonyx as json >>> from pathlib import Path >>> from tempfile import TemporaryDirectory >>> with TemporaryDirectory() as tmpdir: ... filename = Path(tmpdir) / "file.json" ... json.write(["filesystem API"], filename) ... filename.read_text("utf-8") ... '["filesystem API"]\n' .. note:: The item separator is automatically stripped when indented. .. warning:: Avoid specifying ABCs for ``types``, that is very slow. """ Encoder( allow=allow, check_circular=check_circular, commas=commas, end=end, ensure_ascii=ensure_ascii, hook=hook, indent=indent, indent_leaves=indent_leaves, max_indent_level=max_indent_level, quoted_keys=quoted_keys, separators=separators, skipkeys=skipkeys, sort_keys=sort_keys, trailing_comma=trailing_comma, types=types, ).write(obj, filename, encoding)
[docs] def dump( obj: object, fp: _SupportsWrite[str] | None = None, *, allow: Container[str] = NOTHING, check_circular: bool = True, commas: bool = True, end: str = "\n", ensure_ascii: bool = False, hook: _Hook | None = None, indent: int | str | None = None, indent_leaves: bool = True, max_indent_level: int | None = None, quoted_keys: bool = True, separators: tuple[str, str] = (", ", ": "), skipkeys: bool = False, sort_keys: bool = False, trailing_comma: bool = False, types: dict[str, type | tuple[type, ...]] | None = None, ) -> None: r"""Serialize a Python object to an open JSON file. .. versionchanged:: 2.0 - Added ``commas``, ``indent_leaves``, ``max_indent_level``, ``quoted_keys`` and ``types``. - Made :class:`tuple` serializable by default instead of :class:`enum.Enum` and :class:`decimal.Decimal`. - Replaced ``item_separator`` and ``key_separator`` with ``separators``. .. versionchanged:: 2.1 Added ``check_circular``, ``hook`` and ``skipkeys``. :param obj: a Python object :param fp: an open JSON file :param allow: the JSON deviations from :mod:`jsonyx.allow` :param check_circular: check for circular references :param commas: separate items by commas when indented :param end: the string to append at the end :param ensure_ascii: escape non-ASCII characters :param hook: the :ref:`hook <encoding_hook>` used for transforming data :param indent: the number of spaces or string to indent with :param indent_leaves: indent leaf objects and arrays :param max_indent_level: the level up to which to indent :param quoted_keys: quote keys which are :ref:`identifiers <identifiers>` :param separators: the item and key separator :param skipkeys: skip non-string keys :param sort_keys: sort the keys of objects :param trailing_comma: add a trailing comma when indented :param types: a dictionary of :ref:`additional types <protocol_types>` :raises RecursionError: if the object is too deeply nested :raises TypeError: for unserializable values :raises TruncatedSyntaxError: when failing to write to the file :raises ValueError: for invalid values Example: Writing to standard output: >>> import jsonyx as json >>> json.dump('"foo\bar') "\"foo\bar" >>> json.dump("\\") "\\" >>> json.dump("\u20AC") "€" Writing to an open file: >>> import jsonyx as json >>> from io import StringIO >>> io = StringIO() >>> json.dump(["streaming API"], io) >>> io.getvalue() '["streaming API"]\n' .. note:: The item separator is automatically stripped when indented. .. warning:: Avoid specifying ABCs for ``types``, that is very slow. """ Encoder( allow=allow, check_circular=check_circular, commas=commas, end=end, ensure_ascii=ensure_ascii, hook=hook, indent=indent, indent_leaves=indent_leaves, max_indent_level=max_indent_level, quoted_keys=quoted_keys, separators=separators, skipkeys=skipkeys, sort_keys=sort_keys, trailing_comma=trailing_comma, types=types, ).dump(obj, fp)
[docs] def dumps( obj: object, *, allow: Container[str] = NOTHING, check_circular: bool = True, commas: bool = True, end: str = "\n", ensure_ascii: bool = False, hook: _Hook | None = None, indent: int | str | None = None, indent_leaves: bool = True, max_indent_level: int | None = None, quoted_keys: bool = True, separators: tuple[str, str] = (", ", ": "), skipkeys: bool = False, sort_keys: bool = False, trailing_comma: bool = False, types: dict[str, type | tuple[type, ...]] | None = None, ) -> str: r"""Serialize a Python object to a JSON string. .. versionchanged:: 2.0 - Added ``commas``, ``indent_leaves``, ``max_indent_level``, ``quoted_keys`` and ``types``. - Made :class:`tuple` serializable by default instead of :class:`enum.Enum` and :class:`decimal.Decimal`. - Replaced ``item_separator`` and ``key_separator`` with ``separators``. .. versionchanged:: 2.1 Added ``check_circular``, ``hook`` and ``skipkeys``. :param obj: a Python object :param allow: the JSON deviations from :mod:`jsonyx.allow` :param check_circular: check for circular references :param commas: separate items by commas when indented :param end: the string to append at the end :param ensure_ascii: escape non-ASCII characters :param hook: the :ref:`hook <encoding_hook>` used for transforming data :param indent: the number of spaces or string to indent with :param indent_leaves: indent leaf objects and arrays :param max_indent_level: the level up to which to indent :param quoted_keys: quote keys which are :ref:`identifiers <identifiers>` :param separators: the item and key separator :param skipkeys: skip non-string keys :param sort_keys: sort the keys of objects :param trailing_comma: add a trailing comma when indented :param types: a dictionary of :ref:`additional types <protocol_types>` :raises RecursionError: if the object is too deeply nested :raises TypeError: for unserializable values :raises ValueError: for invalid values :return: a JSON string Example: >>> import jsonyx as json >>> json.dumps(["foo", {"bar": ("baz", None, 1.0, 2)}]) '["foo", {"bar": ["baz", null, 1.0, 2]}]\n' .. note:: The item separator is automatically stripped when indented. .. warning:: Avoid specifying ABCs for ``types``, that is very slow. """ return Encoder( allow=allow, check_circular=check_circular, commas=commas, end=end, ensure_ascii=ensure_ascii, hook=hook, indent=indent, indent_leaves=indent_leaves, max_indent_level=max_indent_level, quoted_keys=quoted_keys, separators=separators, skipkeys=skipkeys, sort_keys=sort_keys, trailing_comma=trailing_comma, types=types, ).dumps(obj)
[docs] def apply_patch( obj: Any, patch: _Operation | list[_Operation], *, allow: Container[str] = NOTHING, use_decimal: bool = False, ) -> Any: """Apply a JSON patch to a Python object. .. versionadded:: 2.0 :param obj: a Python object :param patch: a :doc:`JSON patch </json-patch-spec>` :param allow: the JSON deviations from :mod:`jsonyx.allow` :param use_decimal: use :class:`decimal.Decimal` instead of :class:`float` :raises AssertionError: if an assertion fails :raises IndexError: if an index is out of range :raises JSONSyntaxError: if a query is invalid :raises KeyError: if a key is not found :raises TypeError: if a value has the wrong type :raises ValueError: if a value is invalid :return: the patched Python object Example: >>> import jsonyx as json >>> json.apply_patch([1, 2, 3], {"op": "del", "path": "$[1]"}) [1, 3] .. tip:: Using a :ref:`filter` instead of an index is more robust. """ return Manipulator(allow=allow, use_decimal=use_decimal).apply_patch( obj, patch, )
[docs] def paste_values( current_nodes: _Node | list[_Node], values: Any | list[Any], operation: _Operation, *, allow: Container[str] = NOTHING, use_decimal: bool = False, ) -> None: """Paste value to a node or values to a list of nodes. .. versionadded:: 2.0 :param current_nodes: a node or a list of nodes :param values: a value or a list of values :param operation: a JSON :ref:`copy` or :ref:`move` operation :param allow: the JSON deviations from :mod:`jsonyx.allow` :param use_decimal: use :class:`decimal.Decimal` instead of :class:`float` :raises IndexError: if an index is out of range :raises JSONSyntaxError: if a query is invalid :raises KeyError: if a key is not found :raises TypeError: if a value has the wrong type :raises ValueError: if a value is invalid Example: >>> import jsonyx as json >>> obj = [1, 2, 3] >>> root = [obj] >>> node = root, 0 # pointer to obj >>> json.paste_values(node, 4, {"mode": "append"}) >>> root[0] [1, 2, 3, 4] """ Manipulator(allow=allow, use_decimal=use_decimal).paste_values( current_nodes, values, operation, )
[docs] def select_nodes( nodes: _Node | list[_Node], query: str, *, allow: Container[str] = NOTHING, allow_slice: bool = False, relative: bool = False, use_decimal: bool = False, ) -> list[_Node]: """Select nodes from a node or a list of nodes. .. versionadded:: 2.0 :param nodes: a node or a list of nodes :param query: an :ref:`JSON select query <query>` :param allow: the JSON deviations from :mod:`jsonyx.allow` :param allow_slice: allow slice :param relative: query must start with ``"@"`` instead of ``"$"`` :param use_decimal: use :class:`decimal.Decimal` instead of :class:`float` :raises IndexError: if an index is out of range :raises JSONSyntaxError: if the select query is invalid :raises KeyError: if a key is not found :raises TypeError: if a value has the wrong type :return: the selected list of nodes Example: >>> import jsonyx as json >>> obj = [1, 2, 3, 4, 5, 6] >>> root = [obj] >>> node = root, 0 # pointer to obj >>> for target, key in json.select_nodes(node, "$[@ > 3]"): ... target[key] = None ... >>> root[0] [1, 2, 3, None, None, None] """ return Manipulator(allow=allow, use_decimal=use_decimal).select_nodes( nodes, query, allow_slice=allow_slice, relative=relative, )
[docs] def apply_filter( nodes: _Node | list[_Node], query: str, *, allow: Container[str] = NOTHING, use_decimal: bool = False, ) -> list[_Node]: """Apply a JSON filter query to a node or a list of nodes. .. versionadded:: 2.0 :param nodes: a node or a list of nodes :param query: a :ref:`JSON filter query <filter>` :param allow: the JSON deviations from :mod:`jsonyx.allow` :param use_decimal: use :class:`decimal.Decimal` instead of :class:`float` :raises IndexError: if an index is out of range :raises JSONSyntaxError: if the filter query is invalid :raises KeyError: if a key is not found :raises TypeError: if a value has the wrong type :return: the filtered list of nodes Example: >>> import jsonyx as json >>> obj = None >>> root = [obj] >>> node = root, 0 # pointer to obj >>> assert json.apply_filter(node, "@ == null") """ return Manipulator(allow=allow, use_decimal=use_decimal).apply_filter( nodes, query, )
[docs] def load_query_value( s: str, *, allow: Container[str] = NOTHING, use_decimal: bool = False, ) -> Any: """Deserialize a JSON query value to a Python object. .. versionadded:: 2.0 :param s: a :ref:`JSON query value <query_value>` :param allow: the JSON deviations from :mod:`jsonyx.allow` :param use_decimal: use :class:`decimal.Decimal` instead of :class:`float` :raises JSONSyntaxError: if the query value is invalid :return: a Python object Example: >>> import jsonyx as json >>> json.load_query_value("'~'foo'") "'foo" """ return Manipulator(allow=allow, use_decimal=use_decimal).load_query_value( s, )