From c66511b0050ee490bd8b28c6581329bbc8c42567 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:41:40 +0900 Subject: [PATCH] change: marshal server row data --- dzgui/api/servers.py | 7 +- dzgui/model/proxy_model.py | 240 ++++++++++++++++++++---------- dzgui/model/servers.py | 14 +- dzgui/views/trees/tree_servers.py | 6 +- 4 files changed, 180 insertions(+), 87 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 8c331e4..ab48474 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -13,6 +13,7 @@ from typing import Any, Optional, TYPE_CHECKING, Union from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT from dzgui.const.endpoints import STEAM_SERVERS +from dzgui.model.proxy_model import ServerRow from dzgui.util.format import format_exception from dzgui.util import strings @@ -139,7 +140,9 @@ def sanitize(name: str) -> str: return name -def parse_json(json: list) -> list: +def parse_json( + json: list[dict[str, Any]], +) -> list[ServerRow]: """ Server metadata is underspecified and server operators tend to insert random garbage in the headers. In case @@ -224,7 +227,7 @@ def parse_json(json: list) -> list: mapname = row["map"].lower() players = row["players"] max_players = row["max_players"] - raw = ( + raw = ServerRow( name, mapname, view, diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index c892110..fb67861 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,6 +1,8 @@ import re -from typing import Any, TYPE_CHECKING, Union +from dataclasses import dataclass, fields +from operator import attrgetter +from typing import TYPE_CHECKING, Union from dzgui.const.enum import FilterMode from dzgui.model.model_factory import ModelFactory @@ -12,8 +14,118 @@ if TYPE_CHECKING: from dzgui.api.servers import Record from dzgui.model.servers import NewPlayerCount -DAY_REG = r"([0][6-9]|[1][0-7])" -NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])" + +@dataclass(slots=True, frozen=True) +class ServerRow: + name: str + mapname: str + view: str + time: str + players: int + max_players: int + queue: int + ip: str + qport: int + ping: str + provider: str + 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", + ) + + # TODO: tests for marshalled row insertion + @classmethod + def marshall_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.DAY_REG = r"([0][6-9]|[1][0-7])" + self.NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])" + + 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(self.DAY_REG, row.time)] + + def filter_night(self, rows: list[ServerRow]) -> list[ServerRow]: + return [row for row in rows if not re.match(self.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: @@ -30,20 +142,16 @@ class ProxyModelManager: def __init__(self, filter_man: "FilterManager") -> None: self.filter_cache: dict[ - tuple[str], - list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]], + tuple[str, ...], + list[ServerRow], ] = {} self.proxy_model: "FastInsertListStore" self.filter_man = filter_man + self.filtration_manager = FiltrationManager() - self.control_model: ( - list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]] - | None - ) = None - self.filtered: list[ - tuple[str, str, str, str, int, int, int, str, int, int, str, bool] - ] = [] + self.control_model: list[ServerRow] | None = None + self.filtered: list[ServerRow] = [] self.success = True def has_control_model(self) -> bool: @@ -54,9 +162,7 @@ class ProxyModelManager: def append_row(self, row: list) -> None: self.proxy_model.append(row) - def append_row_to_control( - self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool] - ) -> None: + def append_row_to_control(self, row: ServerRow) -> None: if self.control_model is None: raise AttributeError("Trying to add rows to a non-existent model") self.control_model.append(row) @@ -64,22 +170,25 @@ class ProxyModelManager: def update_playercount(self, playercount: "NewPlayerCount") -> None: treeiter = playercount.treeiter - self.proxy_model[treeiter][4] = playercount.players - self.proxy_model[treeiter][6] = playercount.queue + field = [f.name for f in fields(ServerRow)] + 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 # TODO: use dataclass for record rows def append_row_to_history( self, - history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool], + history: ServerRow, ) -> None: - addr = history[7] - qport = history[8] + addr = history.ip + qport = history.qport if self.control_model is None: raise AttributeError("Trying to append row to empty model") found = False 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) self.control_model.append(item) found = True @@ -88,6 +197,7 @@ class ProxyModelManager: if found is False: self.control_model.append(history) + # NOTE: truncate history to 10 records max if len(self.control_model) == 11: del self.control_model[0] @@ -99,7 +209,7 @@ class ProxyModelManager: addr = f"{record.ip}:{record.gameport}" qport = record.qport 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) break self.wipe_cache() @@ -121,6 +231,9 @@ class ProxyModelManager: when running in the main loop with 40k+ records 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 # instead of just a serial list of strings @@ -129,8 +242,9 @@ class ProxyModelManager: if skip_cache is False: if filters in self.filter_cache: cached_rows = self.filter_cache[filters] + marshalled_rows = ServerRow.marshall_rows(cached_rows) clone = ModelFactory().make_server_store() - clone.extend(cached_rows) + clone.extend(marshalled_rows) self.set_proxy_model(clone) self.set_filtered(cached_rows) return None @@ -149,17 +263,18 @@ class ProxyModelManager: clone = ModelFactory().make_server_store() if len(rows) > 0: rows = self.sort_rows(rows) - clone.extend(rows) + marshalled_rows = ServerRow.marshall_rows(rows) + clone.extend(marshalled_rows) self.set_cache(filters, rows) self.set_proxy_model(clone) return clone - def sort_rows(self, rows: list) -> list: - rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower())) + def sort_rows(self, rows: list[ServerRow]) -> list[ServerRow]: + rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x.name.lower())) 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 """ @@ -168,7 +283,7 @@ class ProxyModelManager: self.set_filtered(self.filter_toggle_off(filters, f)) 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 """ @@ -178,10 +293,10 @@ class ProxyModelManager: if sel_map == strings.all_maps: 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 - def filter_keyword(self, filters: tuple) -> list: + def filter_keyword(self, filters: tuple[str, ...]) -> list[ServerRow]: keyword = self.filter_man.get_active_keyword() rows = self.filtered @@ -191,13 +306,15 @@ class ProxyModelManager: filtered = [ row for row in rows - if keyword in row[0].lower() - or keyword in row[1].lower() - or keyword in row[7].lower() + if keyword in row.name.lower() + or keyword in row.mapname.lower() + or keyword in row.ip.lower() ] 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 """ @@ -211,43 +328,11 @@ class ProxyModelManager: self.set_filtered(None) return [] - rows = self.filtered - match filter_type: - 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]] + # TODO: pack filter type enum in filter checkbox, rather than stringwise comparison + rows = self.filtration_manager.filter(filter_type, self.filtered) 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""" self.set_filtered(self.control_model) self.set_filtered(self.filter_map(filters)) @@ -258,31 +343,36 @@ class ProxyModelManager: self.set_filtered(self.filter_toggle_off(filters, f)) 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 - def set_filtered(self, rows: list | None) -> None: + def set_filtered(self, rows: list[ServerRow] | None) -> None: if rows is None: self.filtered = [] else: self.filtered = rows - def get_filtered(self) -> list: + def get_filtered(self) -> list[ServerRow]: return self.filtered def set_proxy_model(self, model: "FastInsertListStore") -> None: """ 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 - def set_control(self, rows: list) -> None: + def set_control(self, rows: list[ServerRow]) -> None: """ Raw data model prior to filtration """ self.control_model = rows - def get_control(self) -> list[Any]: + def get_control(self) -> list[ServerRow]: if self.control_model is None: raise AttributeError( "Expected a populated control model, but it is Nonetype" @@ -293,7 +383,7 @@ class ProxyModelManager: self.filtered = [] self.filter_cache = {} - def push(self, data: list[Any]) -> None: + def push(self, data: list[ServerRow]) -> None: self.wipe_cache() self.set_control(data) cur_keyword = self.filter_man.get_active_keyword() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index a1d3082..8c1b05e 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -26,7 +26,7 @@ from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.api.servers import A2SInfo, Record 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 logger = logging.getLogger(APP_NAME) @@ -241,10 +241,7 @@ class ServerModelManager: def update_history( self, - rows: ( - list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]] - | None - ) = None, + rows: list["ServerRow"] | None = None, ) -> None: proxy_man = self._get_proxy_man() if rows is not None: @@ -267,7 +264,6 @@ class ServerModelManager: self.first_iteration = False 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: row, record = data proxy_man = self._get_proxy_man() @@ -453,13 +449,13 @@ class ServerModelManager: dialog.show_all() dialog.run() - def _push_data(self, data: list[Any]) -> None: + def _push_data(self, data: list["ServerRow"]) -> None: self._get_proxy_man().push(data) self._sort_unique_maps(data) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) - def _sort_unique_maps(self, data: list) -> None: - u_maps = set([row[1] for row in data]) + def _sort_unique_maps(self, data: list[Any]) -> None: + u_maps = set([row.mapname for row in data]) self._set_new_maps(sorted(u_maps)) def _set_new_maps(self, maps: list[str]) -> None: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 3143c6d..e2b8a06 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -412,5 +412,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore if row_index >= start[0] <= end[0]: # NOTE: fetch raw data rows 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))