Source code for qblox_scheduler.json_utils

# Repository: https://gitlab.com/qblox/packages/software/qblox-scheduler
# Licensed according to the LICENSE file on the main branch
#
# Copyright 2020-2025, Quantify Consortium
# Copyright 2025, Qblox B.V.
"""Module containing quantify JSON utilities."""

from __future__ import annotations

import functools
import json
import os
import pathlib
from datetime import datetime, timezone
from enum import Enum
from types import ModuleType
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit

import fastjsonschema
import numpy as np
from pydantic import BaseModel
from qcodes.instrument import Instrument

from qblox_scheduler import enums
from qblox_scheduler.analysis.data_handling import OutputDirectoryManager
from qblox_scheduler.helpers import inspect as inspect_helpers
from qblox_scheduler.helpers.importers import import_python_object_from_string

if TYPE_CHECKING:
    from collections.abc import Callable
    from typing_extensions import Self

    from qblox_scheduler.operations import Operation

[docs] lru_cache = functools.lru_cache(maxsize=200)
[docs] DEFAULT_TYPES = [ complex, float, int, bool, str, np.ndarray, np.complex128, np.int32, np.uint32, np.int64, ]
[docs] def validate_json(data: dict, schema: str) -> object: """Validate schema using jsonschema-rs.""" return fastjsonschema.validate(schema, data)
[docs] def load_json_schema(relative_to: str | pathlib.Path, filename: str) -> str: """ Load a JSON schema from file. Expects a 'schemas' directory in the same directory as ``relative_to``. .. tip:: Typical usage of the form ``schema = load_json_schema(__file__, 'definition.json')`` Parameters ---------- relative_to the file to begin searching from filename the JSON file to load Returns ------- dict the schema """ path = pathlib.Path(relative_to).resolve().parent.joinpath("schemas", filename) with path.open(mode="r", encoding="utf-8") as file: return json.load(file)
[docs] def load_json_schema_url(relative_to: str | pathlib.Path, url: str) -> str: """ Load a JSON schema from ID URL. Expects a 'schemas' directory in the same directory as ``relative_to``. Parameters ---------- relative_to the file to begin searching from url the JSON ID URL to load Returns ------- dict the schema """ filename = urlsplit(url).netloc + ".json" return load_json_schema(relative_to, filename)
@lru_cache
[docs] def load_json_validator(relative_to: str | pathlib.Path, filename: str) -> Callable: """ Load a JSON validator from file. Expects a 'schemas' directory in the same directory as ``relative_to``. Parameters ---------- relative_to the file to begin searching from filename the JSON file to load Returns ------- Callable The validator """ definition = load_json_schema(relative_to, filename) handlers = { "local": functools.partial(load_json_schema_url, relative_to), } validator = fastjsonschema.compile(definition, handlers=handlers, formats={}) return validator # type: ignore # (complicated return type)
[docs] class UnknownDeserializationTypeError(Exception): """Raised when an unknown deserialization type is encountered."""
[docs] class JSONSchemaValMixin: """ A mixin that adds validation utilities to classes that have a data attribute like a :class:`UserDict` based on JSONSchema. This requires the class to have a class variable "schema_filename" """
[docs] schema_filename: str
@classmethod
[docs] def is_valid(cls, object_to_be_validated: Operation) -> bool: """ Checks if the object is valid according to its schema. Raises ------ fastjsonschema.JsonSchemaException if the data is invalid Returns ------- : """ validator_method = load_json_validator(__file__, cls.schema_filename) validator_method(object_to_be_validated.data) return True # if no exception was raised during validation
[docs] class SchedulerJSONDecoder(json.JSONDecoder): """ The Quantify Scheduler JSONDecoder. The SchedulerJSONDecoder is used to convert a string with JSON content into instances of classes in qblox-scheduler. For a few types, :data:`~.DEFAULT_TYPES` contains the mapping from type name to the python object. This dictionary can be expanded with classes from modules specified in the keyword argument ``modules``. Classes not contained in :data:`~.DEFAULT_TYPES` by default must implement ``__getstate__``, such that it returns a dictionary containing at least the keys ``"deserialization_type"`` and ``"data"``, and ``__setstate__``, which should be able to parse the data from ``__getstate__``. The value of ``"deserialization_type"`` must be either the name of the class specified in :data:`~.DEFAULT_TYPES` or the fully qualified name of the class, which can be obtained from :func:`~qblox_scheduler.helpers.importers.export_python_object_to_path_string`. Keyword Arguments ----------------- modules : list[ModuleType], Optional A list of custom modules containing serializable classes, by default [] """ # noqa: D416
[docs] _classes: dict[str, type[Any]]
def __init__(self, *args, **kwargs) -> None: extended_modules: list[ModuleType] = kwargs.pop("modules", []) invalid_modules = list(filter(lambda o: not isinstance(o, ModuleType), extended_modules)) if invalid_modules: raise ValueError( f"Attempting to create a Schedule decoder class SchedulerJSONDecoder. " f"The following modules provided are not an instance of the ModuleType:" f" {invalid_modules} ." ) super().__init__( *args, **kwargs, object_hook=self.custom_object_hook, ) self._classes = inspect_helpers.get_classes(*[enums, *extended_modules]) self._classes.update({t.__name__: t for t in DEFAULT_TYPES})
[docs] def decode_dict( # noqa: PLR0911 self, obj: dict[str, Any] ) -> dict[str, Any] | np.ndarray | object | Instrument: """ Returns the deserialized JSON dictionary. Parameters ---------- obj The dictionary to deserialize. Returns ------- : The deserialized result. """ # If "deserialization_type" is present in `obj` it means the object was # serialized using `__getstate__` and should be deserialized using # `__setstate__`. if "deserialization_type" in obj: try: class_type = self._get_type_from_string(obj["deserialization_type"]) except UnknownDeserializationTypeError as exc: raise UnknownDeserializationTypeError( f"Object '{obj}' cannot be deserialized to type '{obj['deserialization_type']}'" ) from exc if "mode" in obj and obj["mode"] == "__init__": if class_type == np.ndarray: return np.array(obj["data"]) elif issubclass(class_type, BaseModel): return class_type.model_validate(obj["data"]) elif issubclass(class_type, Instrument): return class_type(**obj["data"]) else: return class_type(obj["data"]) # type: ignore if "mode" in obj and obj["mode"] == "type": return class_type new_obj = class_type.__new__(class_type) # type: ignore if hasattr(new_obj, "__json_setstate__"): # Handle JSON deserialization separately from YAML. new_obj.__json_setstate__(obj) else: new_obj.__setstate__(obj) return new_obj return obj
[docs] def custom_object_hook(self, obj: object) -> object: """ The ``object_hook`` hook will be called with the result of every JSON object decoded and its return value will be used in place of the given ``dict``. Parameters ---------- obj A pair of JSON objects. Returns ------- : The deserialized result. """ if isinstance(obj, dict): return self.decode_dict(obj) return obj
[docs] def _get_type_from_string(self, deserialization_type: str) -> type: """ Get the python type based on the description string. The following methods are tried, in order: 1. Try to find the string in :data:`~.DEFAULT_TYPES` or the extended modules passed to this class' initializer. 2. Try to import the type. This works only if ``deserialization_type`` is formatted as a dot-separated path to the type. E.g. ``qblox_scheduler.json_utils.SchedulerJSONDecoder``. \ Parameters ---------- deserialization_type Description of a type. Raises ------ UnknownDeserializationTypeError If the type cannot be found by any of the methods described. Returns ------- Type The ``Type`` found. """ try: return self._classes[deserialization_type] except KeyError: pass try: return import_python_object_from_string(deserialization_type) except (AttributeError, ModuleNotFoundError, ValueError): pass raise UnknownDeserializationTypeError( f"Type '{deserialization_type}' is not a known deserialization type." ) from None
[docs] class SchedulerJSONEncoder(json.JSONEncoder): """ Custom JSONEncoder which encodes a Quantify Scheduler object into a JSON file format string. """
[docs] def default(self, o: object) -> object: # noqa: PLR0911 """ Overloads the json.JSONEncoder default method that returns a serializable object. It will try 3 different serialization methods which are, in order, check if the object is to be serialized to a string using repr. If not, try to use ``__getstate__``. Finally, try to serialize the ``__dict__`` property. """ try: if isinstance(o, (complex, np.generic, Enum, enums.StrEnum)): return { "deserialization_type": type(o).__name__, "mode": "__init__", "data": str(o), } elif isinstance(o, np.ndarray): return { "deserialization_type": type(o).__name__, "mode": "__init__", "data": list(o), } elif o in DEFAULT_TYPES: assert isinstance(o, type) return {"deserialization_type": o.__name__, "mode": "type"} elif hasattr(o, "__json_getstate__"): # Handle JSON serialization separately from YAML. return o.__json_getstate__() # pyright: ignore[reportAttributeAccessIssue] elif hasattr(o, "__getstate__") and not isinstance(o, type): # We only call __getstate__ on instances, not on class objects. # Pyright does not support verifying this properly. # <https://github.com/microsoft/pyright/issues/7363> return o.__getstate__() # pyright: ignore[reportAttributeAccessIssue] elif isinstance(o, type) and hasattr(o, "__module__") and hasattr(o, "__name__"): # Handle class objects by returning their fully qualified name return { "deserialization_type": "type", "mode": "type", "data": f"{o.__module__}.{o.__name__}", } elif isinstance(o, BaseModel): # Special case for pydantic models return o.model_dump(mode="json") elif hasattr(o, "__dict__"): return o.__dict__ # Try string representation as a last resort return {"string_representation": str(o)} except Exception: # noqa: BLE001 # If we hit any serialization error, use a safe fallback return {"unserializable_object": True, "type": str(type(o).__name__)}
[docs] class JSONSerializable: """ Mixin to allow de/serialization of arbitrary objects using :class:`~SchedulerJSONEncoder` and :class:`~SchedulerJSONDecoder`. """
[docs] def to_dict(self) -> dict[str, Any]: """ Convert the object to a dictionary representation. Returns ------- Dictionary representation of the object. """ return json.loads(self.to_json())
@classmethod
[docs] def from_dict(cls, data: dict[str, Any]) -> Self: """ Convert a dictionary to an instance of the attached class. Parameters ---------- data The dictionary data to convert. Returns ------- The deserialized object. """ return json.loads(json.dumps(data), cls=SchedulerJSONDecoder)
[docs] def to_json(self) -> str: """ Convert the object's data structure to a JSON string. Returns ------- : The json string containing the serialized object. """ return json.dumps(self, cls=SchedulerJSONEncoder)
[docs] def to_json_file( self, path: str | None = None, add_timestamp: bool = True, ) -> str: """ Convert the object's data structure to a JSON string and store it in a file. Examples -------- Saving a :class:`~qblox_scheduler.QuantumDevice` will use its name and current timestamp .. code-block:: python from qblox_scheduler import QuantumDevice single_qubit_device = QuantumDevice("single_qubit_device") ... single_qubit_device.to_json_file() single_qubit_device = QuantumDevice.from_json_file("/tmp/single_qubit_device_2024-11-14_13-36-59_UTC.json") Parameters ---------- path The path to the directory where the file is created. Default is `None`, in which case the file will be saved in the directory determined by :func:`~qblox_scheduler.analysis.data_handling.OutputDirectoryManager.get_datadir()`. add_timestamp Specify whether to append timestamp to the filename. Default is True. Returns ------- : The name of the file containing the serialized object. """ # noqa: E501 if path is None: path = str(OutputDirectoryManager.get_datadir()) name = getattr(self, "name") # noqa: B009 This is to shut up the linter about self.name if add_timestamp: timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S_%Z") filename = os.path.join(path, f"{name}_{timestamp}.json") else: filename = os.path.join(path, f"{name}.json") with open(filename, "w") as file: file.write(self.to_json()) return filename
@classmethod
[docs] def from_json(cls, data: str) -> Self: """ Convert the JSON data to an instance of the attached class. Parameters ---------- data The JSON data in str format. Returns ------- : The deserialized object. """ return json.loads(data, cls=SchedulerJSONDecoder)
@classmethod
[docs] def from_json_file(cls, filename: str | pathlib.Path) -> Self: """ Read JSON data from a file and convert it to an instance of the attached class. Examples -------- .. code-block:: python from qblox_scheduler import QuantumDevice single_qubit_device = QuantumDevice.from_json_file("/tmp/single_qubit_device_2024-11-14_13-36-59_UTC.json") Parameters ---------- filename The name of the file containing the serialized object. Returns ------- : The deserialized object. """ # noqa: E501 with open(filename) as file: deserialized_obj = cls.from_json(file.read()) return deserialized_obj