Compare commits

...
Author SHA1 Message Date
aclist 903f8fc31d chore: update example.png 2026-11-05 22:19:19 +09:00
aclist 6b583f68d6 chore: bump version 2026-11-05 22:04:05 +09:00
aclist 3b3ccd0de6 chore: update changelog 2026-11-05 22:03:31 +09:00
aclist 70aacaa866 chore: update debounce comments 2026-11-05 22:03:03 +09:00
aclist 7582ab4d3b Merge pull request #513 from aclist/feat/sqlite
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
feat: sqlite
2026-11-05 21:56:27 +09:00
aclist 225a2a148e Merge pull request #511 from aclist/feat/validate-mod
feat: validate mod
2026-11-05 21:38:44 +09:00
aclist 9117655ebd Merge pull request #518 from aclist/fix/catch-record-timeout
fix: handle query timeout on add/remove actions
2026-11-05 21:30:29 +09:00
aclist fdf92dca40 Merge pull request #521 from aclist/feat/last-updated
feat: last updated (patch)
2026-11-05 21:27:57 +09:00
aclist a97766855d chore: dclear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-05 21:27:07 +09:00
aclist 3097701e98 change: drop explicit import 2026-11-05 21:26:37 +09:00
aclist 895e08d228 fix: error handling 2026-11-05 21:26:20 +09:00
aclist 08a6f698a5 Merge pull request #514 from aclist/feat/last-updated
feat: last updated timestamp
2026-11-05 21:21:41 +09:00
aclist 78be8c93b7 Merge pull request #516 from aclist/chore/test-list-store
chore: add tests
2026-11-05 21:17:05 +09:00
aclist 8a2889d15d Merge pull request #515 from aclist/fix/insensitive-filters
fix: prevent toggling when panel is insensitive
2026-11-05 21:16:19 +09:00
aclist 1a33f71716 Merge pull request #520 from aclist/feat/unmanned-screenshot
feat: unmanned screenshot
2026-11-05 21:15:02 +09:00
aclist ae13dc9a89 chore: add newline 2026-11-05 21:14:27 +09:00
aclist d5706f969c change: use base class type 2026-11-05 21:09:42 +09:00
aclist 790e34b20c chore: clear typehinting errors 2026-11-05 21:09:10 +09:00
aclist 35230ae03f feat: capture composited screenshot 2026-11-05 21:08:52 +09:00
aclist 596d3a59ac fix: handle query timeout on add/remove actions 2026-11-04 17:50:45 +09:00
aclist e7cef215c6 drop: resolved comment
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-11-02 12:19:10 +09:00
aclist 12daf041ff chore: add tests 2026-11-02 12:17:50 +09:00
aclist 90ef6cbdbb chore: add tests 2026-11-02 12:17:04 +09:00
aclist 2cc26b6573 fix: prevent toggling when panel is insensitive 2026-11-01 23:47:06 +09:00
aclist 0ec1c157f4 chore: clear typehinting errors 2026-11-01 23:45:01 +09:00
aclist 0cf6439538 chore: remove unused imports 2026-11-01 23:43:12 +09:00
aclist e3c1ef59ee feat: get last updated timestamp 2026-11-01 23:42:06 +09:00
aclist 78144e9127 chore: update tests 2026-10-31 00:26:51 +09:00
aclist 33b8dfba70 change: simplify logic 2026-10-31 00:26:27 +09:00
aclist abf0060cac feat: import ipaddress 2026-10-31 00:26:08 +09:00
aclist 95daa9163b change: update paths 2026-10-31 00:25:32 +09:00
aclist ff068e5396 feat: marshal geo DB 2026-10-31 00:23:27 +09:00
aclist 80680b86b0 chore: update test decorator 2026-10-28 15:46:00 +09:00
aclist a4ac357c8f feat: validate atomic mods 2026-10-28 15:44:30 +09:00
aclist 728b8b8242 Merge pull request #509 from aclist/fix/marks-on-fixtures
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: do not apply marks to fixtures
2026-10-26 20:07:47 +09:00
aclist 3b51392d39 fix: do not apply marks to fixtures 2026-10-26 20:06:56 +09:00
aclist 159e2da355 Merge pull request #508 from aclist/prerelease/7.0.0b29-patch-4
chore: update test
2026-10-26 19:42:25 +09:00
aclist b83a333cea chore: update test 2026-10-26 19:42:03 +09:00
aclist 46117ecdda Merge pull request #507 from aclist/prerelease/7.0.0b29-patch-3
fix: add path out of loop
2026-10-26 19:25:32 +09:00
aclist 30119fec46 fix: add path out of loop 2026-10-26 19:24:42 +09:00
aclist ac01818549 Merge pull request #506 from aclist/prerelease/7.0.0b29-patch-2
fix: add /tmp to wizard
2026-10-26 19:21:40 +09:00
aclist d486712a4c fix: add /tmp to wizard 2026-10-26 19:20:17 +09:00
aclist d116702145 Merge pull request #505 from aclist/prerelease/7.0.0b29-patch-1
Prerelease/7.0.0b29 patch 1
2026-10-26 19:11:15 +09:00
38 changed files with 614 additions and 100 deletions

No files matched your search

