Skip to content

XML Layer

The low-level document layer. Most users never need it; it powers the byte fidelity contract and the CLI tools.

Parse and save .prproj documents with byte fidelity.

Classes

PremiereDocument

A parsed .prproj: the element tree plus its gzip framing.

by_object_id / by_object_uid index the top-level object table (direct children of PremiereData), built once at parse; mutating the tree does not update them. Inline object definitions nested deeper (the project view state and its column classes) reuse IDs from their own scope; they are not indexed and resolve refuses refs located inside them.

Source code in src/py_premiere/xml/document.py
class PremiereDocument:
    """A parsed `.prproj`: the element tree plus its gzip framing.

    `by_object_id` / `by_object_uid` index the top-level object table (direct
    children of `PremiereData`), built once at parse; mutating the tree does
    not update them. Inline object definitions nested deeper (the project
    view state and its column classes) reuse IDs from their own scope; they
    are not indexed and `resolve` refuses refs located inside them.
    """

    def __init__(self, root: ET.Element, framing: GzipFraming | None) -> None:
        self.root = root
        self.framing = framing
        self.by_object_id: dict[str, ET.Element] = {}
        self.by_object_uid: dict[str, ET.Element] = {}
        # Built on the first `payload()` that needs it: most documents never
        # ask, and the walk touches every element.
        self._by_binary_hash: dict[str, bytes] | None = None
        #: Highest integer ObjectID handed out so far; `next_object_id` reads
        #: it instead of rescanning the table.
        self._highest_object_id = 0
        for element in root:
            object_id = element.get("ObjectID")
            if object_id is not None:
                if object_id in self.by_object_id:
                    raise ValueError(f"duplicate ObjectID {object_id!r}")
                self.by_object_id[object_id] = element
                if object_id.isdigit():
                    self._highest_object_id = max(
                        self._highest_object_id, int(object_id)
                    )
            object_uid = element.get("ObjectUID")
            if object_uid is not None:
                if object_uid in self.by_object_uid:
                    raise ValueError(f"duplicate ObjectUID {object_uid!r}")
                self.by_object_uid[object_uid] = element
        # Everything under a nested (non-top-level) ObjectID/ObjectUID carrier
        # lives in that object's own ID scope.
        self._scoped: set[int] = set()
        for top in root:
            for nested in top.iter():
                if nested is top:
                    continue
                if (
                    nested.get("ObjectID") is not None
                    or nested.get("ObjectUID") is not None
                ):
                    for descendant in nested.iter():
                        self._scoped.add(id(descendant))

    @classmethod
    def from_bytes(cls, data: bytes) -> PremiereDocument:
        xml_bytes, framing = decompress_prproj(data)
        try:
            root = ET.fromstring(xml_bytes)
        except ET.ParseError as error:
            # ParseError is a SyntaxError; callers of a file reader expect a
            # ValueError for "this is not a project file".
            raise ValueError(f"not a Premiere project file: {error}") from None
        document = cls(root, framing)
        # Self-check: ElementTree normalizes away constructs the serializer
        # cannot reproduce (comments, PIs, CDATA, entity forms, writer idioms
        # outside the known set). Any such file would silently corrupt on
        # save, so refuse it loudly at parse time instead.
        reserialized = document.to_xml_bytes()
        if reserialized != xml_bytes:
            raise ValueError(
                "cannot faithfully round-trip this file; "
                + _divergence_context(reserialized, xml_bytes)
            )
        return document

    def resolve(self, element: ET.Element) -> ET.Element:
        """Follow an element's `ObjectRef`/`ObjectURef` to its target element."""
        if id(element) in self._scoped:
            raise ValueError(
                f"<{element.tag}> is inside a scoped inline-definition subtree; "
                "its refs do not resolve through the top-level object table"
            )
        ref = element.get("ObjectRef")
        if ref is not None:
            try:
                return self.by_object_id[ref]
            except KeyError:
                raise ValueError(f"unknown ObjectID {ref!r}") from None
        uref = element.get("ObjectURef")
        if uref is not None:
            try:
                return self.by_object_uid[uref]
            except KeyError:
                raise ValueError(f"unknown ObjectUID {uref!r}") from None
        raise ValueError(f"<{element.tag}> carries no ObjectRef/ObjectURef")

    def payload(self, element: ET.Element) -> bytes | None:
        """Decode a base64 element's payload, following the hash index.

        Premiere stores a given binary payload ONCE and writes every further
        copy of it as an empty element carrying the same `BinaryHash` - a
        timeline caption's styled text and a Motion Graphics template's
        parameter values are both stored that way. An empty copy therefore
        has to be resolved through the hash to the populated one.

        `None` when the element holds no payload and no copy of its hash
        does either.
        """
        text = (element.text or "").strip()
        if not text:
            binary_hash = element.get("BinaryHash")
            if binary_hash is None:
                return None
            if self._by_binary_hash is None:
                self._by_binary_hash = self._index_payloads()
            return self._by_binary_hash.get(binary_hash)
        return base64.b64decode(_WHITESPACE.sub("", text))

    def payload_stored(self, binary_hash: str) -> bool:
        """Whether some element already carries this payload's text.

        Writers use this to follow Premiere's store-once rule: the first
        occurrence of a payload carries the base64 text, every further copy
        is an empty element with the same `BinaryHash`.
        """
        if self._by_binary_hash is None:
            self._by_binary_hash = self._index_payloads()
        return binary_hash in self._by_binary_hash

    def _index_payloads(self) -> dict[str, bytes]:
        payloads: dict[str, bytes] = {}
        for element in self.root.iter():
            binary_hash = element.get("BinaryHash")
            text = (element.text or "").strip()
            if binary_hash is None or not text or binary_hash in payloads:
                continue
            payloads[binary_hash] = base64.b64decode(_WHITESPACE.sub("", text))
        return payloads

    def next_object_id(self) -> str:
        """The next free integer ObjectID in the top-level object table.

        Answered from a high-water mark kept in step by `attach_object`, so
        creating N objects costs N rather than N times the table size.
        """
        return str(self._highest_object_id + 1)

    def add_object(self, element: ET.Element) -> str:
        """Append a new top-level object under a freshly minted ObjectID."""
        object_id = self.next_object_id()
        if "ObjectID" in element.attrib:
            element.set("ObjectID", object_id)
        else:
            # Premiere writes the identifier ahead of ClassID/Version on every
            # one of the 11k top-level objects in the corpus, and the
            # serializer emits attributes in insertion order - so setting it
            # on an element that has none has to put it in front, not append.
            rest = dict(element.attrib)
            element.attrib.clear()
            element.attrib["ObjectID"] = object_id
            element.attrib.update(rest)
        self.attach_object(element)
        return object_id

    def attach_object(self, element: ET.Element) -> ET.Element:
        """Append a top-level object that already carries its identifier.

        For objects Premiere keys by `ObjectUID`, and for graphs whose IDs
        were allocated up front so their internal refs could be wired before
        anything was spliced in.
        """
        append_uniform_child(self.root, element)
        self._index_object(element)
        # The new object may carry a binary payload other elements will
        # hash-reference.
        self._by_binary_hash = None
        return element

    def remove_object(self, element: ET.Element) -> None:
        """Detach a top-level object and drop it from the indexes."""
        remove_child(self.root, element)
        object_id = element.get("ObjectID")
        if object_id is not None:
            self.by_object_id.pop(object_id, None)
        object_uid = element.get("ObjectUID")
        if object_uid is not None:
            self.by_object_uid.pop(object_uid, None)
        # It may have been the carrier of a hash-shared payload.
        self._by_binary_hash = None

    def owned_objects(
        self, seeds: list[ET.Element], index: ReferenceIndex | None = None
    ) -> list[ET.Element]:
        """Top-level objects reachable from `seeds` that nothing else needs.

        The graph Premiere deletes along with a panel item (master clip,
        template clips, source, media, streams...). A shared object - Media
        another item still references, say - survives because its external
        referrer keeps it out of the set. Pass `index` to spend one pass over
        the tree across several removals instead of one per call.
        """
        if index is None:
            index = ReferenceIndex(self)
        deleted: dict[int, ET.Element] = {id(seed): seed for seed in seeds}
        changed = True
        while changed:
            changed = False
            for element_id in list(deleted):
                for target in index.targets_of.get(element_id, []):
                    if id(target) in deleted:
                        continue
                    external = index.referrers.get(id(target))
                    if external is None or external <= deleted.keys():
                        deleted[id(target)] = target
                        changed = True
        return list(deleted.values())

    def _index_object(self, element: ET.Element) -> None:
        object_id = element.get("ObjectID")
        if object_id is not None:
            self.by_object_id[object_id] = element
            if object_id.isdigit():
                self._highest_object_id = max(self._highest_object_id, int(object_id))
        object_uid = element.get("ObjectUID")
        if object_uid is not None:
            self.by_object_uid[object_uid] = element

    def to_xml_bytes(self) -> bytes:
        """The decompressed XML payload (the byte-fidelity contract)."""
        return serialize_document(self.root)

    def to_bytes(self) -> bytes:
        """The full file as written to disk (gzip framing applied)."""
        return compress_prproj(self.to_xml_bytes(), self.framing)

    def save(self, path: str | Path) -> None:
        """Write the document to `path`, overwriting it if it exists.

        The document layer carries no policy: `Project.save` is the one that
        refuses to overwrite and keeps the project's own path in step.
        """
        Path(path).write_bytes(self.to_bytes())

