Source code for osi_utilities.tracefile.readers.textformat

# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: Copyright (c) 2026, Bayerische Motoren Werke Aktiengesellschaft (BMW AG)

"""Text human-readable (.txth) trace file reader.

Reads OSI trace files in Google protobuf TextFormat where messages
are separated by a known top-level field boundary.
"""

from __future__ import annotations

import logging
from pathlib import Path

from google.protobuf import text_format

from osi_utilities._types import MessageType, ReadResult, ReadStatus
from osi_utilities.filename import infer_message_type_from_filename
from osi_utilities.message_types import (
    get_message_class,
)
from osi_utilities.tracefile.readers.base import TraceReader

logger = logging.getLogger(__name__)


[docs] class ProtobufTextFormatTraceReader(TraceReader): """Reader for text human-readable OSI trace files (.txth). .. deprecated:: 0.3.0 The ``.txth`` text format is not reliably deserializable. The OSI specification states that it is "not unambiguously deserializable" — protobuf text format output is not guaranteed to be stable across library versions, field ordering may change, and float/double precision varies. Round-tripping (write then read) can silently lose data. Prefer ``.osi`` (binary) for single-channel or ``.mcap`` for multi-channel trace files. Messages are stored in Google protobuf TextFormat. Each message is delimited by reading until the text can be parsed as a complete message. See the `Protocol Buffers Text Format Language Specification <https://protobuf.dev/reference/protobuf/textformat-spec/>`_. """ def __init__(self) -> None: self._message_type = MessageType.UNKNOWN self._message_class: type | None = None self._has_next = False self._buffer = ""
[docs] def set_message_type(self, message_type: MessageType) -> None: """Set message type to be used on open(). Pass ``MessageType.UNKNOWN`` to enable filename-based inference. """ self._message_type = message_type
[docs] def open(self, path: Path) -> bool: """Open a .txth trace file. Args: path: Path to the .txth file. Returns: True on success, False on failure. """ if self._message_class is not None: logger.error("Reader is already open. Call close() before re-opening.") return False if self._message_type == MessageType.UNKNOWN: self._message_type = infer_message_type_from_filename(path.name) if self._message_type == MessageType.UNKNOWN: logger.error("Cannot determine message type for '%s'. Specify it explicitly.", path) return False try: self._message_class = get_message_class(self._message_type) except ValueError as e: logger.error("Failed to get message class: %s", e) return False try: self._buffer = Path(path).read_text(encoding="utf-8") except OSError as e: logger.error("Failed to open file '%s': %s", path, e) return False self._has_next = len(self._buffer.strip()) > 0 return True
[docs] def read_message(self) -> ReadResult | None: """Read the next message from the text trace file. Returns: ReadResult on success, None if no more messages. """ if self._message_class is None or not self._buffer.strip(): self._has_next = False return None # Try to parse the entire remaining buffer as one message. # If it contains multiple messages, we parse greedily: TextFormat # will consume as much as it can for one message. message = self._message_class() try: text_format.Parse(self._buffer, message) self._buffer = "" self._has_next = False return ReadResult(message=message, message_type=self._message_type, status=ReadStatus.OK) except text_format.ParseError: # If full buffer fails, the file may have multiple concatenated messages. # Try splitting on the first top-level field name appearing again. logger.debug("Buffer contains multiple messages, splitting at field boundary.") # Multi-message: find the boundary by looking for a repeated top-level field # The C++ implementation reads line by line and tries parsing. lines = self._buffer.split("\n") if not lines: self._has_next = False return None # Detect the first top-level field name (not indented, contains ':' or '{') first_field = None for line in lines: stripped = line.strip() if stripped and not stripped.startswith("#"): # Extract field name if ":" in stripped: first_field = stripped.split(":")[0].strip() elif "{" in stripped: first_field = stripped.split("{")[0].strip() break if first_field is None: self._has_next = False return None # Find the next occurrence of this field after the first message split_idx = None for i, line in enumerate(lines): if i == 0: continue stripped = line.strip() if not line[0:1].isspace() and ( stripped.startswith(first_field + ":") or stripped.startswith(first_field + " {") or stripped == first_field + " {" ): split_idx = i break if split_idx is not None: msg_text = "\n".join(lines[:split_idx]) self._buffer = "\n".join(lines[split_idx:]) else: msg_text = self._buffer self._buffer = "" message = self._message_class() try: text_format.Parse(msg_text, message) except text_format.ParseError as e: self._buffer = "" self._has_next = False return ReadResult( message=None, message_type=self._message_type, status=ReadStatus.ERROR, error_message=f"Failed to parse text format message: {e}", ) self._has_next = len(self._buffer.strip()) > 0 return ReadResult(message=message, message_type=self._message_type, status=ReadStatus.OK)
[docs] def has_next(self) -> bool: return self._has_next
[docs] def close(self) -> None: self._buffer = "" self._has_next = False self._message_type = MessageType.UNKNOWN self._message_class = None
@property def message_type(self) -> MessageType: return self._message_type