Merge pull request #536 from aclist/change/server-row-dataclass

change: marshal server row data
This commit is contained in:
aclist authored and GitHub committed 2026-11-11 04:59:58 +09:00
commit 95bc05eab5
8 files changed
+193 -100

No files matched your search

-1
View File
@@ -4,7 +4,6 @@ import re
from concurrent.futures import wait from concurrent.futures import wait
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from dzgui.api.steam import get_app_path from dzgui.api.steam import get_app_path
+3 -2
View File
@@ -13,6 +13,7 @@ from typing import Any, Optional, TYPE_CHECKING, Union
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.model.proxy_model import ServerRow
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
from dzgui.util import strings from dzgui.util import strings
@@ -141,7 +142,7 @@ def sanitize(name: str) -> str:
def parse_json( def parse_json(
json: list[dict[str, Any]], json: list[dict[str, Any]],
) -> list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]: ) -> list[ServerRow]:
""" """
Server metadata is underspecified and server operators Server metadata is underspecified and server operators
tend to insert random garbage in the headers. In case tend to insert random garbage in the headers. In case
@@ -226,7 +227,7 @@ def parse_json(
mapname = row["map"].lower() mapname = row["map"].lower()
players = row["players"] players = row["players"]
max_players = row["max_players"] max_players = row["max_players"]
raw = ( raw = ServerRow(
name, name,
mapname, mapname,
view, view,
+148 -75
View File
@@ -1,7 +1,8 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass, fields
from typing import Any, TYPE_CHECKING, Union from operator import attrgetter
from typing import TYPE_CHECKING
from dzgui.const.enum import FilterMode from dzgui.const.enum import FilterMode
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
@@ -33,6 +34,100 @@ class ServerRow:
provider: str provider: str
modded: bool modded: bool
# NOTE: construct getter once for fast lookup in lieu of field introspection
_to_tuple = attrgetter(
"name",
"mapname",
"view",
"time",
"players",
"max_players",
"queue",
"ip",
"qport",
"ping",
"provider",
"modded",
)
@classmethod
def marshal_rows(
cls, rows: list["ServerRow"]
) -> list[tuple[str, str, str, str, int, int, int, str, int, str, str, bool]]:
"""Marshals data before passing into FastInsertListStore's extend() method"""
getter = cls._to_tuple
return [getter(row) for row in rows]
class FiltrationManager:
def __init__(self) -> None:
self.filter_map = {
strings.filter_3pp: self.filter_3pp,
strings.filter_1pp: self.filter_1pp,
strings.filter_official: self.filter_official,
strings.filter_unofficial: self.filter_unofficial,
strings.filter_empty: self.filter_empty,
strings.filter_full: self.filter_full,
strings.filter_duplicate: self.filter_duplicate,
strings.filter_day: self.filter_day,
strings.filter_night: self.filter_night,
strings.filter_nonascii: self.filter_nonascii,
strings.filter_lowpop: self.filter_lowpop,
strings.filter_modded: self.filter_modded,
strings.filter_unmodded: self.filter_unmodded,
}
def filter_3pp(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.view != strings.filter_3pp]
def filter_1pp(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.view != strings.filter_1pp]
def filter_official(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.provider != strings.filter_official]
def filter_unofficial(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.provider != strings.filter_unofficial]
def filter_empty(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.players != 0]
def filter_full(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.players != row.max_players]
def filter_duplicate(self, rows: list[ServerRow]) -> list[ServerRow]:
seen = set()
final = []
for row in rows:
if row.name not in seen:
seen.add(row.name)
final.append(row)
rows = final
return rows
def filter_day(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if not re.match(DAY_REG, row.time)]
def filter_night(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if not re.match(NIGHT_REG, row.time)]
def filter_nonascii(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.name.isascii()]
def filter_lowpop(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if (row.players / row.max_players * 100) > 30]
def filter_modded(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if not row.modded]
def filter_unmodded(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.modded]
def filter(self, filter_type: str, rows: list[ServerRow]) -> list[ServerRow]:
func = self.filter_map[filter_type]
return func(rows)
class ProxyModelManager: class ProxyModelManager:
""" """
@@ -49,19 +144,15 @@ class ProxyModelManager:
def __init__(self, filter_man: "FilterManager") -> None: def __init__(self, filter_man: "FilterManager") -> None:
self.filter_cache: dict[ self.filter_cache: dict[
tuple[str, ...], tuple[str, ...],
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]], list[ServerRow],
] = {} ] = {}
self.proxy_model: "FastInsertListStore" self.proxy_model: "FastInsertListStore"
self.filter_man = filter_man self.filter_man = filter_man
self.filtration_manager = FiltrationManager()
self.control_model: ( self.control_model: list[ServerRow] | None = None
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]] self.filtered: list[ServerRow] = []
| None
) = None
self.filtered: list[
tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
] = []
self.success = True self.success = True
def has_control_model(self) -> bool: def has_control_model(self) -> bool:
@@ -69,9 +160,7 @@ class ProxyModelManager:
return False return False
return True return True
def append_row_to_control( def append_row_to_control(self, row: ServerRow) -> None:
self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
) -> None:
if self.control_model is None: if self.control_model is None:
raise AttributeError("Trying to add rows to a non-existent model") raise AttributeError("Trying to add rows to a non-existent model")
self.control_model.append(row) self.control_model.append(row)
@@ -79,21 +168,24 @@ class ProxyModelManager:
def update_playercount(self, playercount: "NewPlayerCount") -> None: def update_playercount(self, playercount: "NewPlayerCount") -> None:
treeiter = playercount.treeiter treeiter = playercount.treeiter
self.proxy_model[treeiter][4] = playercount.players field = [f.name for f in fields(ServerRow)]
self.proxy_model[treeiter][6] = playercount.queue index_players = field.index("players")
index_queue = field.index("queue")
self.proxy_model[treeiter][index_players] = playercount.players
self.proxy_model[treeiter][index_queue] = playercount.queue
def append_row_to_history( def append_row_to_history(
self, self,
history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool], history: ServerRow,
) -> None: ) -> None:
addr = history[7] addr = history.ip
qport = history[8] qport = history.qport
if self.control_model is None: if self.control_model is None:
raise AttributeError("Trying to append row to empty model") raise AttributeError("Trying to append row to empty model")
found = False found = False
for i, row in enumerate(self.control_model): for i, row in enumerate(self.control_model):
if addr == row[7] and qport == row[8]: if addr == row.ip and qport == row.qport:
item = self.control_model.pop(i) item = self.control_model.pop(i)
self.control_model.append(item) self.control_model.append(item)
found = True found = True
@@ -102,6 +194,7 @@ class ProxyModelManager:
if found is False: if found is False:
self.control_model.append(history) self.control_model.append(history)
# NOTE: truncate history to 10 records max
if len(self.control_model) == 11: if len(self.control_model) == 11:
del self.control_model[0] del self.control_model[0]
@@ -113,7 +206,7 @@ class ProxyModelManager:
addr = f"{record.ip}:{record.gameport}" addr = f"{record.ip}:{record.gameport}"
qport = record.qport qport = record.qport
for row in self.control_model: for row in self.control_model:
if addr == row[7] and qport == row[8]: if addr == row.ip and qport == row.qport:
self.control_model.remove(row) self.control_model.remove(row)
break break
self.wipe_cache() self.wipe_cache()
@@ -122,19 +215,22 @@ class ProxyModelManager:
def clear_proxy_model(self) -> None: def clear_proxy_model(self) -> None:
self.proxy_model.clear() self.proxy_model.clear()
def get_proxy_model(self) -> Union["FastInsertListStore", None]: def get_proxy_model(self) -> "FastInsertListStore | None":
if hasattr(self, "proxy_model"): if hasattr(self, "proxy_model"):
return self.proxy_model return self.proxy_model
return None return None
def filter( def filter(
self, mode: FilterMode, skip_cache: bool = False self, mode: FilterMode, skip_cache: bool = False
) -> Union["FastInsertListStore", None]: ) -> "FastInsertListStore | None":
""" """
Native Gtk.TreeView.refilter() method was not performant enough Native Gtk.TreeView.refilter() method was not performant enough
when running in the main loop with 40k+ records when running in the main loop with 40k+ records
skip_cache: used when updating a record on Saved Servers/History and forcing a refilter skip_cache: used when updating a record on Saved Servers/History and forcing a refilter
NOTE: data must be marshalled from ServerRow format before constructing list stores
The "clone" is the proxy model (list of tuples) actually fed into Gtk.TreeView
""" """
# TODO: filter cache: return a dataclass object with clearly enumerated map, keyword, and filter values # TODO: filter cache: return a dataclass object with clearly enumerated map, keyword, and filter values
# instead of just a serial list of strings # instead of just a serial list of strings
@@ -143,8 +239,9 @@ class ProxyModelManager:
if skip_cache is False: if skip_cache is False:
if filters in self.filter_cache: if filters in self.filter_cache:
cached_rows = self.filter_cache[filters] cached_rows = self.filter_cache[filters]
marshalled_rows = ServerRow.marshal_rows(cached_rows)
clone = ModelFactory().make_server_store() clone = ModelFactory().make_server_store()
clone.extend(cached_rows) clone.extend(marshalled_rows)
self.set_proxy_model(clone) self.set_proxy_model(clone)
self.set_filtered(cached_rows) self.set_filtered(cached_rows)
return None return None
@@ -163,17 +260,18 @@ class ProxyModelManager:
clone = ModelFactory().make_server_store() clone = ModelFactory().make_server_store()
if len(rows) > 0: if len(rows) > 0:
rows = self.sort_rows(rows) rows = self.sort_rows(rows)
clone.extend(rows) marshalled_rows = ServerRow.marshal_rows(rows)
clone.extend(marshalled_rows)
self.set_cache(filters, rows) self.set_cache(filters, rows)
self.set_proxy_model(clone) self.set_proxy_model(clone)
return clone return clone
def sort_rows(self, rows: list) -> list: def sort_rows(self, rows: list[ServerRow]) -> list[ServerRow]:
rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower())) rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x.name.lower()))
return rows return rows
def filter_initial(self, filters: tuple) -> list: def filter_initial(self, filters: tuple[str, ...]) -> list[ServerRow]:
""" """
Simply culls the control model of any disabled filters Simply culls the control model of any disabled filters
""" """
@@ -182,7 +280,7 @@ class ProxyModelManager:
self.set_filtered(self.filter_toggle_off(filters, f)) self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered return self.filtered
def filter_map(self, filters: tuple) -> list: def filter_map(self, filters: tuple[str, ...]) -> list[ServerRow]:
""" """
Multi-filtration for any context starts by narrowing by map Multi-filtration for any context starts by narrowing by map
""" """
@@ -192,10 +290,10 @@ class ProxyModelManager:
if sel_map == str_filters.all_maps: if sel_map == str_filters.all_maps:
return rows return rows
rows = [row for row in rows if row[1] == sel_map] rows = [row for row in rows if row.mapname == sel_map]
return rows return rows
def filter_keyword(self, filters: tuple) -> list: def filter_keyword(self, filters: tuple[str, ...]) -> list[ServerRow]:
keyword = self.filter_man.get_active_keyword() keyword = self.filter_man.get_active_keyword()
rows = self.filtered rows = self.filtered
@@ -205,13 +303,15 @@ class ProxyModelManager:
filtered = [ filtered = [
row row
for row in rows for row in rows
if keyword in row[0].lower() if keyword in row.name.lower()
or keyword in row[1].lower() or keyword in row.mapname.lower()
or keyword in row[7].lower() or keyword in row.ip.lower()
] ]
return filtered return filtered
def filter_toggle_off(self, filters: tuple, filter_type: str) -> list: def filter_toggle_off(
self, filters: tuple[str, ...], filter_type: str
) -> list[ServerRow]:
""" """
Sub-filtration of the current model Sub-filtration of the current model
""" """
@@ -225,43 +325,11 @@ class ProxyModelManager:
self.set_filtered(None) self.set_filtered(None)
return [] return []
rows = self.filtered # TODO: pack filter type enum in filter checkbox, rather than stringwise comparison
match filter_type: rows = self.filtration_manager.filter(filter_type, self.filtered)
case strings.filter_3pp:
rows = [row for row in rows if row[2] != strings.filter_3pp]
case strings.filter_1pp:
rows = [row for row in rows if row[2] != strings.filter_1pp]
case strings.filter_official:
rows = [row for row in rows if row[10] != strings.filter_official]
case strings.filter_unofficial:
rows = [row for row in rows if row[10] != strings.filter_unofficial]
case strings.filter_empty:
rows = [row for row in rows if row[4] != 0]
case strings.filter_full:
rows = [row for row in rows if row[4] != row[5]]
case strings.filter_duplicate:
seen = set()
final = []
for row in rows:
if row[0] not in seen:
seen.add(row[0])
final.append(row)
rows = final
case strings.filter_day:
rows = [row for row in rows if not re.match(DAY_REG, row[3])]
case strings.filter_night:
rows = [row for row in rows if not re.match(NIGHT_REG, row[3])]
case strings.filter_nonascii:
rows = [row for row in rows if row[0].isascii()]
case strings.filter_lowpop:
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
case strings.filter_modded:
rows = [row for row in rows if not row[11]]
case strings.filter_unmodded:
rows = [row for row in rows if row[11]]
return rows return rows
def filter_toggle_on(self, filters: tuple[str, ...]) -> list: def filter_toggle_on(self, filters: tuple[str, ...]) -> list[ServerRow]:
"""Effectively applies all filters""" """Effectively applies all filters"""
self.set_filtered(self.control_model) self.set_filtered(self.control_model)
self.set_filtered(self.filter_map(filters)) self.set_filtered(self.filter_map(filters))
@@ -272,31 +340,36 @@ class ProxyModelManager:
self.set_filtered(self.filter_toggle_off(filters, f)) self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered return self.filtered
def set_cache(self, filters: tuple[str, ...], filtered_rows: list[tuple]) -> None: def set_cache(
self, filters: tuple[str, ...], filtered_rows: list[ServerRow]
) -> None:
self.filter_cache[filters] = filtered_rows self.filter_cache[filters] = filtered_rows
def set_filtered(self, rows: list | None) -> None: def set_filtered(self, rows: list[ServerRow] | None) -> None:
if rows is None: if rows is None:
self.filtered = [] self.filtered = []
else: else:
self.filtered = rows self.filtered = rows
def get_filtered(self) -> list: def get_filtered(self) -> list[ServerRow]:
return self.filtered return self.filtered
def set_proxy_model(self, model: "FastInsertListStore") -> None: def set_proxy_model(self, model: "FastInsertListStore") -> None:
""" """
FastInsertListStore representation of the raw model after filtration FastInsertListStore representation of the raw model after filtration
This attribute is saved from within a thread so that it later be retrieved
and safely packed into the TreeView in the main loop, cf. get_proxy_model()
""" """
self.proxy_model = model self.proxy_model = model
def set_control(self, rows: list) -> None: def set_control(self, rows: list[ServerRow]) -> None:
""" """
Raw data model prior to filtration Raw data model prior to filtration
""" """
self.control_model = rows self.control_model = rows
def get_control(self) -> list[Any]: def get_control(self) -> list[ServerRow]:
if self.control_model is None: if self.control_model is None:
raise AttributeError( raise AttributeError(
"Expected a populated control model, but it is Nonetype" "Expected a populated control model, but it is Nonetype"
@@ -307,7 +380,7 @@ class ProxyModelManager:
self.filtered = [] self.filtered = []
self.filter_cache = {} self.filter_cache = {}
def push(self, data: list[Any]) -> None: def push(self, data: list[ServerRow]) -> None:
self.wipe_cache() self.wipe_cache()
self.set_control(data) self.set_control(data)
cur_keyword = self.filter_man.get_active_keyword() cur_keyword = self.filter_man.get_active_keyword()
+4 -9
View File
@@ -26,7 +26,7 @@ from gi.repository import Gtk, GLib
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
from dzgui.model.proxy_model import ProxyModelManager from dzgui.model.proxy_model import ProxyModelManager, ServerRow
from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.views.trees.tree_servers import ServerTreeView
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -241,10 +241,7 @@ class ServerModelManager:
def update_history( def update_history(
self, self,
rows: ( rows: list["ServerRow"] | None = None,
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]
| None
) = None,
) -> None: ) -> None:
proxy_man = self._get_proxy_man() proxy_man = self._get_proxy_man()
if rows is not None: if rows is not None:
@@ -267,7 +264,6 @@ class ServerModelManager:
self.first_iteration = False self.first_iteration = False
self.emitter.emit("load_maps", store) self.emitter.emit("load_maps", store)
# TODO: dataclass for record rows; check for other dict annotations
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None: def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
row, record = data row, record = data
proxy_man = self._get_proxy_man() proxy_man = self._get_proxy_man()
@@ -453,13 +449,13 @@ class ServerModelManager:
dialog.show_all() dialog.show_all()
dialog.run() # type: ignore[no-untyped-call] dialog.run() # type: ignore[no-untyped-call]
def _push_data(self, data: list[Any]) -> None: def _push_data(self, data: list["ServerRow"]) -> None:
self._get_proxy_man().push(data) self._get_proxy_man().push(data)
self._sort_unique_maps(data) self._sort_unique_maps(data)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success))
def _sort_unique_maps(self, data: list[Any]) -> None: def _sort_unique_maps(self, data: list[Any]) -> None:
u_maps = set([row[1] for row in data]) u_maps = set([row.mapname for row in data])
self._set_new_maps(sorted(u_maps)) self._set_new_maps(sorted(u_maps))
def _set_new_maps(self, maps: list[str]) -> None: def _set_new_maps(self, maps: list[str]) -> None:
@@ -473,7 +469,6 @@ class ServerModelManager:
@call_on_thread(dialog.filtering) @call_on_thread(dialog.filtering)
def refilter(self, mode: FilterMode) -> None: def refilter(self, mode: FilterMode) -> None:
# FIXME: causes two wait dialogs when map selection change signal emits after loading servers
self.first_iteration = False self.first_iteration = False
proxy_man = self._get_proxy_man() proxy_man = self._get_proxy_man()
proxy_man.filter(mode) proxy_man.filter(mode)
+6 -11
View File
@@ -11,6 +11,7 @@ from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.init.prefix import get_version from dzgui.init.prefix import get_version
from dzgui.util.redact import redact_home from dzgui.util.redact import redact_home
from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -27,28 +28,22 @@ def get_cpu_model() -> str:
def print_servers(servers: list[str]) -> str: def print_servers(servers: list[str]) -> str:
s = "" return "\n".join(servers)
for server in servers:
s += server + "\n"
return s
def print_mods(mods: list[int]) -> str: def print_mods(mods: list[int]) -> str:
s = "" return "\n".join(map(str, mods))
for mod in mods:
s += str(mod) + "\n"
return s
def write_diagnostic(config: Path, outfile: Path) -> None: def write_diagnostic(config: Path, outfile: Path) -> None:
date = datetime.now().isoformat() date = datetime.now().isoformat()
try: try:
# FIXME: ID_LIKE not available on CachyOS # NOTE: ID_LIKE not available on CachyOS
distro = platform.freedesktop_os_release()["ID_LIKE"] distro = platform.freedesktop_os_release()["ID_LIKE"]
except Exception as e: except Exception as e:
logger.warn(e) logger.warn(e)
distro = "Unknown" distro = unknown
kernel = os.uname().release kernel = os.uname().release
cpu = get_cpu_model() cpu = get_cpu_model()
@@ -68,7 +63,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
servers = lookup(config, Preferences.IP_LIST) servers = lookup(config, Preferences.IP_LIST)
servers_pretty = print_servers(servers) servers_pretty = print_servers(servers)
# FIXME: extraneous newlines in lists of mods
template = f"""\ template = f"""\
{APP_NAME} version {version} {APP_NAME} version {version}
Date: {date} Date: {date}
@@ -82,6 +76,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
Mods: Mods:
{mods_pretty} {mods_pretty}
Servers: Servers:
{servers_pretty} {servers_pretty}
""" """
+1 -1
View File
@@ -1,4 +1,4 @@
from typing import Callable, Literal, Self, TYPE_CHECKING, Union from typing import Callable, Literal, Self, TYPE_CHECKING
from dzgui.util.clip import copy_clipboard from dzgui.util.clip import copy_clipboard
from dzgui.strings import buttons from dzgui.strings import buttons
+5 -1
View File
@@ -413,5 +413,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
if row_index >= start[0] <= end[0]: if row_index >= start[0] <= end[0]:
# NOTE: fetch raw data rows # NOTE: fetch raw data rows
real_model = self.proxy_man.get_control() real_model = self.proxy_man.get_control()
value = real_model[row_index][col_index] from dataclasses import fields
from dzgui.model.proxy_model import ServerRow
key = fields(ServerRow)[col_index].name
value = getattr(real_model[row_index], key)
cell.set_property("text", str(value)) cell.set_property("text", str(value))
+26
View File
@@ -0,0 +1,26 @@
from dzgui.model.model_factory import ModelFactory
from dzgui.model.proxy_model import ServerRow
def test_row_conversion() -> None:
"""Converted FastInsertListStore is expected to have identical elements"""
data = [
"Test Server",
"chernarusplus",
"1PP",
"05:00",
10,
10,
0,
"127.0.0.0:27016",
27017,
99,
"Unofficial",
False,
]
rows = [ServerRow(*data)]
marshalled = ServerRow.marshal_rows(rows)
store = ModelFactory().make_server_store()
store.extend(marshalled)
converted = [[el for el in row] for row in store]
assert data == converted[0]