Attributes

by_object_id instance-attribute
by_object_id = {}
by_object_uid instance-attribute
by_object_uid = {}
framing instance-attribute
framing = framing
root instance-attribute
root = root

Methods:

__init__
__init__(root, framing)
Source code in src/py_premiere/xml/document.py
def __init__(self, root: ET.Element, framing: GzipFraming | None) -> None:
    self.root = root
    self.framing = framing
    self.by_object_id: dict[str, ET.Element] = {}
    self.by_object_uid: dict[str, ET.Element] = {}
    # Built on the first `payload()` that needs it: most documents never
    # ask, and the walk touches every element.
    self._by_binary_hash: dict[str, bytes] | None = None
    #: Highest integer ObjectID handed out so far; `next_object_id` reads
    #: it instead of rescanning the table.
    self._highest_object_id = 0
    for element in root:
        object_id = element.get("ObjectID")
        if object_id is not None:
            if object_id in self.by_object_id:
                raise ValueError(f"duplicate ObjectID {object_id!r}")
            self.by_object_id[object_id] = element
            if object_id.isdigit():
                self._highest_object_id = max(
                    self._highest_object_id, int(object_id)
                )
        object_uid = element.get("ObjectUID")
        if object_uid is not None:
            if object_uid in self.by_object_uid:
                raise ValueError(f"duplicate ObjectUID {object_uid!r}")
            self.by_object_uid[object_uid] = element
    # Everything under a nested (non-top-level) ObjectID/ObjectUID carrier
    # lives in that object's own ID scope.
    self._scoped: set[int] = set()
    for top in root:
        for nested in top.iter():
            if nested is top:
                continue
            if (
                nested.get("ObjectID") is not None
                or nested.get("ObjectUID") is not None
            ):
                for descendant in nested.iter():
                    self._scoped.add(id(descendant))
