Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom metadata serializers to QPY load() and dump() #7550

Merged
merged 8 commits into from
Mar 11, 2022
33 changes: 24 additions & 9 deletions qiskit/qpy/binary_io/circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from qiskit.synthesis import evolution as evo_synth


def _read_header_v2(file_obj, version, vectors):
def _read_header_v2(file_obj, version, vectors, metadata_deserializer=None):
data = formats.CIRCUIT_HEADER_V2._make(
struct.unpack(
formats.CIRCUIT_HEADER_V2_PACK,
Expand All @@ -59,11 +59,11 @@ def _read_header_v2(file_obj, version, vectors):
"num_instructions": data.num_instructions,
}
metadata_raw = file_obj.read(data.metadata_size)
metadata = json.loads(metadata_raw)
metadata = json.loads(metadata_raw, cls=metadata_deserializer)
return header, name, metadata


def _read_header(file_obj):
def _read_header(file_obj, metadata_deserializer=None):
data = formats.CIRCUIT_HEADER._make(
struct.unpack(formats.CIRCUIT_HEADER_PACK, file_obj.read(formats.CIRCUIT_HEADER_SIZE))
)
Expand All @@ -76,7 +76,7 @@ def _read_header(file_obj):
"num_instructions": data.num_instructions,
}
metadata_raw = file_obj.read(data.metadata_size)
metadata = json.loads(metadata_raw)
metadata = json.loads(metadata_raw, cls=metadata_deserializer)
return header, name, metadata


Expand Down Expand Up @@ -562,14 +562,20 @@ def _write_registers(file_obj, in_circ_regs, full_bits):
return len(in_circ_regs) + len(out_circ_regs)


def write_circuit(file_obj, circuit):
def write_circuit(file_obj, circuit, metadata_serializer=None):
"""Write a single QuantumCircuit object in the file like object.

Args:
file_obj (FILE): The file like object to write the circuit data in.
circuit (QuantumCircuit): The circuit data to write.
metadata_serializer (JSONEncoder): An optional JSONEncoder class that
will be passed the :attr:`.QuantumCircuit.metadata` dictionary for
each circuit in ``circuits`` and will be used as the ``cls`` kwarg
on the ``json.dump()`` call to JSON serialize that dictionary.
"""
metadata_raw = json.dumps(circuit.metadata, separators=(",", ":")).encode(common.ENCODE)
metadata_raw = json.dumps(
circuit.metadata, separators=(",", ":"), cls=metadata_serializer
).encode(common.ENCODE)
metadata_size = len(metadata_raw)
num_instructions = len(circuit)
circuit_name = circuit.name.encode(common.ENCODE)
Expand Down Expand Up @@ -616,12 +622,19 @@ def write_circuit(file_obj, circuit):
instruction_buffer.close()


def read_circuit(file_obj, version):
def read_circuit(file_obj, version, metadata_deserializer=None):
"""Read a single QuantumCircuit object from the file like object.

Args:
file_obj (FILE): The file like object to read the circuit data from.
version (int): QPY version.
metadata_deserializer (JSONDecoder): An optional JSONDecoder class
that will be used for the ``cls`` kwarg on the internal
``json.load`` call used to deserialize the JSON payload used for
the :attr:`.QuantumCircuit.metadata` attribute for any circuits
in the QPY file. If this is not specified the circuit metadata will
be parsed as JSON with the stdlib ``json.load()`` function using
the default ``JSONDecoder`` class.

Returns:
QuantumCircuit: The circuit object from the file.
Expand All @@ -631,9 +644,11 @@ def read_circuit(file_obj, version):
"""
vectors = {}
if version < 2:
header, name, metadata = _read_header(file_obj)
header, name, metadata = _read_header(file_obj, metadata_deserializer=metadata_deserializer)
else:
header, name, metadata = _read_header_v2(file_obj, version, vectors)
header, name, metadata = _read_header_v2(
file_obj, version, vectors, metadata_deserializer=metadata_deserializer
)

