This page walks through the common tasks: serializing, deserializing, combining archives, streaming and extending msglc.
!!! note
Snippets are runnable in order from top to bottom.
Later examples reuse files created by earlier ones (data.msg, large.msg, dict.msg, list.msg).
dump writes a JSON compatible object to a file.
msgpack is the default format.
from msglc import dump
data = {"a": [1, 2, 3], "b": {"c": 4, "d": 5, "e": [0x221548313] * 10}}
dump("data.msg", data)dump also accepts a UPath, an in-memory buffer, or an existing LazyReader (see Repacking a reader).
dumps serializes to a BytesIO buffer and rewinds it, ready to be read.
from msglc import dumps
from msglc.reader import LazyReader
buffer = dumps({"a": [1, 2, 3]})
with LazyReader(buffer) as reader:
print(reader.to_obj()) # -> {'a': [1, 2, 3]}dump has two backends:
| Backend | Default | Notes |
|---|---|---|
python |
yes | Works everywhere, supports the widest range of types. |
rust |
no | Faster for writing local or remote files; only a subset of Python types. |
from msglc import dump
data = {"a": [1, 2, 3], "b": {"c": 4, "d": 5, "e": [0x221548313] * 10}}
dump("data.msg", data, backend="rust")!!! note
The rust backend only applies when the target is a str path or a UPath.
When an in-memory buffer is passed, dump silently falls back to the python backend.
The rust backend does not support every type.
In particular datetime objects and integers outside [-2**63, 2**64 - 1] raise an error; use the default backend (or CBOR) for those.
A codec defines the underlying serialization format.
The default is msgpack (through msgspec when it is installed).
To use CBOR, pass CBORCodec.
from msglc import dump
from msglc.codec import CBORCodec
data = {"a": [1, 2, 3], "b": {"c": 4, "d": 5, "e": [0x221548313] * 10}}
dump("data.cbor", data, packer=CBORCodec)Available codecs in msglc.codec:
| Codec | Protocol | Backed by |
|---|---|---|
MsgspecCodec |
msgpack | msgspec (falls back to MsgpackCodec if absent) |
MsgpackCodec |
msgpack | msgpack |
OrmsgpackCodec |
msgpack | ormsgpack (falls back to MsgpackCodec if absent) |
CBORCodec |
cbor | cbor2 |
The codec is chosen automatically when reading, based on the file header.
Passing a mismatched unpacker raises ValueError.
Standard JSON types (dict, list, str, int, float, bool, None) are always supported.
A few extra Python types are handled too:
| Python type | Encoded as |
|---|---|
tuple |
list |
set |
sorted list |
bytes / bytearray / memoryview |
binary value |
numpy.ndarray |
nested list, or a binary blob when numpy_encoder=True |
from msglc import dump
from msglc.reader import LazyReader
dump("types.msg", {"tuple": (1, 2, 3), "set": {3, 1, 2}, "bytes": b"abc", "big": 2**63})
with LazyReader("types.msg") as reader:
print(reader["tuple"]) # -> [1, 2, 3]
print(reader["set"]) # -> [1, 2, 3]
print(reader["bytes"]) # -> b'abc'
print(reader["big"]) # -> 9223372036854775808!!! warning "Integers and CBOR"
msgpack cannot represent integers outside [-2**63, 2**64 - 1].
Use CBORCodec for arbitrary precision integers.
With numpy_encoder=False (default), arrays are converted to nested lists.
With numpy_encoder=True, the array is stored as a binary blob and is decoded back to an numpy.ndarray on read.
import numpy
from msglc import dump
from msglc.config import configure
from msglc.reader import LazyReader
arr = numpy.arange(3000, dtype=numpy.float64)
configure(numpy_encoder=True)
dump("arr.msg", {"arr": arr})
with LazyReader("arr.msg") as reader:
got = reader["arr"]
print(type(got).__name__, got.shape) # -> ndarray (3000,)
configure(numpy_encoder=False)!!! note
The binary numpy representation is decoded back to an ndarray only when the containing node is indexed.
If the whole document is small enough to be decoded as a single leaf, the value is returned as raw bytes and can be restored with pickle.loads.
Keep arrays inside a larger container (or lower small_obj_optimization_threshold) to get an ndarray directly.
See Small objects.
LazyReader is a context manager.
It reads only the table of contents up front and defers reading the actual data until it is accessed.
from msglc.reader import LazyReader
with LazyReader("data.msg") as reader:
print(reader.read()) # -> the whole document
print(reader["b/c"]) # -> 4, subscripting is supported
print(reader.read("b/c")) # -> 4, or use an explicit path
print(reader.visit("b/c")) # -> 4, string-only variant of `read`read(path=None)acceptsNone, a/-separatedstr, alistof keys, or aslice.visit(path="")is a lighter string-only variant ofread.
All access has to happen before the with block exits, because the file handle is closed afterwards.
/ separates the nodes of the path.
Any intermediate node can be exposed as a lazy container.
with LazyReader("data.msg") as reader:
print(reader["a"]) # -> [1, 2, 3]
print(reader["a/0"]) # -> 1Lists support negative indices and slices.
Two-part slices behave like Python, but three-part slices use the start:step:stop order, not Python's start:stop:step.
from msglc import dump
large = {"items": [{"id": i, "payload": "x" * 100} for i in range(1000)]}
dump("large.msg", large)with LazyReader("large.msg") as reader: # large.msg has a 1000-element "items" list
print(reader["items/0"]) # -> first element
print(reader["items/-1"]) # -> last element
print(reader["items/2:5"]) # -> elements 2, 3, 4
print(reader["items/0:250:"]) # -> every 250th element: 0, 250, 500, 750!!! warning "Slice order"
The three-part form is start:step:stop.
"0:2:10" means start at 0, step by 2, stop at 10, which is different from Python's [0:2:10].
Invalid index types raise TypeError; out-of-range access is normalized like Python.
A lazy container mirrors the interface of dict or list.
with LazyReader("data.msg") as reader:
print(list(reader.keys())) # -> ['a', 'b']
print(reader.get("missing")) # -> None
print("b" in reader) # -> True
print(len(reader)) # -> 2LazyDict supports get, keys, values, items, in, len and iteration.
LazyList supports len, iteration and subscripting.
When a container is large enough to be indexed, it is returned as a LazyDict or LazyList.
Otherwise the node is small and is decoded eagerly, so you get a plain dict or list.
from msglc.reader import LazyReader
with LazyReader("large.msg") as reader:
root = reader.unwrap()
print(type(root).__name__) # -> LazyDict
print(type(root["items"]).__name__) # -> LazyList
print(len(root["items"])) # -> 1000
print(root["items"][7]) # -> {'id': 7, 'payload': 'xxx...'}By default each read node is cached so it is fetched at most once.
Pass cached=False to skip the cache; this keeps memory flat for one-pass reads at the cost of re-reading shared nodes.
with LazyReader("data.msg", cached=False) as reader:
print(reader.read("b/c")) # -> 4to_obj recursively reads a value and returns plain Python objects that are safe to use outside the with block.
unwrap peels the LazyReader wrapper and returns the underlying lazy container or primitive without forcing a full read.
from msglc.reader import LazyReader, to_obj
with LazyReader("data.msg") as reader:
obj = reader.to_obj() # plain dict / list / value
node = reader.unwrap() # LazyDict / LazyList / value
print(obj["a"]) # safe after the context exitsThere is also a module-level to_obj(value) for converting an arbitrary lazy value.
Pass a LazyStats counter to measure how much data is actually read.
It is useful when tuning block size.
from msglc.reader import LazyReader, LazyStats
stats = LazyStats()
with LazyReader("large.msg", counter=stats) as reader:
_ = reader["items/0"]
print(stats) # e.g. "2 calls, 11887 bytes, 5943.5 bytes per call."A LazyReader can be passed straight to dump.
The data is copied lazily, so no full decode is performed.
from msglc import dump
from msglc.reader import LazyReader
with LazyReader("data.msg") as reader:
dump("data-copy.msg", reader)async_read, async_visit and async_to_obj run the blocking reads on a worker thread, which is handy for async servers.
import asyncio
from msglc.reader import LazyReader, async_to_obj
async def main():
with LazyReader("data.msg") as reader:
print(await reader.async_read("b/c")) # -> 4
print((await async_to_obj(reader))["a"]) # -> [1, 2, 3]
asyncio.run(main())combine merges several serialized files into one without decoding and re-encoding the payload.
Each input is copied as a raw byte block and a start offset is recorded in the table of contents.
With names, the archive uses a dict layout; without names it uses a list layout.
from msglc import dump, combine, FileInfo
from msglc.reader import LazyReader
dump("dict.msg", {str(v): v for v in range(1000)})
dump("list.msg", [float(v) for v in range(1000)])
# dict layout: {"dict": {...}, "list": [...]}
combine("combined.msg", [FileInfo("dict.msg", "dict"), FileInfo("list.msg", "list")])
with LazyReader("combined.msg") as reader:
print(reader["dict/101"]) # -> 101
print(reader["list/101"]) # -> 101.0
print(reader["dict"]["101"]) # -> 101, also reachable without a path# list layout: [{...}, [...]]
combine("combined.msg", [FileInfo("dict.msg"), FileInfo("list.msg")])
with LazyReader("combined.msg") as reader:
print(reader[0]["101"]) # -> 101
print(reader[1]["101"]) # -> 101.0!!! note "Naming rules"
Names are optional, but they must be either present on all inputs or on none.
Names must be unique.
Violations raise ValueError.
FileInfo describes where an input comes from.
It accepts a local path, a UPath, an open buffer, an existing LazyReader, or an in-memory object via obj=.
from msglc import combine, FileInfo
from msglc.reader import LazyReader
from upath import UPath
combine(
"combined.msg",
[
FileInfo(None, obj={"k": "v"}), # serialize an in-memory object
FileInfo(UPath("dict.msg")), # a UPath
FileInfo(LazyReader("list.msg")), # another reader, copied lazily
],
)
with LazyReader("combined.msg") as reader:
print(reader[0]["k"]) # -> v
print(reader[1]["101"]) # -> 101
print(reader[2]["101"]) # -> 101.0Each FileInfo may carry its own fs= so inputs can come from different filesystems.
Inputs may use different formats.
The metadata of the combined archive is encoded with the codec passed to combine, while every payload keeps its original encoding.
from msglc import dump, combine, FileInfo
from msglc.codec import CBORCodec, MsgspecCodec
from msglc.reader import LazyReader
dump("dict.cbor", {str(v): v for v in range(1000)}, backend="rust", packer=CBORCodec)
dump("list.msg", [float(v) for v in range(1000)], backend="rust")
combine(
"combined.msg", [FileInfo("dict.cbor"), FileInfo("list.msg")], packer=MsgspecCodec
)
with LazyReader("combined.msg") as reader:
print(reader[0]["101"]) # -> 101
print(reader[1]["101"]) # -> 101.0The output of combine is itself a valid archive and can be combined again.
append is shorthand for combine(..., mode="a").
from msglc import dump, combine, append, FileInfo
from msglc.reader import LazyReader
dump("dict.msg", {str(v): v for v in range(1000)})
dump("list.msg", [float(v) for v in range(1000)])
combine("inner.msg", [FileInfo("dict.msg", "d"), FileInfo("list.msg", "l")])
combine("outer.msg", [FileInfo("inner.msg", "inner")])
with LazyReader("outer.msg") as reader:
print(reader["inner/d/101"]) # -> 101
combine("app.msg", [FileInfo("dict.msg")])
append("app.msg", [FileInfo("list.msg")])
with LazyReader("app.msg") as reader:
print(reader[0]["101"], reader[1]["101"]) # -> 101 101.0Because combining only copies bytes, an individual child can be extracted by binary copying. That is the exact reverse of combining and requires no decoding.
from msglc import combine, FileInfo
from msglc.reader import LazyReader
combine("combined.msg", [FileInfo(None, "child_dict", obj={"a": "a", "b": "b"})])
with LazyReader("combined.msg") as combined, open("child.msg", "wb") as f:
f.write(combined["child_dict"].raw_data(chunked=False))
with LazyReader("child.msg") as child:
print(child.to_obj()) # -> {'a': 'a', 'b': 'b'}protocol_raw_data returns the payload encoded in the plain msgpack/cbor format, without any msglc metadata.
It can be decoded by any compliant decoder.
from msgpack import unpackb
from msglc import dump
from msglc.reader import LazyReader
dump("data.msg", {"a": [1, 2, 3]})
with LazyReader("data.msg") as reader:
print(unpackb(reader.protocol_raw_data(chunked=False))) # -> {'a': [1, 2, 3]}Both protocol_raw_data and raw_data accept chunked=True (default) to return a generator of chunks, or chunked=False to return a single bytes object.
The writer does not require the object to exist in memory first.
It recognizes any dict subclass that implements items() and any list subclass that implements __iter__, so data can be produced on the fly.
from collections.abc import Generator
from msglc import dump
from msglc.reader import LazyReader
class Stream(dict):
def __init__(self, generator: Generator):
super().__init__()
self._gen = generator
def items(self): # only `items()` is used
yield from self._gen
def example():
yield "a", 1
yield "b", 2
dump("stream.msg", Stream(example()))
with LazyReader("stream.msg") as reader:
print(reader.to_obj()) # -> {'a': 1, 'b': 2}!!! note "Custom containers"
The Python writer recognizes container subclasses unconditionally.
The rust writer recognizes them only when enable_custom_container is True (the default).
See Configuration.
Because data is streamed to disk, both writing and reading use memory that is independent of the file size.
import tracemalloc
from pathlib import Path
from msglc import dump
from msglc.reader import LazyDict, LazyList, LazyReader
class Stream(dict):
def __init__(self, generator):
super().__init__()
self._gen = generator
def items(self):
yield from self._gen
def nest_generator(depth):
n = 0
if depth > 0:
while n < 10:
yield str(n), Stream(nest_generator(depth - 1))
n += 1
else:
while n < 10:
yield str(n), "A" * (256 * 1024)
n += 1
def visitor(obj):
if isinstance(obj, LazyReader):
yield from visitor(obj.unwrap())
if isinstance(obj, (LazyDict, dict)):
for v in obj.values():
yield from visitor(v)
elif isinstance(obj, (LazyList, list)):
for v in obj:
yield from visitor(v)
else:
yield obj
tracemalloc.start()
dump("example.msg", Stream(nest_generator(2)))
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"File size: {Path('example.msg').stat().st_size / 1024 / 1024:.3f} MB.")
print(f"Peak memory during serialization: {peak / 1024 / 1024:.3f} MB.")
tracemalloc.start()
with LazyReader("example.msg", cached=False) as reader:
for _ in visitor(reader):
...
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Peak memory during deserialization: {peak / 1024 / 1024:.3f} MB.")
# File size: 250.024 MB.
# Peak memory during serialization: ~11 MB.
# Peak memory during deserialization: ~1 MB.The file is roughly 250 MB, yet neither writing nor reading needs more than a few MB of memory.
Subclass LazyCodec (or one of the provided bases) to plug in another serialization format.
Pass it as packer when writing and as unpacker when reading.
import msgpack
from msglc import dump
from msglc.codec import MsgpackCodecBase
from msglc.reader import LazyReader
class MyCodec(MsgpackCodecBase):
def encode(self, data):
return msgpack.packb(data)
def decode(self, data):
return msgpack.unpackb(data)
dump("custom.msg", {"a": [1, 2, 3]}, packer=MyCodec)
with LazyReader("custom.msg", unpacker=MyCodec) as reader:
print(reader.to_obj()) # -> {'a': [1, 2, 3]}A codec must implement encode, decode, stream_decode, write_array_header and write_map_header.
MsgpackCodecBase already provides the last three for msgpack-style framing.
Pass toc_cls to LazyWriter (or to dump) to customize how the table of contents is built.
The class is initialized with packer= and buffer= keyword arguments and must expose pack(obj) -> dict.
from msglc import dump
from msglc.reader import LazyReader
from msglc.toc import TOC
class MyTOC(TOC):
pass
dump("custom_toc.msg", {"a": [1, 2, 3]}, toc_cls=MyTOC)
with LazyReader("custom_toc.msg") as reader:
print(reader.to_obj()) # -> {'a': [1, 2, 3]}toc_cls is ignored by the rust backend.