add_object
add_object(element)

Append a new top-level object under a freshly minted ObjectID.

Source code in src/py_premiere/xml/document.py
def add_object(self, element: ET.Element) -> str:
    """Append a new top-level object under a freshly minted ObjectID."""
    object_id = self.next_object_id()
    if "ObjectID" in element.attrib:
        element.set("ObjectID", object_id)
    else:
        # Premiere writes the identifier ahead of ClassID/Version on every
        # one of the 11k top-level objects in the corpus, and the
        # serializer emits attributes in insertion order - so setting it
        # on an element that has none has to put it in front, not append.
        rest = dict(element.attrib)
        element.attrib.clear()
        element.attrib["ObjectID"] = object_id
        element.attrib.update(rest)
    self.attach_object(element)
    return object_id
attach_object
attach_object(element)

Append a top-level object that already carries its identifier.

For objects Premiere keys by ObjectUID, and for graphs whose IDs were allocated up front so their internal refs could be wired before anything was spliced in.

Source code in src/py_premiere/xml/document.py
def attach_object(self, element: ET.Element) -> ET.Element:
    """Append a top-level object that already carries its identifier.

    For objects Premiere keys by `ObjectUID`, and for graphs whose IDs
    were allocated up front so their internal refs could be wired before
    anything was spliced in.
    """
    append_uniform_child(self.root, element)
    self._index_object(element)
    # The new object may carry a binary payload other elements will
    # hash-reference.
    self._by_binary_hash = None
    return element
from_bytes classmethod
from_bytes(data)
Source code in src/py_premiere/xml/document.py
@classmethod
def from_bytes(cls, data: bytes) -> PremiereDocument:
    xml_bytes, framing = decompress_prproj(data)
    try:
        root = ET.fromstring(xml_bytes)
    except ET.ParseError as error:
        # ParseError is a SyntaxError; callers of a file reader expect a
        # ValueError for "this is not a project file".
        raise ValueError(f"not a Premiere project file: {error}") from None
    document = cls(root, framing)
    # Self-check: ElementTree normalizes away constructs the serializer
    # cannot reproduce (comments, PIs, CDATA, entity forms, writer idioms
    # outside the known set). Any such file would silently corrupt on
    # save, so refuse it loudly at parse time instead.
    reserialized = document.to_xml_bytes()
    if reserialized != xml_bytes:
        raise ValueError(
            "cannot faithfully round-trip this file; "
            + _divergence_context(reserialized, xml_bytes)
        )
    return document
next_object_id
next_object_id()

The next free integer ObjectID in the top-level object table.

Answered from a high-water mark kept in step by attach_object, so creating N objects costs N rather than N times the table size.

Source code in src/py_premiere/xml/document.py
def next_object_id(self) -> str:
    """The next free integer ObjectID in the top-level object table.

    Answered from a high-water mark kept in step by `attach_object`, so
    creating N objects costs N rather than N times the table size.
    """
    return str(self._highest_object_id + 1)
owned_objects
owned_objects(seeds, index=None)

Top-level objects reachable from seeds that nothing else needs.

The graph Premiere deletes along with a panel item (master clip, template clips, source, media, streams...). A shared object - Media another item still references, say - survives because its external referrer keeps it out of the set. Pass index to spend one pass over the tree across several removals instead of one per call.