global_phase = header["global_phase"]
num_qubits = header["num_qubits"]
Expand Down
23 changes: 19 additions & 4 deletions qiskit/qpy/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from qiskit.version import __version__


def dump(circuits, file_obj):
def dump(circuits, file_obj, metadata_serializer=None):
"""Write QPY binary data to a file

This function is used to save a circuit to a file for later use or transfer
Expand Down Expand Up @@ -63,6 +63,10 @@ def dump(circuits, file_obj):
store in the specified file like object. This can either be a
single QuantumCircuit object or a list of QuantumCircuits.
file_obj (file): The file like object to write the QPY data too
metadata_serializer (JSONEncoder): An optional JSONEncoder class that
will be passed the :attr:`.QuantumCircuit.metadata` dictionary for
each circuit in ``circuits`` and will be used as the ``cls`` kwarg
on the ``json.dump()`` call to JSON serialize that dictionary.
"""
if isinstance(circuits, QuantumCircuit):
circuits = [circuits]
Expand All @@ -78,10 +82,10 @@ def dump(circuits, file_obj):
)
file_obj.write(header)
for circuit in circuits:
binary_io.write_circuit(file_obj, circuit)
binary_io.write_circuit(file_obj, circuit, metadata_serializer=metadata_serializer)


def load(file_obj):
def load(file_obj, metadata_deserializer=None):
"""Load a QPY binary file

This function is used to load a serialized QPY circuit file and create
Expand Down Expand Up @@ -111,6 +115,13 @@ def load(file_obj):
Args:
file_obj (File): A file like object that contains the QPY binary
data for a circuit
metadata_deserializer (JSONDecoder): An optional JSONDecoder class
that will be used for the ``cls`` kwarg on the internal
``json.load`` call used to deserialize the JSON payload used for
the :attr:`.QuantumCircuit.metadata` attribute for any circuits
in the QPY file. If this is not specified the circuit metadata will
be parsed as JSON with the stdlib ``json.load()`` function using
the default ``JSONDecoder`` class.
Returns:
list: List of ``QuantumCircuit``
The list of :class:`~qiskit.circuit.QuantumCircuit` objects
Expand Down Expand Up @@ -152,5 +163,9 @@ def load(file_obj):
)
circuits = []
for _ in range(data.num_circuits):
circuits.append(binary_io.read_circuit(file_obj, data.qpy_version))
circuits.append(
binary_io.read_circuit(
file_obj, data.qpy_version, metadata_deserializer=metadata_deserializer
)
)
return circuits
78 changes: 78 additions & 0 deletions releasenotes/notes/custom-serializers-qpy-0097ab79f239fcfc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
features:
- |
Added a new kwarg, ``metadata_serializer`` to the
:func:`.qpy_serialization.dump` function for specifying a custom
``JSONEncoder`` subclass for use when serializing the
:attr`.QuantumCircuit.metadata` attribute and a dual kwarg
``metadata_deserializer`` to the :func:`.qpy_serialization.load` function
for specifying a ``JSONDecoder`` subclass. By default the
:func:`~qiskit.circuit.qpy_serialization.dump` and
:func:`~qiskit.circuit.qpy_serialization.load` functions will attempt to
JSON serialize and deserialize with the stdlib default json encoder and
decoder. Since :attr`.QuantumCircuit.metadata` can contain any Python
dictionary, even those with contents not JSON serializable by the default
encoder, will lead to circuits that can't be serialized. The new
``metadata_serializer`` argument for
:func:`~qiskit.circuit.qpy_serialization.dump` enables users to specify a
custom ``JSONEncoder`` that will be used with the internal ``json.dump()``
call for serializing the :attr`.QuantumCircuit.metadata` dictionary. This
can then be paired with the new ``metadata_deserializer`` argument of the
:func:`.qpy_serialization.load` function to decode those custom JSON
encodings. If ``metadata_serializer`` is specified on
:func:`~qiskit.circuit.qpy_serialization.dump` but ``metadata_deserializer``
is not specified on :func:`~qiskit.circuit.qpy_serialization.load` calls
the QPY will be loaded, but the circuit metadata may not be reconstructed
fully.

For example if you wanted to define a custom serialization for metadata and
then load it you can do something like::

from qiskit.qpy import dump, load
from qiskit.circuit import QuantumCircuit, Parameter
import json

class CustomObject:
"""Custom string container object."""

def __init__(self, string):
self.string = string

def __eq__(self, other):
return self.string == other.string

class CustomSerializer(json.JSONEncoder):
"""Custom json encoder to handle CustomObject."""

def default(self, o):
if isinstance(o, CustomObject):
return {"__type__": "Custom", "value": o.string}
return json.JSONEncoder.default(self, o)

class CustomDeserializer(json.JSONDecoder):
"""Custom json decoder to handle CustomObject."""

def object_hook(self, o):
"""Hook to override default decoder.
Normally specified as a kwarg on load() that overloads the
default decoder. Done here to avoid reimplementing the
decode method.
"""
if "__type__" in o:
obj_type = o["__type__"]
if obj_type == "Custom":
return CustomObject(o["value"])
return o

theta = Parameter("theta")
qc = QuantumCircuit(2, global_phase=theta)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()
circuits = [qc, qc.copy()]
circuits[0].metadata = {"key": CustomObject("Circuit 1")}
circuits[1].metadata = {"key": CustomObject("Circuit 2")}
with io.BytesIO() as qpy_buf:
dump(circuits, qpy_buf, metadata_serializer=CustomSerializer)
qpy_buf.seek(0)
new_circuits = load(qpy_file, metadata_deserializer=CustomDeserializer)
54 changes: 54 additions & 0 deletions test/python/circuit/test_circuit_load_from_qpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"""Test cases for the circuit qasm_file and qasm_string method."""

import io
import json
import random

import numpy as np
Expand Down Expand Up @@ -781,6 +782,59 @@ def test_parameter_vector_global_phase(self):
new_circuit = load(qpy_file)[0]
self.assertEqual(qc, new_circuit)

def test_custom_metadata_serializer_full_path(self):
"""Test that running with custom metadata serialization works."""

class CustomObject:
"""Custom string container object."""

def __init__(self, string):
self.string = string

def __eq__(self, other):
return self.string == other.string

class CustomSerializer(json.JSONEncoder):
"""Custom json encoder to handle CustomObject."""

def default(self, o): # pylint: disable=invalid-name
if isinstance(o, CustomObject):
return {"__type__": "Custom", "value": o.string}
return json.JSONEncoder.default(self, o)

class CustomDeserializer(json.JSONDecoder):
"""Custom json decoder to handle CustomObject."""

def object_hook(self, o): # pylint: disable=invalid-name,method-hidden
"""Hook to override default decoder.

Normally specified as a kwarg on load() that overloads the
default decoder. Done here to avoid reimplementing the
decode method.
"""
if "__type__" in o:
obj_type = o["__type__"]
if obj_type == "Custom":
return CustomObject(o["value"])
return o

theta = Parameter("theta")
qc = QuantumCircuit(2, global_phase=theta)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()
circuits = [qc, qc.copy()]
circuits[0].metadata = {"key": CustomObject("Circuit 1")}
circuits[1].metadata = {"key": CustomObject("Circuit 2")}
qpy_file = io.BytesIO()
dump(circuits, qpy_file, metadata_serializer=CustomSerializer)
qpy_file.seek(0)
new_circuits = load(qpy_file, metadata_deserializer=CustomDeserializer)
self.assertEqual(qc, new_circuits[0])
self.assertEqual(circuits[0].metadata["key"], CustomObject("Circuit 1"))
self.assertEqual(qc, new_circuits[1])
self.assertEqual(circuits[1].metadata["key"], CustomObject("Circuit 2"))

def test_qpy_with_ifelseop(self):
"""Test qpy serialization with an if block."""
qc = QuantumCircuit(2, 2)
Expand Down