diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 722e3dc..b8ed4b8 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -4,7 +4,6 @@ import re from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass from pathlib import Path from dzgui.api.steam import get_app_path diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 6ac446b..546ec15 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 @@ -141,7 +142,7 @@ def sanitize(name: str) -> str: def parse_json( 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 tend to insert random garbage in the headers. In case @@ -226,7 +227,7 @@ def parse_json( 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 5cb3d97..2cd9b24 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -1,7 +1,8 @@ import re -from dataclasses import dataclass -from typing import Any, TYPE_CHECKING, Union +from dataclasses import dataclass, fields +from operator import attrgetter +from typing import TYPE_CHECKING from dzgui.const.enum import FilterMode from dzgui.model.model_factory import ModelFactory @@ -33,6 +34,100 @@ class ServerRow: 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", + ) + + @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: """ @@ -49,19 +144,15 @@ 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]], + 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: @@ -69,9 +160,7 @@ class ProxyModelManager: return False return True - 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) @@ -79,21 +168,24 @@ 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 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 @@ -102,6 +194,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] @@ -113,7 +206,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() @@ -122,19 +215,22 @@ class ProxyModelManager: def clear_proxy_model(self) -> None: self.proxy_model.clear() - def get_proxy_model(self) -> Union["FastInsertListStore", None]: + def get_proxy_model(self) -> "FastInsertListStore | None": if hasattr(self, "proxy_model"): return self.proxy_model return None def filter( self, mode: FilterMode, skip_cache: bool = False - ) -> Union["FastInsertListStore", None]: + ) -> "FastInsertListStore | None": """ Native Gtk.TreeView.refilter() method was not performant enough 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 @@ -143,8 +239,9 @@ class ProxyModelManager: if skip_cache is False: if filters in self.filter_cache: cached_rows = self.filter_cache[filters] + marshalled_rows = ServerRow.marshal_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 @@ -163,17 +260,18 @@ class ProxyModelManager: clone = ModelFactory().make_server_store() if len(rows) > 0: 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_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 """ @@ -182,7 +280,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 """ @@ -192,10 +290,10 @@ class ProxyModelManager: if sel_map == str_filters.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 @@ -205,13 +303,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 """ @@ -225,43 +325,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)) @@ -272,31 +340,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" @@ -307,7 +380,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 dd4cf9b..4e4d24f 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -26,7 +26,7 @@ from gi.repository import Gtk, GLib 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() # 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._sort_unique_maps(data) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) 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)) def _set_new_maps(self, maps: list[str]) -> None: @@ -473,7 +469,6 @@ class ServerModelManager: @call_on_thread(dialog.filtering) def refilter(self, mode: FilterMode) -> None: - # FIXME: causes two wait dialogs when map selection change signal emits after loading servers self.first_iteration = False proxy_man = self._get_proxy_man() proxy_man.filter(mode) diff --git a/dzgui/util/diag.py b/dzgui/util/diag.py index 97423da..56ce725 100644 --- a/dzgui/util/diag.py +++ b/dzgui/util/diag.py @@ -11,6 +11,7 @@ from dzgui.const.enum import Preferences from dzgui.config.query import lookup from dzgui.init.prefix import get_version from dzgui.util.redact import redact_home +from dzgui.util.strings import unknown logger = logging.getLogger(APP_NAME) @@ -27,28 +28,22 @@ def get_cpu_model() -> str: def print_servers(servers: list[str]) -> str: - s = "" - for server in servers: - s += server + "\n" - return s + return "\n".join(servers) def print_mods(mods: list[int]) -> str: - s = "" - for mod in mods: - s += str(mod) + "\n" - return s + return "\n".join(map(str, mods)) def write_diagnostic(config: Path, outfile: Path) -> None: date = datetime.now().isoformat() try: - # FIXME: ID_LIKE not available on CachyOS + # NOTE: ID_LIKE not available on CachyOS distro = platform.freedesktop_os_release()["ID_LIKE"] except Exception as e: logger.warn(e) - distro = "Unknown" + distro = unknown kernel = os.uname().release cpu = get_cpu_model() @@ -68,7 +63,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None: servers = lookup(config, Preferences.IP_LIST) servers_pretty = print_servers(servers) - # FIXME: extraneous newlines in lists of mods template = f"""\ {APP_NAME} version {version} Date: {date} @@ -82,6 +76,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None: Mods: {mods_pretty} + Servers: {servers_pretty} """ diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index c073059..7fc45b4 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -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.strings import buttons diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index d3f1752..37210cb 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -413,5 +413,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)) diff --git a/tests/test_row_marshalling.py b/tests/test_row_marshalling.py new file mode 100644 index 0000000..7e59080 --- /dev/null +++ b/tests/test_row_marshalling.py @@ -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]