Source code in src/py_premiere/xml/document.py
def owned_objects(
    self, seeds: list[ET.Element], index: ReferenceIndex | None = None
) -> list[ET.Element]:
    """Top-level objects reachable from `seeds` that nothing else needs.

    The graph Premiere deletes along with a panel item (master clip,
    template clips, source, media, streams...). A shared object - Media
    another item still references, say - survives because its external
    referrer keeps it out of the set. Pass `index` to spend one pass over
    the tree across several removals instead of one per call.
    """
    if index is None:
        index = ReferenceIndex(self)
    deleted: dict[int, ET.Element] = {id(seed): seed for seed in seeds}
    changed = True
    while changed:
        changed = False
        for element_id in list(deleted):
            for target in index.targets_of.get(element_id, []):
                if id(target) in deleted:
                    continue
                external = index.referrers.get(id(target))
                if external is None or external <= deleted.keys():
                    deleted[id(target)] = target
                    changed = True
    return list(deleted.values())
payload
payload(element)

Decode a base64 element's payload, following the hash index.

Premiere stores a given binary payload ONCE and writes every further copy of it as an empty element carrying the same BinaryHash - a timeline caption's styled text and a Motion Graphics template's parameter values are both stored that way. An empty copy therefore has to be resolved through the hash to the populated one.

None when the element holds no payload and no copy of its hash does either.

Source code in src/py_premiere/xml/document.py
def payload(self, element: ET.Element) -> bytes | None:
    """Decode a base64 element's payload, following the hash index.

    Premiere stores a given binary payload ONCE and writes every further
    copy of it as an empty element carrying the same `BinaryHash` - a
    timeline caption's styled text and a Motion Graphics template's
    parameter values are both stored that way. An empty copy therefore
    has to be resolved through the hash to the populated one.

    `None` when the element holds no payload and no copy of its hash
    does either.
    """
    text = (element.text or "").strip()
    if not text:
        binary_hash = element.get("BinaryHash")
        if binary_hash is None:
            return None
        if self._by_binary_hash is None:
            self._by_binary_hash = self._index_payloads()
        return self._by_binary_hash.get(binary_hash)
    return base64.b64decode(_WHITESPACE.sub("", text))
payload_stored
payload_stored(binary_hash)

Whether some element already carries this payload's text.

Writers use this to follow Premiere's store-once rule: the first occurrence of a payload carries the base64 text, every further copy is an empty element with the same BinaryHash.

Source code in src/py_premiere/xml/document.py
def payload_stored(self, binary_hash: str) -> bool:
    """Whether some element already carries this payload's text.

    Writers use this to follow Premiere's store-once rule: the first
    occurrence of a payload carries the base64 text, every further copy
    is an empty element with the same `BinaryHash`.
    """
    if self._by_binary_hash is None:
        self._by_binary_hash = self._index_payloads()
    return binary_hash in self._by_binary_hash
remove_object
remove_object(element)

Detach a top-level object and drop it from the indexes.

Source code in src/py_premiere/xml/document.py
def remove_object(self, element: ET.Element) -> None:
    """Detach a top-level object and drop it from the indexes."""
    remove_child(self.root, element)
    object_id = element.get("ObjectID")
    if object_id is not None:
        self.by_object_id.pop(object_id, None)
    object_uid = element.get("ObjectUID")
    if object_uid is not None:
        self.by_object_uid.pop(object_uid, None)
    # It may have been the carrier of a hash-shared payload.
    self._by_binary_hash = None
resolve
resolve(element)

Follow an element's ObjectRef/ObjectURef to its target element.

Source code in src/py_premiere/xml/document.py
def resolve(self, element: ET.Element) -> ET.Element:
    """Follow an element's `ObjectRef`/`ObjectURef` to its target element."""
    if id(element) in self._scoped:
        raise ValueError(
            f"<{element.tag}> is inside a scoped inline-definition subtree; "
            "its refs do not resolve through the top-level object table"
        )
    ref = element.get("ObjectRef")
    if ref is not None:
        try:
            return self.by_object_id[ref]
        except KeyError:
            raise ValueError(f"unknown ObjectID {ref!r}") from None
    uref = element.get("ObjectURef")
    if uref is not None:
        try:
            return self.by_object_uid[uref]
        except KeyError:
            raise ValueError(f"unknown ObjectUID {uref!r}") from None
    raise ValueError(f"<{element.tag}> carries no ObjectRef/ObjectURef")
save
save(path)

Write the document to path, overwriting it if it exists.

The document layer carries no policy: Project.save is the one that refuses to overwrite and keeps the project's own path in step.

Source code in src/py_premiere/xml/document.py
def save(self, path: str | Path) -> None:
    """Write the document to `path`, overwriting it if it exists.

    The document layer carries no policy: `Project.save` is the one that
    refuses to overwrite and keeps the project's own path in step.
    """
    Path(path).write_bytes(self.to_bytes())
