feat(plugins): let a plugin send unit data, opt-in per plugin (#103)
ServerContext.send_dmrd(pkt) hands one DMRD frame to the routing core, through the same synthetic ingress path scheduled announcements use (inject_plugin_dmrd -> dmrd_received on the announcement MASTER, with the SERVER_ID as peer). Only plugins listed in PLUGINS.send get it. Guards (PluginDmrdSender, re-read from the live config on every frame): - allowed_src_ids: a plugin can't send as a radio; required. - max_frames_per_s: per-plugin token bucket, starts full. - unit data only in this version (data header, rate 1/2, 3/4, CSBK). - master_kill or removing the entry stops sending at once. Routing of plugin frames (dmrd_received plugin_origin): - delivered by the unit data path only: SUB_MAP / hotspot peer ID, to the destination's exact hotspot, also on the ingress MASTER itself; - never through the private call path, which would learn the plugin's source in SUB_MAP (spreading replies over every hotspot of that MASTER) and keep call state on its shared slot; - no OpenBridge or DATA-GATEWAY fan-out; - plugin events for them carry is_synthetic=True. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>pull/104/head
parent
114ad296f5
commit
0c3432ebfd
@ -0,0 +1,110 @@
|
||||
# ADN DMR Peer Server - plugin DMRD sender
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""``ServerContext.send_dmrd`` for one plugin: guards in front of the routing core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from ....domain import int_id
|
||||
from ..domain.send import SendPermission, is_plugin_sendable, parse_dmrd_header, send_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginDmrdSender:
|
||||
"""Checks each frame against the plugin's live permission, then hands it to the reactor.
|
||||
|
||||
The permission is read from the server config on every call, so a SIGHUP that
|
||||
removes or narrows ``PLUGINS.send.<plugin>`` takes effect at once. Safe to call
|
||||
from any thread; delivery always happens on the reactor.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin: str,
|
||||
server_config: dict[str, Any],
|
||||
deliver: Callable[[bytes, str], Any],
|
||||
call_from_reactor: Callable[..., Any],
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._plugin = plugin
|
||||
self._config = server_config
|
||||
self._deliver = deliver
|
||||
self._call_from_reactor = call_from_reactor
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._tokens = math.inf # starts full: clamped to the rate on first use
|
||||
self._refilled = clock()
|
||||
self._streams_seen: set[bytes] = set()
|
||||
self.dropped = 0
|
||||
|
||||
def __call__(self, pkt: bytes) -> bool:
|
||||
"""Queue one DMRD frame for routing; False (and logged) when a guard rejects it."""
|
||||
permission = send_permission(self._config, self._plugin)
|
||||
if permission is None:
|
||||
return self._reject("not allowed to send (PLUGINS.send)")
|
||||
header = parse_dmrd_header(bytes(pkt)) if isinstance(pkt, (bytes, bytearray)) else None
|
||||
if header is None:
|
||||
return self._reject("not a DMRD frame")
|
||||
if not is_plugin_sendable(header.call_type, header.frame_type, header.dtype_vseq):
|
||||
return self._reject("only unit data may be sent")
|
||||
rf_src = int_id(header.rf_src)
|
||||
stream_id, dst_id = header.stream_id, header.dst_id
|
||||
if rf_src not in permission.allowed_src_ids:
|
||||
return self._reject(f"source {rf_src} not in allowed_src_ids")
|
||||
if not self._take_token(permission):
|
||||
return self._reject(f"over {permission.max_frames_per_s:g} frames/s")
|
||||
with self._lock:
|
||||
first = stream_id not in self._streams_seen
|
||||
if first:
|
||||
if len(self._streams_seen) > 1024:
|
||||
self._streams_seen.clear()
|
||||
self._streams_seen.add(stream_id)
|
||||
if first:
|
||||
logger.info(
|
||||
"(PLUGIN) %s sent stream %s src %s -> dst %s", self._plugin, int_id(stream_id), rf_src, int_id(dst_id)
|
||||
)
|
||||
self._call_from_reactor(self._deliver, bytes(pkt), self._plugin)
|
||||
return True
|
||||
|
||||
def _take_token(self, permission: SendPermission) -> bool:
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
rate = permission.max_frames_per_s
|
||||
self._tokens = min(rate, self._tokens + (now - self._refilled) * rate)
|
||||
self._refilled = now
|
||||
if self._tokens < 1.0:
|
||||
return False
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
|
||||
def _reject(self, reason: str) -> bool:
|
||||
with self._lock:
|
||||
self.dropped += 1
|
||||
dropped = self.dropped
|
||||
if dropped == 1 or dropped % 100 == 0:
|
||||
logger.warning("(PLUGIN) %s: frame dropped, %s (%d dropped so far)", self._plugin, reason, dropped)
|
||||
return False
|
||||
@ -0,0 +1,101 @@
|
||||
# ADN DMR Peer Server - plugin send rules
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""What a plugin may send, and the per-plugin permission read from ``PLUGINS.send``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from ....domain import HBPF_DATA_SYNC
|
||||
|
||||
# CSBK, data header, rate 1/2 and rate 3/4 data blocks: ARS, LRRP, SMS and the like.
|
||||
PLUGIN_SENDABLE_DTYPES = frozenset({3, 6, 7, 8})
|
||||
DEFAULT_MAX_FRAMES_PER_S = 40.0
|
||||
|
||||
|
||||
class DmrdHeader(NamedTuple):
|
||||
seq: int
|
||||
rf_src: bytes
|
||||
dst_id: bytes
|
||||
peer_id: bytes
|
||||
slot: int
|
||||
call_type: str
|
||||
frame_type: int
|
||||
dtype_vseq: int
|
||||
stream_id: bytes
|
||||
|
||||
|
||||
def parse_dmrd_header(pkt: bytes) -> DmrdHeader | None:
|
||||
"""The HBP DMRD header fields of any call type (group, vcsbk or unit)."""
|
||||
if len(pkt) < 53 or pkt[:4] != b"DMRD":
|
||||
return None
|
||||
bits = pkt[15]
|
||||
if bits & 0x40:
|
||||
call_type = "unit"
|
||||
elif (bits & 0x23) == 0x23:
|
||||
call_type = "vcsbk"
|
||||
else:
|
||||
call_type = "group"
|
||||
return DmrdHeader(
|
||||
seq=pkt[4],
|
||||
rf_src=pkt[5:8],
|
||||
dst_id=pkt[8:11],
|
||||
peer_id=pkt[11:15],
|
||||
slot=2 if bits & 0x80 else 1,
|
||||
call_type=call_type,
|
||||
frame_type=(bits & 0x30) >> 4,
|
||||
dtype_vseq=bits & 0xF,
|
||||
stream_id=pkt[16:20],
|
||||
)
|
||||
|
||||
|
||||
def is_plugin_sendable(call_type: str, frame_type: int, dtype_vseq: int) -> bool:
|
||||
"""Plugins send unit data only (no voice) in this version."""
|
||||
return call_type == "unit" and frame_type == HBPF_DATA_SYNC and dtype_vseq in PLUGIN_SENDABLE_DTYPES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SendPermission:
|
||||
allowed_src_ids: frozenset[int]
|
||||
max_frames_per_s: float
|
||||
|
||||
|
||||
def send_permission(server_config: dict[str, Any], plugin: str) -> SendPermission | None:
|
||||
"""The plugin's entry in ``PLUGINS.send``, or None when it may not send.
|
||||
|
||||
An entry without source IDs grants nothing: the allowlist is what stops a
|
||||
plugin from sending as a radio.
|
||||
"""
|
||||
plugins_cfg = server_config.get("PLUGINS") or {}
|
||||
if plugins_cfg.get("master_kill"):
|
||||
return None
|
||||
entry = (plugins_cfg.get("send") or {}).get(plugin)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
try:
|
||||
ids = frozenset(int(i) for i in entry.get("allowed_src_ids") or ())
|
||||
rate = float(entry.get("max_frames_per_s", DEFAULT_MAX_FRAMES_PER_S))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not ids or rate <= 0:
|
||||
return None
|
||||
return SendPermission(ids, rate)
|
||||
@ -0,0 +1,144 @@
|
||||
# ADN DMR Peer Server - tests plugin send guards
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""ServerContext.send_dmrd: opt-in, source allowlist, unit data only, rate limit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.harness.deterministic import PacketSpec
|
||||
|
||||
from adn_server.application.plugins.application.bus import PluginBus
|
||||
from adn_server.application.plugins.application.context import ServerContext
|
||||
from adn_server.application.plugins.application.manager import PluginManager
|
||||
from adn_server.application.plugins.application.sender import PluginDmrdSender
|
||||
from adn_server.domain import HBPF_DATA_SYNC, HBPF_VOICE
|
||||
|
||||
GATEWAY_ID = 900999
|
||||
|
||||
|
||||
def _config(**send) -> dict:
|
||||
return {"PLUGINS": {"send": {"d-aprs": {"allowed_src_ids": [GATEWAY_ID], "max_frames_per_s": 5, **send}}}}
|
||||
|
||||
|
||||
def _frame(src: int = GATEWAY_ID, call_type: str = "unit", frame_type: int = HBPF_DATA_SYNC, dtype: int = 6) -> bytes:
|
||||
return PacketSpec(rf_src=src, dst_id=7140023, call_type=call_type, frame_type=frame_type, dtype_vseq=dtype).data()
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self) -> None:
|
||||
self.t = 100.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.t
|
||||
|
||||
|
||||
def _sender(config: dict, clock=None):
|
||||
delivered: list = []
|
||||
sender = PluginDmrdSender(
|
||||
"d-aprs", config, lambda pkt, name: delivered.append((pkt, name)),
|
||||
call_from_reactor=lambda fn, *a: fn(*a), clock=clock or _Clock(),
|
||||
)
|
||||
return sender, delivered
|
||||
|
||||
|
||||
def test_an_allowed_frame_is_delivered_on_the_reactor_with_the_plugin_name() -> None:
|
||||
sender, delivered = _sender(_config())
|
||||
assert sender(_frame()) is True
|
||||
assert delivered == [(_frame(), "d-aprs")]
|
||||
|
||||
|
||||
def test_nothing_is_sent_without_a_plugins_send_entry() -> None:
|
||||
sender, delivered = _sender({"PLUGINS": {}})
|
||||
assert sender(_frame()) is False and delivered == []
|
||||
|
||||
|
||||
def test_an_entry_without_source_ids_grants_nothing() -> None:
|
||||
sender, delivered = _sender(_config(allowed_src_ids=[]))
|
||||
assert sender(_frame()) is False and delivered == []
|
||||
|
||||
|
||||
def test_a_plugin_cannot_send_as_a_radio() -> None:
|
||||
sender, delivered = _sender(_config())
|
||||
assert sender(_frame(src=7140023)) is False and delivered == []
|
||||
|
||||
|
||||
def test_voice_and_group_frames_are_refused() -> None:
|
||||
sender, delivered = _sender(_config())
|
||||
assert sender(_frame(call_type="group")) is False
|
||||
assert sender(_frame(frame_type=HBPF_VOICE, dtype=1)) is False
|
||||
assert sender(b"not a dmrd frame") is False
|
||||
assert delivered == []
|
||||
|
||||
|
||||
def test_rate_limit_per_plugin() -> None:
|
||||
clock = _Clock()
|
||||
sender, delivered = _sender(_config(max_frames_per_s=5), clock)
|
||||
assert [sender(_frame()) for _ in range(7)] == [True] * 5 + [False] * 2 # starts full
|
||||
clock.t += 0.2 # one more token
|
||||
assert sender(_frame()) is True
|
||||
assert len(delivered) == 6 and sender.dropped == 2
|
||||
|
||||
|
||||
def test_revoking_the_permission_takes_effect_on_the_next_frame() -> None:
|
||||
config = _config()
|
||||
sender, delivered = _sender(config)
|
||||
assert sender(_frame()) is True
|
||||
del config["PLUGINS"]["send"]["d-aprs"] # what a SIGHUP reload leaves behind
|
||||
assert sender(_frame()) is False
|
||||
assert len(delivered) == 1
|
||||
|
||||
|
||||
def test_master_kill_stops_sending() -> None:
|
||||
config = _config()
|
||||
sender, delivered = _sender(config)
|
||||
config["PLUGINS"]["master_kill"] = True
|
||||
assert sender(_frame()) is False and delivered == []
|
||||
|
||||
|
||||
def _plugin_dir(root: Path, name: str) -> None:
|
||||
pkg = root / name / "plugin"
|
||||
pkg.mkdir(parents=True)
|
||||
(root / name / "config.yaml").write_text("enabled: true\n")
|
||||
(pkg / "__init__.py").write_text(
|
||||
"class _P:\n"
|
||||
f" name = {name!r}\n"
|
||||
" ctx = None\n"
|
||||
" def on_load(self, bus, config, ctx):\n"
|
||||
" type(self).ctx = ctx\n"
|
||||
" def on_event(self, event): pass\n"
|
||||
" def on_reload(self, config): pass\n"
|
||||
" def on_shutdown(self): pass\n"
|
||||
"def create_plugin():\n"
|
||||
" return _P()\n"
|
||||
)
|
||||
|
||||
|
||||
def test_only_the_granted_plugin_gets_send_dmrd(tmp_path) -> None:
|
||||
_plugin_dir(tmp_path / "plugins", "d-aprs")
|
||||
_plugin_dir(tmp_path / "plugins", "logger")
|
||||
ctx = ServerContext(config={}, project_root=str(tmp_path), defer_to_thread=None, call_from_reactor=None, call_later=None)
|
||||
made: list[str] = []
|
||||
manager = PluginManager(PluginBus(), ctx, tmp_path, sender_factory=lambda name: made.append(name) or (lambda pkt: True))
|
||||
loaded = {p.name: p for p in manager.discover_and_load(_config())}
|
||||
assert type(loaded["d-aprs"]).ctx.send_dmrd is not None
|
||||
assert type(loaded["logger"]).ctx.send_dmrd is None
|
||||
assert made == ["d-aprs"]
|
||||
@ -0,0 +1,115 @@
|
||||
# ADN DMR Peer Server - tests plugin send routing
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Unit data a plugin sends (ServerContext.send_dmrd) is routed locally, and only locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import (
|
||||
DeterministicScenario,
|
||||
PacketSpec,
|
||||
add_openbridge_system,
|
||||
minimal_config,
|
||||
patch_routing_wall_time,
|
||||
)
|
||||
from tests.routing.unit_data_helpers import idle_hbp_slot
|
||||
|
||||
from adn_server.application.plugins.application.bus import PluginBus
|
||||
from adn_server.application.plugins.application.data_bridge import DataPluginBridge
|
||||
from adn_server.application.plugins.domain.events import UnitDataFrame
|
||||
from adn_server.application.routing.announcement_ptt_inject import inject_plugin_dmrd
|
||||
from adn_server.domain import HBPF_DATA_SYNC, bytes_3, bytes_4
|
||||
|
||||
GATEWAY_ID = 900999
|
||||
RADIO_ID = 7140023
|
||||
RADIO_HOTSPOT = bytes_4(714002301)
|
||||
SERVER_ID = bytes_4(2131)
|
||||
|
||||
|
||||
def _scenario(radio_on: str = "SYSTEM-B") -> DeterministicScenario:
|
||||
config = minimal_config(("SYSTEM", "SYSTEM-B"))
|
||||
add_openbridge_system(config, "OBP-1")
|
||||
config["SYSTEMS"]["OBP-1"]["VER"] = 5
|
||||
config["GLOBAL"]["DATA_GATEWAY"] = True
|
||||
add_openbridge_system(config, "DATA-GATEWAY")
|
||||
config["SYSTEMS"]["DATA-GATEWAY"]["ENABLED"] = True
|
||||
sc = DeterministicScenario(config=config)
|
||||
for name in ("SYSTEM", "SYSTEM-B"):
|
||||
sc.protocols[name].STATUS[2] = idle_hbp_slot()
|
||||
sc.config["SYSTEMS"][name]["GROUP_HANGTIME"] = 0
|
||||
sc.protocols[radio_on]._peers[RADIO_HOTSPOT] = {}
|
||||
sc.config["_SUB_MAP"] = {bytes_3(RADIO_ID): (radio_on, 2, sc.clock.time(), RADIO_HOTSPOT)}
|
||||
return sc
|
||||
|
||||
|
||||
def _ars_ack(dst: int = RADIO_ID, *, call_type: str = "unit", frame_type: int = HBPF_DATA_SYNC) -> bytes:
|
||||
return PacketSpec(
|
||||
rf_src=GATEWAY_ID, dst_id=dst, peer_id=1, slot=2, call_type=call_type,
|
||||
frame_type=frame_type, dtype_vseq=6, stream_id=0x0A0B0C0D, payload=bytes(range(33)),
|
||||
).data()
|
||||
|
||||
|
||||
def _send(sc: DeterministicScenario, pkt: bytes):
|
||||
with patch_routing_wall_time(sc.clock):
|
||||
return inject_plugin_dmrd(sc.routing, "SYSTEM", pkt, pkt_time=sc.clock.time(), server_id=SERVER_ID, plugin="d-aprs")
|
||||
|
||||
|
||||
def test_reaches_the_radio_on_another_system_and_nothing_else() -> None:
|
||||
sc = _scenario("SYSTEM-B")
|
||||
_send(sc, _ars_ack())
|
||||
assert [peer for peer, _ in sc.protocols["SYSTEM-B"].sent_to_peer] == [RADIO_HOTSPOT]
|
||||
assert sc.capture.packets == [] # no OpenBridge, no DATA-GATEWAY
|
||||
|
||||
|
||||
def test_reaches_a_radio_behind_the_ingress_master_itself() -> None:
|
||||
# The common case: every hotspot sits on the proxy MASTER the plugin injects on.
|
||||
sc = _scenario("SYSTEM")
|
||||
_send(sc, _ars_ack())
|
||||
assert [peer for peer, _ in sc.protocols["SYSTEM"].sent_to_peer] == [RADIO_HOTSPOT]
|
||||
|
||||
|
||||
def test_unknown_destination_is_not_flooded_to_openbridge() -> None:
|
||||
sc = _scenario()
|
||||
_send(sc, _ars_ack(dst=3341234))
|
||||
assert sc.capture.for_system("OBP-1") == []
|
||||
assert sc.capture.for_system("DATA-GATEWAY") == []
|
||||
|
||||
|
||||
def test_the_plugin_source_is_never_learned_in_sub_map() -> None:
|
||||
sc = _scenario()
|
||||
_send(sc, _ars_ack())
|
||||
assert bytes_3(GATEWAY_ID) not in sc.config["_SUB_MAP"]
|
||||
|
||||
|
||||
def test_group_traffic_from_a_plugin_is_refused() -> None:
|
||||
sc = _scenario()
|
||||
assert _send(sc, _ars_ack(dst=213, call_type="group", frame_type=0)) is False
|
||||
assert sc.capture.packets == []
|
||||
|
||||
|
||||
def test_plugins_see_their_own_frames_as_synthetic() -> None:
|
||||
sc = _scenario()
|
||||
events: list[object] = []
|
||||
bus = PluginBus()
|
||||
bus.subscribe(events.append)
|
||||
sc.routing._data_plugin_bridge = DataPluginBridge(bus, sc.config)
|
||||
_send(sc, _ars_ack())
|
||||
frames = [e for e in events if isinstance(e, UnitDataFrame)]
|
||||
assert frames and all(f.context.is_synthetic for f in frames)
|
||||
Loading…
Reference in new issue