Added API guardrails with copy

API has been update to return copies when returning immutable objects to
avoid passing internal references to the user that could then modify
them, breaking the internal state. Even if the copy operation is
obsolete in some cases, it is kept in as a safety should the API
functions be modified which would cause a return reference issue, the
concerned API methods are not to be requested at high frequency either
way so the performance hit is estimated to be minimal, the only API
method that may be queried at a high freqency is fetching live values
which are returned as floats which are immutable and therefore not
concerned
This commit is contained in:
h3cx committed 2026-09-18 22:21:31 +02:00
1 parent 377e6fb323
commit 142646396e
4 files changed
+29 -22

No files matched your search

+10 -12
View File
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from copy import copy
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
from pathlib import Path from pathlib import Path
@@ -225,11 +226,6 @@ class Core:
return stopped return stopped
def set_mode(self, mode: CoreMode) -> None: def set_mode(self, mode: CoreMode) -> None:
if mode not in ("realtime", "playback"):
raise CoreError(
f"Unknown core mode {mode!r}; expected 'realtime' or 'playback'",
code="invalid_mode",
)
if self._recording.is_set(): if self._recording.is_set():
raise CoreError( raise CoreError(
"Cannot change core mode while a recording is active", "Cannot change core mode while a recording is active",
@@ -241,7 +237,7 @@ class Core:
self._live_values = {} self._live_values = {}
def get_mode(self) -> CoreMode: def get_mode(self) -> CoreMode:
return self._mode.value return copy(self._mode.value)
def load_playback_engine(self, input: Path | None = None) -> None: def load_playback_engine(self, input: Path | None = None) -> None:
if self._mode.value != "playback": if self._mode.value != "playback":
@@ -255,11 +251,14 @@ class Core:
"Cannot load playback because no recording is available", "Cannot load playback because no recording is available",
code="no_playback_data", code="no_playback_data",
) )
loaded_data = self._processing_buffer loaded_data = copy(self._processing_buffer)
else: else:
loaded_data = DLPak().read(input) loaded_data = DLPak().read(input)
self._playback_engine.set_data(loaded_data) self._playback_engine.set_data(loaded_data)
def load_processing_buffer(self, input: Path | None = None) -> None:
pass
# def unload_playback_engine # def unload_playback_engine
def start_recording(self) -> None: def start_recording(self) -> None:
@@ -299,8 +298,7 @@ class Core:
time.sleep(1) time.sleep(1)
self._recording_buffer.normalize() self._recording_buffer.normalize()
self._recording_buffer.sort() self._recording_buffer.sort()
self._processing_buffer = DLPak() self._processing_buffer = DLPak().set_data(copy(self._recording_buffer))
self._processing_buffer.set_data(self._recording_buffer)
connector_signal_descriptors = ( connector_signal_descriptors = (
self._connector_registry.get_all_signal_descriptors() self._connector_registry.get_all_signal_descriptors()
) )
@@ -343,7 +341,7 @@ class Core:
if signal is None and self._core_config.playback_mode == "reprocess": if signal is None and self._core_config.playback_mode == "reprocess":
signal = self._derive_registry.get_signal_descriptor(signal_id) signal = self._derive_registry.get_signal_descriptor(signal_id)
return signal return copy(signal)
def get_all_signal_descriptors(self) -> list[SignalDescriptor]: def get_all_signal_descriptors(self) -> list[SignalDescriptor]:
root_signals: list[SignalDescriptor] = [] root_signals: list[SignalDescriptor] = []
@@ -358,7 +356,7 @@ class Core:
or self._mode.value == "realtime" or self._mode.value == "realtime"
): ):
derive_signals = self._derive_registry.get_all_signal_descriptors() derive_signals = self._derive_registry.get_all_signal_descriptors()
return root_signals + derive_signals return copy(root_signals + derive_signals)
def get_live_value(self, signal_id: UUID) -> float | None: def get_live_value(self, signal_id: UUID) -> float | None:
with self._live_values_lock: with self._live_values_lock:
@@ -391,7 +389,7 @@ class Core:
) )
unit = self._derive_registry.register(dlunit) unit = self._derive_registry.register(dlunit)
return unit.uuid() return copy(unit.uuid())
def unbind_derive_unit(self, unit_id: UUID) -> None: def unbind_derive_unit(self, unit_id: UUID) -> None:
self._derive_registry.unregister(unit_id) self._derive_registry.unregister(unit_id)
+7 -4
View File
@@ -12,14 +12,17 @@ from pydantic import BaseModel, Field
from dynalab_core.buffer import ValueBuffer from dynalab_core.buffer import ValueBuffer
from dynalab_core.errors import DLPakError from dynalab_core.errors import DLPakError
from dynalab_core.protocols.packets.handshake import SignalDescriptor from dynalab_core.protocols.packets.handshake import (
JsonSignalDescriptor,
SignalDescriptor,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class RecordManifest(BaseModel): class RecordManifest(BaseModel):
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
signals: list[SignalDescriptor] = Field(default_factory=list) signals: list[JsonSignalDescriptor] = Field(default_factory=list)
comments: str = "" comments: str = ""
@@ -45,11 +48,11 @@ class DLPak:
data_signal_ids = self._data.get_signal_ids() data_signal_ids = self._data.get_signal_ids()
manifest_signals: list[SignalDescriptor] = [] manifest_signals: list[JsonSignalDescriptor] = []
for signal in all_signals: for signal in all_signals:
if signal.id in data_signal_ids: if signal.id in data_signal_ids:
manifest_signals.append(signal) manifest_signals.append(signal.to_json())
self._manifest = RecordManifest( self._manifest = RecordManifest(
timestamp=timestamp, signals=manifest_signals, comments="" timestamp=timestamp, signals=manifest_signals, comments=""
+11 -5
View File
@@ -11,7 +11,10 @@ from zipfile import ZIP_DEFLATED, ZipFile
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from dynalab_core.errors import DLPakError, DLUnitError from dynalab_core.errors import DLPakError, DLUnitError
from dynalab_core.protocols.packets.handshake import SignalDescriptor from dynalab_core.protocols.packets.handshake import (
JsonSignalDescriptor,
SignalDescriptor,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -19,8 +22,8 @@ log = logging.getLogger(__name__)
class DeriveManifest(BaseModel): class DeriveManifest(BaseModel):
unit_uuid: UUID unit_uuid: UUID
input_signals: list[SignalDescriptor] = Field(default_factory=list) input_signals: list[JsonSignalDescriptor] = Field(default_factory=list)
return_signal: SignalDescriptor return_signal: JsonSignalDescriptor
comments: str = "" comments: str = ""
@@ -42,10 +45,13 @@ class DLUnit:
return_signal: SignalDescriptor, return_signal: SignalDescriptor,
) -> None: ) -> None:
json_input_signals = [signal.to_json() for signal in input_signals]
json_return_signal = return_signal.to_json()
self._manifest = DeriveManifest( self._manifest = DeriveManifest(
unit_uuid=unit_uuid, unit_uuid=unit_uuid,
input_signals=input_signals, input_signals=json_input_signals,
return_signal=return_signal, return_signal=json_return_signal,
) )
def read(self, file: str | Path) -> Self: def read(self, file: str | Path) -> Self:
+1 -1
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from typing import Literal from typing import Literal
CoreMode = Literal["realtime", "playback"] CoreMode = Literal["realtime", "playback", "offline"]
@dataclass @dataclass