to_bytes
to_bytes()

The full file as written to disk (gzip framing applied).

Source code in src/py_premiere/xml/document.py
def to_bytes(self) -> bytes:
    """The full file as written to disk (gzip framing applied)."""
    return compress_prproj(self.to_xml_bytes(), self.framing)
to_xml_bytes
to_xml_bytes()

The decompressed XML payload (the byte-fidelity contract).

Source code in src/py_premiere/xml/document.py
def to_xml_bytes(self) -> bytes:
    """The decompressed XML payload (the byte-fidelity contract)."""
    return serialize_document(self.root)

ReferenceIndex

Which top-level objects point at which, from one pass over the tree.

A snapshot, deliberately not cached on the document: every edit that adds or drops a reference invalidates it. A caller removing several objects builds one and hands it to each step instead of paying the pass again.

Source code in src/py_premiere/xml/document.py
class ReferenceIndex:
    """Which top-level objects point at which, from one pass over the tree.

    A snapshot, deliberately not cached on the document: every edit that adds
    or drops a reference invalidates it. A caller removing several objects
    builds one and hands it to each step instead of paying the pass again.
    """

    def __init__(self, document: PremiereDocument) -> None:
        self.referrers: dict[int, set[int]] = {}
        self.targets_of: dict[int, list[ET.Element]] = {}
        for top in document.root:
            for node in top.iter():
                if id(node) in document._scoped:
                    # Inline-definition subtrees (project view state, column
                    # classes) reuse ids from their own scope; their refs do
                    # not point at top-level objects.
                    continue
                for attribute, index in (
                    ("ObjectRef", document.by_object_id),
                    ("ObjectURef", document.by_object_uid),
                ):
                    ref = node.get(attribute)
                    if ref is None:
                        continue
                    target = index.get(ref)
                    if target is None or target is top:
                        continue
                    self.referrers.setdefault(id(target), set()).add(id(top))
                    self.targets_of.setdefault(id(top), []).append(target)

    def referrers_outside(
        self, target: ET.Element, ignoring: list[ET.Element]
    ) -> set[int]:
        """Ids of the top-level objects still pointing at `target`."""
        return self.referrers.get(id(target), set()) - {
            id(element) for element in ignoring
        }

Attributes

referrers instance-attribute
referrers = {}
targets_of instance-attribute
targets_of = {}

Methods:

__init__
__init__(document)
Source code in src/py_premiere/xml/document.py
def __init__(self, document: PremiereDocument) -> None:
    self.referrers: dict[int, set[int]] = {}
    self.targets_of: dict[int, list[ET.Element]] = {}
    for top in document.root:
        for node in top.iter():
            if id(node) in document._scoped:
                # Inline-definition subtrees (project view state, column
                # classes) reuse ids from their own scope; their refs do
                # not point at top-level objects.
                continue
            for attribute, index in (
                ("ObjectRef", document.by_object_id),
                ("ObjectURef", document.by_object_uid),
            ):
                ref = node.get(attribute)
                if ref is None:
                    continue
                target = index.get(ref)
                if target is None or target is top:
                    continue
                self.referrers.setdefault(id(target), set()).add(id(top))
                self.targets_of.setdefault(id(top), []).append(target)
referrers_outside
referrers_outside(target, ignoring)

Ids of the top-level objects still pointing at target.

Source code in src/py_premiere/xml/document.py
def referrers_outside(
    self, target: ET.Element, ignoring: list[ET.Element]
) -> set[int]:
    """Ids of the top-level objects still pointing at `target`."""
    return self.referrers.get(id(target), set()) - {
        id(element) for element in ignoring
    }

Functions:

parse_prproj

parse_prproj(path)

Parse a .prproj file from disk.

Source code in src/py_premiere/xml/document.py
def parse_prproj(path: str | Path) -> PremiereDocument:
    """Parse a `.prproj` file from disk."""
    return PremiereDocument.from_bytes(Path(path).read_bytes())

Gzip framing for .prproj files.

Premiere writes a fixed 10-byte gzip header (flags=0, mtime=0, xfl=0; only the OS byte varies by writer) followed by a stock zlib level-6 deflate stream. Reproducing the file byte-for-byte therefore only requires keeping the original header and compressing with zlib defaults. The round-trip test suite is the tripwire for platforms whose zlib emits different bytes. Headers with optional fields (FNAME etc., e.g. a file re-gzipped by CLI tools) are parsed and preserved verbatim.

Attributes

DEFAULT_OS_BYTE module-attribute

DEFAULT_OS_BYTE = 10

GZIP_MAGIC module-attribute

GZIP_MAGIC = b'\x1f\x8b'

Classes

GzipFraming

Raw gzip header of a parsed file, re-emitted verbatim on save.