+7
View File
@@ -53,6 +53,10 @@
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme - Support local light/dark mode changes on system theme
- Server time calculator - Server time calculator
- Add "Unmodded" filter to servers
- List timestamp of last DayZ client update
- Validate mods via Mod page to pull in updates
- Support for Nasdara (Badlands) DLC
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
@@ -74,6 +78,7 @@
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow - Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
- Use SQLite DB for geolocation records
### Dropped ### Dropped
- Battlemetrics API support - Battlemetrics API support
@@ -92,6 +97,8 @@
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file - History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table - Current keyword filter not being applied when refreshing table
- Steam Flatpak cannot run DZGUI as a non-Steam game
- Collisions between native Steam and Steam Flatpak clients
### Unreleased ### Unreleased
- Load offline mods - Load offline mods
+28 -3
View File
@@ -1,15 +1,18 @@
import logging import logging
import re import re
from collections.abc import Iterator from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TYPE_CHECKING
from warnings import deprecated from warnings import deprecated
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from collections.abc import Iterator
class ACF: class ACF:
def __init__(self, file: Path) -> None: def __init__(self, file: Path) -> None:
@@ -42,7 +45,7 @@ class ACF:
def dequote(cls, s: str) -> str: def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"') return s.rstrip('"').lstrip('"')
def parse(self, lines: Iterator[list[str]]) -> dict[str, str]: def parse(self, lines: "Iterator[list[str]]") -> dict[str, str]:
acf: dict[str, Any] = {} acf: dict[str, Any] = {}
try: try:
while True: while True:
@@ -69,6 +72,28 @@ class ACF:
except StopIteration: except StopIteration:
return acf return acf
def search_key(self, key: str) -> str:
return str(self.dict["AppState"][key])
def date_to_str(self, epoch: int) -> str | None:
if epoch == 0:
return None
return datetime.fromtimestamp(epoch).isoformat(" ")
def get_date_by_key(self, key: str) -> str | None:
try:
stamp = int(self.search_key(key))
return self.date_to_str(stamp)
except Exception as e:
logger.critical(e)
return None
def get_last_updated(self) -> str | None:
return self.get_date_by_key("LastUpdated")
def get_last_played(self) -> str | None:
return self.get_date_by_key("LastPlayed")
def get_allows_downloads(self) -> int | None: def get_allows_downloads(self) -> int | None:
try: try:
# TODO: consider coercing strs to ints on initial import # TODO: consider coercing strs to ints on initial import
+30 -3
View File
@@ -12,6 +12,7 @@ from typing import Any, Generator, Union
from warnings import deprecated from warnings import deprecated
from dzgui.api.acf import ACF from dzgui.api.acf import ACF
from dzgui.const.endpoints import VALIDATE
from dzgui.init.prereqs import has_any_client from dzgui.init.prereqs import has_any_client
from dzgui.init.prereqs import has_flatpak_client, has_steam_client from dzgui.init.prereqs import has_flatpak_client, has_steam_client
from dzgui.const.constants import ( from dzgui.const.constants import (
@@ -316,10 +317,9 @@ def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT) update_workshop(key, mod, UNSUB_ENDPOINT)
@deprecated("Use subscribe()") def validate_mod(client: str, mod: str) -> None:
def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod]) subprocess.Popen([*client_args, "+workshop_download_item", str(APPID_DAYZ), mod])
@deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672") @deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672")
@@ -389,6 +389,26 @@ def get_running_app() -> int | None:
return None return None
def get_last_updated(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
try:
return ACF(acf).get_last_updated()
except Exception as e:
logger.critical(e)
return None
def get_last_played(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
try:
return ACF(acf).get_last_played()
except Exception as e:
logger.critical(e)
return None
def get_app_allows_downloads(path: Path, appid: int) -> bool: def get_app_allows_downloads(path: Path, appid: int) -> bool:
try: try:
root_path = get_app_path(path, appid) root_path = get_app_path(path, appid)
@@ -500,3 +520,10 @@ def get_app_name(appid: int) -> str | None:
except Exception as e: except Exception as e:
logger.debug(e) logger.debug(e)
return None return None
@deprecated("Currently unused")
def validate_dayz(client: str) -> None:
client_args = concat_bash_args(client)
uri = VALIDATE + str(APPID_DAYZ)
subprocess.Popen([*client_args, uri])
+3 -3
View File
@@ -121,7 +121,7 @@ def load_steam_icon() -> None:
logger.critical(e) logger.critical(e)
def load_gui(version: str, is_debug: bool) -> None: def build_gui(version: str, is_debug: bool) -> App | None:
lock = lock_acquire() # noqa lock = lock_acquire() # noqa
set_locale() set_locale()
@@ -151,7 +151,7 @@ def load_gui(version: str, is_debug: bool) -> None:
wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths) wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths)
if not wizard.is_setup_complete(): if not wizard.is_setup_complete():
return return None
setup_logger(XDG.debug) setup_logger(XDG.debug)
with open(XDG.debug, "w") as f: with open(XDG.debug, "w") as f:
@@ -186,4 +186,4 @@ def load_gui(version: str, is_debug: bool) -> None:
print(boot.all_ok) print(boot.all_ok)
load_steam_icon() load_steam_icon()
app = App(prefs) app = App(prefs)
app.run(None) return app
+102 -3
View File
@@ -3,6 +3,9 @@ import logging
import re import re
import requests import requests
import shutil import shutil
import socket
import sqlite3
import time
from pathlib import Path from pathlib import Path
@@ -22,15 +25,16 @@ def find_download_url(url: str) -> str | None:
return str(urls[0]) return str(urls[0])
def get_ipdb(ips_path: Path) -> None: def get_ipdb(db_path: Path) -> None:
try: try:
url = find_download_url(DB_IP) url = find_download_url(DB_IP)
if url is None: if url is None:
return return
ips_path = db_path.with_suffix(".csv")
month_file = db_path.parent / ".month"
date = find_date(url) date = find_date(url)
month_file = ips_path.parent / ".month" if db_path.exists() and month_file.exists():
if ips_path.exists() and month_file.exists():
old_date = month_file.read_text().rstrip("\n") old_date = month_file.read_text().rstrip("\n")
if old_date == date: if old_date == date:
logger.info(f"IP DB date matches: {date}") logger.info(f"IP DB date matches: {date}")
@@ -45,6 +49,9 @@ def get_ipdb(ips_path: Path) -> None:
logger.info("Stripping IPv6 records") logger.info("Stripping IPv6 records")
strip_ipv6(ips_path) strip_ipv6(ips_path)
create_table(db_path, ips_path)
ips_path.unlink()
with open(month_file, "w") as f: with open(month_file, "w") as f:
f.write(date) f.write(date)
logger.info(f"Wrote {date} to {month_file}") logger.info(f"Wrote {date} to {month_file}")
@@ -108,3 +115,95 @@ def strip_ipv6(path: Path) -> None:
out.write(merged) out.write(merged)
path.unlink() path.unlink()
alt_path.rename(path) alt_path.rename(path)
def ip_to_int(ip: str) -> int:
return int.from_bytes(socket.inet_aton(ip), "big")
def marshal_rows(ip_file: Path) -> list[tuple[int, int, str, str]]:
YIELD = 0.003
SLEEP = 0.001
with open(ip_file, "r") as f:
rows: list[tuple[int, int, str, str]] = []
# NOTE: yield to GIL periodically to reduce load on spinner
last_yield = time.monotonic()
for row in f:
start, end, lat, lon = row.rstrip("\n").split(",")
int_start = ip_to_int(start)
int_end = ip_to_int(end)
rows.append((int_start, int_end, lat, lon))
now = time.monotonic()
if (now - last_yield) >= YIELD:
time.sleep(SLEEP)
last_yield = time.monotonic()
return rows
def create_table(db_file: Path, ip_file: Path) -> None:
create = """
CREATE TABLE IF NOT EXISTS ips (
start_range INTEGER PRIMARY KEY,
end_range INTEGER,
latitude REAL,
longitude REAL
) WITHOUT ROWID;
"""
insert = """
INSERT INTO ips
(start_range, end_range, latitude, longitude)
values(?, ?, ?, ?)
"""
if db_file.exists():
db_file.unlink()
conn = sqlite3.connect(":memory:")
c = conn.cursor()
try:
c.execute(create)
rows = marshal_rows(ip_file)
c.executemany(insert, rows)
logger.info(f"Inserted {len(rows)} IP records into database")
conn.commit()
with sqlite3.connect(db_file) as dest:
conn.backup(dest)
logger.info(f"Serialized IP records to database: '{db_file}'")
conn.close()
except Exception as e:
logger.critical(e)
def read_from_table(db_path: Path, ip: str) -> tuple[float, float]:
if not db_path.exists():
raise FileNotFoundError(f"No file found at the location '{db_path}")
conn = sqlite3.connect(db_path)
c = conn.cursor()
int_ip = ip_to_int(ip)
query = """
SELECT end_range, latitude, longitude
FROM ips
WHERE start_range <= ?
ORDER BY start_range DESC
LIMIT 1
"""
index = """
CREATE INDEX IF NOT EXISTS start_index
ON ips(start_range);
"""
try:
c.execute(index)
conn.commit()
c.execute(query, (int_ip,))
row = c.fetchone()
end, lat, lon = row
conn.close()
return lat, lon
except Exception as e:
logger.critical(e)
raise e
+3 -3
View File
@@ -22,7 +22,7 @@ class Xdg:
version: Path version: Path
system: Path system: Path
debug: Path debug: Path
ips: Path db: Path
filters: Path filters: Path
shortcut: Path shortcut: Path
@@ -88,7 +88,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
resolution = state / "dzg.res.json" resolution = state / "dzg.res.json"
history = state / "dzg.history" history = state / "dzg.history"
versions = state / "dzg.versions" versions = state / "dzg.versions"
ips = state / "ips.csv" db = state / "ips.db"
filters = state / "dzg.filters.json" filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER shortcut = share / APP_NAME_LOWER
@@ -102,7 +102,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
versions, versions,
system, system,
debug, debug,
ips, db,
filters, filters,
shortcut, shortcut,
) )
+1
View File
@@ -18,6 +18,7 @@ COORDS_API = "http://ip-api.com/json"
# steam URIs # steam URIs
WORKSHOP = "steam://url/CommunityFilePage/" WORKSHOP = "steam://url/CommunityFilePage/"
OPEN_URL = "steam://openurl/" OPEN_URL = "steam://openurl/"
VALIDATE = "steam://validate/"
# user facing # user facing
GITHUB_PAGES = "https://aclist.github.io/dzgui/index" GITHUB_PAGES = "https://aclist.github.io/dzgui/index"
+6 -1
View File
@@ -157,6 +157,7 @@ class ContextMenu(EnumWithAttrs):
COPY_SERVER_IP = {"label": strings.copy_ip} COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name} COPY_SERVER_NAME = {"label": strings.copy_name}
UNSUB_MOD = {"label": strings.unsub_mod} UNSUB_MOD = {"label": strings.unsub_mod}
VALIDATE_MOD = {"label": strings.validate_mod}
OPEN_WORKSHOP = {"label": strings.open_workshop} OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players} REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history} REMOVE_HISTORY = {"label": strings.remove_history}
@@ -172,7 +173,7 @@ class ContextMenuGroup(Enum):
""" """
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,) SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD) MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD, ContextMenu.VALIDATE_MOD)
MOD_OFFLINE = (None,) MOD_OFFLINE = (None,)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,) LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = ( SERVER_BROWSER = (
@@ -246,6 +247,10 @@ class ModButton(EnumWithAttrs):
"label": strings.mod_panel.select_stale, "label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip, "tooltip": strings.mod_panel.select_stale_tooltip,
} }
VALIDATE = {
"label": strings.mod_panel.validate,
"tooltip": strings.mod_panel.validate_tooltip,
}
class ButtonType(EnumWithAttrs): class ButtonType(EnumWithAttrs):
+10
View File
@@ -102,6 +102,16 @@ class Controller(GObject.GObject):
except AttributeError: except AttributeError:
logger.critical(f"{attr} is not a valid AppNavigation attribute.") logger.critical(f"{attr} is not a valid AppNavigation attribute.")
def validate_mods(
self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None
) -> None:
if treeview is None:
view = self.mediator.modtreeview
else:
view = treeview
mod_man = view.get_mod_man()
mod_man.validate_mods()
def get_prefs(self) -> UserPrefs: def get_prefs(self) -> UserPrefs:
return self.prefs return self.prefs
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+2 -2
View File
@@ -10,10 +10,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
def get_local_coords(path: "Path") -> Coords | None: def get_local_coords(db_path: "Path") -> Coords | None:
try: try:
my_ip = get_local_ip() my_ip = get_local_ip()
return get_coords(path, my_ip) return get_coords(db_path, my_ip)
except Exception as e: except Exception as e:
logger.warn(e) logger.warn(e)
return None return None
+5 -2
View File
@@ -42,6 +42,9 @@ def main() -> None:
test_libgi_missing() test_libgi_missing()
# NOTE: only import ligbirepository modules after the above check # NOTE: only import ligbirepository modules after the above check
from dzgui.app_init import load_gui from dzgui.app_init import build_gui
load_gui(version, args.debug) app = build_gui(version, args.debug)
if app is None:
sys.exit(0)
app.run(None)
+6 -2
View File
@@ -75,10 +75,14 @@ class ContextMenuManager:
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)): if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action: match action:
case ContextMenu.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.OPEN_WORKSHOP: case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page() self.open_mod_page()
case ContextMenu.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.VALIDATE_MOD:
self.controller.validate_mods(self.treeview)
case _:
pass
if isinstance(self.treeview, LogTreeView): if isinstance(self.treeview, LogTreeView):
match action: match action:
+26 -2
View File
@@ -4,7 +4,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.steam import unsubscribe from dzgui.api.steam import unsubscribe, validate_mod
from dzgui.api.mods import ( from dzgui.api.mods import (
get_delimited_mods, get_delimited_mods,
find_stale_mods, find_stale_mods,
@@ -85,7 +85,7 @@ class ModManager:
total_mods = len(self.store) total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods) self.emitter.emit("mods_updated", msg, total_mods)
def unsub_mods(self) -> None: def get_selected_mods(self) -> list[tuple[str, Gtk.TreeIter]]:
sel = self.treeview.get_selection() sel = self.treeview.get_selection()
model, pathlist = sel.get_selected_rows() model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection # NOTE: reverse when multiple selection
@@ -96,6 +96,15 @@ class ModManager:
continue continue
mod, _iter = res mod, _iter = res
mods.append((mod, _iter)) mods.append((mod, _iter))
return mods
def validate_mods(self) -> None:
mods = self.get_selected_mods()
self.thread_man.set_job_count(len(mods))
self.validate_all_mods(mods)
def unsub_mods(self) -> None:
mods = self.get_selected_mods()
self.thread_man.set_job_count(len(mods)) self.thread_man.set_job_count(len(mods))
self.unsub_all_mods(mods) self.unsub_all_mods(mods)
@@ -109,6 +118,21 @@ class ModManager:
mod = model.get_value(tree_iter, 2) mod = model.get_value(tree_iter, 2)
return mod, tree_iter return mod, tree_iter
def validate_atomic_mod(self, mod: str) -> None:
# NOTE: propagation to dayz exp will happen during
# subsequent symlink phase on connect
client = self.controller.query_config(Preferences.CLIENT)
validate_mod(client, mod)
@call_on_thread(dialogs.validating_mods)
def validate_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods:
self.validate_atomic_mod(mod)
self.thread_man.increment_dialog()
time.sleep(API_RATE_LIMIT)
func = StoredFunc(lambda: None)
self.thread_man.set_cleanup_func(func)
@call_on_thread(dialogs.deleting_mods) @call_on_thread(dialogs.deleting_mods)
def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None: def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods: for mod, _iter in mods:
+24 -1
View File
@@ -201,19 +201,43 @@ class ServerModelManager:
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def add_by_ip(self, addr: str) -> None: def add_by_ip(self, addr: str) -> None:
self.preserve_on_fail = True
try:
res = Servers.query_by_ip(addr) res = Servers.query_by_ip(addr)
self._parse_single_record(res) self._parse_single_record(res)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def add_by_record(self, record: Servers.Record) -> None: def add_by_record(self, record: Servers.Record) -> None:
self.preserve_on_fail = True
# NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP # NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP
try:
res = Servers.query_by_record(record) res = Servers.query_by_record(record)
self._parse_single_record(res) self._parse_single_record(res)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def remove_by_record(self, record: Servers.Record) -> None: def remove_by_record(self, record: Servers.Record) -> None:
self.preserve_on_fail = True
try:
res = Servers.query_by_record(record) res = Servers.query_by_record(record)
self._parse_single_record(res, delete=True) self._parse_single_record(res, delete=True)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
def update_history( def update_history(
self, self,
@@ -280,7 +304,6 @@ class ServerModelManager:
proxy_man.update_playercount(self.playercount) proxy_man.update_playercount(self.playercount)
def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None: def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None:
self.preserve_on_fail = True
try: try:
row = response.as_row() row = response.as_row()
except Exception as e: except Exception as e:
+1
View File
@@ -9,6 +9,7 @@ update_success = "Updated successfully. Please exit and relaunch."
load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings." load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings."
fetching_mods = "Fetching mod metadata" fetching_mods = "Fetching mod metadata"
deleting_mods = "Unsubscribing mods" deleting_mods = "Unsubscribing mods"
validating_mods = "Validating mods"
scanning_mods = "Scanning mods" scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods" parsing_mods = "Parsing mods"
+3 -2
View File
@@ -80,8 +80,9 @@ class CalcDist:
if local is None: if local is None:
return None return None
try: try:
remote = get_coords(prefs.paths.ips, remote_ip) remote = get_coords(prefs.paths.db, remote_ip)
except Exception: except Exception as e:
logger.critical(e)
return None return None
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon) haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
+12 -43
View File
@@ -1,4 +1,5 @@
import a2s import a2s
from ipaddress import ip_address
import requests import requests
import shutil import shutil
import subprocess import subprocess
@@ -7,6 +8,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.config.ipdb import read_from_table
from dzgui.const.constants import REQUEST_TIMEOUT from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import IP_ECHO from dzgui.const.endpoints import IP_ECHO
@@ -33,53 +35,20 @@ def ip2list(ip: str) -> list:
return [int(digit) for digit in ip.split(".")] return [int(digit) for digit in ip.split(".")]
# TODO: tests def is_usable(ip: str) -> bool:
def is_reserved(digits: list) -> bool: address = ip_address(ip)
reserved = [0, 10, 127] if address.is_reserved or address.is_private:
if digits[0] in reserved:
return True
if digits[0] == 192 and digits[1] == 168:
return True
return False return False
return True
def get_coords(ips: "Path", ip: str) -> Coords: def get_coords(db_path: "Path", ip: str) -> Coords:
split = ip2list(ip) if not is_usable(ip):
if is_reserved(split):
raise ReservedIPError(f"{ip} is a reserved IP address.") raise ReservedIPError(f"{ip} is a reserved IP address.")
try:
prefix = f"^{split[0]}.{split[1]}." lat, lon = read_from_table(db_path, ip)
return Coords(lat, lon)
if shutil.which("rg") is not None: except Exception:
args = ["/usr/bin/rg", prefix, str(ips)]
else:
args = ["/usr/bin/grep", "-E", prefix, str(ips)]
proc = subprocess.run(args, capture_output=True, text=True)
if proc.returncode != 0:
raise GeolocationError("Failed to split records")
ip_list = proc.stdout.splitlines()
for address in ip_list:
fields = address.split(",")
upper = fields[1]
upper_digits = ip2list(upper)
if ip == upper:
return Coords(float(fields[-2]), float(fields[-1]))
if split[2] > upper_digits[2]:
continue
if split[2] == upper_digits[2]:
if split[3] > upper_digits[3]:
continue
if split[2] <= upper_digits[2]:
return Coords(float(fields[-2]), float(fields[-1]))
raise GeolocationError("No matching records found") raise GeolocationError("No matching records found")
+80
View File
@@ -0,0 +1,80 @@
from pathlib import Path
from typing import cast, TYPE_CHECKING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.views.base import OuterWindow
from dzgui.views.mixins.context_mixin import ContextMixin
from gi.repository import GdkPixbuf
# TODO: randomize/mock PII distance values
class IdleScreenshot:
def __init__(
self, controller: "Controller", window: "OuterWindow", scrot_path: Path
) -> None:
self.window = window
self.controller = controller
self.scrot_path = scrot_path
clock = window.get_frame_clock()
if clock is None:
raise TypeError("Widget not realized, FrameClock not initialized")
clock.connect("after-paint", self.callback)
def callback(self, clock: Gdk.FrameClock) -> None:
GLib.timeout_add(1000, self.check_idle)
def check_idle(self) -> bool:
tv = self.controller.get_active_treeview()
loaded = tv.is_loaded()
if loaded is True:
self.emit_key_press(tv)
GLib.timeout_add(1000, self.save_and_quit)
return True
return False
def emit_key_press(self, widget: "ContextMixin") -> None:
window = widget.get_parent_window()
if window is None:
raise TypeError(f"Failed to find a valid parent window for {widget}")
event = Gdk.EventKey.new(Gdk.EventType.KEY_PRESS)
# NOTE: GTK introspection returns Gdk.Event type
event_key = cast(Gdk.EventKey, event)
event_key.keyval = Gdk.KEY_l
event_key.state = Gdk.ModifierType.CONTROL_MASK
event_key.window = window
event_key.time = Gdk.CURRENT_TIME
event_key.send_event = True
# TODO: suppress errors from synthetic key events
widget.present_menu(widget, event)
def save_and_quit(self) -> None:
self.save_pixbuf()
self.controller.save_res_and_quit()
def save_pixbuf(self) -> None:
pixbuf = self.capture_application()
if pixbuf:
pixbuf.savev(str(self.scrot_path), "png", [], [])
def capture_application(self) -> "GdkPixbuf.Pixbuf":
# NOTE: composited context menu over X11 window
root = Gdk.get_default_root_window()
xwindow = self.window.get_window()
if xwindow is None:
raise ValueError("Failed to get a valid X11 window")
index, x, y = xwindow.get_origin()
width, height = self.window.get_size()
application = Gdk.pixbuf_get_from_window(root, x, y, width, height)
if application is None:
raise ValueError("Failed to get a valid GdkPixbuf")
return application
+5
View File
@@ -31,6 +31,7 @@ show_details = "Show server details"
refresh_players = "Refresh player count" refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop" open_workshop = "Open in Steam Workshop"
unsub_mod = "Unsubscribe mod" unsub_mod = "Unsubscribe mod"
validate_mod = "Validate mod"
copy_name = "Copy name to clipboard" copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard" copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard" copy_log = "Copy record(s) to clipboard"
@@ -200,6 +201,8 @@ class ModPanelStrings:
clear_highlights: str clear_highlights: str
select_stale: str select_stale: str
select_stale_tooltip: str select_stale_tooltip: str
validate: str
validate_tooltip: str
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
@@ -319,6 +322,8 @@ mod_panel = ModPanelStrings(
clear_highlights="Clears highlights and reverts the table to a default state", clear_highlights="Clears highlights and reverts the table to a default state",
select_stale="Select stale", select_stale="Select stale",
select_stale_tooltip="Only selects highlighted stale mods", select_stale_tooltip="Only selects highlighted stale mods",
validate="Validate selected",
validate_tooltip="Validates selected mods and updates them if necessary",
) )
+20 -2
View File
@@ -1,7 +1,7 @@
import logging import logging
import signal import signal
from typing import TYPE_CHECKING, Literal, Self from typing import Any, Literal, Self, TYPE_CHECKING
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
@@ -47,7 +47,7 @@ logger = logging.getLogger(APP_NAME)
class OuterWindow(Gtk.ApplicationWindow): class OuterWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application) -> None: def __init__(self, app: "App") -> None:
super().__init__( super().__init__(
application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER
) )
@@ -78,6 +78,15 @@ class OuterWindow(Gtk.ApplicationWindow):
MainController.loaded = True MainController.loaded = True
MainController.populate_model(MainController.get_active_treeview()) MainController.populate_model(MainController.get_active_treeview())
try:
from dzgui.util.save_screenshot import IdleScreenshot
scrot = app.get_automation_param("screenshot_path")
self.fullscreen()
IdleScreenshot(MainController, self, scrot)
except KeyError:
pass
def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None:
self.halt_proc_and_quit() self.halt_proc_and_quit()
@@ -291,6 +300,14 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
self._setup_signals() self._setup_signals()
self.connect("activate", self._on_activate) self.connect("activate", self._on_activate)
self.automation_params: dict[str, Any] = {}
def get_automation_param(self, key: str) -> Any:
return self.automation_params[key]
def set_automation_param(self, key: str, value: Any) -> None:
self.automation_params[key] = value
def _on_activate(self, app: Self) -> None: def _on_activate(self, app: Self) -> None:
self.win = OuterWindow(app) self.win = OuterWindow(app)
@@ -331,4 +348,5 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
) -> None: ) -> None:
self.win.halt_proc_and_quit() self.win.halt_proc_and_quit()
MainController = Controller() MainController = Controller()
+2
View File
@@ -313,6 +313,8 @@ class FilterPanel(Gtk.Box):
return True return True
def toggle_check(self, digit: int) -> None: def toggle_check(self, digit: int) -> None:
if not self.button_grid.is_sensitive():
return
checks = self.button_grid.get_checkboxes() checks = self.button_grid.get_checkboxes()
check = checks[digit] check = checks[digit]
state = check.get_active() state = check.get_active()
+3
View File
@@ -50,6 +50,7 @@ class ModSelectionPanel(Gtk.Box):
ModButton.SELECT_ALL, ModButton.SELECT_ALL,
ModButton.UNSELECT_ALL, ModButton.UNSELECT_ALL,
ModButton.UNSUB_SELECTED, ModButton.UNSUB_SELECTED,
ModButton.VALIDATE,
) )
for button in buttons: for button in buttons:
b = EnumeratedModButton(button) b = EnumeratedModButton(button)
@@ -114,3 +115,5 @@ class ModSelectionPanel(Gtk.Box):
self.swap_sensitive(False) self.swap_sensitive(False)
case ModButton.SELECT_STALE: case ModButton.SELECT_STALE:
self.controller.select_colorized() self.controller.select_colorized()
case ModButton.VALIDATE:
self.controller.validate_mods()
+2 -2
View File
@@ -132,9 +132,9 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
preboot.signatures, preboot.signatures,
False, False,
), ),
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False), (StoredFunc(get_ipdb, self.xdg.db), preboot.geo, False),
# TODO: handle IP DB failure and use coords fallback # TODO: handle IP DB failure and use coords fallback
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True), (StoredFunc(get_local_coords, self.xdg.db), preboot.coords, True),
(StoredFunc(check_updates, self.version), preboot.updates, True), (StoredFunc(check_updates, self.version), preboot.updates, True),
] ]
self.results: list[Any] = [] self.results: list[Any] = []
+3
View File
@@ -14,6 +14,7 @@ from dzgui.const.constants import (
HERO_PATH, HERO_PATH,
FLATPAK_ROOT_PATH, FLATPAK_ROOT_PATH,
LEGACY_CONFIG_PATH, LEGACY_CONFIG_PATH,
TMP_PATH
) )
from dzgui.const.boilerplate import config_boilerplate from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.endpoints import STEAM_API_SETUP
@@ -432,6 +433,8 @@ class FlatpakOverridePage(EnumeratedWizardPage):
store = Gtk.ListStore(str) store = Gtk.ListStore(str)
for path in xdg_paths.values(): for path in xdg_paths.values():
store.append((str(path),)) store.append((str(path),))
store.append((TMP_PATH,))
tree = SimpleTree(store, wizard.tree_column) tree = SimpleTree(store, wizard.tree_column)
tree.set_halign(Gtk.Align.CENTER) tree.set_halign(Gtk.Align.CENTER)
+1 -3
View File
@@ -16,9 +16,7 @@ logger = logging.getLogger(APP_NAME)
class ContextMixin(TreeView): class ContextMixin(TreeView):
def present_menu( def present_menu(
self, self, widget: Gtk.Widget, event: Gdk.Event
widget: Gtk.Widget,
event: Gdk.EventButton | Gdk.EventKey,
) -> bool: ) -> bool:
if self.menu is None: if self.menu is None:
+13 -2
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from typing import Callable, TYPE_CHECKING from typing import Callable, TYPE_CHECKING
from dzgui.api import pefile as PeFile from dzgui.api import pefile as PeFile
from dzgui.api.steam import get_last_updated
from dzgui.config import query from dzgui.config import query
from dzgui.const.constants import ( from dzgui.const.constants import (
@@ -340,8 +341,10 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
[LeftLabel(options.flatpak_label), self.flatpak_toggle], [LeftLabel(options.flatpak_label), self.flatpak_toggle],
) )
self.dayz_version_label = Gtk.Label(label=strings.null) self.dayz_version_label = Gtk.Label(label=strings.null, halign=Gtk.Align.START)
self.dayz_exp_version_label = Gtk.Label(label=strings.null) self.dayz_exp_version_label = Gtk.Label(
label=strings.null, halign=Gtk.Align.START
)
version_rows = [ version_rows = [
[LeftLabel(APPNAME_DAYZ), self.dayz_version_label], [LeftLabel(APPNAME_DAYZ), self.dayz_version_label],
@@ -467,10 +470,18 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ)
if dayz_version is None: if dayz_version is None:
dayz_version = strings.null dayz_version = strings.null
else:
last_updated = get_last_updated(steam_path, APPID_DAYZ)
if last_updated is not None:
dayz_version += f" (updated at {last_updated})"
dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
if dayz_exp_version is None: if dayz_exp_version is None:
dayz_exp_version = strings.null dayz_exp_version = strings.null
else:
last_updated = get_last_updated(steam_path, APPID_DAYZ_EXP)
if last_updated is not None:
dayz_exp_version += f" (updated at {last_updated})"
self.dayz_version_label.set_text(dayz_version) self.dayz_version_label.set_text(dayz_version)
self.dayz_exp_version_label.set_text(dayz_exp_version) self.dayz_exp_version_label.set_text(dayz_exp_version)
+2 -7
View File
@@ -108,14 +108,9 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None:
""" """
Suppresses spamming on keydown Adds debounce to key press/release events to prevent spamming
TODO: explain this better when rapidly navigating through records
""" """
# TODO: multisel
# if event.keyval is Gdk.KEY_space:
# it = self.get_focused_row_iter()
# self.get_selection().select_iter(it)
# return True
model = self.get_model() model = self.get_model()
if model is None: if model is None:
+1 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [ authors = [
{name = "aclist"} {name = "aclist"}
] ]
version = "7.0.0b29" version = "7.0.0b30"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["LICENSE"] license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
+1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
def get_entrypoint() -> None: def get_entrypoint() -> None:
p = root.joinpath("pyproject.toml") p = root.joinpath("pyproject.toml")
with open(p, "rb") as f: with open(p, "rb") as f:
+53
View File
@@ -0,0 +1,53 @@
"AppState"
{
"appid" "444"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "1792595373"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "0"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"3330"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
+53
View File
@@ -0,0 +1,53 @@
"AppState"
{
"appid" "555"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "0"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"3330"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
+9 -1
View File
@@ -3,7 +3,7 @@ import pytest
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
from pathlib import Path from pathlib import Path
from dzgui.api.steam import get_app_allows_downloads from dzgui.api.steam import get_app_allows_downloads, get_last_updated
from tests.fixtures import fixture_path from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest pytestmark = pytest.mark.apitest
@@ -43,6 +43,14 @@ def patch_api() -> None:
mp.undo() mp.undo()
def test_no_last_update(steam_path) -> None:
assert get_last_updated(steam_path, 555) is None
def test_last_update(steam_path) -> None:
assert get_last_updated(steam_path, 444) == "2026-10-22 00:09:33"
def test_app_allows_downloads(steam_path) -> None: def test_app_allows_downloads(steam_path) -> None:
assert get_app_allows_downloads(steam_path, 111) is True assert get_app_allows_downloads(steam_path, 111) is True
-1
View File
@@ -36,7 +36,6 @@ def keys():
# TODO: use a static fixture instead of system config # TODO: use a static fixture instead of system config
@pytest.fixture @pytest.fixture
@pytest.mark.realconfig
def config(): def config():
paths = get_xdg_paths() paths = get_xdg_paths()
xdg = parse_filepaths(paths) xdg = parse_filepaths(paths)
+5 -3
View File
@@ -1,7 +1,7 @@
import configparser import configparser
import pytest import pytest
from dzgui.const.constants import FLATPAK_APPID, GTK_APP_ID from dzgui.const.constants import FLATPAK_APPID, GTK_APP_ID, TMP_PATH
from dzgui.config.xdg import get_xdg_paths from dzgui.config.xdg import get_xdg_paths
from dzgui.util.flatpak import apply_overrides from dzgui.util.flatpak import apply_overrides
@@ -38,6 +38,8 @@ def test_session_bus(override, config) -> None:
def test_paths(override, xdg_paths, config) -> None: def test_paths(override, xdg_paths, config) -> None:
expect = xdg_paths expect: list[str] = []
expect.append(TMP_PATH)
expect.extend([*xdg_paths.values()])
paths = config[PATHS_KEY][PATHS_SUBKEY].split(";") paths = config[PATHS_KEY][PATHS_SUBKEY].split(";")
assert all(str(path) in paths for path in expect.values()) assert all(str(path) in paths for path in expect)
+44 -7
View File
@@ -1,16 +1,40 @@
from dzgui.config.ipdb import get_ipdb
from pathlib import Path
import os import os
import sqlite3
import pytest import pytest
import tempfile import tempfile
from pathlib import Path
from dzgui.const.endpoints import DB_IP
from dzgui.config.ipdb import (
create_table,
find_download_url,
serialize,
strip_ipv6,
unzip,
)
def get_db_len(path: Path) -> int:
conn = sqlite3.connect(path)
c = conn.cursor()
c.execute("SELECT COUNT(*) from ips")
db_len = c.fetchone()[0]
return db_len
@pytest.fixture
def csv() -> Path:
csv = Path(tempfile.NamedTemporaryFile().name)
url = find_download_url(DB_IP)
tmp = serialize(url)
unzip(tmp, csv)
strip_ipv6(csv)
return csv
@pytest.mark.slow @pytest.mark.slow
def test_stripping(): def test_stripping(csv):
d = tempfile.TemporaryDirectory() with open(csv, "rb") as f:
path = Path(d.name).joinpath("ips.csv")
get_ipdb(path)
with open(path, "rb") as f:
first = f.readline().decode().rstrip() first = f.readline().decode().rstrip()
try: try:
f.seek(-2, os.SEEK_END) f.seek(-2, os.SEEK_END)
@@ -25,3 +49,16 @@ def test_stripping():
assert first_els[0] == "0.0.0.0" assert first_els[0] == "0.0.0.0"
assert last_els[0] == "224.0.0.0" assert last_els[0] == "224.0.0.0"
@pytest.mark.slow
def test_row_parity(csv) -> None:
rows = csv.read_text().splitlines()
db = Path(tempfile.NamedTemporaryFile().name)
create_table(db, csv)
csv_len = len(rows)
db_len = get_db_len(db)
assert csv_len == db_len
+47
View File
@@ -0,0 +1,47 @@
import pytest
from dzgui.model.model_factory import FastInsertListStore
@pytest.fixture
def heterogeneous_list() -> None:
return [["a", 0, True], ["b", 1, False]]
@pytest.fixture
def mismatched_length_list() -> None:
return [["a", 0, True], ["b", 1, False, 0]]
@pytest.fixture
def extended_list(heterogeneous_list) -> None:
fs = FastInsertListStore(str, int, bool)
fs.extend(heterogeneous_list)
return fs
@pytest.fixture
def appended_list(heterogeneous_list) -> None:
fs = FastInsertListStore(str, int, bool)
for row in heterogeneous_list:
fs.append(row)
return fs
@pytest.mark.parametrize("target", ["extended_list", "appended_list"])
def test_list_parity(heterogeneous_list, request, target) -> None:
target = request.getfixturevalue(target)
extracted = [[el for el in row] for row in target]
assert heterogeneous_list == extracted
def test_type_disparity(heterogeneous_list) -> None:
with pytest.raises(TypeError):
fs = FastInsertListStore(str, bool, bool)
fs.extend(heterogeneous_list)
def test_length_disparity(mismatched_length_list) -> None:
with pytest.raises(ValueError):
fs = FastInsertListStore(str, int, bool)
fs.extend(mismatched_length_list)