Source code in src/py_premiere/xml/gzip_io.py
class GzipFraming:
    """Raw gzip header of a parsed file, re-emitted verbatim on save."""

    def __init__(self, header: bytes) -> None:
        self.header = header

    @classmethod
    def default(cls) -> GzipFraming:
        # For py-created files (e.g. scripts/make_synthetic_sample.py):
        # mtime=0 and the current Premiere OS byte.
        header = (
            GZIP_MAGIC
            + bytes([8, 0])
            + struct.pack("<I", 0)
            + bytes([0, DEFAULT_OS_BYTE])
        )
        return cls(header)

Attributes

header instance-attribute
header = header

Methods:

__init__
__init__(header)
Source code in src/py_premiere/xml/gzip_io.py
def __init__(self, header: bytes) -> None:
    self.header = header
default classmethod
default()
Source code in src/py_premiere/xml/gzip_io.py
@classmethod
def default(cls) -> GzipFraming:
    # For py-created files (e.g. scripts/make_synthetic_sample.py):
    # mtime=0 and the current Premiere OS byte.
    header = (
        GZIP_MAGIC
        + bytes([8, 0])
        + struct.pack("<I", 0)
        + bytes([0, DEFAULT_OS_BYTE])
    )
    return cls(header)

Functions:

compress_prproj

compress_prproj(xml, framing)

Frame an XML payload the way Premiere would have written it.

Source code in src/py_premiere/xml/gzip_io.py
def compress_prproj(xml: bytes, framing: GzipFraming | None) -> bytes:
    """Frame an XML payload the way Premiere would have written it."""
    if framing is None:
        return xml
    compressor = zlib.compressobj(6, zlib.DEFLATED, -15)
    deflated = compressor.compress(xml) + compressor.flush()
    trailer = struct.pack("<II", zlib.crc32(xml), len(xml) & 0xFFFFFFFF)
    return framing.header + deflated + trailer

decompress_prproj

decompress_prproj(data)

Return (xml payload, framing); framing is None for uncompressed files.

Source code in src/py_premiere/xml/gzip_io.py
def decompress_prproj(data: bytes) -> tuple[bytes, GzipFraming | None]:
    """Return `(xml payload, framing)`; framing is None for uncompressed files."""
    if not data.startswith(GZIP_MAGIC):
        return data, None
    header_length = _header_length(data)
    decompressor = zlib.decompressobj(-15)
    try:
        xml = decompressor.decompress(data[header_length:])
    except zlib.error as exc:
        raise ValueError(f"corrupt deflate stream: {exc}") from exc
    trailer = decompressor.unused_data
    if len(trailer) < 8:
        raise ValueError("truncated gzip trailer")
    if len(trailer) > 8:
        # compress_prproj emits a single member; concatenated members would
        # be silently restructured on save, so refuse them up front.
        raise ValueError("multi-member gzip files are not supported")
    crc, isize = struct.unpack("<II", trailer)
    if crc != zlib.crc32(xml) or isize != len(xml) & 0xFFFFFFFF:
        raise ValueError("gzip CRC/length mismatch")
    return xml, GzipFraming(data[:header_length])

Serializer reproducing Premiere's exact XML output style.

Premiere's writer is uniform: LF line endings, tab indentation, decimal character references, self-closing empty elements, and a \n\n epilogue after the root element. Data newlines exist only inside leaf-element text, stored as &#10;/&#13;; every raw LF byte in the file is formatting. ElementTree collapses both onto \n at parse time, so escaping decisions below are made from element shape: leaf text is data (entity-escaped), branch text and tails are formatting (passed through raw, and refused when non-whitespace - mixed content would make the distinction ambiguous).

These rules are validated two ways: the byte-identity round-trip suite over the sample corpus, and a parse-time self-check in PremiereDocument.from_bytes that refuses any file these rules cannot reproduce byte-for-byte.

Attributes

EPILOGUE module-attribute

EPILOGUE = b'\n\n'

PROLOG module-attribute

PROLOG = b'<?xml version="1.0" encoding="UTF-8" ?>\n'

Functions:

serialize_document

serialize_document(root)

Serialize a document tree to Premiere-style XML payload bytes.

Source code in src/py_premiere/xml/serializer.py
def serialize_document(root: ET.Element) -> bytes:
    """Serialize a document tree to Premiere-style XML payload bytes."""
    parts: list[str] = []
    _write_element(parts, root)
    return PROLOG + "".join(parts).encode("utf-8") + EPILOGUE

serialize_element

serialize_element(element)

Serialize a single element subtree (no prolog/epilogue), for display.

Source code in src/py_premiere/xml/serializer.py
def serialize_element(element: ET.Element) -> bytes:
    """Serialize a single element subtree (no prolog/epilogue), for display."""
    parts: list[str] = []
    _write_element(parts, element)
    return "".join(parts).encode("utf-8")

Tree-mutation helpers that preserve Premiere's formatting.

New elements copy the whitespace of the sibling they displace, so the serializer reproduces the file's indentation style without any re-formatting pass.

Functions:

append_child

append_child(parent, element, empty_indent=None)

Append element as the last child, preserving indentation.

Matches the whitespace of the existing children; for an empty parent the child indent is derived by deepening the parent's own closing whitespace by one tab, or from empty_indent when the parent carries no text to deepen. Works at any nesting depth (nothing is hard-coded), so the serializer reproduces the layout with no reflow.

Source code in src/py_premiere/xml/mutations.py
def append_child(
    parent: ET.Element, element: ET.Element, empty_indent: str | None = None
) -> ET.Element:
    """Append `element` as the last child, preserving indentation.

    Matches the whitespace of the existing children; for an empty parent the
    child indent is derived by deepening the parent's own closing whitespace
    by one tab, or from `empty_indent` when the parent carries no text to
    deepen. Works at any nesting depth (nothing is hard-coded), so the
    serializer reproduces the layout with no reflow.
    """
    children = list(parent)
    if len(children) >= 2:
        element.tail = children[-1].tail
        children[-1].tail = children[-2].tail
    elif len(children) == 1:
        element.tail = children[0].tail
        children[0].tail = parent.text
    else:
        element.tail = parent.text or empty_indent
        parent.text = (parent.text or empty_indent or "") + "\t"
    parent.append(element)
    return element

append_leaf

append_leaf(parent, tag, text)

Append <tag>text</tag> as the last child, preserving indentation.

Source code in src/py_premiere/xml/mutations.py
def append_leaf(parent: ET.Element, tag: str, text: str) -> ET.Element:
    """Append `<tag>text</tag>` as the last child, preserving indentation."""
    element = ET.Element(tag)
    element.text = text
    return append_child(parent, element)

append_pair

append_pair(container, first, object_ref)

Append a <Marker><First>..</First><Second ObjectRef=..></Marker> pair.

container is the inner list element (Markers/Markers); the whitespace of the existing pairs is matched, with the new pair becoming the last.

Source code in src/py_premiere/xml/mutations.py
def append_pair(container: ET.Element, first: str, object_ref: str) -> ET.Element:
    """Append a `<Marker><First>..</First><Second ObjectRef=..></Marker>` pair.

    `container` is the inner list element (`Markers/Markers`); the whitespace
    of the existing pairs is matched, with the new pair becoming the last.
    """
    pairs = list(container)
    indent = "\n\t\t\t"
    pair = ET.Element("Marker", {"Version": "1", "Index": str(len(pairs))})
    pair.text = indent + "\t"
    first_element = ET.SubElement(pair, "First")
    first_element.text = first
    first_element.tail = indent + "\t"
    second = ET.SubElement(pair, "Second", {"ObjectRef": object_ref})
    second.tail = indent
    pair.tail = indent[:-1]
    if pairs:
        pairs[-1].tail = indent
    else:
        container.text = indent
    container.append(pair)
    return pair

append_uniform_child

append_uniform_child(container, element)

Append element to a container whose children share their spacing.

Works for the top-level object table (root) and any list element (Items, ...): children are separated by a uniform tail, and only the last carries the terminal tail before the closing tag. The appended element takes over that terminal tail; the previous last takes the inter-child tail - so the serializer reproduces the layout with no reformatting pass.

Source code in src/py_premiere/xml/mutations.py
def append_uniform_child(container: ET.Element, element: ET.Element) -> None:
    """Append `element` to a container whose children share their spacing.

    Works for the top-level object table (`root`) and any list element
    (`Items`, ...): children are separated by a uniform tail, and only the
    last carries the terminal tail before the closing tag. The appended
    element takes over that terminal tail; the previous last takes the
    inter-child tail - so the serializer reproduces the layout with no
    reformatting pass.
    """
    if len(container) < 2:
        raise ValueError("container has too few children to infer spacing")
    last = container[-1]
    element.tail = last.tail
    last.tail = container[-2].tail
    container.append(element)

build_leaf

build_leaf(parent, tag, text)

Add <tag>text</tag> to a tree that indent_tree will space later.

The counterpart to append_leaf: this one writes no whitespace, because the builders lay the whole tree out in one pass at the end. Use append_leaf to add a leaf to a document that is already formatted.

Source code in src/py_premiere/xml/mutations.py
def build_leaf(parent: ET.Element, tag: str, text: str) -> ET.Element:
    """Add `<tag>text</tag>` to a tree that `indent_tree` will space later.

    The counterpart to `append_leaf`: this one writes no whitespace, because
    the builders lay the whole tree out in one pass at the end. Use
    `append_leaf` to add a leaf to a document that is already formatted.
    """
    element = ET.SubElement(parent, tag)
    element.text = text
    return element

indent_tree

indent_tree(element, depth=0)

Lay a freshly built tree out the way Premiere writes one.

Every element in a .prproj sits at one tab per nesting level, with no exceptions, so a whole tree can be spaced in a single pass. An empty element is written self-closing ONLY when it is a pure reference; Premiere spells an empty content element out in full.

For trees built from scratch (models/skeleton.py, models/sequence_builder.py) - the mutation helpers above are the ones that edit an already-formatted document in place.

Source code in src/py_premiere/xml/mutations.py
def indent_tree(element: ET.Element, depth: int = 0) -> None:
    """Lay a freshly built tree out the way Premiere writes one.

    Every element in a `.prproj` sits at one tab per nesting level, with no
    exceptions, so a whole tree can be spaced in a single pass. An empty
    element is written self-closing ONLY when it is a pure reference;
    Premiere spells an empty content element out in full.

    For trees built from scratch (`models/skeleton.py`,
    `models/sequence_builder.py`) - the mutation helpers above are the ones
    that edit an already-formatted document in place.
    """
    children = list(element)
    close = "\n" + "\t" * depth
    if not children:
        if element.text is None and not _is_reference(element):
            element.text = close
        return
    pad = close + "\t"
    element.text = pad
    for child in children:
        indent_tree(child, depth + 1)
        child.tail = pad
    children[-1].tail = close

insert_before

insert_before(parent, anchor_tag, element)

Insert an already-built element before the anchor_tag child.

The new element takes over the whitespace that preceded the anchor, so the anchor keeps its indentation and nothing reflows.

Source code in src/py_premiere/xml/mutations.py
def insert_before(parent: ET.Element, anchor_tag: str, element: ET.Element) -> None:
    """Insert an already-built `element` before the `anchor_tag` child.

    The new element takes over the whitespace that preceded the anchor, so
    the anchor keeps its indentation and nothing reflows.
    """
    children = list(parent)
    for index, child in enumerate(children):
        if child.tag == anchor_tag:
            element.tail = children[index - 1].tail if index else parent.text
            parent.insert(index, element)
            return
    raise ValueError(f"no <{anchor_tag}> child in <{parent.tag}>")

insert_leaf_before

insert_leaf_before(parent, anchor_tag, tag, text)

Insert <tag>text</tag> before the anchor_tag child of parent.

The new element takes over the whitespace that preceded the anchor (the anchor keeps identical leading whitespace through the new element's tail), so indentation is preserved byte-for-byte around the insertion.

Source code in src/py_premiere/xml/mutations.py
def insert_leaf_before(
    parent: ET.Element, anchor_tag: str, tag: str, text: str
) -> ET.Element:
    """Insert `<tag>text</tag>` before the `anchor_tag` child of `parent`.

    The new element takes over the whitespace that preceded the anchor (the
    anchor keeps identical leading whitespace through the new element's
    tail), so indentation is preserved byte-for-byte around the insertion.
    """
    element = ET.Element(tag)
    element.text = text
    try:
        insert_before(parent, anchor_tag, element)
    except ValueError:
        raise ValueError(
            f"no <{anchor_tag}> child in <{parent.tag}> to anchor <{tag}>"
        ) from None
    return element

remove_child

remove_child(parent, element)

Remove element from parent, preserving surrounding whitespace.

The removed element's tail (the whitespace before whatever followed it) is handed to the preceding sibling - or to the parent's text when it was the first child - so the layout closes up with no reformatting.

Source code in src/py_premiere/xml/mutations.py
def remove_child(parent: ET.Element, element: ET.Element) -> None:
    """Remove `element` from `parent`, preserving surrounding whitespace.

    The removed element's tail (the whitespace before whatever followed it)
    is handed to the preceding sibling - or to the parent's text when it was
    the first child - so the layout closes up with no reformatting.
    """
    previous = None
    for child in parent:
        if child is element:
            break
        previous = child
    else:
        raise ValueError(f"<{element.tag}> is not a child of <{parent.tag}>")
    if previous is not None:
        previous.tail = element.tail
    else:
        parent.text = element.tail
    parent.remove(element)

set_elided_flag

set_elided_flag(parent, tag, present, text='true')

Write <tag>text</tag>, or remove it - the shape Premiere elides.

The format stores most booleans only in their non-default state, so clearing one means deleting the element rather than writing the opposite value. present is whether the element should be STORED, which for a field whose default is the written state (a sync lock, say) is the inverse of the domain value.

Source code in src/py_premiere/xml/mutations.py
def set_elided_flag(
    parent: ET.Element, tag: str, present: bool, text: str = "true"
) -> None:
    """Write `<tag>text</tag>`, or remove it - the shape Premiere elides.

    The format stores most booleans only in their non-default state, so
    clearing one means deleting the element rather than writing the opposite
    value. `present` is whether the element should be STORED, which for a
    field whose default is the written state (a sync lock, say) is the
    inverse of the domain value.
    """
    existing = parent.find(tag)
    if present:
        if existing is None:
            append_leaf(parent, tag, text)
        else:
            existing.text = text
    elif existing is not None:
        remove_child(parent, existing)