From b53df0fbe0cf080916b92ffc44ef2277ffeb15b7 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 25 Dec 2022 17:26:28 +0200
Subject: [PATCH 001/123] Use streaming API to decompress zstd replay files
sometimes replay files don't have content size embedded within
and the plain decompress doesn't work
---
src/fa/replay.py | 310 +++++++++++++++++++++++------------------------
1 file changed, 155 insertions(+), 155 deletions(-)
diff --git a/src/fa/replay.py b/src/fa/replay.py
index 6f6c9ac5a..3ee5825eb 100644
--- a/src/fa/replay.py
+++ b/src/fa/replay.py
@@ -16,6 +16,17 @@
__author__ = 'Thygrrr'
+def decompressReplayData(fileobj, compressionType):
+ if compressionType == "zstd":
+ decompressor = zstandard.ZstdDecompressor()
+ with decompressor.stream_reader(fileobj) as reader:
+ data = QtCore.QByteArray(reader.read())
+ else:
+ b_data = fileobj.read()
+ data = QtCore.qUncompress(QtCore.QByteArray().fromBase64(b_data))
+ return data
+
+
def replay(source, detach=False):
"""
Launches FA streaming the replay from the given location.
@@ -23,180 +34,169 @@ def replay(source, detach=False):
"""
logger.info("fa.exe.replay(" + str(source) + ", detach = " + str(detach))
- if fa.instance.available():
- version = None
- featured_mod_versions = None
- arg_string = None
- replay_id = None
- compression_type = None
- # Convert strings to URLs
- if isinstance(source, str):
- if os.path.isfile(source):
- if source.endswith(".fafreplay"):
- with open(source, "rb") as replay:
- info = json.loads(replay.readline())
- compression_type = info.get("compression")
- if compression_type == "zstd":
- def decompress(data):
- decompressor = zstandard.ZstdDecompressor()
- return QtCore.QByteArray(
- decompressor.decompress(data),
- )
- else:
- def decompress(data):
- return QtCore.qUncompress(
- QtCore.QByteArray.fromBase64(data),
- )
- try:
- binary = decompress(replay.read())
- except BaseException:
- binary = QtCore.QByteArray()
- logger.info(
- "Extracted {} bytes of binary data from "
- ".fafreplay.".format(binary.size()),
- )
-
- if binary.size() == 0:
- logger.info("Invalid replay")
- QtWidgets.QMessageBox.critical(
- None,
- "FA Forever Replay",
- "Sorry, this replay is corrupted.",
- )
- return False
-
- scfa_replay = QtCore.QFile(
- os.path.join(util.CACHE_DIR, "temp.scfareplay"),
+ if not fa.instance.available():
+ return False
+
+ version = None
+ featured_mod_versions = None
+ arg_string = None
+ replay_id = None
+ compression_type = None
+ # Convert strings to URLs
+ if isinstance(source, str):
+ if os.path.isfile(source):
+ if source.endswith(".fafreplay"):
+ with open(source, "rb") as replay:
+ info = json.loads(replay.readline())
+ compression_type = info.get("compression")
+ try:
+ binary = decompressReplayData(replay, compression_type)
+ except Exception as e:
+ logger.error(f"Could not decompress replay: {e}")
+ binary = QtCore.QByteArray()
+ logger.info(
+ "Extracted {} bytes of binary data from "
+ ".fafreplay.".format(binary.size()),
)
- scfa_replay.open(
- QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate,
- )
- scfa_replay.write(binary)
- scfa_replay.flush()
- scfa_replay.close()
-
- mapname = info.get('mapname')
- mod = info['featured_mod']
- replay_id = info['uid']
- featured_mod_versions = info.get('featured_mod_versions')
- arg_string = scfa_replay.fileName()
-
- parser = replayParser(arg_string)
- version = parser.getVersion()
- if mapname == "None":
- mapname = parser.getMapName()
-
- elif source.endswith(".scfareplay"): # compatibility mode
- filename = os.path.basename(source)
- if len(filename.split(".")) > 2:
- mod = filename.rsplit(".", 2)[1]
- logger.info(
- "mod guessed from {} is {}".format(source, mod),
- )
- else:
- # TODO: maybe offer a list of mods for the user.
- mod = "faf"
- logger.warning(
- "no mod could be guessed, using "
- "fallback ('faf') ",
+
+ if binary.size() == 0:
+ logger.info("Invalid replay")
+ QtWidgets.QMessageBox.critical(
+ None,
+ "FA Forever Replay",
+ "Sorry, this replay is corrupted.",
)
+ return False
- mapname = None
- arg_string = source
- parser = replayParser(arg_string)
- version = parser.getVersion()
+ scfa_replay = QtCore.QFile(
+ os.path.join(util.CACHE_DIR, "temp.scfareplay"),
+ )
+ scfa_replay.open(
+ QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate,
+ )
+ scfa_replay.write(binary)
+ scfa_replay.flush()
+ scfa_replay.close()
+
+ mapname = info.get('mapname')
+ mod = info['featured_mod']
+ replay_id = info['uid']
+ featured_mod_versions = info.get('featured_mod_versions')
+ arg_string = scfa_replay.fileName()
+
+ parser = replayParser(arg_string)
+ version = parser.getVersion()
+ if mapname == "None":
+ mapname = parser.getMapName()
+
+ elif source.endswith(".scfareplay"): # compatibility mode
+ filename = os.path.basename(source)
+ if len(filename.split(".")) > 2:
+ mod = filename.rsplit(".", 2)[1]
+ logger.info(
+ "mod guessed from {} is {}".format(source, mod),
+ )
else:
- QtWidgets.QMessageBox.critical(
- None,
- "FA Forever Replay",
- (
- "Sorry, FAF has no idea how to replay "
- "this file:
{}".format(source)
- ),
+ # TODO: maybe offer a list of mods for the user.
+ mod = "faf"
+ logger.warning(
+ "no mod could be guessed, using "
+ "fallback ('faf') ",
)
- logger.info(
- "Replaying {} with mod {} on map {}"
- .format(arg_string, mod, mapname),
- )
-
- # Wrap up file path in "" to ensure proper parsing by FA.exe
- arg_string = '"' + arg_string + '"'
-
- else:
- # Try to interpret the string as an actual url, it may come
- # from the command line
- source = QtCore.QUrl(source)
-
- if isinstance(source, GameUrl):
- url = source.to_url()
- # Determine if it's a faflive url
- if source.game_type == GameUrlType.LIVE_REPLAY:
- mod = source.mod
- mapname = source.map
- replay_id = source.uid
- # whip the URL into shape so ForgedAllianceForever.exe
- # understands it
- url.setScheme("gpgnet")
- url.setQuery(QtCore.QUrlQuery(""))
- arg_string = url.toString()
+ mapname = None
+ arg_string = source
+ parser = replayParser(arg_string)
+ version = parser.getVersion()
else:
QtWidgets.QMessageBox.critical(
None,
"FA Forever Replay",
(
- "App doesn't know how to play replays from "
- "that scheme:
{}".format(url.scheme())
+ "Sorry, FAF has no idea how to replay "
+ "this file:
{}".format(source)
),
)
- return False
- # We couldn't construct a decent argument format to tell
- # ForgedAlliance for this replay
- if not arg_string:
+ logger.info(
+ "Replaying {} with mod {} on map {}"
+ .format(arg_string, mod, mapname),
+ )
+
+ # Wrap up file path in "" to ensure proper parsing by FA.exe
+ arg_string = '"' + arg_string + '"'
+
+ else:
+ # Try to interpret the string as an actual url, it may come
+ # from the command line
+ source = QtCore.QUrl(source)
+
+ if isinstance(source, GameUrl):
+ url = source.to_url()
+ # Determine if it's a faflive url
+ if source.game_type == GameUrlType.LIVE_REPLAY:
+ mod = source.mod
+ mapname = source.map
+ replay_id = source.uid
+ # whip the URL into shape so ForgedAllianceForever.exe
+ # understands it
+ url.setScheme("gpgnet")
+ url.setQuery(QtCore.QUrlQuery(""))
+ arg_string = url.toString()
+ else:
QtWidgets.QMessageBox.critical(
None,
"FA Forever Replay",
(
- "App doesn't know how to play replays from that "
- "source:
{}".format(source)
+ "App doesn't know how to play replays from "
+ "that scheme:
{}".format(url.scheme())
),
)
return False
- # Launch preparation: Start with an empty arguments list
- arguments = ['/replay', arg_string]
-
- # Proper mod loading code
- mod = "faf" if mod == "ladder1v1" else mod
-
- if '/init' not in arguments:
- arguments.append('/init')
- arguments.append("init_" + mod + ".lua")
-
- # Disable defunct bug reporter
- arguments.append('/nobugreport')
-
- # log file
- arguments.append("/log")
- arguments.append('"' + util.LOG_FILE_REPLAY + '"')
-
- if replay_id:
- arguments.append("/replayid")
- arguments.append(str(replay_id))
-
- # Update the game appropriately
- if not check(mod, mapname, version, featured_mod_versions):
- logger.error(
- "Can't watch replays without an updated Forged Alliance game!",
- )
- return False
-
- if fa.instance.run(None, arguments, detach):
- logger.info("Viewing Replay.")
- return True
- else:
- logger.error(
- "Replaying failed. Guru meditation: {}".format(arguments),
- )
- return False
+ # We couldn't construct a decent argument format to tell
+ # ForgedAlliance for this replay
+ if not arg_string:
+ QtWidgets.QMessageBox.critical(
+ None,
+ "FA Forever Replay",
+ (
+ "App doesn't know how to play replays from that "
+ "source:
{}".format(source)
+ ),
+ )
+ return False
+
+ # Launch preparation: Start with an empty arguments list
+ arguments = ['/replay', arg_string]
+
+ # Proper mod loading code
+ mod = "faf" if mod == "ladder1v1" else mod
+
+ if '/init' not in arguments:
+ arguments.append('/init')
+ arguments.append("init_" + mod + ".lua")
+
+ # Disable defunct bug reporter
+ arguments.append('/nobugreport')
+
+ # log file
+ arguments.append("/log")
+ arguments.append('"' + util.LOG_FILE_REPLAY + '"')
+
+ if replay_id:
+ arguments.append("/replayid")
+ arguments.append(str(replay_id))
+
+ # Update the game appropriately
+ if not check(mod, mapname, version, featured_mod_versions):
+ msg = "Can't watch replays without an updated Forged Alliance game!"
+ logger.error(msg)
+ return False
+
+ if fa.instance.run(None, arguments, detach):
+ logger.info("Viewing Replay.")
+ return True
+ else:
+ logger.error("Replaying failed. Guru meditation: {}".format(arguments))
+ return False
From 6e6d3180b0ab55275c25a5ee2be79083f2b12a05 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 17 Feb 2023 12:00:30 +0200
Subject: [PATCH 002/123] pre-commit autoupdate
---
.pre-commit-config.yaml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 749053004..310fca1bf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,23 +1,23 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.1.0
+ rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-docstring-first
- repo: https://github.com/asottile/add-trailing-comma
- rev: v2.2.2
+ rev: v2.4.0
hooks:
- id: add-trailing-comma
- repo: https://github.com/PyCQA/flake8
- rev: 4.0.1
+ rev: 6.0.0
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-autopep8
- rev: v1.6.0
+ rev: v2.0.1
hooks:
- id: autopep8
- repo: https://github.com/PyCQA/isort
- rev: 5.10.1
+ rev: 5.12.0
hooks:
- id: isort
From b88257df043cd93d20ce7c1fb12c22021e095d9c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 4 Mar 2023 22:59:09 +0200
Subject: [PATCH 003/123] Downgrade isort to version 5.10.1
---
.pre-commit-config.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 310fca1bf..656ffbafd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -18,6 +18,6 @@ repos:
hooks:
- id: autopep8
- repo: https://github.com/PyCQA/isort
- rev: 5.12.0
+ rev: 5.10.1
hooks:
- id: isort
From 9fc2e0fec40d5ebf435122d4a28209343275b7df Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 4 Mar 2023 23:03:26 +0200
Subject: [PATCH 004/123] API: send meta in a result dict, not separately
---
src/api/ApiBase.py | 15 ++++++---------
src/api/featured_mod_api.py | 2 +-
src/api/featured_mod_updater.py | 4 ++--
src/api/matchmaker_queue_api.py | 5 +++--
src/api/player_api.py | 11 ++++++-----
src/api/replaysapi.py | 4 ++--
src/api/stats_api.py | 15 ++++++++-------
src/api/vaults_api.py | 33 +++++++++------------------------
8 files changed, 37 insertions(+), 52 deletions(-)
diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py
index 820f77f80..4b14f9dc6 100644
--- a/src/api/ApiBase.py
+++ b/src/api/ApiBase.py
@@ -59,12 +59,10 @@ def onRequestFinished(self, reply):
message_bytes = reply.readAll().data()
message = json.loads(message_bytes.decode('utf-8'))
included = self.parseIncluded(message)
- meta = self.parseMeta(message)
- result = self.parseData(message, included)
- if len(meta) > 0:
- self.handlers[reply](result, meta)
- else:
- self.handlers[reply](result)
+ result = {}
+ result["data"] = self.parseData(message, included)
+ result["meta"] = self.parseMeta(message)
+ self.handlers[reply](result)
self.handlers.pop(reply)
reply.deleteLater()
@@ -127,10 +125,9 @@ def parseSingleData(self, data, included):
return result
def parseMeta(self, message):
- result = {}
if "meta" in message:
- result["meta"] = message["meta"]
- return result
+ return message["meta"]
+ return {}
def waitForCompletion(self):
waitFlag = QtCore.QEventLoop.WaitForMoreEvents
diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py
index 55ad43957..5af136677 100644
--- a/src/api/featured_mod_api.py
+++ b/src/api/featured_mod_api.py
@@ -18,7 +18,7 @@ def handleData(self, message):
"command": "mod_info_api",
"values": [],
}
- for mod in message:
+ for mod in message["data"]:
preparedMod = {
"command": "mod_info_api",
"name": mod["technicalName"],
diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py
index 1674ab437..c4a69be30 100644
--- a/src/api/featured_mod_updater.py
+++ b/src/api/featured_mod_updater.py
@@ -18,7 +18,7 @@ def requestData(self):
self.request({}, self.handleData)
def handleData(self, message):
- self.featuredModFiles = message
+ self.featuredModFiles = message["data"]
def getFiles(self):
self.requestData()
@@ -35,7 +35,7 @@ def requestData(self, queryDict={}):
self.request(queryDict, self.handleData)
def handleFeaturedModId(self, message):
- self.featuredModId = message[0]['id']
+ self.featuredModId = message['data'][0]['id']
def requestFeaturedModIdByName(self, technicalName):
queryDict = dict(filter='technicalName=={}'.format(technicalName))
diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py
index 3f5bbb94a..edbab4697 100644
--- a/src/api/matchmaker_queue_api.py
+++ b/src/api/matchmaker_queue_api.py
@@ -13,12 +13,13 @@ def __init__(self, dispatch):
def requestData(self, queryDict={}):
self.request(queryDict, self.handleData)
- def handleData(self, message):
+ def handleData(self, message: dict) -> None:
preparedData = {
"command": "matchmaker_queue_info",
"values": [],
+ "meta": message["meta"],
}
- for queue in message:
+ for queue in message["data"]:
preparedQueue = {
"technicalName": queue["technicalName"],
"ratingType": queue["leaderboard"]["technicalName"],
diff --git a/src/api/player_api.py b/src/api/player_api.py
index a69b12447..41bc8887e 100644
--- a/src/api/player_api.py
+++ b/src/api/player_api.py
@@ -14,13 +14,13 @@ def requestDataForLeaderboard(self, leaderboardName, queryDict={}):
self.leaderboardName = leaderboardName
self.request(queryDict, self.handleDataForLeaderboard)
- def handleDataForLeaderboard(self, message, meta):
+ def handleDataForLeaderboard(self, message: dict) -> None:
preparedData = dict(
command='stats',
type='player',
leaderboardName=self.leaderboardName,
- values=message,
- meta=meta['meta'],
+ values=message['data'],
+ meta=message['meta'],
)
self.dispatch.dispatch(preparedData)
@@ -35,9 +35,10 @@ def requestDataForAliasViewer(self, nameToFind):
}
self.request(queryDict, self.handleDataForAliasViewer)
- def handleDataForAliasViewer(self, message, meta=None):
+ def handleDataForAliasViewer(self, message: dict) -> None:
preparedData = dict(
command='alias_info',
- values=message,
+ values=message['data'],
+ meta=message['meta'],
)
self.dispatch.dispatch(preparedData)
diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py
index fbf6a7978..445bf8dea 100644
--- a/src/api/replaysapi.py
+++ b/src/api/replaysapi.py
@@ -13,7 +13,7 @@ def __init__(self, dispatch):
def requestData(self, args):
self.request(args, self.handleData)
- def handleData(self, message, meta):
+ def handleData(self, message):
preparedData = dict(
command="replay_vault",
action="search_result",
@@ -24,6 +24,6 @@ def handleData(self, message, meta):
playerStats={},
)
- preparedData['replays'] = message
+ preparedData["replays"] = message["data"]
self.dispatch.dispatch(preparedData)
diff --git a/src/api/stats_api.py b/src/api/stats_api.py
index c7971b0e4..6c9c76532 100644
--- a/src/api/stats_api.py
+++ b/src/api/stats_api.py
@@ -1,6 +1,6 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiBase import ApiBase
logger = logging.getLogger(__name__)
@@ -14,29 +14,30 @@ def __init__(self, dispatch, leaderboardName):
def requestData(self, queryDict={}):
self.request(queryDict, self.handleData)
- def handleData(self, message, meta):
+ def handleData(self, message: dict) -> None:
preparedData = dict(
command='stats',
type='leaderboardRating',
leaderboardName=self.leadeboardName,
- values=message,
- meta=meta['meta'],
+ values=message["data"],
+ meta=message["meta"],
)
self.dispatch.dispatch(preparedData)
class LeaderboardApiConnector(ApiBase):
- def __init__(self, dispatch):
+ def __init__(self, dispatch=None):
ApiBase.__init__(self, '/data/leaderboard')
self.dispatch = dispatch
def requestData(self, queryDict={}):
self.request(queryDict, self.handleData)
- def handleData(self, message):
+ def handleData(self, message: dict) -> None:
preparedData = dict(
command='stats',
type='leaderboard',
- values=message,
+ values=message["data"],
+ meta=message["meta"],
)
self.dispatch.dispatch(preparedData)
diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py
index 1737923b5..bb736de04 100644
--- a/src/api/vaults_api.py
+++ b/src/api/vaults_api.py
@@ -13,18 +13,13 @@ def __init__(self, dispatch):
def requestData(self, query={}):
self.request(query, self.handleData)
- def handleData(self, message, meta):
- if len(meta) > 0:
- data = dict(
- command='vault_meta',
- page=meta['meta']['page'],
- )
- self.dispatch.dispatch(data)
+ def handleData(self, message: dict) -> None:
preparedData = dict(
command='modvault_info',
values=[],
+ meta=message['meta'],
)
- for mod in message:
+ for mod in message['data']:
preparedMod = dict(
name=mod['displayName'],
uid=mod['latestVersion']['uid'],
@@ -58,18 +53,13 @@ def __init__(self, dispatch):
def requestData(self, query={}):
self.request(query, self.handleData)
- def handleData(self, message, meta):
- if len(meta) > 0:
- data = dict(
- command='vault_meta',
- page=meta['meta']['page'],
- )
- self.dispatch.dispatch(data)
+ def handleData(self, message: dict) -> None:
preparedData = dict(
command='mapvault_info',
values=[],
+ meta=message['meta'],
)
- for _map in message:
+ for _map in message['data']:
preparedMap = dict(
name=_map['displayName'],
folderName=_map['latestVersion']['folderName'],
@@ -106,18 +96,13 @@ def __init__(self, dispatch):
def requestData(self, query={}):
self.request(query, self.handleData)
- def handleData(self, message, meta):
- if len(meta) > 0:
- data = dict(
- command='vault_meta',
- page=meta['meta']['page'],
- )
- self.dispatch.dispatch(data)
+ def handleData(self, message: dict) -> None:
preparedData = dict(
command='mapvault_info',
values=[],
+ meta=message['meta'],
)
- for data in message:
+ for data in message['data']:
if len(data['mapVersion']) > 0:
_map = data['mapVersion']
preparedMap = dict(
From 3fad81b22017d90308910c076472316a92111eba Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 4 Mar 2023 23:10:55 +0200
Subject: [PATCH 005/123] Parse meta correctly in vaults, remove vaultMeta
signal
---
src/client/connection.py | 2 -
src/vaults/mapvault/mapitem.py | 93 ++++++++++++++
src/vaults/mapvault/mapvault.py | 111 ++---------------
src/vaults/modvault/moditem.py | 87 +++++++++++++
src/vaults/modvault/modvault.py | 106 ++--------------
src/vaults/vault.py | 208 +++++---------------------------
src/vaults/vaultitem.py | 166 +++++++++++++++++++++++++
7 files changed, 395 insertions(+), 378 deletions(-)
create mode 100644 src/vaults/mapvault/mapitem.py
create mode 100644 src/vaults/modvault/moditem.py
create mode 100644 src/vaults/vaultitem.py
diff --git a/src/client/connection.py b/src/client/connection.py
index 06b5502af..6674969bf 100644
--- a/src/client/connection.py
+++ b/src/client/connection.py
@@ -379,7 +379,6 @@ class LobbyInfo(QtCore.QObject):
social = QtCore.pyqtSignal(dict)
serverSession = QtCore.pyqtSignal(dict)
mapVaultInfo = QtCore.pyqtSignal(dict)
- vaultMeta = QtCore.pyqtSignal(dict)
aliasInfo = QtCore.pyqtSignal(dict)
matchmakerQueueInfo = QtCore.pyqtSignal(dict)
@@ -397,7 +396,6 @@ def __init__(self, dispatcher, gameset, playerset):
self._dispatcher["admin"] = self.handle_admin
self._dispatcher["social"] = self._simple_emit(self.social)
self._dispatcher["session"] = self._simple_emit(self.serverSession)
- self._dispatcher["vault_meta"] = self._simple_emit(self.vaultMeta)
self._dispatcher["alias_info"] = self._simple_emit(self.aliasInfo)
self._dispatcher["modvault_list_info"] = self.handle_modvault_list_info
self._dispatcher["modvault_info"] = self._simple_emit(
diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py
new file mode 100644
index 000000000..7468a0d69
--- /dev/null
+++ b/src/vaults/mapvault/mapitem.py
@@ -0,0 +1,93 @@
+import util
+from fa import maps
+from mapGenerator import mapgenUtils
+from vaults.vaultitem import VaultItem
+
+
+class MapItem(VaultItem):
+ def __init__(self, parent, folderName, *args, **kwargs):
+ VaultItem.__init__(self, parent, *args, **kwargs)
+
+ self.formatterItem = str(
+ util.THEME.readfile("vaults/mapvault/mapinfo.qthtml"),
+ )
+
+ self.height = 0
+ self.width = 0
+ self.maxPlayers = 0
+ self.thumbnail = None
+ self.unranked = False
+ self.folderName = folderName
+ self.thumbstrSmall = ""
+ self.thumbnailLarge = ""
+
+ def update(self, item_dict):
+ self.name = maps.getDisplayName(item_dict["folderName"])
+ self.description = item_dict["description"]
+ self.version = item_dict["version"]
+ self.rating = item_dict["rating"]
+ self.reviews = item_dict["reviews"]
+
+ self.maxPlayers = item_dict["maxPlayers"]
+ self.height = int(item_dict["height"] / 51.2)
+ self.width = int(item_dict["width"] / 51.2)
+
+ self.folderName = item_dict["folderName"]
+ self.date = item_dict['date'][:10]
+ self.unranked = not item_dict["ranked"]
+ self.link = item_dict["link"]
+ self.thumbstrSmall = item_dict["thumbnailSmall"]
+ self.thumbnailLarge = item_dict["thumbnailLarge"]
+
+ self.thumbnail = maps.preview(self.folderName)
+ if self.thumbnail:
+ self.setIcon(self.thumbnail)
+ else:
+ if self.thumbstrSmall == "":
+ if mapgenUtils.isGeneratedMap(self.folderName):
+ self.setItemIcon("games/generated_map.png")
+ else:
+ self.setItemIcon("games/unknown_map.png")
+ else:
+ self.parent.client.map_downloader.download_preview(
+ self.folderName, self._item_dl_request, self.thumbstrSmall,
+ )
+ VaultItem.update(self)
+
+ def shouldBeVisible(self):
+ p = self.parent
+ if p.showType == "all":
+ return True
+ elif p.showType == "unranked":
+ return self.unranked
+ elif p.showType == "ranked":
+ return not self.unranked
+ elif p.showType == "installed":
+ return maps.isMapAvailable(self.folderName)
+ else:
+ return True
+
+ def updateVisibility(self):
+ if self.unranked:
+ self.itemType_ = "Unranked map"
+ if maps.isMapAvailable(self.folderName):
+ self.color = "green"
+ else:
+ self.color = "white"
+
+ self.setText(
+ self.formatterItem.format(
+ color=self.color,
+ version=self.version,
+ title=self.name,
+ description=self.trimmedDescription,
+ rating=self.rating,
+ reviews=self.reviews,
+ date=self.date,
+ modtype=self.itemType_,
+ height=self.height,
+ width=self.width,
+ ),
+ )
+
+ VaultItem.updateVisibility(self)
diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py
index c80df7ae4..6c536cf76 100644
--- a/src/vaults/mapvault/mapvault.py
+++ b/src/vaults/mapvault/mapvault.py
@@ -11,9 +11,9 @@
import util
from api.vaults_api import MapApiConnector, MapPoolApiConnector
from fa import maps
-from mapGenerator import mapgenUtils
from vaults import luaparser
-from vaults.vault import Vault, VaultItem
+from vaults.mapvault.mapitem import MapItem
+from vaults.vault import Vault
from .mapwidget import MapWidget
@@ -44,22 +44,18 @@ def __init__(self, client, *args, **kwargs):
)
self.apiConnector = self.mapApiConnector
+ self.items_uid = "folderName"
+
self.busy_entered()
self.UIButton.hide()
self.uploadButton.hide()
+ def createItem(self, item_key: str) -> MapItem:
+ return MapItem(self, item_key)
+
@QtCore.pyqtSlot(dict)
- def mapInfo(self, message):
- for value in message["values"]:
- folderName = value["folderName"]
- if folderName not in self._items:
- _map = MapItem(self, folderName)
- self._items[folderName] = _map
- self.itemList.addItem(_map)
- else:
- _map = self._items[folderName]
- _map.update(value)
- self.itemList.sortItems(1)
+ def mapInfo(self, message: dict) -> None:
+ super().itemsInfo(message)
@QtCore.pyqtSlot(int)
def sortChanged(self, index):
@@ -256,92 +252,3 @@ def removeMap(self, folder):
shutil.rmtree(maps_folder)
self.installed_maps.remove(folder)
self.updateVisibilities()
-
-
-class MapItem(VaultItem):
- def __init__(self, parent, folderName, *args, **kwargs):
- VaultItem.__init__(self, parent, *args, **kwargs)
-
- self.formatterItem = str(
- util.THEME.readfile("vaults/mapvault/mapinfo.qthtml"),
- )
-
- self.height = 0
- self.width = 0
- self.maxPlayers = 0
- self.thumbnail = None
- self.unranked = False
- self.folderName = folderName
- self.thumbstrSmall = ""
- self.thumbnailLarge = ""
-
- def update(self, item_dict):
- self.name = maps.getDisplayName(item_dict["folderName"])
- self.description = item_dict["description"]
- self.version = item_dict["version"]
- self.rating = item_dict["rating"]
- self.reviews = item_dict["reviews"]
-
- self.maxPlayers = item_dict["maxPlayers"]
- self.height = int(item_dict["height"] / 51.2)
- self.width = int(item_dict["width"] / 51.2)
-
- self.folderName = item_dict["folderName"]
- self.date = item_dict['date'][:10]
- self.unranked = not item_dict["ranked"]
- self.link = item_dict["link"]
- self.thumbstrSmall = item_dict["thumbnailSmall"]
- self.thumbnailLarge = item_dict["thumbnailLarge"]
-
- self.thumbnail = maps.preview(self.folderName)
- if self.thumbnail:
- self.setIcon(self.thumbnail)
- else:
- if self.thumbstrSmall == "":
- if mapgenUtils.isGeneratedMap(self.folderName):
- self.setItemIcon("games/generated_map.png")
- else:
- self.setItemIcon("games/unknown_map.png")
- else:
- self.parent.client.map_downloader.download_preview(
- self.folderName, self._item_dl_request, self.thumbstrSmall,
- )
- VaultItem.update(self)
-
- def shouldBeVisible(self):
- p = self.parent
- if p.showType == "all":
- return True
- elif p.showType == "unranked":
- return self.unranked
- elif p.showType == "ranked":
- return not self.unranked
- elif p.showType == "installed":
- return maps.isMapAvailable(self.folderName)
- else:
- return True
-
- def updateVisibility(self):
- if self.unranked:
- self.itemType_ = "Unranked map"
- if maps.isMapAvailable(self.folderName):
- self.color = "green"
- else:
- self.color = "white"
-
- self.setText(
- self.formatterItem.format(
- color=self.color,
- version=self.version,
- title=self.name,
- description=self.trimmedDescription,
- rating=self.rating,
- reviews=self.reviews,
- date=self.date,
- modtype=self.itemType_,
- height=self.height,
- width=self.width,
- ),
- )
-
- VaultItem.updateVisibility(self)
diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py
new file mode 100644
index 000000000..d5dc5517b
--- /dev/null
+++ b/src/vaults/modvault/moditem.py
@@ -0,0 +1,87 @@
+import os
+import urllib
+
+import util
+from vaults.modvault import utils
+from vaults.vaultitem import VaultItem
+
+
+class ModItem(VaultItem):
+ def __init__(self, parent, uid, *args, **kwargs):
+ VaultItem.__init__(self, parent, *args, **kwargs)
+
+ self.formatterItem = str(
+ util.THEME.readfile("vaults/modvault/modinfo.qthtml"),
+ )
+
+ self.uid = uid
+ self.author = ""
+ self.thumbstr = ""
+ self.isuidmod = False
+ self.uploadedbyuser = False
+
+ def shouldBeVisible(self):
+ p = self.parent
+ if p.showType == "all":
+ return True
+ elif p.showType == "ui":
+ return self.isuimod
+ elif p.showType == "sim":
+ return not self.isuimod
+ elif p.showType == "yours":
+ return self.uploadedbyuser
+ elif p.showType == "installed":
+ return self.uid in self.parent.uids
+ else:
+ return True
+
+ def update(self, item_dict):
+ self.name = item_dict["name"]
+ self.description = item_dict["description"]
+ self.version = item_dict["version"]
+ self.author = item_dict["author"]
+ self.rating = item_dict["rating"]
+ self.reviews = item_dict["reviews"]
+ self.date = item_dict['date'][:10]
+ self.isuimod = item_dict["ui"]
+ self.link = item_dict["link"]
+ self.thumbstr = item_dict["thumbnail"]
+ self.uploadedbyuser = (self.author == self.parent.client.login)
+
+ if self.thumbstr == "":
+ self.setItemIcon("games/unknown_map.png")
+ else:
+ name = os.path.basename(urllib.parse.unquote(self.thumbstr))
+ img = utils.getIcon(name)
+ if img:
+ self.setItemIcon(img, False)
+ else:
+ self.parent.client.mod_downloader.download_preview(
+ name[:-4], self._item_dl_request, self.thumbstr,
+ )
+
+ VaultItem.update(self)
+
+ def updateVisibility(self):
+ if self.isuimod:
+ self.itemType_ = "UI mod"
+ if self.uid in self.parent.uids:
+ self.color = "green"
+ else:
+ self.color = "white"
+
+ self.setText(
+ self.formatterItem.format(
+ color=self.color,
+ version=self.version,
+ title=self.name,
+ description=self.trimmedDescription,
+ rating=self.rating,
+ reviews=self.reviews,
+ date=self.date,
+ modtype=self.itemType_,
+ author=self.author,
+ ),
+ )
+
+ VaultItem.updateVisibility(self)
diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py
index 537ada936..8642b9d23 100644
--- a/src/vaults/modvault/modvault.py
+++ b/src/vaults/modvault/modvault.py
@@ -40,16 +40,13 @@
import logging
import os
-import urllib.error
-import urllib.parse
-import urllib.request
from PyQt5 import QtCore, QtWidgets
-import util
from api.vaults_api import ModApiConnector
from vaults.modvault import utils
-from vaults.vault import Vault, VaultItem
+from vaults.modvault.moditem import ModItem
+from vaults.vault import Vault
from .modwidget import ModWidget
from .uimodwidget import UIModWidget
@@ -76,20 +73,16 @@ def __init__(self, client, *args, **kwargs):
self.apiConnector = ModApiConnector(self.client.lobby_dispatch)
+ self.items_uid = "uid"
+
self.uploadButton.hide()
+ def createItem(self, item_key: str) -> ModItem:
+ return ModItem(self, item_key)
+
@QtCore.pyqtSlot(dict)
- def modInfo(self, message):
- for value in message["values"]:
- uid = value["uid"]
- if uid not in self._items:
- mod = ModItem(self, uid)
- self._items[uid] = mod
- self.itemList.addItem(mod)
- else:
- mod = self._items[uid]
- mod.update(value)
- self.itemList.sortItems(1)
+ def modInfo(self, message: dict) -> None:
+ super().itemsInfo(message)
@QtCore.pyqtSlot(int)
def sortChanged(self, index):
@@ -190,84 +183,3 @@ def removeMod(self, mod):
if utils.removeMod(mod):
self.uids = [m.uid for m in utils.installedMods]
mod.updateVisibility()
-
-
-class ModItem(VaultItem):
- def __init__(self, parent, uid, *args, **kwargs):
- VaultItem.__init__(self, parent, *args, **kwargs)
-
- self.formatterItem = str(
- util.THEME.readfile("vaults/modvault/modinfo.qthtml"),
- )
-
- self.uid = uid
- self.author = ""
- self.thumbstr = ""
- self.isuidmod = False
- self.uploadedbyuser = False
-
- def shouldBeVisible(self):
- p = self.parent
- if p.showType == "all":
- return True
- elif p.showType == "ui":
- return self.isuimod
- elif p.showType == "sim":
- return not self.isuimod
- elif p.showType == "yours":
- return self.uploadedbyuser
- elif p.showType == "installed":
- return self.uid in self.parent.uids
- else:
- return True
-
- def update(self, item_dict):
- self.name = item_dict["name"]
- self.description = item_dict["description"]
- self.version = item_dict["version"]
- self.author = item_dict["author"]
- self.rating = item_dict["rating"]
- self.reviews = item_dict["reviews"]
- self.date = item_dict['date'][:10]
- self.isuimod = item_dict["ui"]
- self.link = item_dict["link"]
- self.thumbstr = item_dict["thumbnail"]
- self.uploadedbyuser = (self.author == self.parent.client.login)
-
- if self.thumbstr == "":
- self.setItemIcon("games/unknown_map.png")
- else:
- name = os.path.basename(urllib.parse.unquote(self.thumbstr))
- img = utils.getIcon(name)
- if img:
- self.setItemIcon(img, False)
- else:
- self.parent.client.mod_downloader.download_preview(
- name[:-4], self._item_dl_request, self.thumbstr,
- )
-
- VaultItem.update(self)
-
- def updateVisibility(self):
- if self.isuimod:
- self.itemType_ = "UI mod"
- if self.uid in self.parent.uids:
- self.color = "green"
- else:
- self.color = "white"
-
- self.setText(
- self.formatterItem.format(
- color=self.color,
- version=self.version,
- title=self.name,
- description=self.trimmedDescription,
- rating=self.rating,
- reviews=self.reviews,
- date=self.date,
- modtype=self.itemType_,
- author=self.author,
- ),
- )
-
- VaultItem.updateVisibility(self)
diff --git a/src/vaults/vault.py b/src/vaults/vault.py
index 8af31ae32..0354c691f 100644
--- a/src/vaults/vault.py
+++ b/src/vaults/vault.py
@@ -1,10 +1,10 @@
import logging
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt5 import QtCore
import util
-from downloadManager import DownloadRequest
from ui.busy_widget import BusyWidget
+from vaults.vaultitem import VaultItem, VaultItemDelegate
logger = logging.getLogger(__name__)
@@ -27,8 +27,6 @@ def __init__(self, client, *args, **kwargs):
self.SortTypeList.currentIndexChanged.connect(self.sortChanged)
self.ShowTypeList.currentIndexChanged.connect(self.showChanged)
- self.client.lobby_info.vaultMeta.connect(self.metaInfo)
-
self.sortType = "alphabetical"
self.showType = "all"
self.searchString = ""
@@ -56,6 +54,7 @@ def __init__(self, client, *args, **kwargs):
self.lastButton.clicked.connect(lambda: self.goToPage(self.totalPages))
self.resetButton.clicked.connect(self.resetSearch)
+ self.items_uid = ""
self._items = {}
self._installed_items = {}
@@ -77,19 +76,36 @@ def updateQuery(self, pageNumber):
self.searchQuery['page[totals]'] = None
@QtCore.pyqtSlot(bool)
- def goToPage(self, page):
- if self.apiConnector is not None:
- self._items.clear()
- self.itemList.clear()
- self.pageBox.setValue(page)
- self.pageNumber = self.pageBox.value()
- self.pageBox.setValue(self.pageNumber)
- self.updateQuery(self.pageNumber)
- self.apiConnector.requestData(self.searchQuery)
- self.updateVisibilities()
+ def goToPage(self, page: int) -> None:
+ if self.apiConnector is None:
+ return
+
+ self._items.clear()
+ self.itemList.clear()
+ self.pageBox.setValue(page)
+ self.pageNumber = self.pageBox.value()
+ self.updateQuery(self.pageNumber)
+ self.apiConnector.requestData(self.searchQuery)
+ self.updateVisibilities()
+
+ def createItem(self, item_key: str) -> VaultItem:
+ return VaultItem(self, item_key)
@QtCore.pyqtSlot(dict)
- def metaInfo(self, message):
+ def itemsInfo(self, message: dict) -> None:
+ for value in message["values"]:
+ item_key = value[self.items_uid]
+ if item_key in self._items:
+ item = self._items[item_key]
+ else:
+ item = self.createItem(item_key)
+ self._items[item_key] = item
+ self.itemList.addItem(item)
+ item.update(value)
+ self.itemList.sortItems(1)
+ self.processMeta(message["meta"])
+
+ def processMeta(self, message: dict) -> None:
self.totalPages = message['page']['totalPages']
self.totalRecords = message['page']['totalRecords']
if self.totalPages < 1:
@@ -128,165 +144,3 @@ def updateVisibilities(self):
for _item in self._items:
self._items[_item].updateVisibility()
self.itemList.sortItems(1)
-
-
-class VaultItemDelegate(QtWidgets.QStyledItemDelegate):
-
- def __init__(self, *args, **kwargs):
- QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
-
- def paint(self, painter, option, index, *args, **kwargs):
- self.initStyleOption(option, index)
-
- painter.save()
-
- html = QtGui.QTextDocument()
- html.setHtml(option.text)
-
- icon = QtGui.QIcon(option.icon)
- iconsize = QtCore.QSize(VaultItem.ICONSIZE, VaultItem.ICONSIZE)
-
- # clear icon and text before letting the control draw itself because
- # we're rendering these parts ourselves
- option.icon = QtGui.QIcon()
- option.text = ""
- option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
- )
-
- # Shadow
- painter.fillRect(
- option.rect.left() + 7,
- option.rect.top() + 7,
- iconsize.width(),
- iconsize.height(),
- QtGui.QColor("#202020"),
- )
-
- iconrect = QtCore.QRect(option.rect.adjusted(3, 3, 0, 0))
- iconrect.setSize(iconsize)
- # Icon
- icon.paint(
- painter, iconrect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
- )
-
- # Frame around the icon
- pen = QtGui.QPen()
- pen.setWidth(1)
- # FIXME: This needs to come from theme.
- pen.setBrush(QtGui.QColor("#303030"))
-
- pen.setCapStyle(QtCore.Qt.RoundCap)
- painter.setPen(pen)
- painter.drawRect(iconrect)
-
- # Description
- painter.translate(
- option.rect.left() + iconsize.width() + 10, option.rect.top() + 4,
- )
- clip = QtCore.QRectF(
- 0, 0, option.rect.width() - iconsize.width() - 15,
- option.rect.height(),
- )
- html.drawContents(painter, clip)
-
- painter.restore()
-
- def sizeHint(self, option, index, *args, **kwargs):
- self.initStyleOption(option, index)
-
- html = QtGui.QTextDocument()
- html.setHtml(option.text)
- html.setTextWidth(VaultItem.TEXTWIDTH)
- return QtCore.QSize(
- (
- VaultItem.ICONSIZE
- + VaultItem.TEXTWIDTH
- + VaultItem.PADDING
- ),
- VaultItem.ICONSIZE + VaultItem.PADDING,
- )
-
-
-class VaultItem(QtWidgets.QListWidgetItem):
- TEXTWIDTH = 230
- ICONSIZE = 100
- PADDING = 10
-
- def __init__(self, parent, *args, **kwargs):
- QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs)
- self.parent = parent
-
- self.name = ""
- self.description = ""
- self.trimmedDescription = ""
- self.version = 0
- self.rating = 0
- self.reviews = 0
- self.date = None
-
- self.itemType_ = ""
- self.color = "white"
-
- self.link = ""
- self.setHidden(True)
-
- self._item_dl_request = DownloadRequest()
- self._item_dl_request.done.connect(self._on_item_downloaded)
-
- def update(self):
- self.ensureIcon()
- self.updateVisibility()
-
- def setItemIcon(self, filename, themed=True):
- icon = util.THEME.icon(filename)
- if not themed:
- pixmap = QtGui.QPixmap(filename)
- if not pixmap.isNull():
- icon.addPixmap(
- pixmap.scaled(
- QtCore.QSize(self.ICONSIZE, self.ICONSIZE),
- ),
- )
- self.setIcon(icon)
-
- def ensureIcon(self):
- if self.icon() is None or self.icon().isNull():
- self.setItemIcon("games/unknown_map.png")
-
- def _on_item_downloaded(self, mapname, result):
- filename, themed = result
- self.setItemIcon(filename, themed)
- self.ensureIcon()
-
- def updateVisibility(self):
- self.setHidden(not self.shouldBeVisible())
- if len(self.description) < 200:
- self.trimmedDescription = self.description
- else:
- self.trimmedDescription = self.description[:197] + "..."
-
- self.setToolTip('
{}
'.format(self.description))
-
- def __ge__(self, other):
- return not self.__lt__(self, other)
-
- def __lt__(self, other):
- if self.parent.sortType == "alphabetical":
- return self.name.lower() > other.name.lower()
- elif self.parent.sortType == "rating":
- if self.rating == other.rating:
- if self.reviews == other.reviews:
- return self.name.lower() > other.name.lower()
- return self.reviews < other.reviews
- return self.rating < other.rating
- elif self.parent.sortType == "size":
- if self.height * self.width == other.height * other.width:
- return self.name.lower() > other.name.lower()
- return self.height * self.width < other.height * other.width
- elif self.parent.sortType == "date":
- if self.date is None:
- return other.date is not None
- if self.date == other.date:
- return self.name.lower() > other.name.lower()
- return self.date < other.date
diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py
new file mode 100644
index 000000000..85d023803
--- /dev/null
+++ b/src/vaults/vaultitem.py
@@ -0,0 +1,166 @@
+from PyQt5 import QtCore, QtGui, QtWidgets
+
+import util
+from downloadManager import DownloadRequest
+
+
+class VaultItem(QtWidgets.QListWidgetItem):
+ TEXTWIDTH = 230
+ ICONSIZE = 100
+ PADDING = 10
+
+ def __init__(self, parent, *args, **kwargs):
+ QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs)
+ self.parent = parent
+
+ self.name = ""
+ self.description = ""
+ self.trimmedDescription = ""
+ self.version = 0
+ self.rating = 0
+ self.reviews = 0
+ self.date = None
+
+ self.itemType_ = ""
+ self.color = "white"
+
+ self.link = ""
+ self.setHidden(True)
+
+ self._item_dl_request = DownloadRequest()
+ self._item_dl_request.done.connect(self._on_item_downloaded)
+
+ def update(self):
+ self.ensureIcon()
+ self.updateVisibility()
+
+ def setItemIcon(self, filename, themed=True):
+ icon = util.THEME.icon(filename)
+ if not themed:
+ pixmap = QtGui.QPixmap(filename)
+ if not pixmap.isNull():
+ icon.addPixmap(
+ pixmap.scaled(
+ QtCore.QSize(self.ICONSIZE, self.ICONSIZE),
+ ),
+ )
+ self.setIcon(icon)
+
+ def ensureIcon(self):
+ if self.icon() is None or self.icon().isNull():
+ self.setItemIcon("games/unknown_map.png")
+
+ def _on_item_downloaded(self, mapname, result):
+ filename, themed = result
+ self.setItemIcon(filename, themed)
+ self.ensureIcon()
+
+ def updateVisibility(self):
+ self.setHidden(not self.shouldBeVisible())
+ if len(self.description) < 200:
+ self.trimmedDescription = self.description
+ else:
+ self.trimmedDescription = self.description[:197] + "..."
+
+ self.setToolTip('{}
'.format(self.description))
+
+ def __ge__(self, other):
+ return not self.__lt__(self, other)
+
+ def __lt__(self, other):
+ if self.parent.sortType == "alphabetical":
+ return self.name.lower() > other.name.lower()
+ elif self.parent.sortType == "rating":
+ if self.rating == other.rating:
+ if self.reviews == other.reviews:
+ return self.name.lower() > other.name.lower()
+ return self.reviews < other.reviews
+ return self.rating < other.rating
+ elif self.parent.sortType == "size":
+ if self.height * self.width == other.height * other.width:
+ return self.name.lower() > other.name.lower()
+ return self.height * self.width < other.height * other.width
+ elif self.parent.sortType == "date":
+ if self.date is None:
+ return other.date is not None
+ if self.date == other.date:
+ return self.name.lower() > other.name.lower()
+ return self.date < other.date
+
+
+class VaultItemDelegate(QtWidgets.QStyledItemDelegate):
+
+ def __init__(self, *args, **kwargs):
+ QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
+
+ def paint(self, painter, option, index, *args, **kwargs):
+ self.initStyleOption(option, index)
+
+ painter.save()
+
+ html = QtGui.QTextDocument()
+ html.setHtml(option.text)
+
+ icon = QtGui.QIcon(option.icon)
+ iconsize = QtCore.QSize(VaultItem.ICONSIZE, VaultItem.ICONSIZE)
+
+ # clear icon and text before letting the control draw itself because
+ # we're rendering these parts ourselves
+ option.icon = QtGui.QIcon()
+ option.text = ""
+ option.widget.style().drawControl(
+ QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ )
+
+ # Shadow
+ painter.fillRect(
+ option.rect.left() + 7,
+ option.rect.top() + 7,
+ iconsize.width(),
+ iconsize.height(),
+ QtGui.QColor("#202020"),
+ )
+
+ iconrect = QtCore.QRect(option.rect.adjusted(3, 3, 0, 0))
+ iconrect.setSize(iconsize)
+ # Icon
+ icon.paint(
+ painter, iconrect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
+ )
+
+ # Frame around the icon
+ pen = QtGui.QPen()
+ pen.setWidth(1)
+ # FIXME: This needs to come from theme.
+ pen.setBrush(QtGui.QColor("#303030"))
+
+ pen.setCapStyle(QtCore.Qt.RoundCap)
+ painter.setPen(pen)
+ painter.drawRect(iconrect)
+
+ # Description
+ painter.translate(
+ option.rect.left() + iconsize.width() + 10, option.rect.top() + 4,
+ )
+ clip = QtCore.QRectF(
+ 0, 0, option.rect.width() - iconsize.width() - 15,
+ option.rect.height(),
+ )
+ html.drawContents(painter, clip)
+
+ painter.restore()
+
+ def sizeHint(self, option, index, *args, **kwargs):
+ self.initStyleOption(option, index)
+
+ html = QtGui.QTextDocument()
+ html.setHtml(option.text)
+ html.setTextWidth(VaultItem.TEXTWIDTH)
+ return QtCore.QSize(
+ (
+ VaultItem.ICONSIZE
+ + VaultItem.TEXTWIDTH
+ + VaultItem.PADDING
+ ),
+ VaultItem.ICONSIZE + VaultItem.PADDING,
+ )
From af582e24f628335c13bd24f3f6d9cad0c40352ea Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 4 Mar 2023 23:13:54 +0200
Subject: [PATCH 006/123] Force single line for imports
---
setup.cfg | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/setup.cfg b/setup.cfg
index 218b455de..e1d35b6d8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,3 +1,2 @@
[isort]
-multi_line_output=3
-include_trailing_comma=True
+force_single_line=True
From 262dffb0c56dc8f43828efebdd12f6bb2a020055 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 14 Mar 2024 19:59:53 +0200
Subject: [PATCH 007/123] Make .ui files compatible with PyQt6
---
res/client/client.ui | 3 ---
res/client/oauth.ui | 3 ---
2 files changed, 6 deletions(-)
diff --git a/res/client/client.ui b/res/client/client.ui
index b4f2a915f..477e67e8b 100644
--- a/res/client/client.ui
+++ b/res/client/client.ui
@@ -31,9 +31,6 @@
Qt::ToolButtonIconOnly
-
- QMainWindow::AllowTabbedDocks|QMainWindow::AnimatedDocks
-
false
diff --git a/res/client/oauth.ui b/res/client/oauth.ui
index 729bfce98..852c030c8 100644
--- a/res/client/oauth.ui
+++ b/res/client/oauth.ui
@@ -35,9 +35,6 @@
0
- -
-
-
From 3cb7808681246ff520a80e3c020f0c5fcf3a02e1 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 14 Mar 2024 20:01:03 +0200
Subject: [PATCH 008/123] pre-commit autoupdate
---
.pre-commit-config.yaml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 656ffbafd..e0eb7f6ad 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,23 +1,23 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
+ rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-docstring-first
- repo: https://github.com/asottile/add-trailing-comma
- rev: v2.4.0
+ rev: v3.1.0
hooks:
- id: add-trailing-comma
- repo: https://github.com/PyCQA/flake8
- rev: 6.0.0
+ rev: 7.0.0
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-autopep8
- rev: v2.0.1
+ rev: v2.0.4
hooks:
- id: autopep8
- repo: https://github.com/PyCQA/isort
- rev: 5.10.1
+ rev: 5.13.2
hooks:
- id: isort
From 1fbd5ac75c67107ee132899fd491c11197093d12 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 14 Mar 2024 21:18:11 +0200
Subject: [PATCH 009/123] Increase line length to 100
---
setup.cfg | 3 +++
1 file changed, 3 insertions(+)
diff --git a/setup.cfg b/setup.cfg
index e1d35b6d8..105ad2804 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,5 @@
[isort]
force_single_line=True
+
+[flake8]
+max_line_length=100
From ecc27a169649e5b1b3b2ea6d22a4adf3b2651a60 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 14 Mar 2024 22:30:44 +0200
Subject: [PATCH 010/123] Partially upgrade to PyQt6
use compatible namespaces and remove/replace deprecated stuff
---
conftest.py | 2 +-
requirements.txt | 11 +-
setup.py | 7 +-
src/__main__.py | 42 +++--
src/api/ApiBase.py | 10 +-
src/chat/_avatarWidget.py | 10 +-
src/chat/channel_tab.py | 2 +-
src/chat/channel_view.py | 21 ++-
src/chat/channel_widget.py | 20 ++-
src/chat/chat_controller.py | 11 +-
src/chat/chat_widget.py | 11 +-
src/chat/chatlineedit.py | 15 +-
src/chat/chatter_menu.py | 4 +-
src/chat/chatter_model.py | 72 +++++---
src/chat/chatter_model_item.py | 3 +-
src/chat/chatterlistview.py | 4 +-
src/chat/gameinfo.py | 2 +-
src/chat/ircconnection.py | 11 +-
src/chat/language_channel_config.py | 19 ++-
src/client/_clientwindow.py | 154 +++++++++---------
src/client/aliasviewer.py | 8 +-
src/client/chat_config.py | 2 +-
src/client/connection.py | 24 +--
src/client/gameannouncer.py | 4 +-
src/client/login.py | 3 +-
src/client/oauth_dialog.py | 9 +-
src/client/playercolors.py | 3 +-
src/client/theme_menu.py | 2 +-
src/client/user.py | 5 +-
src/config/__init__.py | 28 ++--
src/connectivity/ConnectivityDialog.py | 20 ++-
src/connectivity/IceAdapterClient.py | 2 +-
src/connectivity/IceAdapterProcess.py | 18 +-
src/connectivity/IceServersPoller.py | 6 +-
src/connectivity/JsonRpcTcpClient.py | 15 +-
src/coop/_coopwidget.py | 17 +-
src/coop/coopmapitem.py | 24 ++-
src/downloadManager/__init__.py | 20 ++-
src/fa/check.py | 39 +++--
src/fa/game_connection.py | 7 +-
src/fa/game_process.py | 9 +-
src/fa/game_session.py | 4 +-
src/fa/maps.py | 11 +-
src/fa/mods.py | 4 +-
src/fa/replay.py | 12 +-
src/fa/replayserver.py | 10 +-
src/fa/updater.py | 14 +-
src/fa/wizards.py | 35 ++--
src/games/_gameswidget.py | 16 +-
src/games/automatchframe.py | 25 ++-
src/games/gameitem.py | 13 +-
src/games/gamemodel.py | 7 +-
src/games/gamemodelitem.py | 3 +-
src/games/hostgamewidget.py | 12 +-
src/games/mapgenoptionsdialog.py | 7 +-
src/games/moditem.py | 3 +-
src/mapGenerator/mapgenManager.py | 8 +-
src/mapGenerator/mapgenProcess.py | 18 +-
src/model/chat/channel.py | 3 +-
src/model/chat/chat.py | 9 +-
src/model/chat/chatter.py | 2 +-
src/model/game.py | 14 +-
src/model/gameset.py | 2 +-
src/model/ircuser.py | 2 +-
src/model/modelitem.py | 2 +-
src/model/modelitemset.py | 2 +-
src/model/player.py | 2 +-
src/model/qobjectmapping.py | 6 +-
src/news/_newswidget.py | 8 +-
src/news/newsitem.py | 10 +-
src/news/newsmanager.py | 9 +-
src/news/wpapi.py | 11 +-
src/notifications/__init__.py | 5 +-
src/notifications/hook_newgame.py | 10 +-
src/notifications/hook_partyinvite.py | 4 +-
src/notifications/hook_useronline.py | 4 +-
src/notifications/ns_dialog.py | 17 +-
src/notifications/ns_hook.py | 2 +-
src/notifications/ns_settings.py | 29 ++--
src/power/actions.py | 4 +-
src/power/view.py | 7 +-
src/replays/_replayswidget.py | 62 ++++---
src/replays/replayToolbox.py | 6 +-
src/replays/replayitem.py | 19 ++-
src/secondaryServer/secondaryserver.py | 9 +-
src/stats/_statswidget.py | 7 +-
src/stats/itemviews/leaderboardheaderview.py | 44 ++---
.../itemviews/leaderboarditemdelegate.py | 42 +++--
src/stats/itemviews/leaderboardtablemenu.py | 7 +-
src/stats/itemviews/leaderboardtableview.py | 20 ++-
src/stats/leaderboard_widget.py | 27 ++-
src/stats/leaderboardlineedit.py | 7 +-
src/stats/models/leaderboardfiltermodel.py | 2 +-
src/stats/models/leaderboardtablemodel.py | 19 ++-
src/tourneys/_tournamentswidget.py | 6 +-
src/tourneys/tourneyitem.py | 16 +-
src/turn_client.py | 5 +-
src/tutorials/_tutorialswidget.py | 18 +-
src/tutorials/tutorialitem.py | 10 +-
src/ui/status_logo.py | 8 +-
src/unitdb/unitdbtab.py | 9 +-
src/updater/__init__.py | 15 +-
src/updater/base.py | 9 +-
src/updater/process.py | 11 +-
src/updater/widgets.py | 7 +-
src/util/__init__.py | 16 +-
src/util/crash.py | 9 +-
src/util/gameurl.py | 3 +-
src/util/qt.py | 6 +-
src/util/qt_list_model.py | 6 +-
src/util/select_player_dialog.py | 4 +-
src/util/theme.py | 16 +-
src/vaults/dialogs.py | 8 +-
src/vaults/mapvault/mapvault.py | 8 +-
src/vaults/mapvault/mapwidget.py | 4 +-
src/vaults/modvault/modvault.py | 9 +-
src/vaults/modvault/modwidget.py | 6 +-
src/vaults/modvault/uimodwidget.py | 5 +-
src/vaults/modvault/uploadwidget.py | 3 +-
src/vaults/modvault/utils.py | 6 +-
src/vaults/vault.py | 9 +-
src/vaults/vaultitem.py | 13 +-
tests/fa/test_featured.py | 3 +-
tests/fa/test_updater.py | 7 +-
.../unit_tests/client/test_mouse_position.py | 2 +-
tests/unit_tests/client/test_updating.py | 2 +-
126 files changed, 926 insertions(+), 666 deletions(-)
diff --git a/conftest.py b/conftest.py
index efb18d0f8..01227e12e 100644
--- a/conftest.py
+++ b/conftest.py
@@ -22,7 +22,7 @@ def application(qapp, request):
@pytest.fixture(scope="function")
def signal_receiver(application):
- from PyQt5 import QtCore
+ from PyQt6 import QtCore
class SignalReceiver(QtCore.QObject):
def __init__(self, parent=None):
diff --git a/requirements.txt b/requirements.txt
index f0ebc9c6f..f3b75e0b2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,14 +1,15 @@
-cx_Freeze==5.0.2
+cx_Freeze
ipaddress
pathlib
-pyqt5
+pyqt6
pytest
pytest-cov
pytest-mock
-pytest-qt==3.3.0
-semantic_version==2.8.5
+pytest-qt
+pywin32
+semantic_version
idna
-jsonschema==2.6.0
+jsonschema
jinja2
zstandard
irc
diff --git a/setup.py b/setup.py
index 2ac8dbe72..ea8c3dfb4 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
import sys
from pathlib import Path
-import PyQt5.uic
+import PyQt6.uic
import sip
sip.setapi('QString', 2)
@@ -13,7 +13,8 @@
sip.setapi('QProcess', 2)
if sys.platform == 'win32':
- from cx_Freeze import Executable, setup
+ from cx_Freeze import Executable
+ from cx_Freeze import setup
else:
from distutils.core import setup
@@ -36,7 +37,7 @@
# Ugly hack to fix broken PyQt5 (FIXME - necessary?)
for module in ["invoke.py", "load_plugin.py"]:
try:
- silly_file = Path(PyQt5.__path__[0]) / "uic" / "port_v2" / module
+ silly_file = Path(PyQt6.__path__[0]) / "uic" / "port_v2" / module
print("Removing {}".format(silly_file))
silly_file.unlink()
except OSError:
diff --git a/src/__main__.py b/src/__main__.py
index 412c47d7c..5481613cc 100644
--- a/src/__main__.py
+++ b/src/__main__.py
@@ -7,12 +7,16 @@
import os
import sys
+from types import TracebackType
# According to PyQt5 docs we need to import QtWebEngineWidgets before we create
# QApplication
-from PyQt5 import QtWebEngineWidgets # noqa: F401
-from PyQt5 import QtWidgets, uic
-from PyQt5.QtCore import Qt
+from PyQt6 import QtWebEngineWidgets # noqa: F401
+from PyQt6 import uic
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QDialog
+from PyQt6.QtWidgets import QMessageBox
import util
import util.crash
@@ -55,14 +59,18 @@
os.environ.setdefault('QT_ANGLE_PLATFORM', 'd3d9')
-path = os.path.join(os.path.dirname(sys.argv[0]), "PyQt5.uic.widget-plugins")
+path = os.path.join(os.path.dirname(sys.argv[0]), "PyQt6.uic.widget-plugins")
uic.widgetPluginPath.append(path)
# Set up crash reporting
excepthook_original = sys.excepthook
-def excepthook(exc_type, exc_value, traceback_object):
+def excepthook(
+ exc_type: type[BaseException],
+ exc_value: BaseException,
+ traceback_object: TracebackType | None,
+) -> None:
"""
This exception hook will stop the app if an uncaught error occurred,
regardless where in the QApplication.
@@ -80,30 +88,28 @@ def excepthook(exc_type, exc_value, traceback_object):
)
logger.error("Runtime Info:\n{}".format(util.crash.runtime_info()))
dialog = util.crash.CrashDialog((exc_type, exc_value, traceback_object))
- answer = dialog.exec_()
+ answer = dialog.exec()
- if answer == QtWidgets.QDialog.Rejected:
- QtWidgets.QApplication.exit(1)
+ if answer == QDialog.DialogCode.Rejected:
+ QApplication.exit(1)
sys.excepthook = excepthook
-def admin_user_error_dialog():
+def admin_user_error_dialog() -> None:
from config import Settings
ignore_admin = Settings.get("client/ignore_admin", False, bool)
if not ignore_admin:
- box = QtWidgets.QMessageBox()
+ box = QMessageBox()
box.setText(
"FAF should not be run as an administrator!
This "
"probably means you need to fix the file permissions in "
"C:\\ProgramData.
Proceed at your own risk.",
)
- box.setStandardButtons(
- QtWidgets.QMessageBox.Ignore | QtWidgets.QMessageBox.Close,
- )
- box.setIcon(QtWidgets.QMessageBox.Critical)
+ box.setStandardButtons(QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Close)
+ box.setIcon(QMessageBox.Icon.Critical)
box.setWindowTitle("FAF privilege error")
- if box.exec_() == QtWidgets.QMessageBox.Ignore:
+ if box.exec() == QMessageBox.StandardButton.Ignore:
Settings.set("client/ignore_admin", True)
@@ -120,7 +126,7 @@ def run_faf():
faf_client.try_to_auto_login()
# Main update loop
- QtWidgets.QApplication.exec_()
+ QApplication.exec()
if __name__ == '__main__':
@@ -128,8 +134,8 @@ def run_faf():
import config
- QtWidgets.QApplication.setAttribute(Qt.AA_ShareOpenGLContexts)
- app = QtWidgets.QApplication(trailing_args)
+ QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
+ app = QApplication(["FAF Python Client"] + trailing_args)
if sys.platform == 'win32':
import ctypes
diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py
index 4b14f9dc6..0800fbecc 100644
--- a/src/api/ApiBase.py
+++ b/src/api/ApiBase.py
@@ -2,14 +2,16 @@
import logging
import time
-from PyQt5 import QtCore, QtNetwork, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
+from PyQt6 import QtWidgets
from config import Settings
logger = logging.getLogger(__name__)
DO_NOT_ENCODE = QtCore.QByteArray()
-DO_NOT_ENCODE.append(":/?&=.,")
+DO_NOT_ENCODE.append(b":/?&=.,")
class ApiBase(QtCore.QObject):
@@ -29,7 +31,7 @@ def request(self, queryDict, responseHandler):
query = QtCore.QUrlQuery()
for key, value in queryDict.items():
query.addQueryItem(key, str(value))
- stringQuery = query.toString(QtCore.QUrl.FullyDecoded)
+ stringQuery = query.toString(QtCore.QUrl.ComponentFormattingOption.FullyDecoded)
percentEncodedByteArrayQuery = QtCore.QUrl.toPercentEncoding(
stringQuery,
exclude=DO_NOT_ENCODE,
@@ -53,7 +55,7 @@ def request(self, queryDict, responseHandler):
def onRequestFinished(self, reply):
self._running = False
- if reply.error() != QtNetwork.QNetworkReply.NoError:
+ if reply.error() != QtNetwork.QNetworkReply.NetworkError.NoError:
logger.error("API request error: {}".format(reply.error()))
else:
message_bytes = reply.readAll().data()
diff --git a/src/chat/_avatarWidget.py b/src/chat/_avatarWidget.py
index 0b2cc4c28..9e9e61dd1 100644
--- a/src/chat/_avatarWidget.py
+++ b/src/chat/_avatarWidget.py
@@ -1,6 +1,8 @@
-from PyQt5.QtCore import QObject, QSize
-from PyQt5.QtGui import QIcon
-from PyQt5.QtWidgets import QListWidgetItem, QPushButton
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QSize
+from PyQt6.QtGui import QIcon
+from PyQt6.QtWidgets import QListWidgetItem
+from PyQt6.QtWidgets import QPushButton
from downloadManager import DownloadRequest
@@ -28,7 +30,7 @@ def __init__(
@classmethod
def builder(
cls, parent_widget, lobby_connection, lobby_info, avatar_dler,
- theme, **kwargs
+ theme, **kwargs,
):
return lambda: cls(
parent_widget, lobby_connection, lobby_info, avatar_dler, theme,
diff --git a/src/chat/channel_tab.py b/src/chat/channel_tab.py
index 52a4826d9..1a1338ecb 100644
--- a/src/chat/channel_tab.py
+++ b/src/chat/channel_tab.py
@@ -1,6 +1,6 @@
from enum import IntEnum
-from PyQt5.QtCore import QTimer
+from PyQt6.QtCore import QTimer
from chat.chat_widget import TabIcon
diff --git a/src/chat/channel_view.py b/src/chat/channel_view.py
index 72c0252dc..366bdeefe 100644
--- a/src/chat/channel_view.py
+++ b/src/chat/channel_view.py
@@ -2,21 +2,20 @@
import time
import jinja2
-from PyQt5.QtCore import QObject, pyqtSignal
-from PyQt5.QtGui import QDesktopServices
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QDesktopServices
from chat.channel_tab import TabInfo
from chat.channel_widget import ChannelWidget
from chat.chatter_menu import ChatterMenu
-from chat.chatter_model import (
- ChatterEventFilter,
- ChatterFormat,
- ChatterItemDelegate,
- ChatterLayout,
- ChatterLayoutElements,
- ChatterModel,
- ChatterSortFilterModel,
-)
+from chat.chatter_model import ChatterEventFilter
+from chat.chatter_model import ChatterFormat
+from chat.chatter_model import ChatterItemDelegate
+from chat.chatter_model import ChatterLayout
+from chat.chatter_model import ChatterLayoutElements
+from chat.chatter_model import ChatterModel
+from chat.chatter_model import ChatterSortFilterModel
from downloadManager import DownloadRequest
from model.chat.channel import ChannelType
from model.chat.chatline import ChatLineType
diff --git a/src/chat/channel_widget.py b/src/chat/channel_widget.py
index 77990cacf..a4a0af1fc 100644
--- a/src/chat/channel_widget.py
+++ b/src/chat/channel_widget.py
@@ -1,8 +1,12 @@
import logging
import re
-from PyQt5.QtCore import QObject, Qt, QUrl, pyqtSignal
-from PyQt5.QtGui import QTextCursor, QTextDocument
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QTextCursor
+from PyQt6.QtGui import QTextDocument
from util.qt import monkeypatch_method
@@ -101,8 +105,8 @@ def clear_chat(self):
def add_avatar_resource(self, url, pix):
doc = self.chat_area.document()
link = QUrl(url)
- if not doc.resource(QTextDocument.ImageResource, link):
- doc.addResource(QTextDocument.ImageResource, link, pix)
+ if not doc.resource(QTextDocument.ResourceType.ImageResource, link):
+ doc.addResource(QTextDocument.ResourceType.ImageResource, link, pix)
def _set_chatter_filter(self, text):
self.nick_list.model().setFilterFixedString(text)
@@ -127,7 +131,7 @@ def append_line(self, text):
self._sticky_scroll.save_scroll()
cursor = self.chat_area.textCursor()
- cursor.movePosition(QTextCursor.End)
+ cursor.movePosition(QTextCursor.MoveOperation.End)
self.chat_area.setTextCursor(cursor)
self.chat_area.insertHtml(text)
@@ -135,8 +139,8 @@ def append_line(self, text):
def remove_lines(self, number):
cursor = self.chat_area.textCursor()
- cursor.movePosition(QTextCursor.Start)
- cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor, number)
+ cursor.movePosition(QTextCursor.MoveOperation.Start)
+ cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.KeepAnchor, number)
cursor.removeSelectedText()
def set_chatter_delegate(self, delegate):
@@ -144,7 +148,7 @@ def set_chatter_delegate(self, delegate):
def set_chatter_model(self, model):
self.nick_list.setModel(model)
- model.setFilterCaseSensitivity(Qt.CaseInsensitive)
+ model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
def set_chatter_event_filter(self, event_filter):
self.nick_list.viewport().installEventFilter(event_filter)
diff --git a/src/chat/chat_controller.py b/src/chat/chat_controller.py
index 94a75d1c5..0b97ec36f 100644
--- a/src/chat/chat_controller.py
+++ b/src/chat/chat_controller.py
@@ -1,10 +1,15 @@
from enum import Enum
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
-from model.chat.channel import Channel, ChannelID, ChannelType, Lines
+from model.chat.channel import Channel
+from model.chat.channel import ChannelID
+from model.chat.channel import ChannelType
+from model.chat.channel import Lines
from model.chat.channelchatter import ChannelChatter
-from model.chat.chatline import ChatLine, ChatLineType
+from model.chat.chatline import ChatLine
+from model.chat.chatline import ChatLineType
from model.chat.chatter import Chatter
diff --git a/src/chat/chat_widget.py b/src/chat/chat_widget.py
index 59ce83798..308b08ded 100644
--- a/src/chat/chat_widget.py
+++ b/src/chat/chat_widget.py
@@ -1,9 +1,12 @@
from enum import Enum
-from PyQt5.QtCore import QObject, pyqtSignal
-from PyQt5.QtWidgets import QApplication, QTabBar
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QTabBar
-from model.chat.channel import PARTY_CHANNEL_SUFFIX, ChannelType
+from model.chat.channel import PARTY_CHANNEL_SUFFIX
+from model.chat.channel import ChannelType
class TabIcon(Enum):
@@ -37,7 +40,7 @@ def set_theme(self):
self.remove_server_tab_close_button()
def remove_server_tab_close_button(self):
- self.base.tabBar().setTabButton(0, QTabBar.RightSide, None)
+ self.base.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
def add_channel(self, widget, key, index=None):
if key in self._channels:
diff --git a/src/chat/chatlineedit.py b/src/chat/chatlineedit.py
index dd6bd43da..0370531e0 100644
--- a/src/chat/chatlineedit.py
+++ b/src/chat/chatlineedit.py
@@ -4,7 +4,8 @@
@author: thygrrr
"""
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
class ChatLineEdit(QtWidgets.QLineEdit):
@@ -29,20 +30,20 @@ def set_channel(self, channel):
self.channel = channel
def event(self, event):
- if event.type() == QtCore.QEvent.KeyPress:
+ if event.type() == QtCore.QEvent.Type.KeyPress:
# Swallow a selection of keypresses that we want for our history
# support.
- if event.key() == QtCore.Qt.Key_Tab:
+ if event.key() == QtCore.Qt.Key.Key_Tab:
self.try_completion()
return True
- elif event.key() == QtCore.Qt.Key_Space:
+ elif event.key() == QtCore.Qt.Key.Key_Space:
self.accept_completion()
return QtWidgets.QLineEdit.event(self, event)
- elif event.key() == QtCore.Qt.Key_Up:
+ elif event.key() == QtCore.Qt.Key.Key_Up:
self.cancel_completion()
self.prev_history()
return True
- elif event.key() == QtCore.Qt.Key_Down:
+ elif event.key() == QtCore.Qt.Key.Key_Down:
self.cancel_completion()
self.next_history()
return True
@@ -59,7 +60,7 @@ def on_line_entered(self):
self.currentHistoryIndex = len(self.history) - 1
def showEvent(self, event):
- self.setFocus(True)
+ self.setFocus()
return QtWidgets.QLineEdit.showEvent(self, event)
def try_completion(self):
diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py
index caeb22bb7..b1b304cb7 100644
--- a/src/chat/chatter_menu.py
+++ b/src/chat/chatter_menu.py
@@ -1,7 +1,9 @@
import logging
from enum import Enum
-from PyQt5.QtWidgets import QAction, QApplication, QMenu
+from PyQt6.QtGui import QAction
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QMenu
from model.game import GameState
diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py
index a59e130c0..88262a380 100644
--- a/src/chat/chatter_model.py
+++ b/src/chat/chatter_model.py
@@ -1,11 +1,21 @@
-from enum import Enum, IntEnum
-
-from PyQt5 import QtCore, QtGui, QtWidgets
-from PyQt5.QtCore import QObject, QRectF, QSortFilterProxyModel, Qt, pyqtSignal
-from PyQt5.QtGui import QColor, QIcon
+from enum import Enum
+from enum import IntEnum
+from typing import Any
+
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QRect
+from PyQt6.QtCore import QSortFilterProxyModel
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QColor
+from PyQt6.QtGui import QIcon
import util
from chat.chatter_model_item import ChatterModelItem
+from chat.chatterlistview import ChatterListView
from chat.gameinfo import SensitiveMapInfoChecker
from fa import maps
from model.game import GameState
@@ -72,8 +82,8 @@ def build(cls, model, me, user_relations, chat_config, **kwargs):
def lessThan(self, leftIndex, rightIndex):
source = self.sourceModel()
- left = source.data(leftIndex, Qt.DisplayRole)
- right = source.data(rightIndex, Qt.DisplayRole)
+ left = source.data(leftIndex, Qt.ItemDataRole.DisplayRole)
+ right = source.data(rightIndex, Qt.ItemDataRole.DisplayRole)
comp_list = [self._lt_me, self._lt_rank, self._lt_alphabetical]
for lt in comp_list:
@@ -119,14 +129,14 @@ def _get_user_rank(self, item):
return ChatterRank.USER
return ChatterRank.NONPLAYER
- def filterAcceptsRow(self, row, parent):
+ def filterAcceptsRow(self, row: int, parent: QtCore.QModelIndex) -> bool:
source = self.sourceModel()
index = source.index(row, 0, parent)
if not index.isValid():
return False
- data = source.data(index, Qt.DisplayRole)
+ data = source.data(index, Qt.ItemDataRole.DisplayRole)
displayed_name = ChatterFormat.chatter_name(data.chatter)
- return self.filterRegExp().indexIn(displayed_name) != -1
+ return self.filterRegularExpression().match(displayed_name).hasMatch()
def _check_sort_changed(self, option):
if option == "friendsontop":
@@ -369,17 +379,21 @@ def _draw_clear_option(self, painter, option):
option.icon = QtGui.QIcon()
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
- def _handle_highlight(self, painter, option):
- if option.state & QtWidgets.QStyle.State_Selected:
+ def _handle_highlight(
+ self,
+ painter: QtGui.QPainter,
+ option: QtWidgets.QStyleOptionViewItem,
+ ) -> None:
+ if option.state & QtWidgets.QStyle.StateFlag.State_Selected:
painter.fillRect(option.rect, option.palette.highlight)
- def _draw_nick(self, painter, data):
+ def _draw_nick(self, painter: QtGui.QPainter, data: str) -> None:
text = self._formatter.chatter_name(data)
color = QColor(self._formatter.chatter_color(data))
- clip = QRectF(self.layout.sizes[ChatterLayoutElements.NICK])
+ clip = QRect(self.layout.sizes[ChatterLayoutElements.NICK])
text = self._get_elided_text(painter, text, clip.width())
painter.save()
@@ -387,13 +401,13 @@ def _draw_nick(self, painter, data):
pen.setColor(color)
painter.setPen(pen)
- painter.drawText(clip, Qt.AlignLeft | Qt.AlignVCenter, text)
+ painter.drawText(clip, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text)
painter.restore()
- def _get_elided_text(self, painter, text, width):
+ def _get_elided_text(self, painter: QtGui.QPainter, text: str, width: int) -> str:
metrics = painter.fontMetrics()
- return metrics.elidedText(text, Qt.ElideRight, width)
+ return metrics.elidedText(text, Qt.TextElideMode.ElideRight, width)
def _draw_status(self, painter, data):
status = self._formatter.chatter_status(data)
@@ -427,7 +441,7 @@ def _draw_country(self, painter, data):
def _draw_icon(self, painter, icon, element):
rect = self.layout.sizes[element]
- icon.paint(painter, rect, QtCore.Qt.AlignCenter)
+ icon.paint(painter, rect, QtCore.Qt.AlignmentFlag.AlignCenter)
def sizeHint(self, option, index):
return self.layout.size
@@ -551,18 +565,22 @@ def build(cls, chatter_layout, tooltip_handler, menu_handler, **kwargs):
return cls(chatter_layout, tooltip_handler, menu_handler)
def eventFilter(self, obj, event):
- if event.type() == QtCore.QEvent.ToolTip:
+ if event.type() == QtCore.QEvent.Type.ToolTip:
return self._handle_tooltip(obj, event)
- elif event.type() == QtCore.QEvent.MouseButtonRelease:
- if event.button() == QtCore.Qt.RightButton:
+ elif event.type() == QtCore.QEvent.Type.MouseButtonRelease:
+ if event.button() == QtCore.Qt.MouseButton.RightButton:
return self._handle_context_menu(obj, event)
- elif event.type() == QtCore.QEvent.MouseButtonDblClick:
- if event.button() == QtCore.Qt.LeftButton:
+ elif event.type() == QtCore.QEvent.Type.MouseButtonDblClick:
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
return self._handle_double_click(obj, event)
return super().eventFilter(obj, event)
- def _get_data_and_elem(self, widget, event):
- view = widget.parent()
+ def _get_data_and_elem(
+ self,
+ widget: QtWidgets.QWidget,
+ event: QtGui.QMouseEvent,
+ ) -> tuple[Any, ChatterLayoutElements | None]:
+ view: ChatterListView = widget.parent()
idx = view.indexAt(event.pos())
if not idx.isValid():
return None, None
@@ -571,7 +589,7 @@ def _get_data_and_elem(self, widget, event):
elem = self._chatter_layout.element_at_point(point)
return idx.data(), elem
- def _handle_tooltip(self, widget, event):
+ def _handle_tooltip(self, widget: QtWidgets.QWidget, event: QtGui.QMouseEvent) -> bool:
data, elem = self._get_data_and_elem(widget, event)
if data is None:
return False
diff --git a/src/chat/chatter_model_item.py b/src/chat/chatter_model_item.py
index 951ba59cb..1cbd44ddf 100644
--- a/src/chat/chatter_model_item.py
+++ b/src/chat/chatter_model_item.py
@@ -1,6 +1,7 @@
from urllib import parse
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
from downloadManager import DownloadRequest
from fa import maps
diff --git a/src/chat/chatterlistview.py b/src/chat/chatterlistview.py
index 3e39bc186..02723356d 100644
--- a/src/chat/chatterlistview.py
+++ b/src/chat/chatterlistview.py
@@ -1,5 +1,5 @@
-from PyQt5.QtCore import pyqtSignal
-from PyQt5.QtWidgets import QListView
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtWidgets import QListView
class ChatterListView(QListView):
diff --git a/src/chat/gameinfo.py b/src/chat/gameinfo.py
index aea021a19..857525c64 100644
--- a/src/chat/gameinfo.py
+++ b/src/chat/gameinfo.py
@@ -1,4 +1,4 @@
-from PyQt5.QtCore import QObject
+from PyQt6.QtCore import QObject
from model.game import GameState
diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py
index d501c2a75..63dc6bfe8 100644
--- a/src/chat/ircconnection.py
+++ b/src/chat/ircconnection.py
@@ -5,12 +5,17 @@
import irc
import irc.client
-from PyQt5.QtCore import QObject, QSocketNotifier, QTimer, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QSocketNotifier
+from PyQt6.QtCore import QTimer
+from PyQt6.QtCore import pyqtSignal
import config
import util
-from model.chat.channel import ChannelID, ChannelType
-from model.chat.chatline import ChatLine, ChatLineType
+from model.chat.channel import ChannelID
+from model.chat.channel import ChannelType
+from model.chat.chatline import ChatLine
+from model.chat.chatline import ChatLineType
logger = logging.getLogger(__name__)
PONG_INTERVAL = 60000 # milliseconds between pongs
diff --git a/src/chat/language_channel_config.py b/src/chat/language_channel_config.py
index 6e4b4637f..6f2588152 100644
--- a/src/chat/language_channel_config.py
+++ b/src/chat/language_channel_config.py
@@ -1,4 +1,5 @@
-from PyQt5.QtCore import QAbstractListModel, Qt
+from PyQt6.QtCore import QAbstractListModel
+from PyQt6.QtCore import Qt
from chat.lang import LANGUAGE_CHANNELS
@@ -83,25 +84,25 @@ def rowCount(self, parent):
return 0
return len(self._items)
- def data(self, index, role=Qt.DisplayRole):
+ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
item = self._index_item(index)
if item is None:
return None
- if role == Qt.DisplayRole:
+ if role == Qt.ItemDataRole.DisplayRole:
return item.name
- if role == Qt.DecorationRole:
+ if role == Qt.ItemDataRole.DecorationRole:
return item.icon
- if role == Qt.CheckStateRole:
+ if role == Qt.ItemDataRole.CheckStateRole:
return item.checked
return None
- def setData(self, index, value, role=Qt.EditRole):
+ def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
item = self._index_item(index)
if item is None:
return False
- if role == Qt.CheckStateRole:
+ if role == Qt.ItemDataRole.CheckStateRole:
item.checked = value
- self.dataChanged.emit(index, index, [Qt.CheckStateRole])
+ self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole])
return True
return False
@@ -120,7 +121,7 @@ def load_data(self, entries):
def flags(self, index):
if index.isValid():
- return Qt.ItemIsUserCheckable | Qt.ItemIsEnabled
+ return Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled
return 0
def checked_channels(self):
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index e72695d51..5f45ab991 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -4,8 +4,10 @@
from functools import partial
from oauthlib.oauth2 import WebApplicationClient
-from PyQt5 import QtCore, QtGui, QtWidgets
-from PyQt5.QtNetwork import QNetworkAccessManager
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtNetwork import QNetworkAccessManager
from requests_oauthlib import OAuth2Session
import config
@@ -24,34 +26,29 @@
from chat.ircconnection import IrcConnection
from chat.language_channel_config import LanguageChannelConfig
from chat.line_restorer import ChatLineRestorer
-from client.aliasviewer import AliasSearchWindow, AliasWindow
+from client.aliasviewer import AliasSearchWindow
+from client.aliasviewer import AliasWindow
from client.chat_config import ChatConfig
from client.clientstate import ClientState
-from client.connection import (
- ConnectionState,
- Dispatcher,
- LobbyInfo,
- ServerConnection,
- ServerReconnecter,
-)
+from client.connection import ConnectionState
+from client.connection import Dispatcher
+from client.connection import LobbyInfo
+from client.connection import ServerConnection
+from client.connection import ServerReconnecter
from client.gameannouncer import GameAnnouncer
from client.login import LoginWidget
from client.playercolors import PlayerColors
from client.theme_menu import ThemeMenu
-from client.user import (
- User,
- UserRelationController,
- UserRelationModel,
- UserRelations,
- UserRelationTrackers,
-)
+from client.user import User
+from client.user import UserRelationController
+from client.user import UserRelationModel
+from client.user import UserRelations
+from client.user import UserRelationTrackers
from connectivity.ConnectivityDialog import ConnectivityDialog
from coop import CoopWidget
-from downloadManager import (
- MAP_PREVIEW_ROOT,
- AvatarDownloader,
- PreviewDownloader,
-)
+from downloadManager import MAP_PREVIEW_ROOT
+from downloadManager import AvatarDownloader
+from downloadManager import PreviewDownloader
from fa.factions import Factions
from fa.game_runner import GameRunner
from fa.game_session import GameSession
@@ -61,13 +58,16 @@
from games.gamemodel import GameModel
from games.hostgamewidget import build_launcher
from mapGenerator.mapgenManager import MapGeneratorManager
-from model.chat.channel import ChannelID, ChannelType
+from model.chat.channel import ChannelID
+from model.chat.channel import ChannelType
from model.chat.chat import Chat
from model.chat.chatline import ChatLineMetadataBuilder
-from model.gameset import Gameset, PlayerGameIndex
+from model.gameset import Gameset
+from model.gameset import PlayerGameIndex
from model.player import Player
from model.playerset import Playerset
-from model.rating import MatchmakerQueueType, RatingType
+from model.rating import MatchmakerQueueType
+from model.rating import RatingType
from news import NewsWidget
from power import PowerTools
from replays import ReplaysWidget
@@ -79,7 +79,8 @@
from updater import ClientUpdateTools
from vaults.mapvault.mapvault import MapVault
from vaults.modvault.modvault import ModVault
-from vaults.modvault.utils import getModFolder, setModFolder
+from vaults.modvault.utils import getModFolder
+from vaults.modvault.utils import setModFolder
from .mouse_position import MousePosition
from .oauth_dialog import OAuthWidget
@@ -283,7 +284,7 @@ def __init__(self, *args, **kwargs):
# Process used to run Forged Alliance (managed in module fa)
fa.instance.started.connect(self.started_fa)
fa.instance.finished.connect(self.finished_fa)
- fa.instance.error.connect(self.error_fa)
+ fa.instance.errorOccurred.connect(self.error_fa)
self.gameset.added.connect(fa.instance.newServerGame)
# Local Replay Server
@@ -306,13 +307,13 @@ def __init__(self, *args, **kwargs):
# Frameless
self.setWindowFlags(
- QtCore.Qt.FramelessWindowHint
- | QtCore.Qt.WindowSystemMenuHint
- | QtCore.Qt.WindowMinimizeButtonHint,
+ QtCore.Qt.WindowType.FramelessWindowHint
+ | QtCore.Qt.WindowType.WindowSystemMenuHint
+ | QtCore.Qt.WindowType.WindowMinimizeButtonHint,
)
self.rubber_band = QtWidgets.QRubberBand(
- QtWidgets.QRubberBand.Rectangle,
+ QtWidgets.QRubberBand.Shape.Rectangle,
)
self.mouse_position = MousePosition(self)
@@ -352,7 +353,7 @@ def __init__(self, *args, **kwargs):
self.topLayout.addWidget(close)
self.topLayout.setSpacing(0)
self.setSizePolicy(
- QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed,
+ QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed,
)
self.is_window_maximized = False
@@ -491,17 +492,17 @@ def on_disconnected(self):
self.games.stopSearch()
def appStateChanged(self, state):
- if state == QtCore.Qt.ApplicationInactive:
+ if state == QtCore.Qt.ApplicationState.ApplicationInactive:
self._lastDeactivateTime = time.time()
def eventFilter(self, obj, event):
- if event.type() == QtCore.QEvent.HoverMove:
+ if event.type() == QtCore.QEvent.Type.HoverMove:
self.dragging_hover = self.dragging
if self.dragging:
- self.resize_widget(self.mapToGlobal(event.pos()))
+ self.resize_widget(self.mapToGlobal(event.position()))
else:
if not self.is_window_maximized:
- self.mouse_position.update_mouse_position(event.pos())
+ self.mouse_position.update_mouse_position(event.position())
else:
self.mouse_position.reset_to_false()
self.update_cursor_shape()
@@ -514,32 +515,35 @@ def update_cursor_shape(self):
or self.mouse_position.on_bottom_right_edge
):
self.mouse_position.cursor_shape_change = True
- self.setCursor(QtCore.Qt.SizeFDiagCursor)
+ self.setCursor(QtCore.Qt.CursorShape.SizeFDiagCursor)
elif (
self.mouse_position.on_top_right_edge
or self.mouse_position.on_bottom_left_edge
):
- self.setCursor(QtCore.Qt.SizeBDiagCursor)
+ self.setCursor(QtCore.Qt.CursorShape.SizeBDiagCursor)
self.mouse_position.cursor_shape_change = True
elif (
self.mouse_position.on_left_edge
or self.mouse_position.on_right_edge
):
- self.setCursor(QtCore.Qt.SizeHorCursor)
+ self.setCursor(QtCore.Qt.CursorShape.SizeHorCursor)
self.mouse_position.cursor_shape_change = True
elif (
self.mouse_position.on_top_edge
or self.mouse_position.on_bottom_edge
):
- self.setCursor(QtCore.Qt.SizeVerCursor)
+ self.setCursor(QtCore.Qt.CursorShape.SizeVerCursor)
self.mouse_position.cursor_shape_change = True
else:
if self.mouse_position.cursor_shape_change:
self.unsetCursor()
self.mouse_position.cursor_shape_change = False
- def handle_tray_icon_activation(self, reason):
- if reason == QtWidgets.QSystemTrayIcon.Trigger:
+ def handle_tray_icon_activation(
+ self,
+ reason: QtWidgets.QSystemTrayIcon.ActivationReason,
+ ) -> None:
+ if reason is QtWidgets.QSystemTrayIcon.ActivationReason.Trigger:
inactiveTime = time.time() - self._lastDeactivateTime
if (
self.isMinimized()
@@ -548,7 +552,7 @@ def handle_tray_icon_activation(self, reason):
self.show_normal()
else:
self.showMinimized()
- elif reason == QtWidgets.QSystemTrayIcon.Context:
+ elif reason is QtWidgets.QSystemTrayIcon.ActivationReason.Context:
position = QtGui.QCursor.pos()
position.setY(position.y() - self.tray.contextMenu().height())
self.tray.contextMenu().popup(position)
@@ -567,7 +571,7 @@ def show_max_restore(self):
self.is_window_maximized = True
self.current_geometry = self.geometry()
self.setGeometry(
- QtWidgets.QDesktopWidget().availableGeometry(self),
+ QtWidgets.QApplication.primaryScreen().availableGeometry(),
)
def mouseDoubleClickEvent(self, event):
@@ -584,7 +588,7 @@ def mouseReleaseEvent(self, event):
# self.show_max_restore()
def mousePressEvent(self, event):
- if event.button() == QtCore.Qt.LeftButton:
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
if (
self.mouse_position.is_on_edge()
and not self.is_window_maximized
@@ -595,22 +599,23 @@ def mousePressEvent(self, event):
self.dragging = False
self.moving = True
- self.offset = event.pos()
+ self.offset = event.position()
def mouseMoveEvent(self, event):
if self.dragging and not self.dragging_hover:
- self.resize_widget(event.globalPos())
+ self.resize_widget(event.globalPosition())
elif self.moving and self.offset is not None:
- desktop = QtWidgets.QDesktopWidget().availableGeometry(self)
- if event.globalPos().y() == 0:
+ desktop = QtWidgets.QApplication.primaryScreen().availableGeometry()
+ # desktop = QtWidgets.QDesktopWidget().availableGeometry(self)
+ if event.globalPosition().y() == 0:
self.rubber_band.setGeometry(desktop)
self.rubber_band.show()
- elif event.globalPos().x() == 0:
+ elif event.globalPosition().x() == 0:
desktop.setRight(desktop.right() / 2.0)
self.rubber_band.setGeometry(desktop)
self.rubber_band.show()
- elif event.globalPos().x() == desktop.right():
+ elif event.globalPosition().x() == desktop.right():
desktop.setRight(desktop.right() / 2.0)
desktop.moveLeft(desktop.right())
self.rubber_band.setGeometry(desktop)
@@ -621,10 +626,12 @@ def mouseMoveEvent(self, event):
if self.is_window_maximized:
self.show_max_restore()
- self.move(event.globalPos() - self.offset)
+ point_f = event.globalPosition() - self.offset
+ self.move(point_f.toPoint())
- def resize_widget(self, mouse_position):
- if mouse_position.y() == 0:
+ def resize_widget(self, mouse_position: QtCore.QRectF) -> None:
+ mouse_point = mouse_position.toPoint()
+ if mouse_point.y() == 0:
self.rubber_band.setGeometry(
QtWidgets.QDesktopWidget().availableGeometry(self),
)
@@ -638,26 +645,25 @@ def resize_widget(self, mouse_position):
min_width = self.minimumWidth()
min_height = self.minimumHeight()
if self.mouse_position.on_top_left_edge:
- left = mouse_position.x()
- top = mouse_position.y()
-
+ left = mouse_point.x()
+ top = mouse_point.y()
elif self.mouse_position.on_bottom_left_edge:
- left = mouse_position.x()
- bottom = mouse_position.y()
+ left = mouse_point.x()
+ bottom = mouse_point.y()
elif self.mouse_position.on_top_right_edge:
- right = mouse_position.x()
- top = mouse_position.y()
+ right = mouse_point.x()
+ top = mouse_point.y()
elif self.mouse_position.on_bottom_right_edge:
- right = mouse_position.x()
- bottom = mouse_position.y()
+ right = mouse_point.x()
+ bottom = mouse_point.y()
elif self.mouse_position.on_left_edge:
- left = mouse_position.x()
+ left = mouse_point.x()
elif self.mouse_position.on_right_edge:
- right = mouse_position.x()
+ right = mouse_point.x()
elif self.mouse_position.on_top_edge:
- top = mouse_position.y()
+ top = mouse_point.y()
elif self.mouse_position.on_bottom_edge:
- bottom = mouse_position.y()
+ bottom = mouse_point.y()
new_rect = QtCore.QRect(
QtCore.QPoint(left, top),
@@ -844,8 +850,8 @@ def setup(self):
"A player of your skill level is currently searching for a 1v1 "
"game. Click a faction to join them! ",
)
- self.warnPlayer.setAlignment(QtCore.Qt.AlignHCenter)
- self.warnPlayer.setAlignment(QtCore.Qt.AlignVCenter)
+ self.warnPlayer.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
+ self.warnPlayer.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
self.warnPlayer.setProperty("warning", True)
self.warning.addStretch()
self.warning.addWidget(self.warnPlayer)
@@ -1266,7 +1272,7 @@ def set_ice_adapter_window_launch_delay(self):
@QtCore.pyqtSlot()
def switchPath(self):
- fa.wizards.Wizard(self).exec_()
+ fa.wizards.Wizard(self).exec()
@QtCore.pyqtSlot()
def clearSettings(self):
@@ -1337,7 +1343,7 @@ def connectivityDialog(self):
def linkAbout(self):
dialog = util.THEME.loadUi("client/about.ui")
dialog.version_label.setText("Version: {}".format(util.VERSION_STRING))
- dialog.exec_()
+ dialog.exec()
@QtCore.pyqtSlot()
def check_for_updates(self):
@@ -1577,10 +1583,10 @@ def show_login_widget(self):
login_widget.finished.connect(self.on_widget_login_data)
login_widget.rejected.connect(self.on_widget_no_login)
login_widget.request_quit.connect(
- self.on_login_widget_quit, QtCore.Qt.QueuedConnection,
+ self.on_login_widget_quit, QtCore.Qt.ConnectionType.QueuedConnection,
)
login_widget.remember.connect(self.set_remember)
- login_widget.exec_()
+ login_widget.exec()
def on_widget_login_data(self, api_changed):
self.lobby_connection.setHostFromConfig()
@@ -1605,7 +1611,7 @@ def on_widget_login_data(self, api_changed):
)
oauth_widget.finished.connect(self.oauth_finished)
oauth_widget.rejected.connect(self.on_widget_no_login)
- oauth_widget.exec_()
+ oauth_widget.exec()
def oauth_finished(self, state, code, error):
token_url = config.Settings.get("oauth/host") + OAUTH_TOKEN_PATH
diff --git a/src/client/aliasviewer.py b/src/client/aliasviewer.py
index ebbbb2a8a..58a2d1355 100644
--- a/src/client/aliasviewer.py
+++ b/src/client/aliasviewer.py
@@ -1,7 +1,9 @@
import logging
-from PyQt5 import QtWidgets
-from PyQt5.QtCore import QDateTime, Qt, QTimer
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QDateTime
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QTimer
from api.player_api import PlayerApiConnector
@@ -79,7 +81,7 @@ def nick_times(self, name_records):
]
for record in past_records:
- isoTime = QDateTime.fromString(record["changeTime"], Qt.ISODate)
+ isoTime = QDateTime.fromString(record["changeTime"], Qt.DateFormat.ISODate)
record["changeTime"] = isoTime.toLocalTime()
past_records.sort(key=lambda record: record["changeTime"])
diff --git a/src/client/chat_config.py b/src/client/chat_config.py
index fc794e8c7..ab153652c 100644
--- a/src/client/chat_config.py
+++ b/src/client/chat_config.py
@@ -1,4 +1,4 @@
-from PyQt5 import QtCore
+from PyQt6 import QtCore
from chat.chatter_model import ChatterLayoutElements
from client.user import SignallingSet # TODO - move to util
diff --git a/src/client/connection.py b/src/client/connection.py
index 6674969bf..f74441125 100644
--- a/src/client/connection.py
+++ b/src/client/connection.py
@@ -3,11 +3,13 @@
import sys
from enum import IntEnum
-from PyQt5 import QtCore, QtNetwork
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
import fa
from config import Settings
-from model.game import Game, message_to_game_args
+from model.game import Game
+from model.game import message_to_game_args
logger = logging.getLogger(__name__)
@@ -154,8 +156,8 @@ def __init__(self, host, port, dispatch):
QtCore.QObject.__init__(self)
self.socket = QtNetwork.QTcpSocket()
self.socket.readyRead.connect(self.readFromServer)
- self.socket.error.connect(self.socketError)
- self.socket.setSocketOption(QtNetwork.QTcpSocket.KeepAliveOption, 1)
+ self.socket.errorOccurred.connect(self.socketError)
+ self.socket.setSocketOption(QtNetwork.QTcpSocket.SocketOption.KeepAliveOption, 1)
self.socket.stateChanged.connect(self.on_socket_state_change)
self._host = host
@@ -167,7 +169,7 @@ def __init__(self, host, port, dispatch):
self._dispatch = dispatch
def on_socket_state_change(self, state):
- states = QtNetwork.QAbstractSocket
+ states = QtNetwork.QAbstractSocket.SocketState
my_state = None
if state == states.UnconnectedState or state == states.BoundState:
my_state = ConnectionState.DISCONNECTED
@@ -233,7 +235,7 @@ def on_connected(self):
self.connected.emit()
def socket_connected(self):
- return self.socket.state() == QtNetwork.QTcpSocket.ConnectedState
+ return self.socket.state() == QtNetwork.QTcpSocket.SocketState.ConnectedState
def disconnect_(self):
self.socket.disconnectFromHost()
@@ -279,7 +281,7 @@ def writeToServer(self, action, *args, **kw):
message = (action + "\n").encode()
# it looks like there's a crash in Qt
# when sending to an unconnected socket
- if self.socket.state() == QtNetwork.QAbstractSocket.ConnectedState:
+ if self.socket.state() == QtNetwork.QAbstractSocket.SocketState.ConnectedState:
self.socket.write(message)
def send(self, message):
@@ -309,10 +311,10 @@ def on_disconnect(self):
@QtCore.pyqtSlot(QtNetwork.QAbstractSocket.SocketError)
def socketError(self, error):
if (
- error == QtNetwork.QAbstractSocket.SocketTimeoutError
- or error == QtNetwork.QAbstractSocket.NetworkError
- or error == QtNetwork.QAbstractSocket.ConnectionRefusedError
- or error == QtNetwork.QAbstractSocket.RemoteHostClosedError
+ error == QtNetwork.QAbstractSocket.SocketError.SocketTimeoutError
+ or error == QtNetwork.QAbstractSocket.SocketError.NetworkError
+ or error == QtNetwork.QAbstractSocket.SocketError.ConnectionRefusedError
+ or error == QtNetwork.QAbstractSocket.SocketError.RemoteHostClosedError
):
logger.info(
"Timeout/network error: {}".format(self.socket.errorString()),
diff --git a/src/client/gameannouncer.py b/src/client/gameannouncer.py
index 11cdd128f..5c84f7d85 100644
--- a/src/client/gameannouncer.py
+++ b/src/client/gameannouncer.py
@@ -1,4 +1,6 @@
-from PyQt5.QtCore import QObject, QTimer, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QTimer
+from PyQt6.QtCore import pyqtSignal
from fa import maps
from model.game import GameState
diff --git a/src/client/login.py b/src/client/login.py
index 1f2a742e3..f6b50a28c 100644
--- a/src/client/login.py
+++ b/src/client/login.py
@@ -1,6 +1,7 @@
import logging
-from PyQt5 import QtCore, QtGui
+from PyQt6 import QtCore
+from PyQt6 import QtGui
import config
import util
diff --git a/src/client/oauth_dialog.py b/src/client/oauth_dialog.py
index 24a3abf19..02fbe793c 100644
--- a/src/client/oauth_dialog.py
+++ b/src/client/oauth_dialog.py
@@ -1,6 +1,9 @@
import logging
-from PyQt5 import QtCore, QtGui, QtWebEngineWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWebEngineCore
+from PyQt6 import QtWebEngineWidgets
import util
@@ -44,11 +47,11 @@ def navigationRequestAccepted(self, url):
self.finished.emit("", "", error)
-class OAuthWebPage(QtWebEngineWidgets.QWebEnginePage):
+class OAuthWebPage(QtWebEngineCore.QWebEnginePage):
navigationRequestAccepted = QtCore.pyqtSignal(QtCore.QUrl)
def __init__(self):
- QtWebEngineWidgets.QWebEnginePage.__init__(self)
+ QtWebEngineCore.QWebEnginePage.__init__(self)
def acceptNavigationRequest(self, url, type_, isMainFrame):
if "oauth" in url.url() or "localhost" in url.url():
diff --git a/src/client/playercolors.py b/src/client/playercolors.py
index 8c27a17fb..f5960b895 100644
--- a/src/client/playercolors.py
+++ b/src/client/playercolors.py
@@ -2,7 +2,8 @@
import random
from enum import Enum
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
class PlayerAffiliation(Enum):
diff --git a/src/client/theme_menu.py b/src/client/theme_menu.py
index 9551c71d9..2f1c0f422 100644
--- a/src/client/theme_menu.py
+++ b/src/client/theme_menu.py
@@ -1,4 +1,4 @@
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import util
diff --git a/src/client/user.py b/src/client/user.py
index 9f3e404dc..ca0221061 100644
--- a/src/client/user.py
+++ b/src/client/user.py
@@ -1,7 +1,8 @@
from collections.abc import MutableSet
-from PyQt5 import QtCore
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6 import QtCore
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
class User(QtCore.QObject):
diff --git a/src/config/__init__.py b/src/config/__init__.py
index ba805fc99..c4e653daa 100644
--- a/src/config/__init__.py
+++ b/src/config/__init__.py
@@ -4,16 +4,16 @@
import os
import sys
import traceback
-from logging.handlers import MemoryHandler, RotatingFileHandler
+from logging.handlers import MemoryHandler
+from logging.handlers import RotatingFileHandler
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import fafpath
-
-from . import version
-from .develop import default_values as develop_defaults
-from .production import default_values as production_defaults
-from .testing import default_values as testing_defaults
+from config import version
+from config.develop import default_values as develop_defaults
+from config.production import default_values as production_defaults
+from config.testing import default_values as testing_defaults
if sys.platform == 'win32':
import ctypes
@@ -25,8 +25,8 @@
from . import admin
_settings = QtCore.QSettings(
- QtCore.QSettings.IniFormat,
- QtCore.QSettings.UserScope,
+ QtCore.QSettings.Format.IniFormat,
+ QtCore.QSettings.Scope.UserScope,
"ForgedAllianceForever",
"FA Lobby",
)
@@ -306,15 +306,15 @@ def setup_file_handler(filename):
def qt_log_handler(type_, context, text):
loglvl = None
- if type_ == QtCore.QtDebugMsg:
+ if type_ == QtCore.QtMsgType.QtDebugMsg:
loglvl = logging.DEBUG
- elif type_ == QtCore.QtInfoMsg:
+ elif type_ == QtCore.QtMsgType.QtInfoMsg:
loglvl = logging.INFO
- elif type_ == QtCore.QtWarningMsg:
+ elif type_ == QtCore.QtMsgType.QtWarningMsg:
loglvl = logging.WARNING
- elif type_ == QtCore.QtCriticalMsg:
+ elif type_ == QtCore.QtMsgType.QtCriticalMsg:
loglvl = logging.ERROR
- elif type_ == QtCore.QtFatalMsg:
+ elif type_ == QtCore.QtMsgType.QtFatalMsg:
loglvl = logging.CRITICAL
if loglvl is None:
return
diff --git a/src/connectivity/ConnectivityDialog.py b/src/connectivity/ConnectivityDialog.py
index 8d5f5c74a..2c3aad6fa 100644
--- a/src/connectivity/ConnectivityDialog.py
+++ b/src/connectivity/ConnectivityDialog.py
@@ -1,9 +1,13 @@
import pprint
-from PyQt5.QtCore import Qt, QTimer
-from PyQt5.QtWidgets import QHeaderView, QInputDialog, QTableWidgetItem
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QTimer
+from PyQt6.QtWidgets import QHeaderView
+from PyQt6.QtWidgets import QInputDialog
+from PyQt6.QtWidgets import QTableWidgetItem
import client as clientwindow
+from connectivity.IceAdapterClient import IceAdapterClient
from decorators import with_logger
from util import THEME
@@ -21,7 +25,7 @@ class ConnectivityDialog(object):
columnCount = 8
- def __init__(self, ice_adapter_client):
+ def __init__(self, ice_adapter_client: IceAdapterClient) -> None:
self.client = ice_adapter_client
self.client.statusChanged.connect(self.onStatus)
self.client.gpgnetmessageReceived.connect(self.onGpgnetMessage)
@@ -29,7 +33,7 @@ def __init__(self, ice_adapter_client):
self.dialog = THEME.loadUi('connectivity/connectivity.ui')
# need to set the parent like this to make sure this dialog closes on
# closing the client. also needed for consistent theming
- self.dialog.setParent(clientwindow.instance, Qt.Dialog)
+ self.dialog.setParent(clientwindow.instance, Qt.WindowType.Dialog)
# the table header needs theming,
# and using "QHeaderView::section { background-color: green; }"
@@ -43,12 +47,10 @@ def __init__(self, ice_adapter_client):
"""
self.dialog.table_relays.horizontalHeader().setStyleSheet(stylesheet)
self.dialog.table_relays.horizontalHeader().setSectionResizeMode(
- QHeaderView.Stretch,
+ QHeaderView.ResizeMode.Stretch,
)
self.dialog.table_relays.horizontalHeader().setFixedHeight(30)
- self.dialog.table_relays.verticalHeader().setSectionResizeMode(
- QHeaderView.Fixed,
- )
+ self.dialog.table_relays.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
self.dialog.table_relays.verticalHeader().hide()
self.dialog.finished.connect(self.close)
@@ -157,5 +159,5 @@ def onStatus(self, status):
def tableItem(self, data):
item = QTableWidgetItem(str(data))
- item.setTextAlignment(Qt.AlignCenter)
+ item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
return item
diff --git a/src/connectivity/IceAdapterClient.py b/src/connectivity/IceAdapterClient.py
index da450433e..b51e6b4e0 100644
--- a/src/connectivity/IceAdapterClient.py
+++ b/src/connectivity/IceAdapterClient.py
@@ -1,6 +1,6 @@
import json
-from PyQt5.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSignal
import client
from client.connection import ConnectionState
diff --git a/src/connectivity/IceAdapterProcess.py b/src/connectivity/IceAdapterProcess.py
index 0b2caac51..e9d8e7c1d 100644
--- a/src/connectivity/IceAdapterProcess.py
+++ b/src/connectivity/IceAdapterProcess.py
@@ -1,9 +1,11 @@
import os
import sys
-from PyQt5.QtCore import QProcess, QProcessEnvironment
-from PyQt5.QtNetwork import QHostAddress, QTcpServer
-from PyQt5.QtWidgets import QMessageBox
+from PyQt6.QtCore import QProcess
+from PyQt6.QtCore import QProcessEnvironment
+from PyQt6.QtNetwork import QHostAddress
+from PyQt6.QtNetwork import QTcpServer
+from PyQt6.QtWidgets import QMessageBox
import fafpath
from config import Settings
@@ -17,7 +19,7 @@ def __init__(self, player_id, player_login):
# determine free listen port for the RPC server inside the ice adapter
# process
s = QTcpServer()
- s.listen(QHostAddress.LocalHost, 0)
+ s.listen(QHostAddress.SpecialAddress.LocalHost, 0)
self._rpc_server_port = s.serverPort()
s.close()
@@ -93,8 +95,8 @@ def on_error_ready(self):
for line in standard_error.splitlines():
self._logger.debug("ICEERROR: " + line)
- def on_exit(self, code, status):
- if status == QProcess.CrashExit:
+ def on_exit(self, code: int, status: QProcess.ExitStatus) -> None:
+ if status == QProcess.ExitStatus.CrashExit:
self._logger.error("the ICE crashed")
QMessageBox.critical(
None, "ICE adapter error",
@@ -119,10 +121,10 @@ def rpc_port(self):
return self._rpc_server_port
def close(self):
- if self.ice_adapter_process.state() == QProcess.Running:
+ if self.ice_adapter_process.state() == QProcess.ProcessState.Running:
self._logger.info("Waiting for ice adapter process shutdown")
if not self.ice_adapter_process.waitForFinished(1000):
- if self.ice_adapter_process.state() == QProcess.Running:
+ if self.ice_adapter_process.state() == QProcess.ProcessState.Running:
self._logger.error("Terminating ice adapter process")
self.ice_adapter_process.terminate()
if not self.ice_adapter_process.waitForFinished(1000):
diff --git a/src/connectivity/IceServersPoller.py b/src/connectivity/IceServersPoller.py
index 4783aa289..a05ff3d68 100644
--- a/src/connectivity/IceServersPoller.py
+++ b/src/connectivity/IceServersPoller.py
@@ -1,6 +1,8 @@
-from datetime import datetime, timedelta
+from datetime import datetime
+from datetime import timedelta
-from PyQt5.QtCore import QObject, QTimer
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QTimer
from decorators import with_logger
diff --git a/src/connectivity/JsonRpcTcpClient.py b/src/connectivity/JsonRpcTcpClient.py
index 307e68ed4..bf4f86858 100644
--- a/src/connectivity/JsonRpcTcpClient.py
+++ b/src/connectivity/JsonRpcTcpClient.py
@@ -1,8 +1,9 @@
import json
-from PyQt5 import QtCore
-from PyQt5.QtCore import QObject
-from PyQt5.QtNetwork import QAbstractSocket, QTcpSocket
+from PyQt6 import QtCore
+from PyQt6.QtCore import QObject
+from PyQt6.QtNetwork import QAbstractSocket
+from PyQt6.QtNetwork import QTcpSocket
from decorators import with_logger
@@ -14,7 +15,7 @@ def __init__(self, request_handler_instance):
self.socket = QTcpSocket(self)
self.connectionAttempts = 1
self.socket.readyRead.connect(self.onData)
- self.socket.error.connect(self.onSocketError)
+ self.socket.errorOccurred.connect(self.onSocketError)
self.request_handler_instance = request_handler_instance
self.nextid = 1
self.callbacks_result = {}
@@ -29,11 +30,11 @@ def connect_(self, host, port, blocking=False):
self.socket.waitForConnected(5000)
def isConnected(self):
- return self.socket.state() == QAbstractSocket.ConnectedState
+ return self.socket.state() == QAbstractSocket.SocketState.ConnectedState
@QtCore.pyqtSlot(QAbstractSocket.SocketError)
def onSocketError(self, error):
- if (error == QAbstractSocket.ConnectionRefusedError):
+ if (error == QAbstractSocket.SocketError.ConnectionRefusedError):
self.socket.connectToHost(self.host, self.port)
self.connectionAttempts += 1
# self._logger.info("Reconnecting to JSONRPC server {}"
@@ -167,7 +168,7 @@ def call(
callback_error=None,
blocking=False,
):
- if self.socket.state() != QAbstractSocket.ConnectedState:
+ if self.socket.state() != QAbstractSocket.SocketState.ConnectedState:
raise RuntimeError("Not connected to the JSONRPC server.")
rpcObject = {
"method": method,
diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py
index 4e2915970..035cc8620 100644
--- a/src/coop/_coopwidget.py
+++ b/src/coop/_coopwidget.py
@@ -1,12 +1,16 @@
import logging
import os
-from PyQt5 import QtCore, QtGui, QtWidgets
-from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkRequest
import fa
import util
-from coop.coopmapitem import CoopMapItem, CoopMapItemDelegate
+from coop.coopmapitem import CoopMapItem
+from coop.coopmapitem import CoopMapItemDelegate
from coop.coopmodel import CoopGameFilterModel
from fa.replay import replay
from ui.busy_widget import BusyWidget
@@ -43,7 +47,7 @@ def __init__(
self.client.lobby_info.coopInfo.connect(self.processCoopInfo)
self.coopList.header().setSectionResizeMode(
- 0, QtWidgets.QHeaderView.ResizeToContents,
+ 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
self.coopList.setItemDelegate(CoopMapItemDelegate(self))
@@ -100,7 +104,8 @@ def finishRequest(self, reply):
"temp.fafreplay",
),
)
- faf_replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate)
+ open_mode = QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.OpenModeFlag.Truncate
+ faf_replay.open(open_mode)
faf_replay.write(reply.readAll())
faf_replay.flush()
faf_replay.close()
@@ -144,7 +149,7 @@ def processLeaderBoardInfos(self, message):
formatter = self.FORMATTER_LADDER
formatter_header = self.FORMATTER_LADDER_HEADER
cursor = w.textCursor()
- cursor.movePosition(QtGui.QTextCursor.End)
+ cursor.movePosition(QtGui.QTextCursor.MoveOperation.End)
w.setTextCursor(cursor)
color = "lime"
line = formatter_header.format(
diff --git a/src/coop/coopmapitem.py b/src/coop/coopmapitem.py
index d7f3aa464..5b95c6331 100644
--- a/src/coop/coopmapitem.py
+++ b/src/coop/coopmapitem.py
@@ -1,11 +1,13 @@
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
class CoopMapItemDelegate(QtWidgets.QStyledItemDelegate):
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, **kwargs) -> None:
QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
def paint(self, painter, option, index, *args, **kwargs):
@@ -15,7 +17,7 @@ def paint(self, painter, option, index, *args, **kwargs):
html = QtGui.QTextDocument()
textOption = QtGui.QTextOption()
- textOption.setWrapMode(QtGui.QTextOption.WordWrap)
+ textOption.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap)
html.setDefaultTextOption(textOption)
html.setTextWidth(option.rect.width())
@@ -25,7 +27,7 @@ def paint(self, painter, option, index, *args, **kwargs):
# rendering these parts ourselves
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
# Description
painter.translate(option.rect.left(), option.rect.top())
@@ -34,11 +36,17 @@ def paint(self, painter, option, index, *args, **kwargs):
painter.restore()
- def sizeHint(self, option, index, *args, **kwargs):
+ def sizeHint(
+ self,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ *args,
+ **kwargs,
+ ) -> None:
self.initStyleOption(option, index)
html = QtGui.QTextDocument()
textOption = QtGui.QTextOption()
- textOption.setWrapMode(QtGui.QTextOption.WordWrap)
+ textOption.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap)
html.setTextWidth(option.rect.width())
html.setDefaultTextOption(textOption)
html.setHtml(option.text)
@@ -88,9 +96,9 @@ def display(self, column):
return self.viewtext
def data(self, column, role):
- if role == QtCore.Qt.DisplayRole:
+ if role == QtCore.Qt.ItemDataRole.DisplayRole:
return self.display(column)
- elif role == QtCore.Qt.UserRole:
+ elif role == QtCore.Qt.ItemDataRole.UserRole:
return self
return super(CoopMapItem, self).data(column, role)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index 0de65e0c8..4e6334ec0 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -4,9 +4,14 @@
import urllib.parse
import urllib.request
-from PyQt5 import QtCore, QtGui, QtWidgets
-from PyQt5.QtCore import QObject, QUrl, pyqtSignal
-from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkRequest
from config import Settings
@@ -58,7 +63,7 @@ def cancel(self):
def _finish(self):
# check status code
statusCode = self._dfile.attribute(
- QNetworkRequest.HttpStatusCodeAttribute,
+ QNetworkRequest.Attribute.HttpStatusCodeAttribute,
)
if statusCode != 200:
logger.debug(
@@ -71,13 +76,12 @@ def run(self):
self._running = True
req = QNetworkRequest(QUrl(self.addr))
req.setRawHeader(b'User-Agent', b"FAF Client")
- req.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True)
req.setMaximumRedirectsAllowed(3)
self.start.emit(self)
self._dfile = self._nam.get(req)
- self._dfile.error.connect(self._error)
+ self._dfile.errorOccurred.connect(self._error)
self._dfile.finished.connect(self._atFinished)
self._dfile.downloadProgress.connect(self._atProgress)
self._dfile.readyRead.connect(self._kick_read)
@@ -118,7 +122,7 @@ def succeeded(self):
return not self.error and not self.canceled
def waitForCompletion(self):
- waitFlag = QtCore.QEventLoop.WaitForMoreEvents
+ waitFlag = QtCore.QEventLoop.ProcessEventsFlag.WaitForMoreEvents
while self._running:
QtWidgets.QApplication.processEvents(waitFlag)
@@ -159,7 +163,7 @@ def _prepare_dl(self):
def _get_cachefile(self, name):
imgpath = os.path.join(self._target_dir, name)
img = QtCore.QFile(imgpath)
- img.open(QtCore.QIODevice.WriteOnly)
+ img.open(QtCore.QIODevice.OpenModeFlag.WriteOnly)
return img, imgpath
def remove_request(self, req):
diff --git a/src/fa/check.py b/src/fa/check.py
index edbd23798..166f5eec5 100644
--- a/src/fa/check.py
+++ b/src/fa/check.py
@@ -1,22 +1,29 @@
+from __future__ import annotations
+
import binascii
import logging
import os
import zipfile
+from typing import TYPE_CHECKING
-from PyQt5 import QtWidgets
+from PyQt6 import QtWidgets
import config
import fa
import util
from fa.mods import checkMods
-from fa.path import validatePath, writeFAPathLua
+from fa.path import validatePath
+from fa.path import writeFAPathLua
from fa.wizards import Wizard
from mapGenerator.mapgenUtils import isGeneratedMap
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
+
logger = logging.getLogger(__name__)
-def map_(mapname, force=False, silent=False):
+def map_(mapname: str, force: bool = False, silent: bool = False) -> bool:
"""
Assures that the map is available in FA, or returns false.
"""
@@ -28,10 +35,12 @@ def map_(mapname, force=False, silent=False):
if isGeneratedMap(mapname):
import client
- return client.instance.map_generator.generateMap(mapname)
+
+ # FIXME: generateMap, downloadMap should also return bool
+ return bool(client.instance.map_generator.generateMap(mapname))
if force:
- return fa.maps.downloadMap(mapname, silent=silent)
+ return bool(fa.maps.downloadMap(mapname, silent=silent))
auto = config.Settings.get('maps/autodownload', default=False, type=bool)
if not auto:
@@ -46,17 +55,17 @@ def map_(mapname, force=False, silent=False):
"downloaded automatically in the future",
)
msgbox.setStandardButtons(
- QtWidgets.QMessageBox.Yes
- | QtWidgets.QMessageBox.YesToAll
- | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes
+ | QtWidgets.QMessageBox.StandardButton.YesToAll
+ | QtWidgets.QMessageBox.StandardButton.No,
)
- result = msgbox.exec_()
- if result == QtWidgets.QMessageBox.No:
+ result = msgbox.exec()
+ if result == QtWidgets.QMessageBox.StandardButton.No:
return False
- elif result == QtWidgets.QMessageBox.YesToAll:
+ elif result == QtWidgets.QMessageBox.StandardButton.YesToAll:
config.Settings.set('maps/autodownload', True)
- return fa.maps.downloadMap(mapname, silent=silent)
+ return bool(fa.maps.downloadMap(mapname, silent=silent))
def featured_mod(featured_mod, version):
@@ -67,7 +76,7 @@ def sim_mod(sim_mod, version):
pass
-def path(parent):
+def path(parent: ClientWindow) -> bool:
while not validatePath(
util.settings.value(
"ForgedAlliance/app/path", "",
@@ -80,8 +89,8 @@ def path(parent):
),
)
wizard = Wizard(parent)
- result = wizard.exec_()
- if result == QtWidgets.QWizard.Rejected:
+ result = wizard.exec()
+ if result == QtWidgets.QWizard.DialogCode.Rejected:
return False
logger.info("Writing fa_path.lua config file.")
diff --git a/src/fa/game_connection.py b/src/fa/game_connection.py
index 8922ef61d..57a68a294 100644
--- a/src/fa/game_connection.py
+++ b/src/fa/game_connection.py
@@ -1,6 +1,9 @@
-from struct import pack, unpack
+from struct import pack
+from struct import unpack
-from PyQt5.QtCore import QDataStream, QObject, pyqtSignal
+from PyQt6.QtCore import QDataStream
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
from decorators import with_logger
diff --git a/src/fa/game_process.py b/src/fa/game_process.py
index e61176586..db1bb0285 100644
--- a/src/fa/game_process.py
+++ b/src/fa/game_process.py
@@ -3,7 +3,8 @@
import re
import sys
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import config
import util
@@ -106,7 +107,7 @@ def run(self, info, arguments, detach=False, init_file=None):
self.setWorkingDirectory(os.path.dirname(executable))
if not detach:
- self.start(command)
+ self.startCommand(command)
else:
# Remove the wrapping " at the start and end of some
# arguments as QT will double wrap when launching
@@ -127,7 +128,7 @@ def run(self, info, arguments, detach=False, init_file=None):
return False
def running(self):
- return self.state() == QtCore.QProcess.Running
+ return self.state() == QtCore.QProcess.ProcessState.Running
def available(self):
if self.running():
@@ -147,7 +148,7 @@ def close(self):
progress = QtWidgets.QProgressDialog()
progress.setCancelButtonText("Terminate")
progress.setWindowFlags(
- QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint,
+ QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint,
)
progress.setAutoClose(False)
progress.setAutoReset(False)
diff --git a/src/fa/game_session.py b/src/fa/game_session.py
index 9a1a1dec2..30e9ab78e 100644
--- a/src/fa/game_session.py
+++ b/src/fa/game_session.py
@@ -1,7 +1,9 @@
import logging
from enum import IntEnum
-from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal
+from PyQt6.QtCore import QCoreApplication
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
import client
from config import setup_file_handler
diff --git a/src/fa/maps.py b/src/fa/maps.py
index ccea949d4..1f23cc200 100644
--- a/src/fa/maps.py
+++ b/src/fa/maps.py
@@ -12,7 +12,8 @@
import urllib.request
import zipfile
-from PyQt5 import QtCore, QtGui
+from PyQt6 import QtCore
+from PyQt6 import QtGui
# module imports
import util
@@ -184,7 +185,7 @@ def getUserMapsFolder():
)
-def genPrevFromDDS(sourcename, destname, small=False):
+def genPrevFromDDS(sourcename: str, destname: str, small: bool = False) -> None:
"""
this opens supcom's dds file (format: bgra8888) and saves to png
"""
@@ -203,18 +204,18 @@ def genPrevFromDDS(sourcename, destname, small=False):
img,
size,
size,
- QtGui.QImage.Format_RGB888,
+ QtGui.QImage.Format.Format_RGB888,
).rgbSwapped().scaled(
100,
100,
- transformMode=QtCore.Qt.SmoothTransformation,
+ transformMode=QtCore.Qt.TransformationMode.SmoothTransformation,
)
else:
imageFile = QtGui.QImage(
img,
size,
size,
- QtGui.QImage.Format_RGB888,
+ QtGui.QImage.Format.Format_RGB888,
).rgbSwapped()
imageFile.save(destname)
except IOError:
diff --git a/src/fa/mods.py b/src/fa/mods.py
index 65f569768..f531849c6 100644
--- a/src/fa/mods.py
+++ b/src/fa/mods.py
@@ -1,6 +1,6 @@
import logging
-from PyQt5 import QtWidgets
+from PyQt6 import QtWidgets
import config
import fa
@@ -40,7 +40,7 @@ def checkMods(mods): # mods is a dictionary of uid-name pairs
| QtWidgets.QMessageBox.YesToAll
| QtWidgets.QMessageBox.No,
)
- result = msgbox.exec_()
+ result = msgbox.exec()
if result == QtWidgets.QMessageBox.No:
return False
elif result == QtWidgets.QMessageBox.YesToAll:
diff --git a/src/fa/replay.py b/src/fa/replay.py
index 3ee5825eb..359eba922 100644
--- a/src/fa/replay.py
+++ b/src/fa/replay.py
@@ -3,13 +3,15 @@
import os
import zstandard
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import fa
import util
from fa.check import check
from fa.replayparser import replayParser
-from util.gameurl import GameUrl, GameUrlType
+from util.gameurl import GameUrl
+from util.gameurl import GameUrlType
logger = logging.getLogger(__name__)
@@ -71,9 +73,11 @@ def replay(source, detach=False):
scfa_replay = QtCore.QFile(
os.path.join(util.CACHE_DIR, "temp.scfareplay"),
)
- scfa_replay.open(
- QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate,
+ open_mode = (
+ QtCore.QIODevice.OpenModeFlag.WriteOnly
+ | QtCore.QIODevice.OpenModeFlag.Truncate
)
+ scfa_replay.open(open_mode)
scfa_replay.write(binary)
scfa_replay.flush()
scfa_replay.close()
diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py
index 8099d3bcb..4bfbc9411 100644
--- a/src/fa/replayserver.py
+++ b/src/fa/replayserver.py
@@ -4,7 +4,9 @@
import os
import time
-from PyQt5 import QtCore, QtNetwork, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
+from PyQt6 import QtWidgets
import fa
import util
@@ -166,7 +168,7 @@ def writeReplayFile(self):
)
replay = QtCore.QFile(filename)
- replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Text)
+ replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.Text)
replay.write(json.dumps(self.replayInfo).encode('utf-8'))
replay.write(b'\n')
replay.write(QtCore.qCompress(self.replayData).toBase64())
@@ -188,9 +190,9 @@ def __init__(self, client, *args, **kwargs):
self.__logger.debug("initializing...")
self.newConnection.connect(self.acceptConnection)
- def doListen(self):
+ def doListen(self) -> bool:
while not self.isListening():
- self.listen(QtNetwork.QHostAddress.LocalHost, 0)
+ self.listen(QtNetwork.QHostAddress.SpecialAddress.LocalHost, 0)
if self.isListening():
self.__logger.info(
"listening on address {}:{}".format(
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 37761eb3a..eda68ab16 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -14,11 +14,13 @@
import stat
import time
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import config
import util
-from api.featured_mod_updater import FeaturedModFiles, FeaturedModId
+from api.featured_mod_updater import FeaturedModFiles
+from api.featured_mod_updater import FeaturedModId
from api.sim_mod_updater import SimModFiles
from config import Settings
from vaults.dialogs import downloadFile
@@ -51,12 +53,12 @@ def addWatch(self, watch):
watch.finished.connect(self.watchFinished)
@QtCore.pyqtSlot()
- def watchFinished(self):
+ def watchFinished(self) -> None:
for watch in self.watches:
if not watch.isFinished():
return
# equivalent to self.accept(), but clearer
- self.done(QtWidgets.QDialog.Accepted)
+ self.done(QtWidgets.QDialog.DialogCode.Accepted)
def clearLog():
@@ -115,7 +117,7 @@ def __init__(
sim=False,
silent=False,
*args,
- **kwargs
+ **kwargs,
):
"""
Constructor
@@ -154,7 +156,7 @@ def __init__(
else:
self.progress.setCancelButtonText("Cancel")
self.progress.setWindowFlags(
- QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint,
+ QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint,
)
self.progress.setAutoClose(False)
self.progress.setAutoReset(False)
diff --git a/src/fa/wizards.py b/src/fa/wizards.py
index e2cc9cf79..2a68b328a 100644
--- a/src/fa/wizards.py
+++ b/src/fa/wizards.py
@@ -1,20 +1,29 @@
+from __future__ import annotations
+
import os
+from typing import TYPE_CHECKING
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
-from fa.path import typicalForgedAlliancePaths, validatePath
+from fa.path import typicalForgedAlliancePaths
+from fa.path import validatePath
+
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
+
__author__ = 'Thygrrr'
class UpgradePage(QtWidgets.QWizardPage):
- def __init__(self, parent=None):
+ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super(UpgradePage, self).__init__(parent)
self.setTitle("Specify Forged Alliance folder")
self.setPixmap(
- QtWidgets.QWizard.WatermarkPixmap,
+ QtWidgets.QWizard.WizardPixmap.WatermarkPixmap,
util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"),
)
@@ -59,14 +68,14 @@ def comboChanged(self):
self.completeChanged.emit()
@QtCore.pyqtSlot()
- def showChooser(self):
+ def showChooser(self) -> None:
path = QtWidgets.QFileDialog.getExistingDirectory(
self,
"Select Forged Alliance folder",
self.comboBox.currentText(),
(
- QtWidgets.QFileDialog.DontResolveSymlinks
- | QtWidgets.QFileDialog.ShowDirsOnly
+ QtWidgets.QFileDialog.Option.DontResolveSymlinks
+ | QtWidgets.QFileDialog.Option.ShowDirsOnly
),
)
if path:
@@ -86,20 +95,20 @@ class Wizard(QtWidgets.QWizard):
The actual Wizard which walks the user through the install.
"""
- def __init__(self, client, *args, **kwargs):
+ def __init__(self, client: ClientWindow, *args, **kwargs) -> None:
QtWidgets.QWizard.__init__(self, client, *args, **kwargs)
self.client = client # type - ClientWindow
self.upgrade = UpgradePage()
self.addPage(self.upgrade)
- self.setWizardStyle(QtWidgets.QWizard.ModernStyle)
+ self.setWizardStyle(QtWidgets.QWizard.WizardStyle.ModernStyle)
self.setWindowTitle("Forged Alliance Game Path")
self.setPixmap(
- QtWidgets.QWizard.WatermarkPixmap,
+ QtWidgets.QWizard.WizardPixmap.WatermarkPixmap,
util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"),
)
- self.setOption(QtWidgets.QWizard.NoBackButtonOnStartPage, True)
+ self.setOption(QtWidgets.QWizard.WizardOption.NoBackButtonOnStartPage, True)
def accept(self):
util.settings.setValue(
@@ -108,11 +117,11 @@ def accept(self):
QtWidgets.QWizard.accept(self)
-def constructPathChoices(combobox, validated_choices):
+def constructPathChoices(combobox: QtWidgets.QComboBox, validated_choices: list[str]) -> None:
"""
Creates a combobox with all potentially valid paths for FA on this system
"""
combobox.clear()
for path in validated_choices:
- if combobox.findText(path, QtCore.Qt.MatchFixedString) == -1:
+ if combobox.findText(path, QtCore.Qt.MatchFlag.MatchFixedString) == -1:
combobox.addItem(path)
diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py
index ab2a78dea..12f996216 100644
--- a/src/games/_gameswidget.py
+++ b/src/games/_gameswidget.py
@@ -1,8 +1,11 @@
import logging
-from PyQt5 import QtWidgets
-from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
-from PyQt5.QtGui import QColor, QCursor
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSlot
+from PyQt6.QtGui import QColor
+from PyQt6.QtGui import QCursor
import fa
import util
@@ -10,7 +13,8 @@
from config import Settings
from games.automatchframe import MatchmakerQueue
from games.gamemodel import CustomGameFilterModel
-from games.moditem import ModItem, mod_invisible
+from games.moditem import ModItem
+from games.moditem import mod_invisible
from model.chat.channel import PARTY_CHANNEL_SUFFIX
logger = logging.getLogger(__name__)
@@ -265,12 +269,12 @@ def sortGamesComboChanged(self, index):
self._game_model.sort_type = CustomGameFilterModel.SortType(index)
def teamListItemClicked(self, item):
- if QtWidgets.QApplication.mouseButtons() == Qt.LeftButton:
+ if QtWidgets.QApplication.mouseButtons() == Qt.MouseButton.LeftButton:
# for no good reason doesn't always work as expected
item.setSelected(False)
if (
- QtWidgets.QApplication.mouseButtons() == Qt.RightButton
+ QtWidgets.QApplication.mouseButtons() == Qt.MouseButton.RightButton
and self.party.owner_id == self._me.id
):
self.teamList.setCurrentItem(item)
diff --git a/src/games/automatchframe.py b/src/games/automatchframe.py
index 88032eedb..2b8f6cec5 100644
--- a/src/games/automatchframe.py
+++ b/src/games/automatchframe.py
@@ -1,7 +1,12 @@
+from __future__ import annotations
+
import logging
from functools import partial
+from typing import TYPE_CHECKING
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import fa
import util
@@ -13,10 +18,20 @@
logger = logging.getLogger(__name__)
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
+ from games._gameswidget import GamesWidget
+
class MatchmakerQueue(FormClass, BaseClass):
- def __init__(self, games, client, queueName, teamSize):
+ def __init__(
+ self,
+ games: GamesWidget,
+ client: ClientWindow,
+ queueName: str,
+ teamSize: int,
+ ) -> None:
BaseClass.__init__(self, games)
self.setupUi(self)
@@ -60,10 +75,10 @@ def __init__(self, games, client, queueName, teamSize):
self.setFactionIcons(self.subFactions)
keys = (
- QtCore.Qt.Key_1, QtCore.Qt.Key_2, QtCore.Qt.Key_3, QtCore.Qt.Key_4,
+ QtCore.Qt.Key.Key_1, QtCore.Qt.Key.Key_2, QtCore.Qt.Key.Key_3, QtCore.Qt.Key.Key_4,
)
- self.shortcut = QtWidgets.QShortcut(
- QtGui.QKeySequence(QtCore.Qt.CTRL + keys[self.teamSize - 1]),
+ self.shortcut = QtGui.QShortcut(
+ QtGui.QKeySequence(QtCore.Qt.Key.Key_Control + keys[self.teamSize - 1]),
self.client,
self.startSearchRanked,
)
diff --git a/src/games/gameitem.py b/src/games/gameitem.py
index b75e4ee29..0d89a848b 100644
--- a/src/games/gameitem.py
+++ b/src/games/gameitem.py
@@ -2,7 +2,9 @@
import os
import jinja2
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
from fa import maps
@@ -75,7 +77,7 @@ def _draw_clear_option(self, painter, option):
option.icon = QtGui.QIcon()
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
def _draw_icon_shadow(self, painter, option):
@@ -94,13 +96,14 @@ def _draw_icon(self, painter, option, icon):
self.ICON_CLIP_BOTTOM_RIGHT,
self.ICON_CLIP_BOTTOM_RIGHT,
)
- icon.paint(painter, rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
+ alignment_flags = QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop
+ icon.paint(painter, rect, alignment_flags)
def _draw_frame(self, painter, option):
pen = QtGui.QPen()
pen.setWidth(self.FRAME_THICKNESS)
pen.setBrush(self.FRAME_COLOR)
- pen.setCapStyle(QtCore.Qt.RoundCap)
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawRect(
option.rect.left() + self.ICON_CLIP_TOP_LEFT,
@@ -141,7 +144,7 @@ def __init__(self, formatter):
self._formatter = formatter
def eventFilter(self, obj, event):
- if event.type() == QtCore.QEvent.ToolTip:
+ if event.type() == QtCore.QEvent.Type.ToolTip:
return self._handle_tooltip(obj, event)
else:
return super().eventFilter(obj, event)
diff --git a/src/games/gamemodel.py b/src/games/gamemodel.py
index c87c3db51..69c07471c 100644
--- a/src/games/gamemodel.py
+++ b/src/games/gamemodel.py
@@ -1,6 +1,7 @@
from enum import Enum
-from PyQt5.QtCore import QSortFilterProxyModel, Qt
+from PyQt6.QtCore import QSortFilterProxyModel
+from PyQt6.QtCore import Qt
from games.moditem import mod_invisible
from model.game import GameState
@@ -47,8 +48,8 @@ def __init__(self, me, model):
self.sort(0)
def lessThan(self, leftIndex, rightIndex):
- left = self.sourceModel().data(leftIndex, Qt.DisplayRole).game
- right = self.sourceModel().data(rightIndex, Qt.DisplayRole).game
+ left = self.sourceModel().data(leftIndex, Qt.ItemDataRole.DisplayRole).game
+ right = self.sourceModel().data(rightIndex, Qt.ItemDataRole.DisplayRole).game
comp_list = [self._lt_friend, self._lt_type, self._lt_fallback]
diff --git a/src/games/gamemodelitem.py b/src/games/gamemodelitem.py
index b8e9f506b..5eab67fa3 100644
--- a/src/games/gamemodelitem.py
+++ b/src/games/gamemodelitem.py
@@ -1,4 +1,5 @@
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
from downloadManager import DownloadRequest
from fa import maps
diff --git a/src/games/hostgamewidget.py b/src/games/hostgamewidget.py
index ff54d4fb5..99c1dabf0 100644
--- a/src/games/hostgamewidget.py
+++ b/src/games/hostgamewidget.py
@@ -1,6 +1,6 @@
import logging
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import fa.check
import games.mapgenoptionsdialog as MapGenDialog
@@ -8,7 +8,9 @@
import vaults.modvault.utils
from fa import maps
from games.gamemodel import GameModel
-from model.game import Game, GameState, GameVisibility
+from model.game import Game
+from model.game import GameState
+from model.game import GameVisibility
logger = logging.getLogger(__name__)
@@ -65,7 +67,7 @@ def host_game(self, title, main_mod, mapname=None):
if mapname is not None:
self._game_widget.set_map(mapname)
- return self._game_widget.exec_()
+ return self._game_widget.exec()
def _launch_game(self, game, password, mods):
# Make sure the binaries are all up to date, and abort if the update
@@ -155,7 +157,7 @@ def setup(self, title, game):
]
logger.debug("Active Mods detected: {}".format(str(names)))
for name in names:
- ml = self.modList.findItems(name, QtCore.Qt.MatchExactly)
+ ml = self.modList.findItems(name, QtCore.Qt.MatchFlag.MatchExactly.MatchExactly)
logger.debug("found item: {}".format(ml[0].text()))
if ml:
ml[0].setSelected(True)
@@ -252,7 +254,7 @@ def save_last_hosted_settings(self, password):
@QtCore.pyqtSlot()
def generateMap(self):
dialog = MapGenDialog.MapGenDialog(self)
- dialog.exec_()
+ dialog.exec()
def build_launcher(playerset, me, client, view_builder, map_preview_dler):
diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py
index cd8bfd913..faac64ab3 100644
--- a/src/games/mapgenoptionsdialog.py
+++ b/src/games/mapgenoptionsdialog.py
@@ -1,6 +1,7 @@
from enum import Enum
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import config
import util
@@ -127,8 +128,8 @@ def load_stylesheet(self):
def keyPressEvent(self, event):
if (
- event.key() == QtCore.Qt.Key_Enter
- or event.key() == QtCore.Qt.Key_Return
+ event.key() == QtCore.Qt.Key.Key_Enter
+ or event.key() == QtCore.Qt.Key.Key_Return
):
return
QtWidgets.QDialog.keyPressEvent(self, event)
diff --git a/src/games/moditem.py b/src/games/moditem.py
index de3bf81c8..93fd64680 100644
--- a/src/games/moditem.py
+++ b/src/games/moditem.py
@@ -1,6 +1,7 @@
import os
-from PyQt5 import QtGui, QtWidgets
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import client
import util
diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py
index c53e77f68..752fa0f72 100644
--- a/src/mapGenerator/mapgenManager.py
+++ b/src/mapGenerator/mapgenManager.py
@@ -3,7 +3,9 @@
import os
import random
-from PyQt5 import QtCore, QtNetwork, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
+from PyQt6 import QtWidgets
import util
# local imports
@@ -77,7 +79,7 @@ def generateMap(self, mapname=None, args=None):
| QtWidgets.QMessageBox.YesToAll
| QtWidgets.QMessageBox.No,
)
- result = msgbox.exec_()
+ result = msgbox.exec()
if result == QtWidgets.QMessageBox.No:
return False
elif result == QtWidgets.QMessageBox.YesToAll:
@@ -162,7 +164,7 @@ def checkUpdates(self):
progress = QtWidgets.QProgressDialog()
progress.setCancelButtonText("Cancel")
progress.setWindowFlags(
- QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint,
+ QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint,
)
progress.setAutoClose(False)
progress.setAutoReset(False)
diff --git a/src/mapGenerator/mapgenProcess.py b/src/mapGenerator/mapgenProcess.py
index f6f8569e4..8eb56db90 100644
--- a/src/mapGenerator/mapgenProcess.py
+++ b/src/mapGenerator/mapgenProcess.py
@@ -1,8 +1,12 @@
import logging
import re
-from PyQt5.QtCore import QEventLoop, QProcess, Qt
-from PyQt5.QtWidgets import QApplication, QMessageBox, QProgressDialog
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QProcess
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QMessageBox
+from PyQt6.QtWidgets import QProgressDialog
import fafpath
from config import setup_file_handler
@@ -22,7 +26,7 @@ def __init__(self, gen_path, out_path, args):
self._progress.setWindowTitle("Generating map, please wait...")
self._progress.setCancelButtonText("Cancel")
self._progress.setWindowFlags(
- Qt.CustomizeWindowHint | Qt.WindowTitleHint,
+ Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint,
)
self._progress.setAutoReset(False)
self._progress.setModal(1)
@@ -104,18 +108,18 @@ def on_exit(self, code, status):
generatorLogger.info("<<< --------------------- MapGenerator Shutdown")
def close(self):
- if self.map_generator_process.state() == QProcess.Running:
+ if self.map_generator_process.state() == QProcess.ProcessState.Running:
logger.info("Waiting for map generator process shutdown")
if not self.map_generator_process.waitForFinished(300):
- if self.map_generator_process.state() == QProcess.Running:
+ if self.map_generator_process.state() == QProcess.ProcessState.Running:
logger.error("Terminating map generator process")
self.map_generator_process.terminate()
if not self.map_generator_process.waitForFinished(300):
logger.error("Killing map generator process")
self.map_generator_process.kill()
- def waitForCompletion(self):
+ def waitForCompletion(self) -> None:
'''Copied from downloadManager. I hope it's ok :)'''
- waitFlag = QEventLoop.WaitForMoreEvents
+ waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
while self._running:
QApplication.processEvents(waitFlag)
diff --git a/src/model/chat/channel.py b/src/model/chat/channel.py
index 3fb219516..25c4d0942 100644
--- a/src/model/chat/channel.py
+++ b/src/model/chat/channel.py
@@ -1,6 +1,7 @@
from enum import Enum
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
from model.modelitem import ModelItem
from model.transaction import transactional
diff --git a/src/model/chat/chat.py b/src/model/chat/chat.py
index cb839c1db..7af356e8a 100644
--- a/src/model/chat/chat.py
+++ b/src/model/chat/chat.py
@@ -1,9 +1,8 @@
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
-from model.chat.channelchatterset import (
- ChannelChatterRelation,
- ChannelChatterset,
-)
+from model.chat.channelchatterset import ChannelChatterRelation
+from model.chat.channelchatterset import ChannelChatterset
from model.chat.channelset import Channelset
from model.chat.chatterset import Chatterset
diff --git a/src/model/chat/chatter.py b/src/model/chat/chatter.py
index 395143b37..7c191108c 100644
--- a/src/model/chat/chatter.py
+++ b/src/model/chat/chatter.py
@@ -1,4 +1,4 @@
-from PyQt5.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSignal
from model.modelitem import ModelItem
from model.transaction import transactional
diff --git a/src/model/game.py b/src/model/game.py
index 35290e631..98c560a2e 100644
--- a/src/model/game.py
+++ b/src/model/game.py
@@ -3,12 +3,14 @@
import time
from enum import Enum
-from PyQt5.QtCore import QTimer, pyqtSignal
+from PyQt6.QtCore import QTimer
+from PyQt6.QtCore import pyqtSignal
from decorators import with_logger
from model.modelitem import ModelItem
from model.transaction import transactional
-from util.gameurl import GameUrl, GameUrlType
+from util.gameurl import GameUrl
+from util.gameurl import GameUrlType
class GameState(Enum):
@@ -61,7 +63,7 @@ def __init__(
sim_mods,
password_protected,
visibility,
- **kwargs
+ **kwargs,
):
ModelItem.__init__(self)
@@ -112,7 +114,7 @@ def update(self, **kwargs):
self._check_live_replay_timer()
self.emit_update(old, _transaction)
- def _check_live_replay_timer(self):
+ def _check_live_replay_timer(self) -> None:
if (
self.state != GameState.PLAYING
or self._live_replay_timer.isActive()
@@ -123,9 +125,9 @@ def _check_live_replay_timer(self):
if self.has_live_replay:
return
- time_elapsed = time.time() - self.launched_at
+ time_elapsed = round(time.time() - self.launched_at, 0)
time_to_replay = max(self.LIVE_REPLAY_DELAY_SECS - time_elapsed, 0)
- self._live_replay_timer.start(time_to_replay * 1000)
+ self._live_replay_timer.start(int(time_to_replay * 1000))
@transactional
def _emit_live_replay(self, _transaction=None):
diff --git a/src/model/gameset.py b/src/model/gameset.py
index 117402083..acdfc85ff 100644
--- a/src/model/gameset.py
+++ b/src/model/gameset.py
@@ -1,4 +1,4 @@
-from PyQt5.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSignal
from decorators import with_logger
from model import game
diff --git a/src/model/ircuser.py b/src/model/ircuser.py
index cf6f746ad..e18ef4289 100644
--- a/src/model/ircuser.py
+++ b/src/model/ircuser.py
@@ -1,4 +1,4 @@
-from PyQt5.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSignal
from model.modelitem import ModelItem
from model.transaction import transactional
diff --git a/src/model/modelitem.py b/src/model/modelitem.py
index 5feedebb2..d15eec6bd 100644
--- a/src/model/modelitem.py
+++ b/src/model/modelitem.py
@@ -1,4 +1,4 @@
-from PyQt5.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSignal
from model.qobjectmapping import QObject
from model.transaction import transactional
diff --git a/src/model/modelitemset.py b/src/model/modelitemset.py
index b6c646496..3aa6fef21 100644
--- a/src/model/modelitemset.py
+++ b/src/model/modelitemset.py
@@ -1,4 +1,4 @@
-from PyQt5.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSignal
from model.qobjectmapping import QObjectMapping
from model.transaction import transactional
diff --git a/src/model/player.py b/src/model/player.py
index a10af9d09..407edc29d 100644
--- a/src/model/player.py
+++ b/src/model/player.py
@@ -1,4 +1,4 @@
-from PyQt5.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSignal
from model.modelitem import ModelItem
from model.rating import RatingType
diff --git a/src/model/qobjectmapping.py b/src/model/qobjectmapping.py
index 14c00d65c..7e15a6141 100644
--- a/src/model/qobjectmapping.py
+++ b/src/model/qobjectmapping.py
@@ -1,6 +1,8 @@
-from collections.abc import ItemsView, KeysView, ValuesView
+from collections.abc import ItemsView
+from collections.abc import KeysView
+from collections.abc import ValuesView
-from PyQt5.QtCore import QObject
+from PyQt6.QtCore import QObject
class QObjectMapping(QObject):
diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py
index 2ee63eef6..ca15cc249 100644
--- a/src/news/_newswidget.py
+++ b/src/news/_newswidget.py
@@ -1,13 +1,15 @@
import logging
import webbrowser
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
from config import Settings
from util.qt import ExternalLinkPage
-from .newsitem import NewsItem, NewsItemDelegate
+from .newsitem import NewsItem
+from .newsitem import NewsItemDelegate
from .newsmanager import NewsManager
logger = logging.getLogger(__name__)
@@ -24,7 +26,7 @@ def __init__(self, parent=None):
super(Hider, self).__init__(parent)
def eventFilter(self, obj, ev):
- return ev.type() == QtCore.QEvent.Paint
+ return ev.type() == QtCore.QEvent.Type.Paint
def hide(self, widget):
widget.installEventFilter(self)
diff --git a/src/news/newsitem.py b/src/news/newsitem.py
index 03b24067c..b3c27e240 100644
--- a/src/news/newsitem.py
+++ b/src/news/newsitem.py
@@ -1,6 +1,8 @@
import logging
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
@@ -8,12 +10,12 @@
class NewsItemDelegate(QtWidgets.QStyledItemDelegate):
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, **kwargs) -> None:
QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
html = QtGui.QTextDocument()
to = QtGui.QTextOption()
- to.setWrapMode(QtGui.QTextOption.WordWrap)
+ to.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap)
html.setDefaultTextOption(to)
html.setTextWidth(NewsItem.TEXTWIDTH)
@@ -31,7 +33,7 @@ def paint(self, painter, option, index, *args, **kwargs):
option.icon = QtGui.QIcon()
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
# Shadow (100x100 shifted 8 right and 8 down)
diff --git a/src/news/newsmanager.py b/src/news/newsmanager.py
index 70e44c408..b2707933d 100644
--- a/src/news/newsmanager.py
+++ b/src/news/newsmanager.py
@@ -1,7 +1,8 @@
import logging
-from PyQt5 import QtCore
-from PyQt5.QtCore import QObject, Qt
+from PyQt6 import QtCore
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import Qt
import client
@@ -93,7 +94,7 @@ def expandFrame(self, selectedFrame):
for frame in self.newsFrames:
frame.collapse()
- selectedFrame.expand(Qt.ScrollBarAsNeeded, set_filter=False)
+ selectedFrame.expand(Qt.ScrollBarPolicy.ScrollBarAsNeeded, set_filter=False)
self.selectedFrame = selectedFrame
@@ -101,7 +102,7 @@ def resetFrames(self):
logger.info('resetFrames')
self.selectedFrame = None
for frame in self.newsFrames:
- frame.expand(Qt.ScrollBarAlwaysOff, set_filter=True)
+ frame.expand(Qt.ScrollBarPolicy.ScrollBarAlwaysOff, set_filter=True)
def nextPage(self):
pb = client.instance.pageBox
diff --git a/src/news/wpapi.py b/src/news/wpapi.py
index b05c3aa2a..15a6ad780 100644
--- a/src/news/wpapi.py
+++ b/src/news/wpapi.py
@@ -1,12 +1,10 @@
import json
import logging
-from PyQt5 import QtCore
-from PyQt5.QtNetwork import (
- QNetworkAccessManager,
- QNetworkReply,
- QNetworkRequest,
-)
+from PyQt6 import QtCore
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
from config import Settings
@@ -70,5 +68,4 @@ def download(self, page=1, perpage=10):
),
)
request = QNetworkRequest(url)
- request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True)
self.nam.get(request)
diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py
index 718ff90f6..f5a4b74db 100644
--- a/src/notifications/__init__.py
+++ b/src/notifications/__init__.py
@@ -2,13 +2,14 @@
The Notification Systems reacts on events and displays a popup.
Each event_type has a NsHook to customize it.
"""
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import util
from config import Settings
from fa import maps
from notifications.ns_dialog import NotificationDialog
-from notifications.ns_settings import IngameNotification, NsSettingsDialog
+from notifications.ns_settings import IngameNotification
+from notifications.ns_settings import NsSettingsDialog
class Notifications:
diff --git a/src/notifications/hook_newgame.py b/src/notifications/hook_newgame.py
index 8452f10eb..2abbba7b9 100644
--- a/src/notifications/hook_newgame.py
+++ b/src/notifications/hook_newgame.py
@@ -1,7 +1,7 @@
"""
Settings for notifications: if a new game is hosted.
"""
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import config
import notifications as ns
@@ -31,7 +31,7 @@ def __init__(self, parent, eventType):
# remove help button
self.setWindowFlags(
- self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint),
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
)
self.loadSettings()
@@ -40,9 +40,9 @@ def loadSettings(self):
self.mode = Settings.get(self._settings_key + '/mode', 'friends')
if self.mode == 'friends':
- self.checkBoxFriends.setCheckState(QtCore.Qt.Checked)
+ self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Checked)
else:
- self.checkBoxFriends.setCheckState(QtCore.Qt.Unchecked)
+ self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.parent.mode = self.mode
def saveSettings(self):
@@ -51,7 +51,7 @@ def saveSettings(self):
@QtCore.pyqtSlot()
def on_btnSave_clicked(self):
- if self.checkBoxFriends.checkState() == QtCore.Qt.Checked:
+ if self.checkBoxFriends.checkState() == QtCore.Qt.CheckState.Checked:
self.mode = 'friends'
else:
self.mode = 'all'
diff --git a/src/notifications/hook_partyinvite.py b/src/notifications/hook_partyinvite.py
index e0d115261..a2c0c5fb8 100644
--- a/src/notifications/hook_partyinvite.py
+++ b/src/notifications/hook_partyinvite.py
@@ -1,7 +1,7 @@
"""
Settings for notifications: if a player comes online
"""
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import notifications as ns
import util
@@ -32,7 +32,7 @@ def __init__(self, parent, eventType):
# remove help button
self.setWindowFlags(
- self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint),
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
)
self.loadSettings()
diff --git a/src/notifications/hook_useronline.py b/src/notifications/hook_useronline.py
index 41eddea6a..d949bb4c2 100644
--- a/src/notifications/hook_useronline.py
+++ b/src/notifications/hook_useronline.py
@@ -1,7 +1,7 @@
"""
Settings for notifications: if a player comes online
"""
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import notifications as ns
import util
@@ -32,7 +32,7 @@ def __init__(self, parent, eventType):
# remove help button
self.setWindowFlags(
- self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint),
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
)
self.loadSettings()
diff --git a/src/notifications/ns_dialog.py b/src/notifications/ns_dialog.py
index b25bcae9b..8aebba951 100644
--- a/src/notifications/ns_dialog.py
+++ b/src/notifications/ns_dialog.py
@@ -3,7 +3,8 @@
"""
import time
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
@@ -29,7 +30,7 @@ def __init__(self, client, settings, *args, **kwargs):
self.updatePosition()
# Frameless, always on top, steal no focus & no entry at the taskbar
- self.setWindowFlags(QtCore.Qt.ToolTip)
+ self.setWindowFlags(QtCore.Qt.WindowType.ToolTip)
self.labelEvent.setOpenExternalLinks(True)
self.baseHeight = 165
@@ -91,11 +92,11 @@ def hide(self):
# mouseReleaseEvent sometimes not fired
def mousePressEvent(self, event):
- if event.button() == QtCore.Qt.RightButton:
+ if event.button() == QtCore.Qt.MouseButton.RightButton:
self.hide()
def updatePosition(self):
- screen = QtWidgets.QDesktopWidget().screenGeometry()
+ screen_size = QtWidgets.QApplication.primaryScreen().geometry()
dialog_size = self.geometry()
# self.client.notificationSystem.settings.popup_position
position = self.settings.popup_position
@@ -103,13 +104,13 @@ def updatePosition(self):
if position == NotificationPosition.TOP_LEFT:
self.move(0, 0)
elif position == NotificationPosition.TOP_RIGHT:
- self.move(screen.width() - dialog_size.width(), 0)
+ self.move(screen_size.width() - dialog_size.width(), 0)
elif position == NotificationPosition.BOTTOM_LEFT:
- self.move(0, screen.height() - dialog_size.height())
+ self.move(0, screen_size.height() - dialog_size.height())
else:
self.move(
- screen.width() - dialog_size.width(),
- screen.height() - dialog_size.height(),
+ screen_size.width() - dialog_size.width(),
+ screen_size.height() - dialog_size.height(),
)
@QtCore.pyqtSlot()
diff --git a/src/notifications/ns_hook.py b/src/notifications/ns_hook.py
index 3bca343f4..00cb15a57 100644
--- a/src/notifications/ns_hook.py
+++ b/src/notifications/ns_hook.py
@@ -7,7 +7,7 @@
self.button.clicked.connect(self.dialog.show)
"""
-from PyQt5 import QtWidgets
+from PyQt6 import QtWidgets
from config import Settings
diff --git a/src/notifications/ns_settings.py b/src/notifications/ns_settings.py
index d0683cd22..8b7b976ec 100644
--- a/src/notifications/ns_settings.py
+++ b/src/notifications/ns_settings.py
@@ -4,7 +4,8 @@
"""
from enum import Enum
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import notifications as ns
import util
@@ -54,7 +55,7 @@ def __init__(self, client):
# remove help button
self.setWindowFlags(
- self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint),
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
)
# init hooks
@@ -68,7 +69,7 @@ def __init__(self, client):
self.tableView.setModel(model)
# stretch first column
self.tableView.horizontalHeader().setSectionResizeMode(
- 0, QtWidgets.QHeaderView.Stretch,
+ 0, QtWidgets.QHeaderView.ResizeMode.Stretch,
)
for row in range(0, model.rowCount(None)):
@@ -165,9 +166,9 @@ def __init__(self, parent, hooks, *args):
def flags(self, index):
flags = super(QtCore.QAbstractTableModel, self).flags(index)
if index.column() == self.POPUP or index.column() == self.SOUND:
- return flags | QtCore.Qt.ItemIsUserCheckable
+ return flags | QtCore.Qt.ItemFlag.ItemIsUserCheckable
if index.column() == self.SETTINGS:
- return flags | QtCore.Qt.ItemIsEditable
+ return flags | QtCore.Qt.ItemFlag.ItemIsEditable
return flags
def rowCount(self, parent):
@@ -179,14 +180,14 @@ def columnCount(self, parent):
def getHook(self, row):
return self.hooks[row]
- def data(self, index, role=QtCore.Qt.EditRole):
+ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole):
if not index.isValid():
return None
- # if role == QtCore.Qt.TextAlignmentRole and index.column() != 0:
- # return QtCore.Qt.AlignHCenter
+ # if role == QtCore.Qt.ItemDataRole.TextAlignmentRole and index.column() != 0:
+ # return QtCore.Qt.AlignmentFlag.AlignHCenter
- if role == QtCore.Qt.CheckStateRole:
+ if role == QtCore.Qt.ItemDataRole.CheckStateRole:
if index.column() == self.POPUP:
return self.returnChecked(
self.hooks[index.row()].popupEnabled(),
@@ -197,7 +198,7 @@ def data(self, index, role=QtCore.Qt.EditRole):
)
return None
- if role != QtCore.Qt.DisplayRole:
+ if role != QtCore.Qt.ItemDataRole.DisplayRole:
return None
if index.column() == 0:
@@ -205,9 +206,9 @@ def data(self, index, role=QtCore.Qt.EditRole):
return ''
def returnChecked(self, state):
- return QtCore.Qt.Checked if state else QtCore.Qt.Unchecked
+ return QtCore.Qt.CheckState.Checked if state else QtCore.Qt.CheckState.Unchecked
- def setData(self, index, value, role=QtCore.Qt.EditRole):
+ def setData(self, index, value, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole):
if index.column() == self.POPUP:
self.hooks[index.row()].switchPopup()
self.dataChanged.emit(index, index)
@@ -220,8 +221,8 @@ def setData(self, index, value, role=QtCore.Qt.EditRole):
def headerData(self, col, orientation, role):
if (
- orientation == QtCore.Qt.Horizontal
- and role == QtCore.Qt.DisplayRole
+ orientation == QtCore.Qt.Orientation.Horizontal
+ and role == QtCore.Qt.ItemDataRole.DisplayRole
):
return self.headerdata[col]
return None
diff --git a/src/power/actions.py b/src/power/actions.py
index 706c67e39..3f4004cb5 100644
--- a/src/power/actions.py
+++ b/src/power/actions.py
@@ -1,8 +1,8 @@
import logging
from enum import Enum
-from PyQt5.QtCore import QUrl
-from PyQt5.QtGui import QDesktopServices
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QDesktopServices
logger = logging.getLogger(__name__)
diff --git a/src/power/view.py b/src/power/view.py
index d637d83c8..442e7f895 100644
--- a/src/power/view.py
+++ b/src/power/view.py
@@ -1,8 +1,9 @@
-from PyQt5.QtCore import QObject
-from PyQt5.QtWidgets import QMessageBox
+from PyQt6.QtCore import QObject
+from PyQt6.QtWidgets import QMessageBox
from power.actions import BanPeriod
-from util.select_player_dialog import PlayerCompleter, SelectPlayerDialog
+from util.select_player_dialog import PlayerCompleter
+from util.select_player_dialog import SelectPlayerDialog
class CloseGameDialog(SelectPlayerDialog):
diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py
index e09788a51..2d2be09ae 100644
--- a/src/replays/_replayswidget.py
+++ b/src/replays/_replayswidget.py
@@ -4,12 +4,12 @@
import time
import jsonschema
-from PyQt5 import QtCore, QtGui, QtWidgets
-from PyQt5.QtNetwork import (
- QNetworkAccessManager,
- QNetworkReply,
- QNetworkRequest,
-)
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
import client
import fa
@@ -19,9 +19,11 @@
from downloadManager import DownloadRequest
from fa.replay import replay
from model.game import GameState
-from replays.replayitem import ReplayItem, ReplayItemDelegate
+from replays.replayitem import ReplayItem
+from replays.replayitem import ReplayItemDelegate
from replays.replayToolbox import ReplayToolboxHandler
-from util.gameurl import GameUrl, GameUrlType
+from util.gameurl import GameUrl
+from util.gameurl import GameUrlType
logger = logging.getLogger(__name__)
@@ -54,7 +56,7 @@ def _set_show_delay(self):
# Wait until the replayserver makes the replay available
elapsed_time = time.time() - self.launch_time
delay_time = self.LIVEREPLAY_DELAY - elapsed_time
- QtCore.QTimer.singleShot(1000 * delay_time, self._show_item)
+ QtCore.QTimer.singleShot(int(1000 * delay_time), self._show_item)
def _show_item(self):
self.setHidden(False)
@@ -110,7 +112,7 @@ def _set_misc_formatting(self, game):
self.setText(1, game.title + " - [host: " + game.host + "]")
self.setForeground(1, QtGui.QColor(colors.get_color("player")))
self.setText(2, game.featured_mod)
- self.setTextAlignment(2, QtCore.Qt.AlignCenter)
+ self.setTextAlignment(2, QtCore.Qt.AlignmentFlag.AlignCenter)
def _is_me(self, name):
return client.instance.login == name
@@ -190,13 +192,13 @@ def __init__(self, liveTree, client, gameset):
self.liveTree.itemDoubleClicked.connect(self.liveTreeDoubleClicked)
self.liveTree.itemPressed.connect(self.liveTreePressed)
self.liveTree.header().setSectionResizeMode(
- 0, QtWidgets.QHeaderView.ResizeToContents,
+ 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
self.liveTree.header().setSectionResizeMode(
- 1, QtWidgets.QHeaderView.Stretch,
+ 1, QtWidgets.QHeaderView.ResizeMode.Stretch,
)
self.liveTree.header().setSectionResizeMode(
- 2, QtWidgets.QHeaderView.ResizeToContents,
+ 2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
self.client = client
@@ -207,7 +209,7 @@ def __init__(self, liveTree, client, gameset):
self.games = {}
def liveTreePressed(self, item):
- if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.RightButton:
+ if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.MouseButton.RightButton:
return
if self.liveTree.indexOfTopLevelItem(item) != -1:
@@ -426,7 +428,7 @@ def _setup_complete_appearance(self):
self.setToolTip(2, ", ".join(playerlist))
self.setText(3, data['featured_mod'])
- self.setTextAlignment(3, QtCore.Qt.AlignCenter)
+ self.setTextAlignment(3, QtCore.Qt.AlignmentFlag.AlignCenter)
def replay_bucket(self):
if self._metadata is None:
@@ -515,16 +517,16 @@ def __init__(self, myTree):
self.myTree.itemDoubleClicked.connect(self.myTreeDoubleClicked)
self.myTree.itemPressed.connect(self.myTreePressed)
self.myTree.header().setSectionResizeMode(
- 0, QtWidgets.QHeaderView.ResizeToContents,
+ 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
self.myTree.header().setSectionResizeMode(
- 1, QtWidgets.QHeaderView.ResizeToContents,
+ 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
self.myTree.header().setSectionResizeMode(
- 2, QtWidgets.QHeaderView.Stretch,
+ 2, QtWidgets.QHeaderView.ResizeMode.Stretch,
)
self.myTree.header().setSectionResizeMode(
- 3, QtWidgets.QHeaderView.ResizeToContents,
+ 3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
self.myTree.modification_time = 0
@@ -534,7 +536,7 @@ def __init__(self, myTree):
)
def myTreePressed(self, item):
- if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.RightButton:
+ if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.MouseButton.RightButton:
return
if item.isDisabled():
@@ -792,10 +794,10 @@ def searchVault(
if not self.showLatest:
timePeriod = []
timePeriod.append(
- w.dateStart.dateTime().toUTC().toString(QtCore.Qt.ISODate),
+ w.dateStart.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate),
)
timePeriod.append(
- w.dateEnd.dateTime().toUTC().toString(QtCore.Qt.ISODate),
+ w.dateEnd.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate),
)
filters = self.prepareFilters(
@@ -911,7 +913,7 @@ def prepareFilters(
startTime = (
QtCore.QDateTime.currentDateTimeUtc()
.addMonths(-months)
- .toString(QtCore.Qt.ISODate)
+ .toString(QtCore.Qt.DateFormat.ISODate)
)
filters.append('startTime=ge="{}"'.format(startTime))
@@ -927,7 +929,7 @@ def reloadView(self):
self.searchVault(reset=True)
def onlineTreeClicked(self, item):
- if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.RightButton:
+ if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.MouseButton.RightButton:
if isinstance(item.parent, ReplaysWidget): # FIXME - hack
item.pressed(item)
else:
@@ -989,17 +991,11 @@ def onlineTreeDoubleClicked(self, item):
QtWidgets.QMessageBox.No,
) == QtWidgets.QMessageBox.Yes:
req = QNetworkRequest(QtCore.QUrl(item.url))
- req.setAttribute(
- QNetworkRequest.FollowRedirectsAttribute, True,
- )
self.replayDownload.get(req)
else: # start replay
if hasattr(item, "url"):
req = QNetworkRequest(QtCore.QUrl(item.url))
- req.setAttribute(
- QNetworkRequest.FollowRedirectsAttribute, True,
- )
self.replayDownload.get(req)
def _startReplay(self, name):
@@ -1048,7 +1044,7 @@ def resetRefreshPressed(self):
self.searchVault(reset=True)
def onDownloadFinished(self, reply):
- if reply.error() != QNetworkReply.NoError:
+ if reply.error() != QNetworkReply.NetworkError.NoError:
QtWidgets.QMessageBox.warning(
self._w, "Network Error", reply.errorString(),
)
@@ -1057,8 +1053,8 @@ def onDownloadFinished(self, reply):
os.path.join(util.CACHE_DIR, "temp.fafreplay"),
)
faf_replay.open(
- QtCore.QIODevice.WriteOnly
- | QtCore.QIODevice.Truncate,
+ QtCore.QIODevice.OpenModeFlag.WriteOnly
+ | QtCore.QIODevice.OpenModeFlag.Truncate,
)
faf_replay.write(reply.readAll())
faf_replay.flush()
diff --git a/src/replays/replayToolbox.py b/src/replays/replayToolbox.py
index 7740ee308..5eca2b94c 100644
--- a/src/replays/replayToolbox.py
+++ b/src/replays/replayToolbox.py
@@ -1,7 +1,9 @@
import logging
import os
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
from config import Settings
from downloadManager import DownloadRequest
@@ -304,7 +306,7 @@ def prepareFilters(self):
if filterName == "Start time":
startDate = filterBox.dateEdit.dateTime().toUTC().toString(
- QtCore.Qt.ISODate,
+ QtCore.Qt.DateFormat.ISODate,
)
if opName == ">":
finalFilters.append(
diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py
index 48b0f8a4c..f3d614ba6 100644
--- a/src/replays/replayitem.py
+++ b/src/replays/replayitem.py
@@ -1,8 +1,11 @@
import os
import time
-from datetime import datetime, timezone
+from datetime import datetime
+from datetime import timezone
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
from config import Settings
@@ -32,7 +35,7 @@ def paint(self, painter, option, index, *args, **kwargs):
option.icon = QtGui.QIcon()
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
# Shadow
@@ -43,7 +46,7 @@ def paint(self, painter, option, index, *args, **kwargs):
# Icon
icon.paint(
painter, option.rect.adjusted(3, -2, 0, 0),
- QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
+ QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
)
# Frame around the icon
@@ -52,7 +55,7 @@ def paint(self, painter, option, index, *args, **kwargs):
# FIXME: This needs to come from theme.
# pen.setBrush(QtGui.QColor("#303030"))
- # pen.setCapStyle(QtCore.Qt.RoundCap)
+ # pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
# painter.setPen(pen)
# painter.drawRect(option.rect.left()+5-2, option.rect.top()+5-2,
# iconsize.width(), iconsize.height())
@@ -71,7 +74,7 @@ def paint(self, painter, option, index, *args, **kwargs):
painter.restore()
def sizeHint(self, option, index, *args, **kwargs):
- clip = index.model().data(index, QtCore.Qt.UserRole)
+ clip = index.model().data(index, QtCore.Qt.ItemDataRole.UserRole)
self.initStyleOption(option, index)
html = QtGui.QTextDocument()
html.setHtml(option.text)
@@ -518,9 +521,9 @@ def display(self, column):
return self.viewtext
def data(self, column, role):
- if role == QtCore.Qt.DisplayRole:
+ if role == QtCore.Qt.ItemDataRole.DisplayRole:
return self.display(column)
- elif role == QtCore.Qt.UserRole:
+ elif role == QtCore.Qt.ItemDataRole.UserRole:
return self
return super(ReplayItem, self).data(column, role)
diff --git a/src/secondaryServer/secondaryserver.py b/src/secondaryServer/secondaryserver.py
index 78f4c7a2f..d51557a2f 100644
--- a/src/secondaryServer/secondaryserver.py
+++ b/src/secondaryServer/secondaryserver.py
@@ -1,7 +1,8 @@
import json
import logging
-from PyQt5 import QtCore, QtNetwork
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
from config import Settings
@@ -61,7 +62,7 @@ def __init__(self, name, socket, dispatcher, *args, **kwargs):
self.blockSize = 0
self.serverSocket = QtNetwork.QTcpSocket()
- self.serverSocket.error.connect(self.handleServerError)
+ self.serverSocket.errorOccurred.connect(self.handleServerError)
self.serverSocket.readyRead.connect(self.readDataFromServer)
self.serverSocket.connected.connect(self.send_pending)
self.invisible = False
@@ -80,7 +81,7 @@ def send(self, command, *args, **kwargs):
self.logger.info("Pending requests: {}".format(len(self._requests)))
if not (
self.serverSocket.state()
- == QtNetwork.QAbstractSocket.ConnectedState
+ == QtNetwork.QAbstractSocket.SocketState.ConnectedState
):
self.logger.info(
"Connecting to {} {}:{}".format(
@@ -123,7 +124,7 @@ def readDataFromServer(self):
def writeToServer(self, action, *args, **kw):
block = QtCore.QByteArray()
- out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite)
+ out = QtCore.QDataStream(block, QtCore.QIODevice.OpenModeFlag.ReadWrite)
out.setVersion(QtCore.QDataStream.Qt_4_2)
out.writeUInt32(0)
out.writeQString(action)
diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py
index 91b8b3e80..570b0de04 100644
--- a/src/stats/_statswidget.py
+++ b/src/stats/_statswidget.py
@@ -1,7 +1,10 @@
import logging
import time
-from PyQt5 import QtCore, QtWebEngineWidgets, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWebEngineCore
+from PyQt6 import QtWebEngineWidgets
+from PyQt6 import QtWidgets
import util
from api.stats_api import LeaderboardApiConnector
@@ -341,7 +344,7 @@ def busy_entered(self):
)
-class WebEnginePage(QtWebEngineWidgets.QWebEnginePage):
+class WebEnginePage(QtWebEngineCore.QWebEnginePage):
def acceptNavigationRequest(self, url, type, isMainFrame):
if (
url.url().startswith("https://faforever.com/competitive/")
diff --git a/src/stats/itemviews/leaderboardheaderview.py b/src/stats/itemviews/leaderboardheaderview.py
index 0c747002b..1c8249b22 100644
--- a/src/stats/itemviews/leaderboardheaderview.py
+++ b/src/stats/itemviews/leaderboardheaderview.py
@@ -1,11 +1,16 @@
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QModelIndex
+from PyQt6.QtCore import QRect
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QHoverEvent
+from PyQt6.QtGui import QPainter
class VerticalHeaderView(QtWidgets.QHeaderView):
- def __init__(self, *args, **kwargs):
- super().__init__(QtCore.Qt.Vertical, *args, **kwargs)
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(Qt.Orientation.Vertical, *args, **kwargs)
self.setHighlightSections(True)
- self.setSectionResizeMode(self.Fixed)
+ self.setSectionResizeMode(self.ResizeMode.Fixed)
self.setVisible(True)
self.setSectionsClickable(True)
self.setAlternatingRowColors(True)
@@ -13,33 +18,28 @@ def __init__(self, *args, **kwargs):
self.hover = -1
- def paintSection(self, painter, rect, index):
+ def paintSection(self, painter: QPainter, rect: QRect, index: QModelIndex) -> None:
opt = QtWidgets.QStyleOptionHeader()
self.initStyleOption(opt)
opt.rect = rect
opt.section = index
- opt.text = str(
- self.model().headerData(
- index, self.orientation(), QtCore.Qt.DisplayRole,
- ),
- )
- opt.textAlignment = QtCore.Qt.AlignCenter
- state = QtWidgets.QStyle.State_None
+ data = self.model().headerData(index, self.orientation(), Qt.ItemDataRole.DisplayRole)
+ opt.text = str(data)
+
+ opt.textAlignment = Qt.AlignmentFlag.AlignCenter
+
+ state = QtWidgets.QStyle.StateFlag.State_None
if self.highlightSections():
- if self.selectionModel().rowIntersectsSelection(
- index, QtCore.QModelIndex(),
- ):
- state |= QtWidgets.QStyle.State_On
+ if self.selectionModel().rowIntersectsSelection(index, QModelIndex()):
+ state |= QtWidgets.QStyle.StateFlag.State_On
elif index == self.hover:
- state |= QtWidgets.QStyle.State_MouseOver
+ state |= QtWidgets.QStyle.StateFlag.State_MouseOver
opt.state |= state
- self.style().drawControl(
- QtWidgets.QStyle.CE_Header, opt, painter, self,
- )
+ self.style().drawControl(QtWidgets.QStyle.ControlElement.CE_Header, opt, painter, self)
def mouseMoveEvent(self, event):
QtWidgets.QHeaderView.mouseMoveEvent(self, event)
@@ -56,8 +56,8 @@ def mousePressEvent(self, event):
self.parent().updateHoverRow(event)
self.updateHoverSection(event)
- def updateHoverSection(self, event):
- index = self.logicalIndexAt(event.pos())
+ def updateHoverSection(self, event: QHoverEvent) -> None:
+ index = self.logicalIndexAt(event.position().toPoint())
oldHover = self.hover
self.hover = index
diff --git a/src/stats/itemviews/leaderboarditemdelegate.py b/src/stats/itemviews/leaderboarditemdelegate.py
index 952e6209a..dc62c89e7 100644
--- a/src/stats/itemviews/leaderboarditemdelegate.py
+++ b/src/stats/itemviews/leaderboarditemdelegate.py
@@ -1,41 +1,49 @@
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
+from PyQt6.QtGui import QPainter
class LeaderboardItemDelegate(QtWidgets.QStyledItemDelegate):
- def paint(self, painter, option, index):
+ def paint(
+ self,
+ painter: QPainter,
+ option: QtWidgets.QStyleOptionViewItem,
+ index: QtCore.QModelIndex,
+ ) -> None:
opt = QtWidgets.QStyleOptionViewItem(option)
- opt.state &= ~QtWidgets.QStyle.State_HasFocus
- opt.state &= ~QtWidgets.QStyle.State_MouseOver
+ opt.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus
+ opt.state &= ~QtWidgets.QStyle.StateFlag.State_MouseOver
view = opt.styleObject
behavior = view.selectionBehavior()
hoverIndex = view.hoverIndex()
if (
- not (option.state & QtWidgets.QStyle.State_Selected)
- and behavior != QtWidgets.QTableView.SelectItems
+ not (option.state & QtWidgets.QStyle.StateFlag.State_Selected)
+ and behavior is not QtWidgets.QTableView.SelectionBehavior.SelectItems
):
if (
- behavior == QtWidgets.QTableView.SelectRows
+ behavior is QtWidgets.QTableView.SelectionBehavior.SelectRows
and hoverIndex.row() == index.row()
):
- opt.state |= QtWidgets.QStyle.State_MouseOver
+ opt.state |= QtWidgets.QStyle.StateFlag.State_MouseOver
self.initStyleOption(opt, index)
painter.save()
text = opt.text
opt.text = ""
- opt.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, opt, painter, opt.widget,
- )
- if opt.state & QtWidgets.QStyle.State_Selected:
- painter.setPen(QtCore.Qt.white)
+ control_element = QtWidgets.QStyle.ControlElement.CE_ItemViewItem
+ opt.widget.style().drawControl(control_element, opt, painter, opt.widget)
+ if opt.state & QtWidgets.QStyle.StateFlag.State_Selected:
+ painter.setPen(QtCore.Qt.GlobalColor.white)
if index.column() == 0:
rect = QtCore.QRect(opt.rect)
- rect.setLeft(opt.rect.left() + opt.rect.width() // 2.125)
- painter.drawText(
- rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, text,
+ rect.setLeft(int(opt.rect.left() + opt.rect.width() // 2.125))
+ alignment_flags = (
+ QtCore.Qt.AlignmentFlag.AlignLeft
+ | QtCore.Qt.AlignmentFlag.AlignVCenter
)
+ painter.drawText(rect, alignment_flags, text)
else:
- painter.drawText(opt.rect, QtCore.Qt.AlignCenter, text)
+ painter.drawText(opt.rect, QtCore.Qt.AlignmentFlag.AlignCenter, text)
painter.restore()
diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py
index 3b502aaa4..50c14287f 100644
--- a/src/stats/itemviews/leaderboardtablemenu.py
+++ b/src/stats/itemviews/leaderboardtablemenu.py
@@ -1,6 +1,7 @@
from enum import Enum
-from PyQt5 import QtWidgets
+from PyQt6 import QtWidgets
+from PyQt6.QtGui import QAction
class LeaderboardTableMenuItems(Enum):
@@ -52,11 +53,11 @@ def friendActions(self, name, uid, is_me):
yield LeaderboardTableMenuItems.ADD_FRIEND
yield LeaderboardTableMenuItems.ADD_FOE
- def getMenu(self, name, uid):
+ def getMenu(self, name: str, uid: int) -> QtWidgets.QMenu:
menu = QtWidgets.QMenu(self.parent)
def addEntry(item):
- action = QtWidgets.QAction(item.value, menu)
+ action = QAction(item.value, menu)
action.triggered.connect(self.handler(name, uid, item))
menu.addAction(action)
diff --git a/src/stats/itemviews/leaderboardtableview.py b/src/stats/itemviews/leaderboardtableview.py
index 25772ac28..9157c0f47 100644
--- a/src/stats/itemviews/leaderboardtableview.py
+++ b/src/stats/itemviews/leaderboardtableview.py
@@ -1,15 +1,17 @@
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
from .leaderboardheaderview import VerticalHeaderView
from .leaderboardtablemenu import LeaderboardTableMenu
class LeaderboardTableView(QtWidgets.QTableView):
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setMouseTracking(True)
- self.setSelectionBehavior(self.SelectRows)
- self.setSelectionMode(self.SingleSelection)
+ self.setSelectionBehavior(self.SelectionBehavior.SelectRows)
+ self.setSelectionMode(self.SelectionMode.SingleSelection)
self.setAlternatingRowColors(True)
self.setSortingEnabled(True)
@@ -19,13 +21,13 @@ def __init__(self, *args, **kwargs):
def hoverIndex(self):
return QtCore.QModelIndex(self.model().index(self.mHoverRow, 0))
- def updateHoverRow(self, event):
- index = self.indexAt(event.pos())
+ def updateHoverRow(self, event: QtGui.QHoverEvent) -> None:
+ index = self.indexAt(event.position().toPoint())
oldHoverRow = self.mHoverRow
self.mHoverRow = index.row()
if (
- self.selectionBehavior() == self.SelectRows
+ self.selectionBehavior() is self.SelectionBehavior.SelectRows
and oldHoverRow != self.mHoverRow
):
if oldHoverRow != -1:
@@ -45,8 +47,8 @@ def wheelEvent(self, event):
self.updateHoverRow(event)
self.verticalHeader().updateHoverSection(event)
- def mousePressEvent(self, event):
- if event.button() == QtCore.Qt.RightButton:
+ def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
+ if event.button() is QtCore.Qt.MouseButton.RightButton:
row = self.indexAt(event.pos()).row()
if row != -1:
name_index = self.model().index(row, 0)
diff --git a/src/stats/leaderboard_widget.py b/src/stats/leaderboard_widget.py
index f2e203ce7..a8a8d1636 100644
--- a/src/stats/leaderboard_widget.py
+++ b/src/stats/leaderboard_widget.py
@@ -1,4 +1,9 @@
-from PyQt5 import QtCore, QtWidgets
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
from api.player_api import PlayerApiConnector
@@ -9,14 +14,24 @@
from .models.leaderboardfiltermodel import LeaderboardFilterModel
from .models.leaderboardtablemodel import LeaderboardTableModel
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
+
FormClass, BaseClass = util.THEME.loadUiType("stats/leaderboard.ui")
-DATE_FORMAT = QtCore.Qt.ISODate
+DATE_FORMAT = QtCore.Qt.DateFormat.ISODate
class LeaderboardWidget(BaseClass, FormClass):
- def __init__(self, client, parent, leaderboardName, *args, **kwargs):
+ def __init__(
+ self,
+ client: ClientWindow,
+ parent: QtWidgets.QWidget,
+ leaderboardName: str,
+ *args,
+ **kwargs,
+ ) -> None:
super(BaseClass, self).__init__()
self.setupUi(self)
@@ -115,7 +130,7 @@ def __init__(self, client, parent, leaderboardName, *args, **kwargs):
checkbox.stateChanged.connect(self.setShownColumns)
self.tableView.horizontalHeader().setSectionResizeMode(
- QtWidgets.QHeaderView.Stretch,
+ QtWidgets.QHeaderView.ResizeMode.Stretch,
)
self.tableView.horizontalHeader().setFixedHeight(30)
self.tableView.horizontalHeader().setHighlightSections(False)
@@ -177,7 +192,7 @@ def createLeaderboard(self, data):
completer = QtWidgets.QCompleter(
sorted(self.model.logins, key=lambda login: login.lower()),
)
- completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)
completer.popup().setStyleSheet(
"background: rgb(32, 32, 37); color: orange;",
)
@@ -223,7 +238,7 @@ def createPlayerCompleter(self, message):
completer = QtWidgets.QCompleter(
sorted(logins, key=lambda login: login.lower()),
)
- completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)
completer.popup().setStyleSheet(
"background: rgb(32, 32, 37); color: orange;",
)
diff --git a/src/stats/leaderboardlineedit.py b/src/stats/leaderboardlineedit.py
index 94cf57112..bc0d2b63e 100644
--- a/src/stats/leaderboardlineedit.py
+++ b/src/stats/leaderboardlineedit.py
@@ -1,4 +1,5 @@
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
# TODO: probably create a common ancestor of ChatLineEdit and this
@@ -14,10 +15,10 @@ def set_completion_list(self, list_):
self.completionList = list_
def event(self, event):
- if event.type() == QtCore.QEvent.KeyPress:
+ if event.type() == QtCore.QEvent.Type.KeyPress:
# Swallow a selection of keypresses that we want for our history
# support.
- if event.key() == QtCore.Qt.Key_Tab:
+ if event.key() == QtCore.Qt.Key.Key_Tab:
self.try_completion()
return True
else:
diff --git a/src/stats/models/leaderboardfiltermodel.py b/src/stats/models/leaderboardfiltermodel.py
index a4adda7f6..487751b66 100644
--- a/src/stats/models/leaderboardfiltermodel.py
+++ b/src/stats/models/leaderboardfiltermodel.py
@@ -1,6 +1,6 @@
import re
-from PyQt5 import QtCore
+from PyQt6 import QtCore
class LeaderboardFilterModel(QtCore.QSortFilterProxyModel):
diff --git a/src/stats/models/leaderboardtablemodel.py b/src/stats/models/leaderboardtablemodel.py
index bd52be38f..18b04dd69 100644
--- a/src/stats/models/leaderboardtablemodel.py
+++ b/src/stats/models/leaderboardtablemodel.py
@@ -1,4 +1,7 @@
-from PyQt5.QtCore import QAbstractTableModel, QDateTime, QModelIndex, Qt
+from PyQt6.QtCore import QAbstractTableModel
+from PyQt6.QtCore import QDateTime
+from PyQt6.QtCore import QModelIndex
+from PyQt6.QtCore import Qt
class LeaderboardTableModel(QAbstractTableModel):
@@ -22,8 +25,8 @@ def columnCount(self, parent=QModelIndex()):
return self.column_count
def headerData(self, section, orientation, role):
- if role == Qt.DisplayRole:
- if orientation == Qt.Horizontal:
+ if role == Qt.ItemDataRole.DisplayRole:
+ if orientation == Qt.Orientation.Horizontal:
return (
"Name", "Rating", "Mean", "Deviation", "Games", "Won",
"Win rate", "Updated", "Player Id",
@@ -35,14 +38,14 @@ def headerData(self, section, orientation, role):
+ section
+ 1,
)
- elif role == Qt.TextAlignmentRole:
- return Qt.AlignCenter
+ elif role == Qt.ItemDataRole.TextAlignmentRole:
+ return Qt.AlignmentFlag.AlignCenter
- def data(self, index, role=Qt.DisplayRole):
+ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
column = index.column()
row = index.row()
- if role == Qt.DisplayRole:
+ if role == Qt.ItemDataRole.DisplayRole:
if column == 0:
return "{}".format(self.values[row]["player"]["login"])
elif column == 1:
@@ -66,7 +69,7 @@ def data(self, index, role=Qt.DisplayRole):
)
elif column == 7:
dateUTC = QDateTime.fromString(
- self.values[row]["updateTime"], Qt.ISODate,
+ self.values[row]["updateTime"], Qt.DateFormat.ISODate,
)
dateLocal = dateUTC.toLocalTime().toString("yyyy-MM-dd")
return "{}".format(dateLocal)
diff --git a/src/tourneys/_tournamentswidget.py b/src/tourneys/_tournamentswidget.py
index e2ced6ebf..1f653d1b6 100644
--- a/src/tourneys/_tournamentswidget.py
+++ b/src/tourneys/_tournamentswidget.py
@@ -1,9 +1,11 @@
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import secondaryServer
import util
-from tourneys.tourneyitem import TourneyItem, TourneyItemDelegate
+from tourneys.tourneyitem import TourneyItem
+from tourneys.tourneyitem import TourneyItemDelegate
FormClass, BaseClass = util.THEME.loadUiType("tournaments/tournaments.ui")
diff --git a/src/tourneys/tourneyitem.py b/src/tourneys/tourneyitem.py
index 6f8a182a8..497823740 100644
--- a/src/tourneys/tourneyitem.py
+++ b/src/tourneys/tourneyitem.py
@@ -1,4 +1,8 @@
-from PyQt5 import QtCore, QtGui, QtWebEngineWidgets, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWebEngineCore
+from PyQt6 import QtWebEngineWidgets
+from PyQt6 import QtWidgets
import util
@@ -22,7 +26,7 @@ def paint(self, painter, option, index, *args, **kwargs):
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
# Description
@@ -43,9 +47,9 @@ def sizeHint(self, option, index, *args, **kwargs):
)
-class QWebPageChrome(QtWebEngineWidgets.QWebEnginePage):
+class QWebPageChrome(QtWebEngineCore.QWebEnginePage):
def __init__(self, *args, **kwargs):
- QtWebEngineWidgets.QWebEnginePage.__init__(self, *args, **kwargs)
+ QtWebEngineCore.QWebEnginePage.__init__(self, *args, **kwargs)
def userAgentForUrl(self, url):
return (
@@ -131,9 +135,9 @@ def display(self):
return self.viewtext
def data(self, role):
- if role == QtCore.Qt.DisplayRole:
+ if role == QtCore.Qt.ItemDataRole.DisplayRole:
return self.display()
- elif role == QtCore.Qt.UserRole:
+ elif role == QtCore.Qt.ItemDataRole.UserRole:
return self
return super(TourneyItem, self).data(role)
diff --git a/src/turn_client.py b/src/turn_client.py
index 48f43b239..c38d423f4 100644
--- a/src/turn_client.py
+++ b/src/turn_client.py
@@ -1,6 +1,7 @@
import signal
-from PyQt5.QtCore import QCoreApplication, QTimer
+from PyQt6.QtCore import QCoreApplication
+from PyQt6.QtCore import QTimer
from .connectivity import QTurnSocket
@@ -18,4 +19,4 @@ def sigint_handler(*args):
timer.timeout.connect(lambda: None)
c = QTurnSocket()
c.run()
- app.exec_()
+ app.exec()
diff --git a/src/tutorials/_tutorialswidget.py b/src/tutorials/_tutorialswidget.py
index 166779875..f32f94b99 100644
--- a/src/tutorials/_tutorialswidget.py
+++ b/src/tutorials/_tutorialswidget.py
@@ -1,16 +1,16 @@
import logging
import os
-from PyQt5 import QtCore, QtWidgets
-from PyQt5.QtNetwork import (
- QNetworkAccessManager,
- QNetworkReply,
- QNetworkRequest,
-)
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
import fa
import util
-from tutorials.tutorialitem import TutorialItem, TutorialItemDelegate
+from tutorials.tutorialitem import TutorialItem
+from tutorials.tutorialitem import TutorialItemDelegate
logger = logging.getLogger(__name__)
@@ -33,14 +33,14 @@ def __init__(self, client, *args, **kwargs):
logger.info("Tutorials instantiated.")
def finishReplay(self, reply):
- if reply.error() != QNetworkReply.NoError:
+ if reply.error() != QNetworkReply.NetworkError.NoError:
QtWidgets.QMessageBox.warning(
self, "Network Error", reply.errorString(),
)
else:
filename = os.path.join(util.CACHE_DIR, str("tutorial.fafreplay"))
replay = QtCore.QFile(filename)
- replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Text)
+ replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.Text)
replay.write(reply.readAll())
replay.close()
diff --git a/src/tutorials/tutorialitem.py b/src/tutorials/tutorialitem.py
index d4b5c9516..853d76a80 100644
--- a/src/tutorials/tutorialitem.py
+++ b/src/tutorials/tutorialitem.py
@@ -1,5 +1,7 @@
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
from config import Settings
@@ -28,7 +30,7 @@ def paint(self, painter, option, index, *args, **kwargs):
option.icon = QtGui.QIcon()
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
# Shadow
@@ -40,7 +42,7 @@ def paint(self, painter, option, index, *args, **kwargs):
# Icon
icon.paint(
painter, option.rect.adjusted(3, -2, 0, 0),
- QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
+ QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
)
# Frame around the icon
@@ -49,7 +51,7 @@ def paint(self, painter, option, index, *args, **kwargs):
# FIXME: This needs to come from theme.
pen.setBrush(QtGui.QColor("#303030"))
- pen.setCapStyle(QtCore.Qt.RoundCap)
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawRect(
option.rect.left() + 3, option.rect.top() + 3,
diff --git a/src/ui/status_logo.py b/src/ui/status_logo.py
index 91c3e87dd..79ad0075d 100644
--- a/src/ui/status_logo.py
+++ b/src/ui/status_logo.py
@@ -1,5 +1,7 @@
-from PyQt5.QtCore import pyqtSignal
-from PyQt5.QtWidgets import QAction, QLabel, QMenu
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QAction
+from PyQt6.QtWidgets import QLabel
+from PyQt6.QtWidgets import QMenu
import util
from client.clientstate import ClientState
@@ -74,7 +76,7 @@ def contextMenuEvent(self, event):
menu.addAction(conn)
menu.addAction(about)
- action = menu.exec_(self.mapToGlobal(event.pos()))
+ action = menu.exec(self.mapToGlobal(event.pos()))
if action == dc:
self.disconnect_requested.emit()
elif action == rc:
diff --git a/src/unitdb/unitdbtab.py b/src/unitdb/unitdbtab.py
index 6b5486a2b..d684a27cd 100644
--- a/src/unitdb/unitdbtab.py
+++ b/src/unitdb/unitdbtab.py
@@ -1,8 +1,11 @@
import logging
-from PyQt5.QtCore import QObject, QUrl, pyqtSignal
-from PyQt5.QtNetwork import QNetworkCookie
-from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineProfile
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkCookie
+from PyQt6.QtWebEngineCore import QWebEnginePage
+from PyQt6.QtWebEngineCore import QWebEngineProfile
import util
from config import Settings
diff --git a/src/updater/__init__.py b/src/updater/__init__.py
index 535bc6245..372bfcc38 100644
--- a/src/updater/__init__.py
+++ b/src/updater/__init__.py
@@ -1,9 +1,14 @@
-from PyQt5.QtCore import QObject, pyqtSignal
-from PyQt5.QtWidgets import QDialog, QMessageBox
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtWidgets import QDialog
+from PyQt6.QtWidgets import QMessageBox
from semantic_version import Version
-from updater.base import UpdateChecker, UpdateNotifier, UpdateSettings
-from updater.widgets import UpdateDialog, UpdateSettingsDialog
+from updater.base import UpdateChecker
+from updater.base import UpdateNotifier
+from updater.base import UpdateSettings
+from updater.widgets import UpdateDialog
+from updater.widgets import UpdateSettingsDialog
class ClientUpdateTools(QObject):
@@ -48,7 +53,7 @@ def _handle_update(self, releases, mandatory):
)
return
self.dialog.setup(releases)
- result = self.dialog.exec_()
+ result = self.dialog.exec()
if result == QDialog.Rejected and mandatory:
self.mandatory_update_aborted.emit()
diff --git a/src/updater/base.py b/src/updater/base.py
index 1bb0e6f04..51752a33f 100644
--- a/src/updater/base.py
+++ b/src/updater/base.py
@@ -1,8 +1,11 @@
import json
from enum import Enum
-from PyQt5.QtCore import QObject, QUrl, pyqtSignal
-from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
from semantic_version import Version
from config import Settings
@@ -188,7 +191,7 @@ def _req_done(self):
self.finished.emit()
def _process_response(self, rep):
- if rep.error() != QNetworkReply.NoError:
+ if rep.error() != QNetworkReply.NetworkError.NoError:
return None
release_data = bytes(self._rep.readAll())
try:
diff --git a/src/updater/process.py b/src/updater/process.py
index 6756770ad..a5f4d01f8 100644
--- a/src/updater/process.py
+++ b/src/updater/process.py
@@ -2,8 +2,11 @@
import subprocess
import tempfile
-from PyQt5.QtCore import QObject, QUrl, pyqtSignal
-from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
import client
from decorators import with_logger
@@ -47,7 +50,7 @@ def _prepare_download(self, url):
self._rep.setReadBufferSize(0)
self._rep.downloadProgress.connect(self._on_progress)
self._rep.finished.connect(self._on_finished)
- self._rep.error.connect(self.error)
+ self._rep.errorOccurred.connect(self.error)
self._rep.readyRead.connect(self._buffer)
self._rep.sslErrors.connect(self.ssl_error)
@@ -66,7 +69,7 @@ def _on_finished(self):
self._logger.debug('_on_finished')
assert self._tmp
assert self._rep.atEnd()
- if self._rep.error() != QNetworkReply.NoError:
+ if self._rep.error() != QNetworkReply.NetworkError.NoError:
self._logger.error(self._rep.errorString())
return # FIXME - handle
diff --git a/src/updater/widgets.py b/src/updater/widgets.py
index 54cafe3ee..f4a1961fd 100644
--- a/src/updater/widgets.py
+++ b/src/updater/widgets.py
@@ -1,8 +1,9 @@
-from PyQt5.QtWidgets import QLayout
+from PyQt6.QtWidgets import QLayout
import util
from decorators import with_logger
-from updater.base import ReleaseType, UpdateChannel
+from updater.base import ReleaseType
+from updater.base import UpdateChannel
from updater.process import ClientUpdater
FormClass, BaseClass = util.THEME.loadUiType("client/update.ui")
@@ -23,7 +24,7 @@ def __init__(
self.btnAbort.clicked.connect(self.abort)
self.btnSettings.clicked.connect(self.showSettings)
self.cbReleases.currentIndexChanged.connect(self.indexChanged)
- self.layout().setSizeConstraint(QLayout.SetFixedSize)
+ self.layout().setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
@classmethod
def build(cls, settings, parent_widget, current_version, **kwargs):
diff --git a/src/util/__init__.py b/src/util/__init__.py
index 5cd10fd67..139199884 100644
--- a/src/util/__init__.py
+++ b/src/util/__init__.py
@@ -8,17 +8,19 @@
import subprocess
import sys
-from PyQt5 import QtWidgets
-from PyQt5.QtCore import QStandardPaths, QUrl
-from PyQt5.QtGui import QDesktopServices
-from PyQt5.QtWidgets import QMessageBox
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QStandardPaths
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QDesktopServices
+from PyQt6.QtWidgets import QMessageBox
import fafpath
from config import VERSION as VERSION_STRING
-from config import _settings # Stolen from Config because reasons
from config import Settings
+from config import _settings # Stolen from Config because reasons
from mapGenerator import mapgenUtils
-from util.theme import Theme, ThemeSet
+from util.theme import Theme
+from util.theme import ThemeSet
if sys.platform == 'win32':
import win32service
@@ -126,7 +128,7 @@ def getPersonalDir():
else:
dir_ = str(
QStandardPaths.standardLocations(
- QStandardPaths.DocumentsLocation,
+ QStandardPaths.StandardLocation.DocumentsLocation,
)[0],
)
try:
diff --git a/src/util/crash.py b/src/util/crash.py
index 9cfff45b5..ac8c73d76 100644
--- a/src/util/crash.py
+++ b/src/util/crash.py
@@ -2,14 +2,15 @@
import platform
import traceback
-from PyQt5.QtCore import QUrl
-from PyQt5.QtGui import QDesktopServices
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QDesktopServices
import config
import util
from config import Settings
-
-from . import APPDATA_DIR, PERSONAL_DIR, VERSION_STRING
+from util import APPDATA_DIR
+from util import PERSONAL_DIR
+from util import VERSION_STRING
CRASH_REPORT_USER = "pre-login"
diff --git a/src/util/gameurl.py b/src/util/gameurl.py
index 40208fc0e..a7ed29cd5 100644
--- a/src/util/gameurl.py
+++ b/src/util/gameurl.py
@@ -1,6 +1,7 @@
from enum import Enum
-from PyQt5.QtCore import QUrl, QUrlQuery
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import QUrlQuery
class GameUrlType(Enum):
diff --git a/src/util/qt.py b/src/util/qt.py
index d503c7f9d..51ce981ec 100644
--- a/src/util/qt.py
+++ b/src/util/qt.py
@@ -1,7 +1,7 @@
import types
-from PyQt5.QtGui import QDesktopServices
-from PyQt5.QtWebEngineWidgets import QWebEnginePage
+from PyQt6.QtGui import QDesktopServices
+from PyQt6.QtWebEngineCore import QWebEnginePage
class ExternalLinkPage(QWebEnginePage):
@@ -12,7 +12,7 @@ def __init__(self, *args, **kwargs):
self.linkUnderCursor = ""
def acceptNavigationRequest(self, url, navtype, isMainFrame):
- if navtype == QWebEnginePage.NavigationTypeLinkClicked:
+ if navtype == QWebEnginePage.NavigationType.NavigationTypeLinkClicked:
if url.toString() == self.linkUnderCursor:
QDesktopServices.openUrl(url)
return False
diff --git a/src/util/qt_list_model.py b/src/util/qt_list_model.py
index a32ab29e0..e12742441 100644
--- a/src/util/qt_list_model.py
+++ b/src/util/qt_list_model.py
@@ -1,4 +1,6 @@
-from PyQt5.QtCore import QAbstractListModel, QModelIndex, Qt
+from PyQt6.QtCore import QAbstractListModel
+from PyQt6.QtCore import QModelIndex
+from PyQt6.QtCore import Qt
class QtListModel(QAbstractListModel):
@@ -16,7 +18,7 @@ def rowCount(self, parent):
def data(self, index, role):
if not index.isValid() or index.row() >= len(self._itemlist):
return None
- if role != Qt.DisplayRole:
+ if role != Qt.ItemDataRole.DisplayRole:
return None
return self._itemlist[index.row()]
diff --git a/src/util/select_player_dialog.py b/src/util/select_player_dialog.py
index aea34a463..66b528c0b 100644
--- a/src/util/select_player_dialog.py
+++ b/src/util/select_player_dialog.py
@@ -1,4 +1,6 @@
-from PyQt5.QtWidgets import QCompleter, QInputDialog, QLineEdit
+from PyQt6.QtWidgets import QCompleter
+from PyQt6.QtWidgets import QInputDialog
+from PyQt6.QtWidgets import QLineEdit
class SelectPlayerDialog:
diff --git a/src/util/theme.py b/src/util/theme.py
index 00705d097..5c53dc8cb 100644
--- a/src/util/theme.py
+++ b/src/util/theme.py
@@ -1,7 +1,11 @@
import logging
import os
-from PyQt5 import QtCore, QtGui, QtMultimedia, QtWidgets, uic
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtMultimedia
+from PyQt6 import QtWidgets
+from PyQt6 import uic
from semantic_version import Version
logger = logging.getLogger(__name__)
@@ -272,7 +276,7 @@ def theme_changed():
QtWidgets.QMessageBox.NoRole,
)
b_no = box.addButton("Abort", QtWidgets.QMessageBox.NoRole)
- box.exec_()
+ box.exec()
result = box.clickedButton()
if result == b_always:
@@ -382,7 +386,7 @@ def icon(self, filename, themed=True, pix=False):
return self.pixmap(filename, themed)
else:
icon = QtGui.QIcon()
- icon.addPixmap(self.pixmap(filename, themed), QtGui.QIcon.Normal)
+ icon.addPixmap(self.pixmap(filename, themed), QtGui.QIcon.Mode.Normal)
splitExt = os.path.splitext(filename)
if len(splitExt) == 2:
pixDisabled = self.pixmap(
@@ -390,7 +394,7 @@ def icon(self, filename, themed=True, pix=False):
)
if pixDisabled is not None:
icon.addPixmap(
- pixDisabled, QtGui.QIcon.Disabled, QtGui.QIcon.On,
+ pixDisabled, QtGui.QIcon.Mode.Disabled, QtGui.QIcon.State.On,
)
pixActive = self.pixmap(
@@ -398,7 +402,7 @@ def icon(self, filename, themed=True, pix=False):
)
if pixActive is not None:
icon.addPixmap(
- pixActive, QtGui.QIcon.Active, QtGui.QIcon.On,
+ pixActive, QtGui.QIcon.Mode.Active, QtGui.QIcon.State.On,
)
pixSelected = self.pixmap(
@@ -406,6 +410,6 @@ def icon(self, filename, themed=True, pix=False):
)
if pixSelected is not None:
icon.addPixmap(
- pixSelected, QtGui.QIcon.Selected, QtGui.QIcon.On,
+ pixSelected, QtGui.QIcon.Mode.Selected, QtGui.QIcon.State.On,
)
return icon
diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py
index 13ace928a..142a7afad 100644
--- a/src/vaults/dialogs.py
+++ b/src/vaults/dialogs.py
@@ -3,7 +3,9 @@
import os
import zipfile
-from PyQt5 import QtCore, QtNetwork, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
+from PyQt6 import QtWidgets
from downloadManager import FileDownload
@@ -36,14 +38,14 @@ def __init__(self, dler, title, label, silent=False):
else:
self._progress.setCancelButton(None)
self._progress.setWindowFlags(
- QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint,
+ QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint,
)
self._progress.setAutoReset(False)
self._progress.setModal(1)
self._progress.canceled.connect(self._dler.cancel)
progressBar = QtWidgets.QProgressBar(self._progress)
- progressBar.setAlignment(QtCore.Qt.AlignCenter)
+ progressBar.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._progress.setBar(progressBar)
self.timer = QtCore.QTimer()
diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py
index 6c536cf76..db8dd12a6 100644
--- a/src/vaults/mapvault/mapvault.py
+++ b/src/vaults/mapvault/mapvault.py
@@ -6,10 +6,12 @@
import urllib.request
from stat import S_IWRITE
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
-from api.vaults_api import MapApiConnector, MapPoolApiConnector
+from api.vaults_api import MapApiConnector
+from api.vaults_api import MapPoolApiConnector
from fa import maps
from vaults import luaparser
from vaults.mapvault.mapitem import MapItem
@@ -84,7 +86,7 @@ def showChanged(self, index):
@QtCore.pyqtSlot(QtWidgets.QListWidgetItem)
def itemClicked(self, item):
widget = MapWidget(self, item)
- widget.exec_()
+ widget.exec()
def requestMapPool(self, queueName, minRating):
self.apiConnector = self.mapPoolApiConnector
diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py
index 8d0fce11f..b92f11536 100644
--- a/src/vaults/mapvault/mapwidget.py
+++ b/src/vaults/mapvault/mapwidget.py
@@ -1,7 +1,9 @@
import os
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import downloadManager
import util
diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py
index 8642b9d23..620d9069d 100644
--- a/src/vaults/modvault/modvault.py
+++ b/src/vaults/modvault/modvault.py
@@ -41,7 +41,8 @@
import logging
import os
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
from api.vaults_api import ModApiConnector
from vaults.modvault import utils
@@ -111,12 +112,12 @@ def showChanged(self, index):
@QtCore.pyqtSlot(QtWidgets.QListWidgetItem)
def modClicked(self, item):
widget = ModWidget(self, item)
- widget.exec_()
+ widget.exec()
@QtCore.pyqtSlot()
def openUIModForm(self):
dialog = UIModWidget(self)
- dialog.exec_()
+ dialog.exec()
@QtCore.pyqtSlot()
def openUploadForm(self):
@@ -163,7 +164,7 @@ def openUploadForm(self):
modinfo.setFolder(os.path.split(modDir)[1])
modinfo.update()
dialog = UploadModWidget(self, modDir, modinfo)
- dialog.exec_()
+ dialog.exec()
else:
QtWidgets.QMessageBox.information(
self.client,
diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py
index 414edd12c..6ad0d2dd3 100644
--- a/src/vaults/modvault/modwidget.py
+++ b/src/vaults/modvault/modwidget.py
@@ -2,7 +2,9 @@
import os
import urllib.parse
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
from util import strtodate
@@ -128,7 +130,7 @@ def paint(self, painter, option, index, *args, **kwargs):
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
# Description
diff --git a/src/vaults/modvault/uimodwidget.py b/src/vaults/modvault/uimodwidget.py
index cbed39aaa..ad3da49b6 100644
--- a/src/vaults/modvault/uimodwidget.py
+++ b/src/vaults/modvault/uimodwidget.py
@@ -1,5 +1,6 @@
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
from vaults.modvault import utils
@@ -33,7 +34,7 @@ def __init__(self, parent, *args, **kwargs):
names = [mod.totalname for mod in utils.getActiveMods(uimods=True)]
for name in names:
activeModList = self.modList.findItems(
- name, QtCore.Qt.MatchExactly,
+ name, QtCore.Qt.MatchFlag.MatchExactly,
)
if activeModList:
activeModList[0].setSelected(True)
diff --git a/src/vaults/modvault/uploadwidget.py b/src/vaults/modvault/uploadwidget.py
index 964625e16..e35757d83 100644
--- a/src/vaults/modvault/uploadwidget.py
+++ b/src/vaults/modvault/uploadwidget.py
@@ -2,7 +2,8 @@
import tempfile
import zipfile
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
from vaults.modvault import utils
diff --git a/src/vaults/modvault/utils.py b/src/vaults/modvault/utils.py
index ab127aa5a..15c8e37c9 100644
--- a/src/vaults/modvault/utils.py
+++ b/src/vaults/modvault/utils.py
@@ -4,7 +4,9 @@
import shutil
import zipfile
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
from config import Settings
@@ -406,7 +408,7 @@ def generateThumbnail(sourcename, destname):
size = int((len(img) / 3) ** (1.0 / 2))
image = QtGui.QImage(img, size, size, QtGui.QImage.Format_RGB888)
imageFile = image.rgbSwapped().scaled(
- 100, 100, transformMode=QtCore.Qt.SmoothTransformation,
+ 100, 100, transformMode=QtCore.Qt.TransformationMode.SmoothTransformation,
)
imageFile.save(destname)
except IOError:
diff --git a/src/vaults/vault.py b/src/vaults/vault.py
index 0354c691f..f7c5a577b 100644
--- a/src/vaults/vault.py
+++ b/src/vaults/vault.py
@@ -1,10 +1,11 @@
import logging
-from PyQt5 import QtCore
+from PyQt6 import QtCore
import util
from ui.busy_widget import BusyWidget
-from vaults.vaultitem import VaultItem, VaultItemDelegate
+from vaults.vaultitem import VaultItem
+from vaults.vaultitem import VaultItemDelegate
logger = logging.getLogger(__name__)
@@ -102,7 +103,7 @@ def itemsInfo(self, message: dict) -> None:
self._items[item_key] = item
self.itemList.addItem(item)
item.update(value)
- self.itemList.sortItems(1)
+ self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder)
self.processMeta(message["meta"])
def processMeta(self, message: dict) -> None:
@@ -143,4 +144,4 @@ def updateVisibilities(self):
)
for _item in self._items:
self._items[_item].updateVisibility()
- self.itemList.sortItems(1)
+ self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder)
diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py
index 85d023803..2405d5103 100644
--- a/src/vaults/vaultitem.py
+++ b/src/vaults/vaultitem.py
@@ -1,4 +1,6 @@
-from PyQt5 import QtCore, QtGui, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
from downloadManager import DownloadRequest
@@ -109,7 +111,7 @@ def paint(self, painter, option, index, *args, **kwargs):
option.icon = QtGui.QIcon()
option.text = ""
option.widget.style().drawControl(
- QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget,
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
)
# Shadow
@@ -124,9 +126,8 @@ def paint(self, painter, option, index, *args, **kwargs):
iconrect = QtCore.QRect(option.rect.adjusted(3, 3, 0, 0))
iconrect.setSize(iconsize)
# Icon
- icon.paint(
- painter, iconrect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
- )
+ alignment = QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
+ icon.paint(painter, iconrect, alignment)
# Frame around the icon
pen = QtGui.QPen()
@@ -134,7 +135,7 @@ def paint(self, painter, option, index, *args, **kwargs):
# FIXME: This needs to come from theme.
pen.setBrush(QtGui.QColor("#303030"))
- pen.setCapStyle(QtCore.Qt.RoundCap)
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawRect(iconrect)
diff --git a/tests/fa/test_featured.py b/tests/fa/test_featured.py
index 5bb166e25..c6017b87c 100644
--- a/tests/fa/test_featured.py
+++ b/tests/fa/test_featured.py
@@ -3,7 +3,8 @@
import collections
import pytest
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
from fa import updater
diff --git a/tests/fa/test_updater.py b/tests/fa/test_updater.py
index 7c6822dd8..7bd0e61ed 100644
--- a/tests/fa/test_updater.py
+++ b/tests/fa/test_updater.py
@@ -3,7 +3,8 @@
import collections
import pytest
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
from fa import updater
@@ -93,7 +94,7 @@ def test_updater_hides_and_accepts_if_all_watches_are_finished(application):
application.processEvents()
assert not u.isVisible()
- assert u.result() == QtWidgets.QDialog.Accepted
+ assert u.result() == QtWidgets.QDialog.DialogCode.Accepted
def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(
@@ -113,4 +114,4 @@ def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(
application.processEvents()
assert u.isVisible()
- assert not u.result() == QtWidgets.QDialog.Accepted
+ assert not u.result() == QtWidgets.QDialog.DialogCode.Accepted
diff --git a/tests/unit_tests/client/test_mouse_position.py b/tests/unit_tests/client/test_mouse_position.py
index e79902b02..862874e9f 100644
--- a/tests/unit_tests/client/test_mouse_position.py
+++ b/tests/unit_tests/client/test_mouse_position.py
@@ -1,5 +1,5 @@
import pytest
-from PyQt5.QtCore import QPoint
+from PyQt6.QtCore import QPoint
@pytest.mark.parametrize("x,y", [(0, 0)])
diff --git a/tests/unit_tests/client/test_updating.py b/tests/unit_tests/client/test_updating.py
index 166a40f9b..1e3eff129 100644
--- a/tests/unit_tests/client/test_updating.py
+++ b/tests/unit_tests/client/test_updating.py
@@ -1,5 +1,5 @@
import pytest
-from PyQt5 import QtWebEngineWidgets # noqa: F401
+from PyQt6 import QtWebEngineWidgets # noqa: F401
import config
From 074299254229dc08b3e441e3cb64a9e2b90128a1 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 3 Apr 2024 23:28:20 +0300
Subject: [PATCH 011/123] Disallow using HTTP2 in api requests
see https://bugreports.qt.io/browse/QTBUG-123891
also, split some ApiBase's methods into smaller ones
---
src/api/ApiBase.py | 63 ++++++++++++++++++++++++++++++----------------
1 file changed, 41 insertions(+), 22 deletions(-)
diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py
index 0800fbecc..c73b3c7a2 100644
--- a/src/api/ApiBase.py
+++ b/src/api/ApiBase.py
@@ -1,46 +1,59 @@
import json
import logging
import time
+from typing import Any
+from typing import Callable
-from PyQt6 import QtCore
-from PyQt6 import QtNetwork
from PyQt6 import QtWidgets
+from PyQt6.QtCore import QByteArray
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import QUrlQuery
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
from config import Settings
logger = logging.getLogger(__name__)
-DO_NOT_ENCODE = QtCore.QByteArray()
+DO_NOT_ENCODE = QByteArray()
DO_NOT_ENCODE.append(b":/?&=.,")
-class ApiBase(QtCore.QObject):
- def __init__(self, route):
- QtCore.QObject.__init__(self)
-
+class ApiBase(QObject):
+ def __init__(self, route: str = "") -> None:
+ QObject.__init__(self)
self.route = route
- self.manager = QtNetwork.QNetworkAccessManager()
+ self.manager = QNetworkAccessManager()
self.manager.finished.connect(self.onRequestFinished)
self._running = False
-
- self.handlers = {}
+ self.handlers: dict[QNetworkReply | None, Callable[[dict], Any]] = {}
# query arguments like filter=login==Rhyza
- def request(self, queryDict, responseHandler):
+ def request(self, queryDict: dict, responseHandler: Callable[[dict], Any]) -> None:
self._running = True
- query = QtCore.QUrlQuery()
- for key, value in queryDict.items():
+ url = self.build_query_url(queryDict)
+ self.get(url, responseHandler)
+
+ def build_query_url(self, query_dict: dict) -> QUrl:
+ query = QUrlQuery()
+ for key, value in query_dict.items():
query.addQueryItem(key, str(value))
- stringQuery = query.toString(QtCore.QUrl.ComponentFormattingOption.FullyDecoded)
- percentEncodedByteArrayQuery = QtCore.QUrl.toPercentEncoding(
+ stringQuery = query.toString(QUrl.ComponentFormattingOption.FullyDecoded)
+ percentEncodedByteArrayQuery = QUrl.toPercentEncoding(
stringQuery,
exclude=DO_NOT_ENCODE,
)
percentEncodedStrQuery = percentEncodedByteArrayQuery.data().decode()
- url = QtCore.QUrl(Settings.get('api') + self.route)
+ url = url = QUrl(Settings.get('api') + self.route)
url.setQuery(percentEncodedStrQuery)
- request = QtNetwork.QNetworkRequest(url)
+ return url
+ @staticmethod
+ def prepare_request(url: QUrl | None) -> QNetworkRequest:
+ request = QNetworkRequest(url) if url else QNetworkRequest()
api_token = Settings.get('oauth/token', None)
if api_token is not None and api_token.get('expires_at') > time.time():
access_token = api_token.get('access_token')
@@ -49,13 +62,19 @@ def request(self, queryDict, responseHandler):
request.setRawHeader(b'User-Agent', b"FAF Client")
request.setRawHeader(b'Content-Type', b'application/vnd.api+json')
+ # FIXME: remove when https://bugreports.qt.io/browse/QTBUG-123891 is deployed
+ request.setAttribute(QNetworkRequest.Attribute.Http2AllowedAttribute, False)
+ return request
+
+ def get(self, url: QUrl, response_handler: Callable[[dict], Any]) -> None:
+ self._running = True
logger.debug("Sending API request with URL: {}".format(url.toString()))
- reply = self.manager.get(request)
- self.handlers[reply] = responseHandler
+ reply = self.manager.get(self.prepare_request(url))
+ self.handlers[reply] = response_handler
- def onRequestFinished(self, reply):
+ def onRequestFinished(self, reply: QNetworkReply) -> None:
self._running = False
- if reply.error() != QtNetwork.QNetworkReply.NetworkError.NoError:
+ if reply.error() != QNetworkReply.NetworkError.NoError:
logger.error("API request error: {}".format(reply.error()))
else:
message_bytes = reply.readAll().data()
@@ -132,6 +151,6 @@ def parseMeta(self, message):
return {}
def waitForCompletion(self):
- waitFlag = QtCore.QEventLoop.WaitForMoreEvents
+ waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
while self._running:
QtWidgets.QApplication.processEvents(waitFlag)
From 4e0e5ab60b91a8ed28d32e31d6b67942e15446aa Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 4 Apr 2024 20:48:19 +0300
Subject: [PATCH 012/123] Use Qt's built-in OAuth2 flow
instead of oauthlib and requests_oauthlib
---
requirements.txt | 2 -
res/client/oauth.ui | 42 ------------
src/api/ApiBase.py | 22 +++----
src/client/_clientwindow.py | 125 +++++++-----------------------------
src/client/oauth_dialog.py | 64 ------------------
src/config/production.py | 2 +
src/oauth/__init__.py | 0
src/oauth/oauth_flow.py | 97 ++++++++++++++++++++++++++++
8 files changed, 130 insertions(+), 224 deletions(-)
delete mode 100644 res/client/oauth.ui
delete mode 100644 src/client/oauth_dialog.py
create mode 100644 src/oauth/__init__.py
create mode 100644 src/oauth/oauth_flow.py
diff --git a/requirements.txt b/requirements.txt
index f3b75e0b2..7f4deedcb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -13,5 +13,3 @@ jsonschema
jinja2
zstandard
irc
-oauthlib
-requests_oauthlib
diff --git a/res/client/oauth.ui b/res/client/oauth.ui
deleted file mode 100644
index 852c030c8..000000000
--- a/res/client/oauth.ui
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
- OAuthDialog
-
-
-
- 0
- 0
- 640
- 700
-
-
-
-
- 640
- 600
-
-
-
- OAuth Login
-
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
-
-
- 0
-
-
-
-
-
-
diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py
index c73b3c7a2..5162e1332 100644
--- a/src/api/ApiBase.py
+++ b/src/api/ApiBase.py
@@ -1,6 +1,5 @@
import json
import logging
-import time
from typing import Any
from typing import Callable
@@ -15,6 +14,7 @@
from PyQt6.QtNetwork import QNetworkRequest
from config import Settings
+from oauth.oauth_flow import OAuth2Flow
logger = logging.getLogger(__name__)
@@ -23,6 +23,8 @@
class ApiBase(QObject):
+ oauth: OAuth2Flow = OAuth2Flow()
+
def __init__(self, route: str = "") -> None:
QObject.__init__(self)
self.route = route
@@ -31,11 +33,9 @@ def __init__(self, route: str = "") -> None:
self._running = False
self.handlers: dict[QNetworkReply | None, Callable[[dict], Any]] = {}
- # query arguments like filter=login==Rhyza
- def request(self, queryDict: dict, responseHandler: Callable[[dict], Any]) -> None:
- self._running = True
- url = self.build_query_url(queryDict)
- self.get(url, responseHandler)
+ @classmethod
+ def set_oauth(cls, oauth: OAuth2Flow) -> None:
+ cls.oauth = oauth
def build_query_url(self, query_dict: dict) -> QUrl:
query = QUrlQuery()
@@ -54,14 +54,8 @@ def build_query_url(self, query_dict: dict) -> QUrl:
@staticmethod
def prepare_request(url: QUrl | None) -> QNetworkRequest:
request = QNetworkRequest(url) if url else QNetworkRequest()
- api_token = Settings.get('oauth/token', None)
- if api_token is not None and api_token.get('expires_at') > time.time():
- access_token = api_token.get('access_token')
- bearer = 'Bearer {}'.format(access_token).encode('utf-8')
- request.setRawHeader(b'Authorization', bearer)
-
- request.setRawHeader(b'User-Agent', b"FAF Client")
- request.setRawHeader(b'Content-Type', b'application/vnd.api+json')
+ # last 2 args are unused, but for some reason they are required
+ ApiBase.oauth.prepareRequest(request, QByteArray(), QByteArray())
# FIXME: remove when https://bugreports.qt.io/browse/QTBUG-123891 is deployed
request.setAttribute(QNetworkRequest.Attribute.Http2AllowedAttribute, False)
return request
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 5f45ab991..2fa0b68b7 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -1,20 +1,18 @@
import logging
-import sys
import time
from functools import partial
-from oauthlib.oauth2 import WebApplicationClient
from PyQt6 import QtCore
from PyQt6 import QtGui
from PyQt6 import QtWidgets
from PyQt6.QtNetwork import QNetworkAccessManager
-from requests_oauthlib import OAuth2Session
import config
import fa
import notifications as ns
import util
import util.crash
+from api.ApiBase import ApiBase
from chat import ChatMVC
from chat._avatarWidget import AvatarWidget
from chat.channel_autojoiner import ChannelAutojoiner
@@ -69,6 +67,7 @@
from model.rating import MatchmakerQueueType
from model.rating import RatingType
from news import NewsWidget
+from oauth.oauth_flow import OAuth2Flow
from power import PowerTools
from replays import ReplaysWidget
from secondaryServer import SecondaryServer
@@ -83,13 +82,9 @@
from vaults.modvault.utils import setModFolder
from .mouse_position import MousePosition
-from .oauth_dialog import OAuthWidget
logger = logging.getLogger(__name__)
-OAUTH_TOKEN_PATH = "/oauth2/token"
-OAUTH_AUTH_PATH = "/oauth2/auth"
-
FormClass, BaseClass = util.THEME.loadUiType("client/client.ui")
@@ -148,9 +143,11 @@ def __init__(self, *args, **kwargs):
)
self._network_access_manager = QNetworkAccessManager(self)
- self.OAuthSession = None
- self.tokenTimer = QtCore.QTimer()
- self.tokenTimer.timeout.connect(self.checkOAuthToken)
+ self.oauth_flow = OAuth2Flow(parent=self)
+ ApiBase.set_oauth(self.oauth_flow)
+ self.oauth_flow.granted.connect(self.do_connect)
+ self.oauth_flow.granted.connect(self.save_refresh_token)
+ self.oauth_flow.requestFailed.connect(self.show_login_widget)
self.unique_id = None
self._chat_config = ChatConfig(util.settings)
@@ -917,7 +914,7 @@ def disconnect_(self):
self.lobby_connection.disconnect_()
self._chatMVC.connection.disconnect_()
self.games.onLogOut()
- self.tokenTimer.stop()
+ self.oauth_flow.stop_checking_expiration()
config.Settings.set("oauth/token", None, persist=False)
def chat_reconnect(self):
@@ -1504,7 +1501,13 @@ def load_chat(self):
except BaseException:
pass
- def do_connect(self):
+ def save_refresh_token(self) -> None:
+ self.refresh_token = self.oauth_flow.refreshToken()
+
+ def do_connect(self) -> bool:
+ if self.state in (ClientState.CONNECTING, ClientState.CONNECTED, ClientState.LOGGED_IN):
+ return True
+
if not self.replayServer.doListen():
return False
@@ -1516,68 +1519,24 @@ def set_remember(self, remember):
# FIXME - option updating is silly
self.actionSetAutoLogin.setChecked(self.remember)
- def try_to_auto_login(self):
+ def try_to_auto_login(self) -> None:
if (
self._auto_relogin
and self.refresh_token
- and self.refreshOAuthToken()
):
- self.do_connect()
+ self.oauth_flow.setRefreshToken(self.refresh_token)
+ self.oauth_flow.refreshAccessToken()
else:
self.show_login_widget()
- def get_creds_and_login(self):
- if self.OAuthSession.token and self.checkOAuthToken():
- if self.send_token(self.OAuthSession.token.get("access_token")):
- return
+ def get_creds_and_login(self) -> None:
+ if self.send_token(self.oauth_flow.token()):
+ return
QtWidgets.QMessageBox.warning(
self, "Log In", "OAuth token verification failed, please relogin",
)
self.show_login_widget()
- def createOAuthSession(self):
- client_id = config.Settings.get("oauth/client_id")
- refresh_kwargs = dict(client_id=client_id)
- redirect_uri = config.Settings.get("oauth/redirect_uri")
- scope = config.Settings.get("oauth/scope")
- app_client = WebApplicationClient(client_id=client_id)
- OAuth = OAuth2Session(
- client=app_client,
- redirect_uri=redirect_uri,
- scope=scope,
- auto_refresh_kwargs=refresh_kwargs,
- )
- return OAuth
-
- def checkOAuthToken(self):
- if self.OAuthSession.token.get("expires_at", 0) < time.time() + 5:
- self.tokenTimer.stop()
- logger.info("Token expired, going to refresh")
- return self.refreshOAuthToken()
- return True
-
- def refreshOAuthToken(self):
- token_url = config.Settings.get('oauth/host') + OAUTH_TOKEN_PATH
- if not self.OAuthSession:
- self.OAuthSession = self.createOAuthSession()
- try:
- logger.debug("Refreshing OAuth token")
- token = self.OAuthSession.refresh_token(
- token_url,
- refresh_token=self.refresh_token,
- verify=False,
- )
- self.saveOAuthToken(token)
- return True
- except BaseException:
- logger.error("Error during refreshing token")
- return False
-
- def saveOAuthToken(self, token):
- config.Settings.set("oauth/token", token, persist=False)
- self.refresh_token = token.get("refresh_token")
- self.tokenTimer.start(1 * 1000)
-
def show_login_widget(self):
login_widget = LoginWidget(self.remember)
login_widget.finished.connect(self.on_widget_login_data)
@@ -1599,46 +1558,8 @@ def on_widget_login_data(self, api_changed):
self.news.updateNews()
self.games.refreshMods()
- oauth_host = config.Settings.get("oauth/host")
- authorization_endpoint = oauth_host + OAUTH_AUTH_PATH
- self.OAuthSession = self.createOAuthSession()
- authorization_url, oauth_state = self.OAuthSession.authorization_url(
- authorization_endpoint,
- )
- oauth_widget = OAuthWidget(
- oauth_state=oauth_state,
- url=authorization_url,
- )
- oauth_widget.finished.connect(self.oauth_finished)
- oauth_widget.rejected.connect(self.on_widget_no_login)
- oauth_widget.exec()
-
- def oauth_finished(self, state, code, error):
- token_url = config.Settings.get("oauth/host") + OAUTH_TOKEN_PATH
- if state:
- try:
- logger.debug("Fetching OAuth token")
- token = self.OAuthSession.fetch_token(
- token_url,
- code=code,
- include_client_id=True,
- verify=False,
- )
- self.saveOAuthToken(token)
- self.do_connect()
- return
- except BaseException:
- logger.error(
- "Fetching token failed: ",
- exc_info=sys.exc_info(),
- )
- elif error:
- logger.error("Error during logging in: {}".format(error))
-
- QtWidgets.QMessageBox.warning(
- self, "Log In", "Error occured, please retry",
- )
- self.on_widget_no_login()
+ self.oauth_flow.setup_credentials()
+ self.oauth_flow.grant()
def on_widget_no_login(self):
self.state = ClientState.DISCONNECTED
diff --git a/src/client/oauth_dialog.py b/src/client/oauth_dialog.py
deleted file mode 100644
index 02fbe793c..000000000
--- a/src/client/oauth_dialog.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import logging
-
-from PyQt6 import QtCore
-from PyQt6 import QtGui
-from PyQt6 import QtWebEngineCore
-from PyQt6 import QtWebEngineWidgets
-
-import util
-
-logger = logging.getLogger(__name__)
-
-FormClass, BaseClass = util.THEME.loadUiType("client/oauth.ui")
-
-
-class OAuthWidget(FormClass, BaseClass):
- finished = QtCore.pyqtSignal(str, str, str)
- request_quit = QtCore.pyqtSignal()
-
- def __init__(self, oauth_state=None, url=None, *args, **kwargs):
- BaseClass.__init__(self, *args, **kwargs)
- self.setupUi(self)
- self.webview = QtWebEngineWidgets.QWebEngineView()
- self.layout().addWidget(self.webview)
- self.url = QtCore.QUrl(url)
- self.oauth_state = oauth_state
- self.webpage = OAuthWebPage()
- self.webpage.setUrl(self.url)
- self.webview.setPage(self.webpage)
-
- self.webpage.navigationRequestAccepted.connect(
- self.navigationRequestAccepted,
- )
-
- def navigationRequestAccepted(self, url):
- query = QtCore.QUrlQuery(url)
- code = query.queryItemValue("code")
- state = query.queryItemValue("state")
- error = query.queryItemValue("error")
- if state and code:
- self.accept()
- if self.oauth_state == state:
- self.finished.emit(state, code, error)
- else:
- self.finished.emit("", "", "")
- elif error:
- self.reject()
- self.finished.emit("", "", error)
-
-
-class OAuthWebPage(QtWebEngineCore.QWebEnginePage):
- navigationRequestAccepted = QtCore.pyqtSignal(QtCore.QUrl)
-
- def __init__(self):
- QtWebEngineCore.QWebEnginePage.__init__(self)
-
- def acceptNavigationRequest(self, url, type_, isMainFrame):
- if "oauth" in url.url() or "localhost" in url.url():
- self.navigationRequestAccepted.emit(url)
- return True
- elif type_ == 1:
- return False
- else:
- QtGui.QDesktopServices.openUrl(url)
- return False
diff --git a/src/config/production.py b/src/config/production.py
index 4cc473da0..7734871f4 100644
--- a/src/config/production.py
+++ b/src/config/production.py
@@ -42,6 +42,8 @@
'oauth/redirect_uri': "http://localhost",
'oauth/scope': ["openid", "offline", "public_profile", "lobby"],
'oauth/token': None,
+ 'oauth/auth_endpoint': '/oauth2/auth',
+ 'oauth/token_endpoint': '/oauth2/token',
'replay_vault/host': 'https://replay.{host}',
'replay_server/host': 'lobby.{host}',
'replay_server/port': 15000,
diff --git a/src/oauth/__init__.py b/src/oauth/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/oauth/oauth_flow.py b/src/oauth/oauth_flow.py
new file mode 100644
index 000000000..ad691c01a
--- /dev/null
+++ b/src/oauth/oauth_flow.py
@@ -0,0 +1,97 @@
+from PyQt6.QtCore import QDateTime
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QTimer
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QDesktopServices
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetworkAuth import QOAuth2AuthorizationCodeFlow
+from PyQt6.QtNetworkAuth import QOAuthHttpServerReplyHandler
+
+from config import Settings
+from decorators import with_logger
+
+
+class OAuthReplyHandler(QOAuthHttpServerReplyHandler):
+ def callback(self) -> str:
+ with_trailing_slash = super().callback()
+ # remove trailing slash because server does not accept it
+ return with_trailing_slash.removesuffix("/")
+
+
+@with_logger
+class OAuth2Flow(QOAuth2AuthorizationCodeFlow):
+ def __init__(
+ self,
+ manager: QNetworkAccessManager | None = None,
+ parent: QObject | None = None,
+ ) -> None:
+ super().__init__(manager, parent)
+
+ if manager is None:
+ self.setNetworkAccessManager(QNetworkAccessManager())
+
+ self.setup_credentials()
+ reply_handler = OAuthReplyHandler(self)
+ self.setReplyHandler(reply_handler)
+
+ self.authorizeWithBrowser.connect(QDesktopServices.openUrl)
+ self.requestFailed.connect(self.on_request_failed)
+ self.granted.connect(self.on_granted)
+ self.tokenChanged.connect(self.on_token_changed)
+ self.expirationAtChanged.connect(self.on_expiration_at_changed)
+
+ self._check_timer = QTimer(self)
+ self._check_timer.timeout.connect(self.check_token)
+ self._check_interval = 5000
+ self._expires_in = None
+
+ def stop_checking_expiration(self) -> None:
+ self._check_timer.stop()
+ self._expires_in = None
+
+ def start_checking_expiration(self) -> None:
+ self._check_timer.start(self._check_interval)
+
+ def check_token(self) -> None:
+ if self._expires_in is None:
+ return
+
+ self._expires_in -= self._check_interval
+ if self._expires_in <= 60_000:
+ self.refreshAccessToken()
+
+ def on_expiration_at_changed(self, expiration_at: QDateTime) -> None:
+ self._logger.debug(f"Token expiration at changed to: {expiration_at}")
+ self._expires_in = QDateTime.currentDateTime().msecsTo(expiration_at)
+
+ def on_token_changed(self, new_token: str) -> None:
+ self._logger.debug(f"Token changed to: {new_token}")
+
+ def on_granted(self) -> None:
+ self._logger.debug("Token granted successfuly!")
+ self.start_checking_expiration()
+
+ def on_request_failed(self, error: QOAuth2AuthorizationCodeFlow.Error) -> None:
+ self._logger.debug(f"Request failed with an error: {error}")
+ self.stop_checking_expiration()
+
+ def setup_credentials(self) -> None:
+ """
+ Set client's credentials, scopes and OAuth endpoints
+ """
+ # client_id = Settings.get("oauth/client_id")
+ client_id = "faf-java-client" # FIXME: ask to configure ports for python client
+ scopes = Settings.get("oauth/scope")
+
+ oauth_host = QUrl(Settings.get("oauth/host"))
+ auth_endpoint = QUrl(Settings.get("oauth/auth_endpoint"))
+ token_endpoint = QUrl(Settings.get("oauth/token_endpoint"))
+
+ authorization_url = oauth_host.resolved(auth_endpoint)
+ token_url = oauth_host.resolved(token_endpoint)
+
+ self.setUserAgent("FAF Client")
+ self.setAuthorizationUrl(authorization_url)
+ self.setClientIdentifier(client_id)
+ self.setAccessTokenUrl(token_url)
+ self.setScope(" ".join(scopes))
From 3a0d8d9b59750411f15ebe919cad3b1f5c8778ef Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 5 Apr 2024 22:05:32 +0300
Subject: [PATCH 013/123] Factor out ApiAcessor classes from ApiBase class
create user api class, which will be needed for WebSocket connection
---
src/api/ApiAccessors.py | 89 +++++++++++++++++++++++++++++++++
src/api/ApiBase.py | 86 +++++++------------------------
src/api/featured_mod_api.py | 15 +++---
src/api/featured_mod_updater.py | 31 ++++++------
src/api/matchmaker_queue_api.py | 14 +++---
src/api/player_api.py | 22 +++++---
src/api/replaysapi.py | 13 ++---
src/api/sim_mod_updater.py | 16 +++---
src/api/stats_api.py | 25 +++++----
src/api/vaults_api.py | 36 +++++++------
src/config/production.py | 1 +
11 files changed, 201 insertions(+), 147 deletions(-)
create mode 100644 src/api/ApiAccessors.py
diff --git a/src/api/ApiAccessors.py b/src/api/ApiAccessors.py
new file mode 100644
index 000000000..874a02cb4
--- /dev/null
+++ b/src/api/ApiAccessors.py
@@ -0,0 +1,89 @@
+import logging
+
+from api.ApiBase import ApiBase
+
+logger = logging.getLogger(__name__)
+
+
+class ApiAccessor(ApiBase):
+ def __init__(self, route: str = "") -> None:
+ super().__init__(route)
+ self.host_config_key = "api"
+
+
+class UserApiAccessor(ApiBase):
+ def __init__(self, route: str = "") -> None:
+ super().__init__(route)
+ self.host_config_key = "user_api"
+
+
+class DataApiAccessor(ApiAccessor):
+ def parse_message(self, message: dict) -> dict:
+ included = self.parseIncluded(message)
+ result = {}
+ result["data"] = self.parseData(message, included)
+ result["meta"] = self.parseMeta(message)
+ return result
+
+ def parseIncluded(self, message: dict) -> dict:
+ result: dict = {}
+ relationships = []
+ if "included" in message:
+ for inc_item in message["included"]:
+ if not inc_item["type"] in result:
+ result[inc_item["type"]] = {}
+ if "attributes" in inc_item:
+ type_ = inc_item["type"]
+ id_ = inc_item["id"]
+ result[type_][id_] = inc_item["attributes"]
+ if "relationships" in inc_item:
+ for key, value in inc_item["relationships"].items():
+ relationships.append((
+ inc_item["type"], inc_item["id"], key, value,
+ ))
+ message.pop('included')
+ # resolve relationships
+ for r in relationships:
+ result[r[0]][r[1]][r[2]] = self.parseData(r[3], result)
+ return result
+
+ def parseData(self, message: dict, included: dict) -> dict | list:
+ if "data" in message:
+ if isinstance(message["data"], (list)):
+ result = []
+ for data in message["data"]:
+ result.append(self.parseSingleData(data, included))
+ return result
+ elif isinstance(message["data"], (dict)):
+ return self.parseSingleData(message["data"], included)
+ else:
+ logger.error("error in response", message)
+ if "included" in message:
+ logger.error("unexpected 'included' in message", message)
+ return {}
+
+ def parseSingleData(self, data: dict, included: dict) -> dict:
+ result = {}
+ try:
+ if (
+ data["type"] in included
+ and data["id"] in included[data["type"]]
+ ):
+ result = included[data["type"]][data["id"]]
+ result["id"] = data["id"]
+ if "type" not in result:
+ result["type"] = data["type"]
+ if "attributes" in data:
+ for key, value in data["attributes"].items():
+ result[key] = value
+ if "relationships" in data:
+ for key, value in data["relationships"].items():
+ result[key] = self.parseData(value, included)
+ except Exception as e:
+ logger.error(f"Erorr parsing {data}: {e}")
+ return result
+
+ def parseMeta(self, message: dict) -> dict:
+ if "meta" in message:
+ return message["meta"]
+ return {}
diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py
index 5162e1332..630a033b7 100644
--- a/src/api/ApiBase.py
+++ b/src/api/ApiBase.py
@@ -28,6 +28,7 @@ class ApiBase(QObject):
def __init__(self, route: str = "") -> None:
QObject.__init__(self)
self.route = route
+ self.host_config_key = ""
self.manager = QNetworkAccessManager()
self.manager.finished.connect(self.onRequestFinished)
self._running = False
@@ -47,10 +48,22 @@ def build_query_url(self, query_dict: dict) -> QUrl:
exclude=DO_NOT_ENCODE,
)
percentEncodedStrQuery = percentEncodedByteArrayQuery.data().decode()
- url = url = QUrl(Settings.get('api') + self.route)
+ url = self._get_host_url().resolved(QUrl(self.route))
url.setQuery(percentEncodedStrQuery)
return url
+ def _get_host_url(self) -> QUrl:
+ return QUrl(Settings.get(self.host_config_key))
+
+ # query arguments like filter=login==Rhyza
+ def get_by_query(self, query_dict: dict, response_handler: Callable[[dict], Any]) -> None:
+ url = self.build_query_url(query_dict)
+ self.get(url, response_handler)
+
+ def get_by_endpoint(self, endpoint: str, response_handler: Callable[[dict], Any]) -> None:
+ url = self._get_host_url().resolved(QUrl(endpoint))
+ self.get(url, response_handler)
+
@staticmethod
def prepare_request(url: QUrl | None) -> QNetworkRequest:
request = QNetworkRequest(url) if url else QNetworkRequest()
@@ -66,6 +79,9 @@ def get(self, url: QUrl, response_handler: Callable[[dict], Any]) -> None:
reply = self.manager.get(self.prepare_request(url))
self.handlers[reply] = response_handler
+ def parse_message(self, message: dict) -> dict:
+ return message
+
def onRequestFinished(self, reply: QNetworkReply) -> None:
self._running = False
if reply.error() != QNetworkReply.NetworkError.NoError:
@@ -73,77 +89,11 @@ def onRequestFinished(self, reply: QNetworkReply) -> None:
else:
message_bytes = reply.readAll().data()
message = json.loads(message_bytes.decode('utf-8'))
- included = self.parseIncluded(message)
- result = {}
- result["data"] = self.parseData(message, included)
- result["meta"] = self.parseMeta(message)
+ result = self.parse_message(message)
self.handlers[reply](result)
self.handlers.pop(reply)
reply.deleteLater()
- def parseIncluded(self, message):
- result = {}
- relationships = []
- if "included" in message:
- for inc_item in message["included"]:
- if not inc_item["type"] in result:
- result[inc_item["type"]] = {}
- if "attributes" in inc_item:
- type_ = inc_item["type"]
- id_ = inc_item["id"]
- result[type_][id_] = inc_item["attributes"]
- if "relationships" in inc_item:
- for key, value in inc_item["relationships"].items():
- relationships.append((
- inc_item["type"], inc_item["id"], key, value,
- ))
- message.pop('included')
- # resolve relationships
- for r in relationships:
- result[r[0]][r[1]][r[2]] = self.parseData(r[3], result)
- return result
-
- def parseData(self, message, included):
- if "data" in message:
- if isinstance(message["data"], (list)):
- result = []
- for data in message["data"]:
- result.append(self.parseSingleData(data, included))
- return result
- elif isinstance(message["data"], (dict)):
- return self.parseSingleData(message["data"], included)
- else:
- logger.error("error in response", message)
- if "included" in message:
- logger.error("unexpected 'included' in message", message)
- return {}
-
- def parseSingleData(self, data, included):
- result = {}
- try:
- if (
- data["type"] in included
- and data["id"] in included[data["type"]]
- ):
- result = included[data["type"]][data["id"]]
- result["id"] = data["id"]
- if "type" not in result:
- result["type"] = data["type"]
- if "attributes" in data:
- for key, value in data["attributes"].items():
- result[key] = value
- if "relationships" in data:
- for key, value in data["relationships"].items():
- result[key] = self.parseData(value, included)
- except BaseException:
- logger.error("error parsing ", data)
- return result
-
- def parseMeta(self, message):
- if "meta" in message:
- return message["meta"]
- return {}
-
def waitForCompletion(self):
waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
while self._running:
diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py
index 5af136677..203aad324 100644
--- a/src/api/featured_mod_api.py
+++ b/src/api/featured_mod_api.py
@@ -1,19 +1,20 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
+from client.connection import Dispatcher
logger = logging.getLogger(__name__)
-class FeaturedModApiConnector(ApiBase):
- def __init__(self, dispatch):
- ApiBase.__init__(self, '/data/featuredMod')
+class FeaturedModApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__('/data/featuredMod')
self.dispatch = dispatch
- def requestData(self):
- self.request({}, self.handleData)
+ def requestData(self) -> None:
+ self.get_by_query({}, self.handleData)
- def handleData(self, message):
+ def handleData(self, message: dict) -> None:
preparedData = {
"command": "mod_info_api",
"values": [],
diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py
index c4a69be30..e1d2cbd6e 100644
--- a/src/api/featured_mod_updater.py
+++ b/src/api/featured_mod_updater.py
@@ -1,21 +1,17 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
logger = logging.getLogger(__name__)
-class FeaturedModFiles(ApiBase):
-
- def __init__(self, mod_id, version):
- ApiBase.__init__(
- self,
- '/featuredMods/{}/files/{}'.format(mod_id, version),
- )
+class FeaturedModFiles(DataApiAccessor):
+ def __init__(self, mod_id: int, version: str) -> None:
+ super().__init__('/featuredMods/{}/files/{}'.format(mod_id, version))
self.featuredModFiles = []
- def requestData(self):
- self.request({}, self.handleData)
+ def requestData(self) -> None:
+ self.get_by_query({}, self.handleData)
def handleData(self, message):
self.featuredModFiles = message["data"]
@@ -26,20 +22,21 @@ def getFiles(self):
return self.featuredModFiles
-class FeaturedModId(ApiBase):
- def __init__(self):
- ApiBase.__init__(self, '/data/featuredMod')
+class FeaturedModId(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__('/data/featuredMod')
self.featuredModId = 0
- def requestData(self, queryDict={}):
- self.request(queryDict, self.handleData)
+ def requestData(self, queryDict: dict | None = None) -> None:
+ queryDict = queryDict or {}
+ self.get_by_query(queryDict, self.handleData)
def handleFeaturedModId(self, message):
self.featuredModId = message['data'][0]['id']
- def requestFeaturedModIdByName(self, technicalName):
+ def requestFeaturedModIdByName(self, technicalName: str) -> None:
queryDict = dict(filter='technicalName=={}'.format(technicalName))
- self.request(queryDict, self.handleFeaturedModId)
+ self.get_by_query(queryDict, self.handleFeaturedModId)
def requestAndGetFeaturedModIdByName(self, technicalName):
self.requestFeaturedModIdByName(technicalName)
diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py
index edbab4697..e4ca62d43 100644
--- a/src/api/matchmaker_queue_api.py
+++ b/src/api/matchmaker_queue_api.py
@@ -1,17 +1,19 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
+from client.connection import Dispatcher
logger = logging.getLogger(__name__)
-class matchmakerQueueApiConnector(ApiBase):
- def __init__(self, dispatch):
- ApiBase.__init__(self, '/data/matchmakerQueue')
+class matchmakerQueueApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__('/data/matchmakerQueue')
self.dispatch = dispatch
- def requestData(self, queryDict={}):
- self.request(queryDict, self.handleData)
+ def requestData(self, queryDict: dict | None = None) -> None:
+ queryDict = queryDict or {}
+ self.get_by_query(queryDict, self.handleData)
def handleData(self, message: dict) -> None:
preparedData = {
diff --git a/src/api/player_api.py b/src/api/player_api.py
index 41bc8887e..235a22638 100644
--- a/src/api/player_api.py
+++ b/src/api/player_api.py
@@ -1,18 +1,24 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
+from client.connection import Dispatcher
logger = logging.getLogger(__name__)
-class PlayerApiConnector(ApiBase):
- def __init__(self, dispatch):
- ApiBase.__init__(self, '/data/player')
+class PlayerApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__('/data/player')
self.dispatch = dispatch
- def requestDataForLeaderboard(self, leaderboardName, queryDict={}):
+ def requestDataForLeaderboard(
+ self,
+ leaderboardName: str,
+ queryDict: dict | None = None,
+ ) -> None:
+ queryDict = queryDict or {}
self.leaderboardName = leaderboardName
- self.request(queryDict, self.handleDataForLeaderboard)
+ self.get_by_query(queryDict, self.handleDataForLeaderboard)
def handleDataForLeaderboard(self, message: dict) -> None:
preparedData = dict(
@@ -24,7 +30,7 @@ def handleDataForLeaderboard(self, message: dict) -> None:
)
self.dispatch.dispatch(preparedData)
- def requestDataForAliasViewer(self, nameToFind):
+ def requestDataForAliasViewer(self, nameToFind: str) -> None:
queryDict = {
'include': 'names',
'filter': '(login=="{name}",names.name=="{name}")'.format(
@@ -33,7 +39,7 @@ def requestDataForAliasViewer(self, nameToFind):
'fields[player]': 'login,names',
'fields[nameRecord]': 'name,changeTime,player',
}
- self.request(queryDict, self.handleDataForAliasViewer)
+ self.get_by_query(queryDict, self.handleDataForAliasViewer)
def handleDataForAliasViewer(self, message: dict) -> None:
preparedData = dict(
diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py
index 445bf8dea..f76ebd17e 100644
--- a/src/api/replaysapi.py
+++ b/src/api/replaysapi.py
@@ -1,17 +1,18 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
+from client.connection import Dispatcher
logger = logging.getLogger(__name__)
-class ReplaysApiConnector(ApiBase):
- def __init__(self, dispatch):
- ApiBase.__init__(self, '/data/game')
+class ReplaysApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__('/data/game')
self.dispatch = dispatch
- def requestData(self, args):
- self.request(args, self.handleData)
+ def requestData(self, params: dict) -> None:
+ self.get_by_query(params, self.handleData)
def handleData(self, message):
preparedData = dict(
diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py
index 75a706381..e8cae3158 100644
--- a/src/api/sim_mod_updater.py
+++ b/src/api/sim_mod_updater.py
@@ -1,23 +1,23 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
logger = logging.getLogger(__name__)
-class SimModFiles(ApiBase):
- def __init__(self):
- ApiBase.__init__(self, '/data/modVersion')
+class SimModFiles(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__('/data/modVersion')
self.simModUrl = ''
- def requestData(self, queryDict):
- self.request(queryDict, self.handleData)
+ def requestData(self, queryDict: dict) -> None:
+ self.get_by_query(queryDict, self.handleData)
def getUrlFromMessage(self, message):
self.simModUrl = message[0]['downloadUrl']
- def requestAndGetSimModUrlByUid(self, uid):
+ def requestAndGetSimModUrlByUid(self, uid: int) -> str:
queryDict = dict(filter='uid=={}'.format(uid))
- self.request(queryDict, self.getUrlFromMessage)
+ self.get_by_query(queryDict, self.getUrlFromMessage)
self.waitForCompletion()
return self.simModUrl
diff --git a/src/api/stats_api.py b/src/api/stats_api.py
index 6c9c76532..ad8f1fc0c 100644
--- a/src/api/stats_api.py
+++ b/src/api/stats_api.py
@@ -1,18 +1,20 @@
import logging
-from api.ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
+from client.connection import Dispatcher
logger = logging.getLogger(__name__)
-class LeaderboardRatingApiConnector(ApiBase):
- def __init__(self, dispatch, leaderboardName):
- ApiBase.__init__(self, '/data/leaderboardRating')
+class LeaderboardRatingApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher, leaderboardName: str) -> None:
+ super().__init__('/data/leaderboardRating')
self.dispatch = dispatch
self.leadeboardName = leaderboardName
- def requestData(self, queryDict={}):
- self.request(queryDict, self.handleData)
+ def requestData(self, queryDict: dict | None = None) -> None:
+ queryDict = queryDict or {}
+ self.get_by_query(queryDict, self.handleData)
def handleData(self, message: dict) -> None:
preparedData = dict(
@@ -25,13 +27,14 @@ def handleData(self, message: dict) -> None:
self.dispatch.dispatch(preparedData)
-class LeaderboardApiConnector(ApiBase):
- def __init__(self, dispatch=None):
- ApiBase.__init__(self, '/data/leaderboard')
+class LeaderboardApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher | None = None) -> None:
+ super().__init__('/data/leaderboard')
self.dispatch = dispatch
- def requestData(self, queryDict={}):
- self.request(queryDict, self.handleData)
+ def requestData(self, queryDict: dict | None = None) -> None:
+ queryDict = queryDict or {}
+ self.get_by_query(queryDict, self.handleData)
def handleData(self, message: dict) -> None:
preparedData = dict(
diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py
index bb736de04..ab8af345d 100644
--- a/src/api/vaults_api.py
+++ b/src/api/vaults_api.py
@@ -1,17 +1,19 @@
import logging
-from .ApiBase import ApiBase
+from api.ApiAccessors import DataApiAccessor
+from client.connection import Dispatcher
logger = logging.getLogger(__name__)
-class ModApiConnector(ApiBase):
- def __init__(self, dispatch):
- ApiBase.__init__(self, '/data/mod')
+class ModApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__('/data/mod')
self.dispatch = dispatch
- def requestData(self, query={}):
- self.request(query, self.handleData)
+ def requestData(self, params: dict | None = None) -> None:
+ params = params or {}
+ self.get_by_query(params, self.handleData)
def handleData(self, message: dict) -> None:
preparedData = dict(
@@ -45,13 +47,14 @@ def handleData(self, message: dict) -> None:
self.dispatch.dispatch(preparedData)
-class MapApiConnector(ApiBase):
- def __init__(self, dispatch):
- ApiBase.__init__(self, '/data/map')
+class MapApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__('/data/map')
self.dispatch = dispatch
- def requestData(self, query={}):
- self.request(query, self.handleData)
+ def requestData(self, params: dict | None = None) -> None:
+ params = params or {}
+ self.get_by_query(params, self.handleData)
def handleData(self, message: dict) -> None:
preparedData = dict(
@@ -88,13 +91,14 @@ def handleData(self, message: dict) -> None:
self.dispatch.dispatch(preparedData)
-class MapPoolApiConnector(ApiBase):
- def __init__(self, dispatch):
- ApiBase.__init__(self, '/data/mapPoolAssignment')
+class MapPoolApiConnector(DataApiAccessor):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__('/data/mapPoolAssignment')
self.dispatch = dispatch
- def requestData(self, query={}):
- self.request(query, self.handleData)
+ def requestData(self, params: dict | None) -> None:
+ params = params or {}
+ self.get_by_query(params, self.handleData)
def handleData(self, message: dict) -> None:
preparedData = dict(
diff --git a/src/config/production.py b/src/config/production.py
index 7734871f4..bdd5808bd 100644
--- a/src/config/production.py
+++ b/src/config/production.py
@@ -12,6 +12,7 @@
default_values = {
'display_name': 'Main Server (recommended)',
'api': 'https://api.{host}',
+ 'user_api': 'https://user.{host}',
'chat/host': 'irc.{host}',
'chat/port': 6697,
'client/data_path': APPDATA_DIR,
From 6ca9da89249475c0295d54001db3329e6c0e0f2c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 6 Apr 2024 10:03:42 +0300
Subject: [PATCH 014/123] Switch to WebSocket connection
---
src/client/connection.py | 48 +++++++++++++++++++++++++++-------------
1 file changed, 33 insertions(+), 15 deletions(-)
diff --git a/src/client/connection.py b/src/client/connection.py
index f74441125..f8928169b 100644
--- a/src/client/connection.py
+++ b/src/client/connection.py
@@ -5,8 +5,12 @@
from PyQt6 import QtCore
from PyQt6 import QtNetwork
+from PyQt6.QtCore import QByteArray
+from PyQt6.QtCore import QUrl
+from PyQt6.QtWebSockets import QWebSocket
import fa
+from api.ApiAccessors import UserApiAccessor
from config import Settings
from model.game import Game
from model.game import message_to_game_args
@@ -151,13 +155,13 @@ class ServerConnection(QtCore.QObject):
connected = QtCore.pyqtSignal()
disconnected = QtCore.pyqtSignal()
received_pong = QtCore.pyqtSignal()
+ access_url_ready = QtCore.pyqtSignal(QtCore.QUrl)
def __init__(self, host, port, dispatch):
QtCore.QObject.__init__(self)
- self.socket = QtNetwork.QTcpSocket()
- self.socket.readyRead.connect(self.readFromServer)
+ self.socket = QWebSocket()
+ self.socket.binaryMessageReceived.connect(self.on_binary_message_received)
self.socket.errorOccurred.connect(self.socketError)
- self.socket.setSocketOption(QtNetwork.QTcpSocket.SocketOption.KeepAliveOption, 1)
self.socket.stateChanged.connect(self.on_socket_state_change)
self._host = host
@@ -168,6 +172,9 @@ def __init__(self, host, port, dispatch):
self._dispatch = dispatch
+ self.api_accessor = UserApiAccessor()
+ self.access_url_ready.connect(self.open_websocket)
+
def on_socket_state_change(self, state):
states = QtNetwork.QAbstractSocket.SocketState
my_state = None
@@ -225,7 +232,22 @@ def setPortFromConfig(self):
def do_connect(self):
self._disconnect_requested = False
self.state = ConnectionState.CONNECTING
- self.socket.connectToHost(self._host, self._port)
+ self.api_accessor.get_by_endpoint("/lobby/access", self.handle_lobby_access_api_response)
+
+ def extract_url_from_api_response(self, data: dict) -> None:
+ # FIXME: remove this workaround when bug is resolved
+ # see https://bugreports.qt.io/browse/QTBUG-120492
+ url = data["accessUrl"].replace("com?", "com/?")
+ return QUrl(url)
+
+ def handle_lobby_access_api_response(self, data: dict) -> None:
+ url = self.extract_url_from_api_response(data)
+ self.access_url_ready.emit(url)
+
+ @QtCore.pyqtSlot(QtCore.QUrl)
+ def open_websocket(self, url: QUrl) -> None:
+ logger.debug(f"Opening WebSocket url: {url}")
+ self.socket.open(url)
def on_connecting(self):
self.state = ConnectionState.CONNECTING
@@ -238,7 +260,7 @@ def socket_connected(self):
return self.socket.state() == QtNetwork.QTcpSocket.SocketState.ConnectedState
def disconnect_(self):
- self.socket.disconnectFromHost()
+ self.socket.close()
def set_upnp(self, port):
fa.upnp.createPortMapping(
@@ -265,15 +287,11 @@ def processDataFromServer(self, data):
exc_info=sys.exc_info(),
)
- @QtCore.pyqtSlot()
- def readFromServer(self):
- while not self.socket.atEnd():
- if self.socket.bytesAvailable() == 0:
- return
-
- data = self.socket.readAll().data().decode()
- logger.debug("Server: '{}'".format(data))
- self._data += data
+ @QtCore.pyqtSlot(QByteArray)
+ def on_binary_message_received(self, message: QByteArray) -> None:
+ data = message.data().decode()
+ logger.debug("Server: '{}'".format(data))
+ self._data += data
if self._data.endswith("\n"):
self.processDataFromServer(self._data)
@@ -282,7 +300,7 @@ def writeToServer(self, action, *args, **kw):
# it looks like there's a crash in Qt
# when sending to an unconnected socket
if self.socket.state() == QtNetwork.QAbstractSocket.SocketState.ConnectedState:
- self.socket.write(message)
+ self.socket.sendBinaryMessage(message)
def send(self, message):
data = json.dumps(message)
From 0b97942bbbf09ebf1cf29b14a82afdbdd4a8bbc4 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 6 Apr 2024 14:32:28 +0300
Subject: [PATCH 015/123] Fix QSoundEffect usage
Qt6 doesn't allow to play sounds immediately from filepath
so create QSoundEffect objects inside classes that use them
and play whenever needed
---
src/chat/channel_tab.py | 8 ++++++--
src/notifications/ns_dialog.py | 5 ++++-
src/util/theme.py | 9 +++------
3 files changed, 13 insertions(+), 9 deletions(-)
diff --git a/src/chat/channel_tab.py b/src/chat/channel_tab.py
index 1a1338ecb..1208c4cf0 100644
--- a/src/chat/channel_tab.py
+++ b/src/chat/channel_tab.py
@@ -1,6 +1,7 @@
from enum import IntEnum
from PyQt6.QtCore import QTimer
+from PyQt6.QtMultimedia import QSoundEffect
from chat.chat_widget import TabIcon
@@ -29,6 +30,9 @@ def __init__(self, cid, widget, theme, chat_config):
self._ping_timer.setSingleShot(True)
self._ping_timer.setInterval(self._chat_config.channel_ping_timeout)
+ self._ping_sound = QSoundEffect()
+ self._ping_sound.setSource(self._theme.sound("chat/sfx/query.wav"))
+
def _config_updated(self, option):
c = self._chat_config
if option == "channel_blink_interval":
@@ -69,14 +73,14 @@ def _start_blinking(self):
self._timer.start()
self._ping()
- def _ping(self):
+ def _ping(self) -> None:
self._widget.alert_tab()
if not self._chat_config.soundeffects:
return
if self._ping_timer.isActive():
return
self._ping_timer.start()
- self._theme.sound("chat/sfx/query.wav")
+ self._ping_sound.play()
def _stop_blinking(self):
self._timer.stop()
diff --git a/src/notifications/ns_dialog.py b/src/notifications/ns_dialog.py
index 8aebba951..5c56e9fcb 100644
--- a/src/notifications/ns_dialog.py
+++ b/src/notifications/ns_dialog.py
@@ -5,6 +5,7 @@
from PyQt6 import QtCore
from PyQt6 import QtWidgets
+from PyQt6.QtMultimedia import QSoundEffect
import util
@@ -41,6 +42,8 @@ def __init__(self, client, settings, *args, **kwargs):
lambda: self.acceptPartyInvite(sender_id=self.sender_id),
)
+ self.sound_effect = QSoundEffect()
+ self.sound_effect.setSource(util.THEME.sound("chat/sfx/query.wav"))
# TODO: integrate into client.css
# self.setStyleSheet(self.client.styleSheet())
@@ -71,7 +74,7 @@ def newEvent(
self.labelTime.setText(time.strftime("%H:%M:%S", time.localtime()))
QtCore.QTimer.singleShot(lifetime * 1000, self.hide)
if sound:
- util.THEME.sound("chat/sfx/query.wav")
+ self.sound_effect.play()
self.setFixedHeight(height or self.baseHeight)
self.setFixedWidth(width or self.baseWidth)
diff --git a/src/util/theme.py b/src/util/theme.py
index 5c53dc8cb..fa0652469 100644
--- a/src/util/theme.py
+++ b/src/util/theme.py
@@ -3,7 +3,6 @@
from PyQt6 import QtCore
from PyQt6 import QtGui
-from PyQt6 import QtMultimedia
from PyQt6 import QtWidgets
from PyQt6 import uic
from semantic_version import Version
@@ -361,8 +360,9 @@ def readfile(self, filename, themed=True):
return self._theme_callchain("readfile", filename, themed)
@_warn_resource_null
- def _sound(self, filename, themed=True):
- return self._theme_callchain("sound", filename, themed)
+ def sound(self, filename: str, themed: bool = True) -> QtCore.QUrl:
+ filepath = self._theme_callchain("sound", filename, themed)
+ return QtCore.QUrl.fromLocalFile(filepath)
def pixmap(self, filename, themed=True):
# If we receive None, return the default pixmap
@@ -371,9 +371,6 @@ def pixmap(self, filename, themed=True):
return QtGui.QPixmap()
return ret
- def sound(self, filename, themed=True):
- QtMultimedia.QSound.play(self._sound(filename, themed))
-
def reloadStyleSheets(self):
self.stylesheets_reloaded.emit()
From 3b8fd28ad8d4e10d05dec7b4dac139a8820e693c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 7 Apr 2024 17:46:52 +0300
Subject: [PATCH 016/123] Update leftover Qt6 incompatible Enum usage
---
src/updater/__init__.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/updater/__init__.py b/src/updater/__init__.py
index 372bfcc38..29714d5d3 100644
--- a/src/updater/__init__.py
+++ b/src/updater/__init__.py
@@ -4,6 +4,7 @@
from PyQt6.QtWidgets import QMessageBox
from semantic_version import Version
+from updater.base import Releases
from updater.base import UpdateChecker
from updater.base import UpdateNotifier
from updater.base import UpdateSettings
@@ -41,7 +42,7 @@ def build(cls, current_version, parent_widget, network_manager):
)
return cls(update_settings, checker, notifier, dialog, parent_widget)
- def _handle_update(self, releases, mandatory):
+ def _handle_update(self, releases: Releases, mandatory: bool) -> None:
branch = self.update_settings.updater_branch.to_reltype()
versions = releases.versions(
branch, self.update_settings.updater_downgrade,
@@ -54,7 +55,7 @@ def _handle_update(self, releases, mandatory):
return
self.dialog.setup(releases)
result = self.dialog.exec()
- if result == QDialog.Rejected and mandatory:
+ if result is QDialog.DialogCode.Rejected and mandatory:
self.mandatory_update_aborted.emit()
def settings_dialog(self):
From 7d9346326ff3bdfaa9ccd4cd3091809bc6fc9444 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 7 Apr 2024 18:13:57 +0300
Subject: [PATCH 017/123] Prevent comparing int to None
while handling tray icon activation
---
src/client/_clientwindow.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 2fa0b68b7..d12365144 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -490,7 +490,7 @@ def on_disconnected(self):
def appStateChanged(self, state):
if state == QtCore.Qt.ApplicationState.ApplicationInactive:
- self._lastDeactivateTime = time.time()
+ self._lastDeactivateTime = time.monotonic()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.Type.HoverMove:
@@ -541,7 +541,11 @@ def handle_tray_icon_activation(
reason: QtWidgets.QSystemTrayIcon.ActivationReason,
) -> None:
if reason is QtWidgets.QSystemTrayIcon.ActivationReason.Trigger:
- inactiveTime = time.time() - self._lastDeactivateTime
+ if self._lastDeactivateTime is None:
+ self.showMinimized()
+ return
+
+ inactiveTime = time.monotonic() - self._lastDeactivateTime
if (
self.isMinimized()
or inactiveTime >= self.keepActiveForTrayIcon
From 447ffd3149fef39100980ac53059d675295e6086 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Mon, 8 Apr 2024 21:04:54 +0300
Subject: [PATCH 018/123] Fix ircconnection
* use SASL authentication to log in
* take irc password from a dedicated service (see https://github.com/FAForever/server/issues/977)
---
src/chat/ircconnection.py | 32 +++++++++++++++++++++++++++-----
src/client/_clientwindow.py | 9 ++++++---
2 files changed, 33 insertions(+), 8 deletions(-)
diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py
index 63dc6bfe8..d91deabd4 100644
--- a/src/chat/ircconnection.py
+++ b/src/chat/ircconnection.py
@@ -12,6 +12,7 @@
import config
import util
+from api.ApiAccessors import UserApiAccessor
from model.chat.channel import ChannelID
from model.chat.channel import ChannelType
from model.chat.chatline import ChatLine
@@ -81,13 +82,17 @@ def __init__(self):
class IrcConnection(IrcSignals, irc.client.SimpleIRCClient):
- def __init__(self, host, port, use_ssl):
+ token_received = pyqtSignal(str)
+
+ def __init__(self, host: int, port: int, use_ssl: bool) -> None:
IrcSignals.__init__(self)
irc.client.SimpleIRCClient.__init__(self)
self.host = host
self.port = port
self.use_ssl = use_ssl
+ self.api_accessor = UserApiAccessor()
+ self.token_received.connect(self.on_token_received)
if self.use_ssl:
self.factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
else:
@@ -127,7 +132,21 @@ def disconnect_(self):
self._notifier.activated.disconnect()
self._notifier = None
- def connect_(self, nick, username, password):
+ def set_nick_and_username(self, nick: str, username: str) -> None:
+ self._nick = nick
+ self._username = username
+
+ def begin_connection_process(self) -> None:
+ self.api_accessor.get_by_endpoint("/irc/ergochat/token", self.handle_irc_token)
+
+ def handle_irc_token(self, data: dict) -> None:
+ irc_token = data["value"]
+ self.token_received.emit(irc_token)
+
+ def on_token_received(self, token: str) -> None:
+ self.connect_(self._nick, self._username, f"token:{token}")
+
+ def connect_(self, nick: str, username: str, password: str) -> bool:
logger.info(
"Connecting to IRC at: {}:{}. TLS: {}".format(
self.host, self.port, self.use_ssl,
@@ -145,13 +164,13 @@ def connect_(self, nick, username, password):
nick,
connect_factory=self.factory,
ircname=nick,
- username=username,
+ sasl_login=username,
password=password,
)
self._notifier = QSocketNotifier(
- self.connection.socket.fileno(), QSocketNotifier.Read, self,
+ self.connection.socket.fileno(), QSocketNotifier.Type.Read, self,
)
- self._notifier.activated.connect(self.reactor.process_once)
+ self._notifier.activated.connect(lambda: self.reactor.process_once())
self._timer.start(PONG_INTERVAL)
return True
except irc.client.IRCError:
@@ -205,6 +224,9 @@ def _log_client_message(self, text):
def on_welcome(self, c, e):
self._log_event(e)
+ if not self._connected:
+ self._connected = True
+ self.on_connected()
def _send_nickserv_creds(self, fmt):
self._log_client_message(
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index d12365144..11a9b1c35 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -888,7 +888,8 @@ def add_warning_button(faction):
def _connect_chat(self, me):
if not self.use_chat:
return
- self._chatMVC.connection.connect_(me.login, me.id, self.irc_password)
+ self._chatMVC.connection.set_nick_and_username(me.login, f"{me.login}@FAF")
+ self._chatMVC.connection.begin_connection_process()
def warningHide(self):
"""
@@ -1750,8 +1751,10 @@ def handle_welcome(self, message):
self.game_session.gameFullSignal.connect(self.emit_game_full)
- def handle_irc_password(self, message):
- self.irc_password = message.get("password", "")
+ def handle_irc_password(self, message: dict) -> None:
+ # DEPRECATED: this command is meaningless and can be removed at any time
+ # see https://github.com/FAForever/server/issues/977
+ ...
def handle_registration_response(self, message):
if message["result"] == "SUCCESS":
From 1cf5c6ad910be0d1c56c2f9a4f333e06fd495ff7 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 9 Apr 2024 20:49:03 +0300
Subject: [PATCH 019/123] Fix ice adapter usage
* launch it with java.exe from jre
* pass required game_id argument
* fetch ice_servers from api and don't expect them from the server
(see https://github.com/FAForever/server/pull/982)
---
src/client/_clientwindow.py | 4 +-
src/connectivity/IceAdapterProcess.py | 18 +++----
src/connectivity/IceServersPoller.py | 71 +++++++++------------------
src/fa/game_session.py | 13 ++---
4 files changed, 36 insertions(+), 70 deletions(-)
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 11a9b1c35..691753ed8 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -1833,7 +1833,7 @@ def join_game(self, uid, password=None):
self.lobby_connection.send(msg)
def handle_game_launch(self, message):
-
+ self.game_session.game_uid = message['uid']
self.game_session.startIceAdapter()
logger.info("Handling game_launch via JSON {}".format(message))
@@ -1911,8 +1911,6 @@ def handle_game_launch(self, message):
launched_at=time.time(),
)
- self.game_session.game_uid = message['uid']
-
fa.run(
info, self.game_session.relay_port, self.replayServer.serverPort(),
arguments, self.game_session.game_uid,
diff --git a/src/connectivity/IceAdapterProcess.py b/src/connectivity/IceAdapterProcess.py
index e9d8e7c1d..d513aeefa 100644
--- a/src/connectivity/IceAdapterProcess.py
+++ b/src/connectivity/IceAdapterProcess.py
@@ -14,7 +14,7 @@
@with_logger
class IceAdapterProcess(object):
- def __init__(self, player_id, player_login):
+ def __init__(self, player_id: int, player_login: str, game_id: int) -> None:
# determine free listen port for the RPC server inside the ice adapter
# process
@@ -24,12 +24,13 @@ def __init__(self, player_id, player_login):
s.close()
if sys.platform == 'win32':
- exe_path = os.path.join(
- fafpath.get_libdir(), "ice-adapter", "faf-ice-adapter.exe",
- )
+ exe_path = fafpath.get_java_path()
+ args = [
+ "-jar", os.path.join(fafpath.get_libdir(), "ice-adapter", "faf-ice-adapter.jar"),
+ ]
else: # Expect it to be in PATH already
exe_path = "faf-ice-adapter"
-
+ args = []
show_adapter_window = Settings.get(
"iceadapter/info_window", default=False, type=bool,
)
@@ -37,13 +38,12 @@ def __init__(self, player_id, player_login):
"iceadapter/delay_ui_seconds", default=10, type=int,
)
self.ice_adapter_process = QProcess()
- args = [
+ args.extend([
"--id", str(player_id),
"--login", player_login,
+ "--game-id", str(game_id),
"--rpc-port", str(self._rpc_server_port),
- "--gpgnet-port", "0",
- "--log-level", "debug",
- ]
+ ])
if show_adapter_window:
args.extend(["--info-window", "--delay-ui", str(delay_adapter_ui)])
if Settings.contains('iceadapter/args'):
diff --git a/src/connectivity/IceServersPoller.py b/src/connectivity/IceServersPoller.py
index a05ff3d68..1e10eb96c 100644
--- a/src/connectivity/IceServersPoller.py
+++ b/src/connectivity/IceServersPoller.py
@@ -1,65 +1,38 @@
-from datetime import datetime
-from datetime import timedelta
-
from PyQt6.QtCore import QObject
-from PyQt6.QtCore import QTimer
+from PyQt6.QtCore import pyqtSignal
+from api.ApiAccessors import ApiAccessor
+from connectivity.IceAdapterClient import IceAdapterClient
from decorators import with_logger
@with_logger
class IceServersPoller(QObject):
- def __init__(self, dispatcher, ice_adapter_client, lobby_connection):
+ ice_servers_received = pyqtSignal(list)
+
+ def __init__(self, ice_adapter_client: IceAdapterClient, game_uid: int) -> None:
QObject.__init__(self)
- self._dispatcher = dispatcher
self._ice_adapter_client = ice_adapter_client
- self._server_connection = lobby_connection
- self._dispatcher["ice_servers"] = self.handle_ice_servers
- self._valid_until = datetime.now()
- self.request_ice_servers()
- # credentials need to be valid for 10h,
- # usual ttlfor each request is 24h
- self.min_valid_seconds = 10 * 3600
- self._last_received_ice_servers = None
- self._last_relayed_ice_servers = None
+ self._game_uid = game_uid
- self._check_timer = QTimer(self)
- self._check_timer.timeout.connect(self.check_ice_servers)
- self._check_timer.start(5000)
+ self.ice_servers_received.connect(self.set_ice_servers)
- def check_ice_servers(self):
- seconds_left = (self._valid_until - datetime.now()).seconds
- if seconds_left < self.min_valid_seconds:
- self._logger.debug("ICE servers expired: requesting new list")
- self.request_ice_servers()
+ self._api_accessor = ApiAccessor()
+ self.request_ice_servers()
- # check if we have a list not sent to the ice-adapter
- if (
- not self._last_relayed_ice_servers
- and self._last_received_ice_servers
- and self._ice_adapter_client.connected
- ):
- self._ice_adapter_client.call(
- "setIceServers", [self._last_received_ice_servers],
- )
- self._last_relayed_ice_servers = self._last_received_ice_servers
+ def request_ice_servers(self) -> None:
+ self._api_accessor.get_by_endpoint(
+ f"/ice/session/game/{self._game_uid}",
+ self.handle_ice_servers,
+ )
- def request_ice_servers(self):
- self._server_connection.send({'command': 'ice_servers'})
+ def handle_ice_servers(self, message: dict) -> None:
+ servers = message["servers"]
+ self.ice_servers_received.emit(servers)
- def handle_ice_servers(self, message):
- ttl = int(message['ttl'])
- self._valid_until = datetime.now() + timedelta(seconds=ttl)
- self._logger.debug(
- "ice_servers valid until {}".format(self._valid_until),
- )
- self._last_received_ice_servers = message['ice_servers']
+ def set_ice_servers(self, servers: list[dict]) -> None:
if self._ice_adapter_client.connected:
- self._ice_adapter_client.call(
- "setIceServers", [self._last_received_ice_servers],
- )
- self._last_relayed_ice_servers = self._last_received_ice_servers
+ self._logger.debug(f"Settings IceServers to: {servers}")
+ self._ice_adapter_client.call("setIceServers", [servers])
else:
- self._logger.warn(
- "ICE servers received, but not connected to ice-adapter",
- )
+ self._logger.warn("ICE servers received, but not connected to ice-adapter")
diff --git a/src/fa/game_session.py b/src/fa/game_session.py
index 30e9ab78e..39fa1e6d8 100644
--- a/src/fa/game_session.py
+++ b/src/fa/game_session.py
@@ -69,6 +69,7 @@ def startIceAdapter(self):
self.ice_adapter_process = IceAdapterProcess(
player_id=self.player_id,
player_login=self.player_login,
+ game_id=self.game_uid,
)
self.ice_adapter_client = IceAdapterClient(game_session=self)
self.ice_adapter_client.statusChanged.connect(self.onIceAdapterStarted)
@@ -78,20 +79,14 @@ def startIceAdapter(self):
while self._relay_port == 0:
QCoreApplication.processEvents()
- def onIceAdapterStarted(self, status):
+ def onIceAdapterStarted(self, status: dict) -> None:
self._relay_port = status["gpgnet"]["local_port"]
logger.info(
"ICE adapter started an listening on port {} for GPGNet "
"connections".format(self._relay_port),
)
- self.ice_adapter_client.statusChanged.disconnect(
- self.onIceAdapterStarted,
- )
- self.ice_servers_poller = IceServersPoller(
- dispatcher=client.instance.lobby_dispatch,
- ice_adapter_client=self.ice_adapter_client,
- lobby_connection=client.instance.lobby_connection,
- )
+ self.ice_adapter_client.statusChanged.disconnect(self.onIceAdapterStarted)
+ self.ice_servers_poller = IceServersPoller(self.ice_adapter_client, self.game_uid)
def closeIceAdapter(self):
if self.ice_adapter_client:
From b9b0e95e968e5b1a81fa5a86d3cff932cea75f98 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 10 Apr 2024 19:19:28 +0300
Subject: [PATCH 020/123] Set request parameters when downloading file if
necessary
also, pass cloudflare parameters needed to download game files
resolves https://github.com/FAForever/client/issues/1133
---
src/downloadManager/__init__.py | 25 ++++++++++++++++++++-----
src/fa/updater.py | 9 +++++----
src/vaults/dialogs.py | 11 +++++++++--
3 files changed, 34 insertions(+), 11 deletions(-)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index 4e6334ec0..4b38191f9 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -26,12 +26,20 @@ class FileDownload(QObject):
progress = pyqtSignal(object)
finished = pyqtSignal(object)
- def __init__(self, nam, addr, dest, destpath=None):
+ def __init__(
+ self,
+ nam: QNetworkAccessManager,
+ addr: str,
+ dest: str,
+ destpath: str | None = None,
+ request_params: dict | None = None,
+ ) -> None:
QObject.__init__(self)
self._nam = nam
self.addr = addr
self.dest = dest
self.destpath = destpath
+ self.request_params = request_params or {}
self.canceled = False
self.error = False
@@ -72,15 +80,22 @@ def _finish(self):
self.error = True
self.finished.emit(self)
- def run(self):
- self._running = True
- req = QNetworkRequest(QUrl(self.addr))
+ def prepare_request(self) -> QNetworkRequest:
+ qurl = QUrl(self.addr)
+ # in https://github.com/FAForever/faf-java-api/pull/637
+ # hmac verification was introduced
+ req = QNetworkRequest(qurl)
+ for key, value in self.request_params.items():
+ req.setRawHeader(key.encode(), value.encode())
req.setRawHeader(b'User-Agent', b"FAF Client")
req.setMaximumRedirectsAllowed(3)
+ return req
+ def run(self):
+ self._running = True
self.start.emit(self)
- self._dfile = self._nam.get(req)
+ self._dfile = self._nam.get(self.prepare_request())
self._dfile.errorOccurred.connect(self._error)
self._dfile.finished.connect(self._atFinished)
self._dfile.downloadProgress.connect(self._atProgress)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index eda68ab16..570da0b98 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -202,21 +202,22 @@ def getFeaturedModIdByName(self, technicalName):
def requestSimUrlByUid(self, uid):
return SimModFiles().requestAndGetSimModUrlByUid(uid)
- def fetchFile(self, _file, filegroup):
+ def fetchFile(self, _file: dict, filegroup: str) -> None:
name = _file['name']
targetDir = os.path.join(util.APPDATA_DIR, filegroup, name)
- logger.info('Updater: Downloading {}'.format(_file['url']))
+ logger.info('Updater: Downloading {}'.format(_file['cacheableUrl']))
downloaded = downloadFile(
- url=_file['url'],
+ url=_file['cacheableUrl'],
target_dir=targetDir,
name=(
'Downloading FA file : {url} '
- .format(url=_file['url'])
+ .format(url=_file['cacheableUrl'])
),
category='Update',
silent=False,
+ request_params={_file["hmacParameter"]: _file["hmacToken"]},
)
if not downloaded:
diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py
index 142a7afad..2dbce1fe7 100644
--- a/src/vaults/dialogs.py
+++ b/src/vaults/dialogs.py
@@ -204,7 +204,14 @@ def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent):
return ret
-def downloadFile(url, target_dir, name, category, silent):
+def downloadFile(
+ url: str,
+ target_dir: str,
+ name: str,
+ category: str,
+ silent: bool,
+ request_params: dict | None = None,
+) -> None:
"""
Basically a copy of downloadVaultAssetNoMsg without zip
"""
@@ -214,7 +221,7 @@ def downloadFile(url, target_dir, name, category, silent):
output = io.BytesIO()
capitCat = category[0].upper() + category[1:]
- dler = FileDownload(_global_nam, url, output)
+ dler = FileDownload(_global_nam, url, output, request_params=request_params)
ddialog = VaultDownloadDialog(
dler, "Downloading {}".format(category), name, silent,
)
From 2aada998315ea8bc7d29370c41fb4dcab81758bd Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 11 Apr 2024 21:39:02 +0300
Subject: [PATCH 021/123] Add more download progress info
downloaded size and total size
---
src/vaults/dialogs.py | 29 ++++++++++++++++-------------
1 file changed, 16 insertions(+), 13 deletions(-)
diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py
index 2dbce1fe7..a75ef6f66 100644
--- a/src/vaults/dialogs.py
+++ b/src/vaults/dialogs.py
@@ -48,29 +48,32 @@ def __init__(self, dler, title, label, silent=False):
progressBar.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._progress.setBar(progressBar)
+ self.progress_measure_interaval = 250
self.timer = QtCore.QTimer()
- self.timer.setInterval(500)
+ self.timer.setInterval(self.progress_measure_interaval)
self.timer.timeout.connect(self.updateLabel)
self.bytes_prev = 0
- def updateLabel(self):
- self._progress.setLabelText(
- '{label}\n\n{downloaded} MiB ({speed} MiB/s)'
- .format(
- label=self.label,
- downloaded=self.getDownloadProgressMiB(),
- speed=self.getDownloadSpeed(),
- ),
- )
+ def updateLabel(self) -> None:
+ label_text = f"{self.label}\n\n{self.get_download_progress_mb()}"
+ speed_text = f"({self.get_download_speed()} MB/s)"
+ if self._dler.bytes_total > 0:
+ label_text = f"{label_text}/{self.get_download_size_mb()} MB\n\n{speed_text}"
+ else:
+ label_text = f"{label_text} MB {speed_text}"
+ self._progress.setLabelText(label_text)
- def getDownloadSpeed(self):
+ def get_download_speed(self) -> float:
bytes_diff = self._dler.bytes_progress - self.bytes_prev
self.bytes_prev = self._dler.bytes_progress
- return round(bytes_diff * 2 / 1024 / 1024, 2)
+ return round(bytes_diff * (1000 / self.progress_measure_interaval) / 1024 / 1024, 2)
- def getDownloadProgressMiB(self):
+ def get_download_progress_mb(self) -> float:
return round(self._dler.bytes_progress / 1024 / 1024, 2)
+ def get_download_size_mb(self) -> float:
+ return round(self._dler.bytes_total / 1024 / 1024, 2)
+
def run(self):
self.updateLabel()
self.timer.start()
From bb82911573d128e3147536171d0e5a161d592fdc Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 11 Apr 2024 22:38:29 +0300
Subject: [PATCH 022/123] Remove deprecated replay filter parameters
and use approximation to filter replays by rating
---
src/replays/_replayswidget.py | 33 +++++----------------------------
1 file changed, 5 insertions(+), 28 deletions(-)
diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py
index 2d2be09ae..377a2d4be 100644
--- a/src/replays/_replayswidget.py
+++ b/src/replays/_replayswidget.py
@@ -853,34 +853,11 @@ def prepareFilters(
.format(leaderboardId),
)
- if minRating and minRating > 0:
- if leaderboardId == 1:
- filters.append(
- 'playerStats.player.globalRating.rating=ge="{}"'
- .format(minRating),
- )
- elif leaderboardId == 2:
- filters.append(
- 'playerStats.player.ladder1v1Rating.rating=ge="{}"'
- .format(minRating),
- )
- else:
- filters.append(
- 'playerStats.ratingChanges.meanBefore=ge="{}"'
- .format(minRating + 300),
- )
- else:
- if minRating and minRating > 0:
- if modListIndex == "ladder1v1":
- filters.append(
- 'playerStats.player.ladder1v1Rating.rating=ge="{}"'
- .format(minRating),
- )
- else:
- filters.append(
- 'playerStats.player.globalRating.rating=ge="{}"'
- .format(minRating),
- )
+ if minRating and minRating > 0:
+ filters.append(
+ 'playerStats.ratingChanges.meanBefore=ge="{}"'
+ .format(minRating + 300),
+ )
if mapName:
filters.append(
From c479a2d95748b89736aa249fb326f8b783240b30 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 12 Apr 2024 20:26:55 +0300
Subject: [PATCH 023/123] Remove QtWebEngine usage from NewsWidget
---
res/news/news.ui | 21 +--
res/news/news_page.html | 14 ++
res/news/{news_webview.css => news_style.css} | 4 +-
res/news/news_webview_frame.html | 10 --
src/news/_newswidget.py | 151 ++++++++----------
5 files changed, 89 insertions(+), 111 deletions(-)
create mode 100644 res/news/news_page.html
rename res/news/{news_webview.css => news_style.css} (89%)
delete mode 100644 res/news/news_webview_frame.html
diff --git a/res/news/news.ui b/res/news/news.ui
index b3021b139..05c95e5fe 100644
--- a/res/news/news.ui
+++ b/res/news/news.ui
@@ -217,32 +217,13 @@
-
-
-
-
- 0
- 0
-
-
-
-
- about:blank
-
-
-
+
-
-
- QWebEngineView
- QWidget
- QtWebEngineWidgets/QWebEngineView
-
-
diff --git a/res/news/news_page.html b/res/news/news_page.html
new file mode 100644
index 000000000..19092e357
--- /dev/null
+++ b/res/news/news_page.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ {title}
+
+
+
diff --git a/res/news/news_webview.css b/res/news/news_style.css
similarity index 89%
rename from res/news/news_webview.css
rename to res/news/news_style.css
index 098594891..62c44e455 100644
--- a/res/news/news_webview.css
+++ b/res/news/news_style.css
@@ -1,4 +1,6 @@
-img { display: block; max-width: 100%; height: auto !important; }
+img {
+ float: left;
+}
body {
font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 15px;
diff --git a/res/news/news_webview_frame.html b/res/news/news_webview_frame.html
deleted file mode 100644
index c91977642..000000000
--- a/res/news/news_webview_frame.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-{title}
-
-
-{content}
-
-
diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py
index ca15cc249..39604c2f5 100644
--- a/src/news/_newswidget.py
+++ b/src/news/_newswidget.py
@@ -1,12 +1,18 @@
import logging
-import webbrowser
-from PyQt6 import QtCore
from PyQt6 import QtWidgets
+from PyQt6.QtCore import QByteArray
+from PyQt6.QtCore import QPoint
+from PyQt6.QtCore import QSize
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QImage
+from PyQt6.QtGui import QTextDocument
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
import util
from config import Settings
-from util.qt import ExternalLinkPage
from .newsitem import NewsItem
from .newsitem import NewsItemDelegate
@@ -15,60 +21,33 @@
logger = logging.getLogger(__name__)
-class Hider(QtCore.QObject):
- """
- Hides a widget by blocking its paint event. This is useful if a
- widget is in a layout that you do not want to change when the
- widget is hidden.
- """
-
- def __init__(self, parent=None):
- super(Hider, self).__init__(parent)
-
- def eventFilter(self, obj, ev):
- return ev.type() == QtCore.QEvent.Type.Paint
-
- def hide(self, widget):
- widget.installEventFilter(self)
- widget.update()
-
- def unhide(self, widget):
- widget.removeEventFilter(self)
- widget.update()
-
- def hideWidget(self, sender):
- if sender.isWidgetType():
- self.hide(sender)
-
-
FormClass, BaseClass = util.THEME.loadUiType("news/news.ui")
class NewsWidget(FormClass, BaseClass):
- CSS = util.THEME.readstylesheet('news/news_webview.css')
+ CSS = util.THEME.readstylesheet('news/news_style.css')
- HTML = str(util.THEME.readfile('news/news_webview_frame.html'))
+ HTML = util.THEME.readfile('news/news_page.html')
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, **kwargs) -> None:
BaseClass.__init__(self, *args, **kwargs)
self.setupUi(self)
+ self.nam = QNetworkAccessManager()
+ self.reply: QNetworkReply | None = None
+
self.newsManager = NewsManager(self)
self.newsItems = []
+ self.images = {}
# open all links in external browser
- self.newsWebView.setPage(ExternalLinkPage(self))
-
- # hide webview until loaded to avoid FOUC
- self.hider = Hider()
- self.hider.hide(self.newsWebView)
- self.newsWebView.loadFinished.connect(self.loadFinished)
+ self.newsTextBrowser.setOpenExternalLinks(True)
self.settingsFrame.hide()
self.hideNewsEdit.setText(Settings.get('news/hideWords', ""))
- self.newsList.setIconSize(QtCore.QSize(0, 0))
+ self.newsList.setIconSize(QSize(0, 0))
self.newsList.setItemDelegate(NewsItemDelegate(self))
self.newsList.currentItemChanged.connect(self.itemChanged)
self.newsSettings.pressed.connect(self.showSettings)
@@ -80,62 +59,74 @@ def addNews(self, newsPost):
newsItem = NewsItem(newsPost, self.newsList)
self.newsItems.append(newsItem)
- # QtWebEngine has no user CSS support yet, so let's just prepend it to the
- # HTML
- def _injectCSS(self, body, link, img):
- img = (
- ''.format(img)
- )
- body = body + '
'
- link = (
- ''.format(link)
- )
- css = ''.format(self.CSS)
- return css + img + body + link
-
- def updateNews(self):
- self.hider.hide(self.newsWebView)
+ def updateNews(self) -> None:
+ self.hider.hide(self.newsTextBrowser)
self.newsItems = []
self.newsList.clear()
self.newsManager.WpApi.download()
- def itemChanged(self, current, previous):
- if current is not None:
- if current.newsPost['external_link'] == '':
- link = current.newsPost['link']
- else:
- link = current.newsPost['external_link']
- self.newsWebView.page().setHtml(
- self.HTML.format(
- title=current.newsPost['title'],
- content=self._injectCSS(
- current.newsPost['excerpt'], link,
- current.newsPost['img_url'],
- ),
- ),
- )
-
- def linkClicked(self, url):
- webbrowser.open(url.toString())
-
- def loadFinished(self, ok):
- self.hider.unhide(self.newsWebView)
- self.newsWebView.loadFinished.disconnect(self.loadFinished)
+ def download_image(self, img_url: QUrl) -> None:
+ request = QNetworkRequest(img_url)
+ self.reply = self.nam.get(request)
+ self.reply.finished.connect(self.item_image_downloaded)
+
+ def add_image_resource(self, img_url: QUrl, image_data: QByteArray) -> None:
+ img = QImage()
+ img.loadFromData(image_data)
+ scaled = img.scaled(QSize(900, 500))
+
+ self.images[img_url] = scaled
+ self.newsTextBrowser.document().addResource(
+ QTextDocument.ResourceType.ImageResource,
+ img_url,
+ scaled,
+ )
+
+ def item_image_downloaded(self) -> None:
+ if self.reply.error() is not self.reply.NetworkError.NoError:
+ return
+ self.add_image_resource(self.reply.request().url(), self.reply.readAll())
+ self.show_newspage()
+
+ def itemChanged(self, current: NewsItem | None, previous: NewsItem | None) -> None:
+ if current is None:
+ return
+ url = QUrl(current.newsPost["img_url"])
+ if url in self.images:
+ self.show_newspage()
+ else:
+ self.download_image(url)
+
+ def show_newspage(self) -> None:
+ current = self.newsList.currentItem()
+
+ if current.newsPost['external_link'] == '':
+ external_link = current.newsPost['link']
+ else:
+ external_link = current.newsPost['external_link']
+
+ content = current.newsPost["excerpt"].strip().removeprefix("").removesuffix("
")
+ html = self.HTML.format(
+ style=self.CSS,
+ title=current.newsPost['title'],
+ content=content,
+ img_source=current.newsPost["img_url"],
+ external_link=external_link,
+ )
+ self.newsTextBrowser.setHtml(html)
def showAll(self):
for item in self.newsItems:
item.setHidden(False)
self.updateLabel(0)
- def showEditToolTip(self):
+ def showEditToolTip(self) -> None:
"""
Default tooltips are too slow and disappear when user starts typing
"""
widget = self.hideNewsEdit
position = widget.mapToGlobal(
- QtCore.QPoint(0 + widget.width(), 0 - widget.height() / 2),
+ QPoint(0 + widget.width(), 0 - widget.height() / 2),
)
QtWidgets.QToolTip.showText(
position,
From 3f8c5dd6fcb1445491d2c879af6c066be2536796 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 15:50:15 +0300
Subject: [PATCH 024/123] Remove QtWebEngine from UnitDB tab
just open those urls in Web browser
---
res/unitdb/unitdb.ui | 42 ++++++------
src/client/_clientwindow.py | 4 +-
src/config/__init__.py | 1 -
src/unitdb/unitdbtab.py | 133 ++++--------------------------------
4 files changed, 33 insertions(+), 147 deletions(-)
diff --git a/res/unitdb/unitdb.ui b/res/unitdb/unitdb.ui
index 2c8fd5118..03c2f5156 100644
--- a/res/unitdb/unitdb.ui
+++ b/res/unitdb/unitdb.ui
@@ -25,6 +25,12 @@
-
+
+
+ 0
+ 0
+
+
0
@@ -34,7 +40,7 @@
16777215
- 30
+ 100
@@ -46,6 +52,12 @@
-
+
+
+ 0
+ 0
+
+
0
@@ -59,6 +71,12 @@
-
+
+
+ 0
+ 0
+
+
0
@@ -73,30 +91,8 @@
- -
-
-
-
- 0
- 11
-
-
-
-
- about:blank
-
-
-
-
-
-
- QWebEngineView
- QWidget
- QtWebEngineWidgets/QWebEngineView
-
-
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 691753ed8..394409ead 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -74,7 +74,7 @@
from stats import StatsWidget
from ui.busy_widget import BusyWidget
from ui.status_logo import StatusLogo
-from unitdb import unitdbtab
+from unitdb.unitdbtab import UnitDBTab
from updater import ClientUpdateTools
from vaults.mapvault.mapvault import MapVault
from vaults.modvault.modvault import ModVault
@@ -816,7 +816,7 @@ def setup(self):
self, self.gameset, self.players, self.me,
)
- self._unitdb = unitdbtab.build_db_tab(config.UNITDB_CONFIG_FILE)
+ self._unitdb = UnitDBTab()
# TODO: some day when the tabs only do UI we'll have all this in the
# .ui file
diff --git a/src/config/__init__.py b/src/config/__init__.py
index c4e653daa..08995b261 100644
--- a/src/config/__init__.py
+++ b/src/config/__init__.py
@@ -33,7 +33,6 @@
_unpersisted_settings = {}
CONFIG_PATH = os.path.dirname(_settings.fileName())
-UNITDB_CONFIG_FILE = os.path.join(CONFIG_PATH, "unitdb.conf")
class Settings:
diff --git a/src/unitdb/unitdbtab.py b/src/unitdb/unitdbtab.py
index d684a27cd..580c86580 100644
--- a/src/unitdb/unitdbtab.py
+++ b/src/unitdb/unitdbtab.py
@@ -1,138 +1,29 @@
-import logging
-
-from PyQt6.QtCore import QObject
from PyQt6.QtCore import QUrl
-from PyQt6.QtCore import pyqtSignal
-from PyQt6.QtNetwork import QNetworkCookie
-from PyQt6.QtWebEngineCore import QWebEnginePage
-from PyQt6.QtWebEngineCore import QWebEngineProfile
+from PyQt6.QtGui import QDesktopServices
import util
from config import Settings
-from ui.busy_widget import BusyWidget
-
-logger = logging.getLogger(__name__)
FormClass, BaseClass = util.THEME.loadUiType("unitdb/unitdb.ui")
-class UnitDbView(FormClass, BaseClass, BusyWidget):
- entered = pyqtSignal()
-
- def __init__(self):
+class UnitDbView(FormClass, BaseClass):
+ def __init__(self) -> None:
super(BaseClass, self).__init__()
- BusyWidget.__init__(self)
self.setupUi(self)
- # Isolate profile so we only grab cookies from unitdb
- self._page_profile = QWebEngineProfile()
- self.unitdbWebView.setPage(QWebEnginePage(self._page_profile))
-
- def busy_entered(self):
- self.entered.emit()
-
class UnitDBTab:
- def __init__(self, db_widget, cookie_store):
- self.db_widget = db_widget
- self._cookie_store = cookie_store
- self._db_url = Settings.get("UNITDB_URL")
- self._db_url_alt = Settings.get("UNITDB_SPOOKY_URL")
- self._current_cookie = CurrentCookie(
- self._qt_cookie_store, b"unitDB-settings",
- )
- self._current_cookie.new_cookie.connect(self._new_cookie)
+ def __init__(self) -> None:
+ self.db_widget = UnitDbView()
+ self._db_url = QUrl(Settings.get("UNITDB_URL"))
+ self._db_url_alt = QUrl(Settings.get("UNITDB_SPOOKY_URL"))
- self.alternativeDB = Settings.get(
- 'unitDB/alternative', type=bool, default=False,
- )
- self._first_entered = True
- self.db_widget.entered.connect(self.entered)
self.db_widget.fafDbButton.pressed.connect(self.open_default_tab)
- self.db_widget.spookyDbButton.pressed.connect(
- self.open_alternative_tab,
- )
-
- @property
- def _db_view(self):
- return self.db_widget.unitdbWebView
-
- @property
- def _qt_cookie_store(self):
- return self._db_view.page().profile().cookieStore()
-
- def _load_settings(self):
- qt_store = self._qt_cookie_store
- try:
- for cookie in self._cookie_store.load_cookie():
- qt_store.setCookie(cookie)
- except (IOError, FileNotFoundError) as e:
- logger.warning("Failed to load unitdb settings: {}".format(e))
-
- def _save_settings(self):
- self._cookie_store.save_cookie(self._current_cookie.cookie)
-
- def _new_cookie(self):
- try:
- self._save_settings()
- except IOError as e:
- logger.warning("Failed to save unitdb settings: {}".format(e))
-
- def entered(self):
- if self._first_entered:
- self._first_entered = False
- self._load_settings()
-
- if self.alternativeDB:
- self._db_view.setUrl(QUrl(self._db_url_alt))
- else:
- self._db_view.setUrl(QUrl(self._db_url))
-
- def open_default_tab(self):
- if self.alternativeDB:
- self.alternativeDB = False
- Settings.set('unitDB/alternative', False)
- self._db_view.setUrl(QUrl(self._db_url))
-
- def open_alternative_tab(self):
- if not self.alternativeDB:
- self.alternativeDB = True
- Settings.set('unitDB/alternative', True)
- self._db_view.setUrl(QUrl(self._db_url_alt))
-
-
-class UnitDBCookieStorage:
- def __init__(self, store_file):
- self._store_file = store_file
-
- def load_cookie(self):
- with open(self._store_file, 'rb+') as store:
- cookies = store.read()
- return QNetworkCookie.parseCookies(cookies)
-
- def save_cookie(self, cookie):
- with open(self._store_file, 'wb+') as store:
- store.write(cookie + b'\n' if cookie is not None else b'')
-
-
-class CurrentCookie(QObject):
- new_cookie = pyqtSignal()
-
- def __init__(self, source, filter_bytes=None):
- QObject.__init__(self)
- self._source = source
- self._filter_bytes = filter_bytes
- self.cookie = None
- self._source.cookieAdded.connect(self._cookie_added)
-
- def _cookie_added(self, cookie):
- raw = cookie.toRawForm()
- if self._filter_bytes is None or self._filter_bytes in raw:
- self.cookie = raw
- self.new_cookie.emit()
+ self.db_widget.spookyDbButton.pressed.connect(self.open_alternative_tab)
+ def open_default_tab(self) -> None:
+ QDesktopServices.openUrl(self._db_url)
-def build_db_tab(store_file):
- db_view = UnitDbView()
- storage = UnitDBCookieStorage(store_file)
- return UnitDBTab(db_view, storage)
+ def open_alternative_tab(self) -> None:
+ QDesktopServices.openUrl(self._db_url_alt)
From d9b9f4fea2b1bd437aaa9f3dd177d4de581e13a0 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 16:20:18 +0300
Subject: [PATCH 025/123] Remove QtWebEngine from stats widget
---
res/stats/stats.ui | 6 ------
src/stats/_statswidget.py | 32 --------------------------------
2 files changed, 38 deletions(-)
diff --git a/res/stats/stats.ui b/res/stats/stats.ui
index e5799a13d..5aea04150 100644
--- a/res/stats/stats.ui
+++ b/res/stats/stats.ui
@@ -42,12 +42,6 @@
-
-
- Website
-
-
-
Ladder
diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py
index 570b0de04..b8f7f494e 100644
--- a/src/stats/_statswidget.py
+++ b/src/stats/_statswidget.py
@@ -2,14 +2,11 @@
import time
from PyQt6 import QtCore
-from PyQt6 import QtWebEngineCore
-from PyQt6 import QtWebEngineWidgets
from PyQt6 import QtWidgets
import util
from api.stats_api import LeaderboardApiConnector
from ui.busy_widget import BusyWidget
-from util.qt import injectWebviewCSS
from .leaderboard_widget import LeaderboardWidget
@@ -34,15 +31,8 @@ def __init__(self, client):
self.client.lobby_info.statsInfo.connect(self.processStatsInfos)
- self.webview = QtWebEngineWidgets.QWebEngineView()
- self.webpage = WebEnginePage()
- self.webpage.profile().setHttpUserAgent("FAF Client")
-
- self.websiteTab.layout().addWidget(self.webview)
-
self.selected_player = None
self.selected_player_loaded = False
- self.webview.loadFinished.connect(self.webview.show)
self.leagues.currentChanged.connect(self.leagueUpdate)
self.currentChanged.connect(self.busy_entered)
self.pagesDivisions = {}
@@ -65,10 +55,6 @@ def __init__(self, client):
self.load_stylesheet()
# setup other tabs
- self.webpage.setUrl(
- QtCore.QUrl("https://faforever.com/competitive/leaderboards/1v1"),
- )
- self.webview.setPage(self.webpage)
self.apiConnector = LeaderboardApiConnector(self.client.lobby_dispatch)
self.apiConnector.requestData(dict(sort="id"))
@@ -329,27 +315,9 @@ def processStatsInfos(self, message):
self.leaderboardsTabChanged,
)
- def _injectCSS(self):
- if util.THEME.themeurl("ladder/style.css"):
- injectWebviewCSS(
- self.webview.page(),
- util.THEME.readstylesheet("ladder/style.css"),
- )
-
@QtCore.pyqtSlot()
def busy_entered(self):
if self.currentIndex() == self.indexOf(self.leaderboardsTab):
self.leaderboards.currentChanged.emit(
self.leaderboards.currentIndex(),
)
-
-
-class WebEnginePage(QtWebEngineCore.QWebEnginePage):
- def acceptNavigationRequest(self, url, type, isMainFrame):
- if (
- url.url().startswith("https://faforever.com/competitive/")
- or url.url().startswith("https://challonge.com/")
- ):
- return True
- else:
- return False
From 22f6fc3ed2a8fb44739523c31fd99d81ba331752 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 16:22:35 +0300
Subject: [PATCH 026/123] Remove QtWebEngine from tournaments
they (tournaments) won't come back anyway
---
src/tourneys/tourneyitem.py | 21 +++------------------
1 file changed, 3 insertions(+), 18 deletions(-)
diff --git a/src/tourneys/tourneyitem.py b/src/tourneys/tourneyitem.py
index 497823740..abeb4b552 100644
--- a/src/tourneys/tourneyitem.py
+++ b/src/tourneys/tourneyitem.py
@@ -1,7 +1,5 @@
from PyQt6 import QtCore
from PyQt6 import QtGui
-from PyQt6 import QtWebEngineCore
-from PyQt6 import QtWebEngineWidgets
from PyQt6 import QtWidgets
import util
@@ -47,17 +45,6 @@ def sizeHint(self, option, index, *args, **kwargs):
)
-class QWebPageChrome(QtWebEngineCore.QWebEnginePage):
- def __init__(self, *args, **kwargs):
- QtWebEngineCore.QWebEnginePage.__init__(self, *args, **kwargs)
-
- def userAgentForUrl(self, url):
- return (
- "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.2 "
- "(KHTML, like Gecko) Chrome/15.0.874.121 Safari/535.2"
- )
-
-
class TourneyItem(QtWidgets.QListWidgetItem):
FORMATTER_SWISS_OPEN = str(
util.THEME.readfile("tournaments/formatters/open.qthtml"),
@@ -98,11 +85,9 @@ def update(self, message, client):
self.players = message.get('participants', [])
if old_state != self.state and self.state == "started":
- widget = QtWebEngineWidgets.QWebEngineView()
- webPage = QWebPageChrome()
- widget.setPage(webPage)
- widget.setUrl(QtCore.QUrl(self.url))
- self.parent.topTabs.addTab(widget, self.title)
+ # create a widget and add it to the parent's tabs
+ # anyway, this tournaments feature most likely won't return
+ ...
self.playersname = []
for player in self.players:
From 2f23da5a2dfd5347505a846f31d2fa06b7dfe852 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 16:30:57 +0300
Subject: [PATCH 027/123] Remove leftovers of QtWebEngine appearance
---
setup.py | 3 --
src/__main__.py | 3 --
src/util/qt.py | 35 ------------------------
tests/unit_tests/client/test_updating.py | 1 -
4 files changed, 42 deletions(-)
diff --git a/setup.py b/setup.py
index ea8c3dfb4..9f72ffbcc 100644
--- a/setup.py
+++ b/setup.py
@@ -104,11 +104,8 @@ def get_jsonschema_includes():
'audio',
'libeay32.dll',
'ssleay32.dll',
- 'libEGL.dll', # For QtWebEngine
'libGLESv2.dll', # ditto
'icudtl.dat', # ditto
- 'qtwebengine_resources.pak', # ditto
- 'QtWebEngineProcess.exe', # ditto
('lib/faf-uid.exe', 'lib/faf-uid.exe'),
('lib/ice-adapter', 'lib/ice-adapter'),
('lib/qt.conf', 'qt.conf'),
diff --git a/src/__main__.py b/src/__main__.py
index 5481613cc..8e3d3d06a 100644
--- a/src/__main__.py
+++ b/src/__main__.py
@@ -9,9 +9,6 @@
import sys
from types import TracebackType
-# According to PyQt5 docs we need to import QtWebEngineWidgets before we create
-# QApplication
-from PyQt6 import QtWebEngineWidgets # noqa: F401
from PyQt6 import uic
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication
diff --git a/src/util/qt.py b/src/util/qt.py
index 51ce981ec..b0373130c 100644
--- a/src/util/qt.py
+++ b/src/util/qt.py
@@ -1,40 +1,5 @@
import types
-from PyQt6.QtGui import QDesktopServices
-from PyQt6.QtWebEngineCore import QWebEnginePage
-
-
-class ExternalLinkPage(QWebEnginePage):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.linkHovered.connect(self.saveHoveredLink)
- self.linkUnderCursor = ""
-
- def acceptNavigationRequest(self, url, navtype, isMainFrame):
- if navtype == QWebEnginePage.NavigationType.NavigationTypeLinkClicked:
- if url.toString() == self.linkUnderCursor:
- QDesktopServices.openUrl(url)
- return False
- return True
-
- def saveHoveredLink(self, url):
- self.linkUnderCursor = url
-
-
-def injectWebviewCSS(page, css):
- # Hacky way to inject CSS into QWebEnginePage, since QtWebengine doesn't
- # have a way to inject user CSS yet
- # We should eventually remove all QtWebEngine uses anyway
- js = """
- var css = document.createElement("style");
- css.type = "text/css";
- css.innerHTML = `{}`;
- document.head.appendChild(css);
- """
- js = js.format(css)
- page.runJavaScript(js)
-
def monkeypatch_method(obj, name, fn):
old_fn = getattr(obj, name)
diff --git a/tests/unit_tests/client/test_updating.py b/tests/unit_tests/client/test_updating.py
index 1e3eff129..a0c97e64c 100644
--- a/tests/unit_tests/client/test_updating.py
+++ b/tests/unit_tests/client/test_updating.py
@@ -1,5 +1,4 @@
import pytest
-from PyQt6 import QtWebEngineWidgets # noqa: F401
import config
From 4b70abe6999d1d163142f553b6c115657cf58f5a Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 22:31:11 +0300
Subject: [PATCH 028/123] Fix type annotation of FileDownload's dest attribute
---
src/downloadManager/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index 4b38191f9..693f2b9b1 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -30,7 +30,7 @@ def __init__(
self,
nam: QNetworkAccessManager,
addr: str,
- dest: str,
+ dest: QtCore.QIODevice,
destpath: str | None = None,
request_params: dict | None = None,
) -> None:
From 7b96ab94f4ad3d8e8538f2145e9d7f114a2fd75f Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 22:36:22 +0300
Subject: [PATCH 029/123] ClientWindow: Refer to the screen the widget is on
when manipulating geometry
by using QWidget.screen instead of QApplication.primaryScreen
---
src/client/_clientwindow.py | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 394409ead..8b247edaf 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -571,9 +571,7 @@ def show_max_restore(self):
else:
self.is_window_maximized = True
self.current_geometry = self.geometry()
- self.setGeometry(
- QtWidgets.QApplication.primaryScreen().availableGeometry(),
- )
+ self.setGeometry(self.screen().availableGeometry())
def mouseDoubleClickEvent(self, event):
self.show_max_restore()
@@ -607,8 +605,7 @@ def mouseMoveEvent(self, event):
self.resize_widget(event.globalPosition())
elif self.moving and self.offset is not None:
- desktop = QtWidgets.QApplication.primaryScreen().availableGeometry()
- # desktop = QtWidgets.QDesktopWidget().availableGeometry(self)
+ desktop = self.screen().availableGeometry()
if event.globalPosition().y() == 0:
self.rubber_band.setGeometry(desktop)
self.rubber_band.show()
From 43294063b370841b136cd6dd86d1b22732decc0c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 22:42:04 +0300
Subject: [PATCH 030/123] Restore maximized window correctly
apparently there is a bug in Qt, and restoring
geometry of maximized window results in non-maximized
one, so we additionaly store 'maximized' flag
---
src/client/_clientwindow.py | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 8b247edaf..23afd4f30 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -1359,6 +1359,8 @@ def checkPlayerAliases(self):
def saveWindow(self):
util.settings.beginGroup("window")
util.settings.setValue("geometry", self.saveGeometry())
+ if self.is_window_maximized:
+ util.settings.setValue("maximized", True)
util.settings.endGroup()
def show_autojoin_settings_dialog(self):
@@ -1459,12 +1461,14 @@ def load_settings(self):
# Load settings
util.settings.beginGroup("window")
geometry = util.settings.value("geometry", None)
- if geometry:
- self.restoreGeometry(geometry)
- util.settings.endGroup()
-
- util.settings.beginGroup("ForgedAlliance")
+ # FIXME: looks like bug in Qt: restoring from maximized geometry doesn't work
+ # see https://bugreports.qt.io/browse/QTBUG-123335 (?)
+ maximized = util.settings.value("maximized", False)
util.settings.endGroup()
+ if maximized:
+ self.setGeometry(self.screen().availableGeometry())
+ elif geometry:
+ self.restoreGeometry(geometry)
def load_chat(self):
cc = self._chat_config
From e7d01de1c15a775a40bd35b507ad532e7a84226a Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 13 Apr 2024 22:45:17 +0300
Subject: [PATCH 031/123] Fix existing tests
---
tests/fa/test_featured.py | 11 ++++++-----
tests/fa/test_updater.py | 6 +++---
tests/unit_tests/themes/test_theme.py | 2 +-
tests/unit_tests/themes/test_themeset.py | 8 ++++----
4 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/tests/fa/test_featured.py b/tests/fa/test_featured.py
index c6017b87c..3e83739ed 100644
--- a/tests/fa/test_featured.py
+++ b/tests/fa/test_featured.py
@@ -1,6 +1,7 @@
__author__ = 'Thygrrr'
-import collections
+
+from typing import Callable
import pytest
from PyQt6 import QtCore
@@ -46,7 +47,7 @@ def test_updater_has_progress_bar_mod_progress(application):
def test_updater_has_method_append_log(application):
assert isinstance(
updater.UpdaterProgressDialog(None).appendLog,
- collections.Callable,
+ Callable,
)
@@ -57,7 +58,7 @@ def test_updater_append_log_accepts_string(application):
def test_updater_has_method_add_watch(application):
assert isinstance(
updater.UpdaterProgressDialog(None).addWatch,
- collections.Callable,
+ Callable,
)
@@ -94,7 +95,7 @@ def test_updater_hides_and_accepts_if_all_watches_are_finished(application):
application.processEvents()
assert not u.isVisible()
- assert u.result() == QtWidgets.QDialog.Accepted
+ assert u.result() == QtWidgets.QDialog.DialogCode.Accepted
def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(
@@ -114,4 +115,4 @@ def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(
application.processEvents()
assert u.isVisible()
- assert not u.result() == QtWidgets.QDialog.Accepted
+ assert not u.result() == QtWidgets.QDialog.DialogCode.Accepted
diff --git a/tests/fa/test_updater.py b/tests/fa/test_updater.py
index 7bd0e61ed..c8bd8f20f 100644
--- a/tests/fa/test_updater.py
+++ b/tests/fa/test_updater.py
@@ -1,6 +1,6 @@
__author__ = 'Thygrrr'
-import collections
+from typing import Callable
import pytest
from PyQt6 import QtCore
@@ -46,7 +46,7 @@ def test_updater_has_progress_bar_mod_progress(application):
def test_updater_has_method_append_log(application):
assert isinstance(
updater.UpdaterProgressDialog(None).appendLog,
- collections.Callable,
+ Callable,
)
@@ -57,7 +57,7 @@ def test_updater_append_log_accepts_string(application):
def test_updater_has_method_add_watch(application):
assert isinstance(
updater.UpdaterProgressDialog(None).addWatch,
- collections.Callable,
+ Callable,
)
diff --git a/tests/unit_tests/themes/test_theme.py b/tests/unit_tests/themes/test_theme.py
index a2a39cdc8..0988cdb06 100644
--- a/tests/unit_tests/themes/test_theme.py
+++ b/tests/unit_tests/themes/test_theme.py
@@ -70,7 +70,7 @@ def test_version_correctly_read(tmpdir):
def test_pixmap_cache_caches(tmpdir, mocker):
- mocker.patch('PyQt5.QtGui.QPixmap', side_effect=[1, 2])
+ mocker.patch('PyQt6.QtGui.QPixmap', side_effect=[1, 2])
themedir = tmpdir.mkdir("theme")
themedir.join("file").write("content")
themedir.join("second_file").write("content")
diff --git a/tests/unit_tests/themes/test_themeset.py b/tests/unit_tests/themes/test_themeset.py
index 3b45f656f..c761dcc03 100644
--- a/tests/unit_tests/themes/test_themeset.py
+++ b/tests/unit_tests/themes/test_themeset.py
@@ -154,8 +154,8 @@ def test_loadTheme(mocker):
def test_returns_when_not_found(mocker):
- mocker.patch("PyQt5.QtMultimedia.QSound")
- mocker.patch("PyQt5.QtGui.QPixmap")
+ mocker.patch("PyQt6.QtMultimedia.QSoundEffect")
+ mocker.patch("PyQt6.QtGui.QPixmap")
setting_mock = mocker.Mock()
setting_mock.configure_mock(get=lambda x, y=None: None)
@@ -202,8 +202,8 @@ def test_returns_when_not_found(mocker):
def test_theme_call_order(mocker):
- mocker.patch("PyQt5.QtMultimedia.QSound")
- mocker.patch("PyQt5.QtGui.QPixmap")
+ mocker.patch("PyQt6.QtCore.QUrl.fromLocalFile")
+ mocker.patch("PyQt6.QtGui.QPixmap")
setting_mock = mocker.Mock()
setting_mock.configure_mock(get=lambda x, y=None: None)
From 29314182b142f55ba2bf5b19e87d14d3cf9aa34c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 14 Apr 2024 18:54:35 +0300
Subject: [PATCH 032/123] ircconnection: Remove the ssl.wrap_socket wrapper
Remove the ssl.wrap_socket() function usage, it was deprecated in Python 3.7:
instead, create an ssl.SSLContext object and call its ssl.SSLContext.wrap_socket method
---
src/chat/ircconnection.py | 39 +++++++++++++++++++--------------------
1 file changed, 19 insertions(+), 20 deletions(-)
diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py
index d91deabd4..7bdcec821 100644
--- a/src/chat/ircconnection.py
+++ b/src/chat/ircconnection.py
@@ -84,19 +84,15 @@ def __init__(self):
class IrcConnection(IrcSignals, irc.client.SimpleIRCClient):
token_received = pyqtSignal(str)
- def __init__(self, host: int, port: int, use_ssl: bool) -> None:
+ def __init__(self, host: int, port: int) -> None:
IrcSignals.__init__(self)
irc.client.SimpleIRCClient.__init__(self)
self.host = host
self.port = port
- self.use_ssl = use_ssl
self.api_accessor = UserApiAccessor()
self.token_received.connect(self.on_token_received)
- if self.use_ssl:
- self.factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
- else:
- self.factory = irc.connection.Factory()
+ self.factory = self.create_connection_factory(self.port_uses_ssl(port))
self._password = None
self._nick = None
@@ -109,19 +105,26 @@ def __init__(self, host: int, port: int, use_ssl: bool) -> None:
self._connected = False
@classmethod
- def build(cls, settings, use_ssl=True, **kwargs):
- port = settings.get('chat/port', 6697 if use_ssl else 6667, int)
+ def build(cls, settings, **kwargs):
+ port = settings.get('chat/port', 6697, int)
host = settings.get('chat/host', 'irc.' + config.defaults['host'], str)
- return cls(host, port, use_ssl)
+ return cls(host, port)
+
+ @staticmethod
+ def port_uses_ssl(port: int) -> bool:
+ return port == 6697
+
+ @staticmethod
+ def create_connection_factory(use_ssl: bool) -> irc.connection.Factory:
+ if use_ssl:
+ # unverified because certificate is self-signed
+ context = ssl._create_unverified_context()
+ return irc.connection.Factory(wrapper=context.wrap_socket)
+ return irc.connection.Factory()
def setPortFromConfig(self):
self.port = config.Settings.get('chat/port', type=int)
- if self.port == 6697:
- self.use_ssl = True
- self.factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
- else:
- self.use_ssl = False
- self.factory = irc.connection.Factory()
+ self.factory = self.create_connection_factory(self.port_uses_ssl(self.port))
def setHostFromConfig(self):
self.host = config.Settings.get('chat/host', type=str)
@@ -147,11 +150,7 @@ def on_token_received(self, token: str) -> None:
self.connect_(self._nick, self._username, f"token:{token}")
def connect_(self, nick: str, username: str, password: str) -> bool:
- logger.info(
- "Connecting to IRC at: {}:{}. TLS: {}".format(
- self.host, self.port, self.use_ssl,
- ),
- )
+ logger.info(f"Connecting to IRC at: {self.host}:{self.port}")
self._nick = nick
self._username = username
From d93219037dd9bfeb146b6d4896fb9c0fa355e8b9 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Mon, 15 Apr 2024 15:30:17 +0300
Subject: [PATCH 033/123] Adapt QMessageBox's buttons syntax to PyQt6
---
src/client/_clientwindow.py | 12 ++++++------
src/fa/mods.py | 10 +++++-----
src/fa/replayserver.py | 6 +++---
src/games/_gameswidget.py | 8 ++++----
src/mapGenerator/mapgenManager.py | 10 +++++-----
src/replays/_replayswidget.py | 6 +++---
src/tourneys/_tournamentswidget.py | 8 ++++----
src/util/__init__.py | 8 ++++----
src/util/theme.py | 8 ++++----
src/vaults/mapvault/mapvault.py | 14 +++++++-------
src/vaults/mapvault/mapwidget.py | 6 +++---
src/vaults/modvault/modvault.py | 8 ++++----
src/vaults/modvault/modwidget.py | 6 +++---
src/vaults/modvault/utils.py | 6 +++---
14 files changed, 58 insertions(+), 58 deletions(-)
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 23afd4f30..ccba3c462 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -1005,10 +1005,10 @@ def closeEvent(self, event):
"Seems like you still have Forged Alliance running!"
"
Close anyway?"
),
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
- if result == QtWidgets.QMessageBox.No:
+ if result == QtWidgets.QMessageBox.StandardButton.No:
event.ignore()
return
@@ -1280,10 +1280,10 @@ def clearSettings(self):
"Clear Settings",
"Are you sure you wish to clear all settings, "
"login info, etc. used by this program?",
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
- if result == QtWidgets.QMessageBox.Yes:
+ if result == QtWidgets.QMessageBox.StandardButton.Yes:
util.settings.clear()
util.settings.sync()
QtWidgets.QMessageBox.information(
diff --git a/src/fa/mods.py b/src/fa/mods.py
index f531849c6..d1b2a84c8 100644
--- a/src/fa/mods.py
+++ b/src/fa/mods.py
@@ -36,14 +36,14 @@ def checkMods(mods): # mods is a dictionary of uid-name pairs
"downloaded automatically in the future",
)
msgbox.setStandardButtons(
- QtWidgets.QMessageBox.Yes
- | QtWidgets.QMessageBox.YesToAll
- | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes
+ | QtWidgets.QMessageBox.StandardButton.YesToAll
+ | QtWidgets.QMessageBox.StandardButton.No,
)
result = msgbox.exec()
- if result == QtWidgets.QMessageBox.No:
+ if result == QtWidgets.QMessageBox.StandardButton.No:
return False
- elif result == QtWidgets.QMessageBox.YesToAll:
+ elif result == QtWidgets.QMessageBox.StandardButton.YesToAll:
config.Settings.set('mods/autodownload', True)
for uid in to_download:
diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py
index 4bfbc9411..d315e9cb5 100644
--- a/src/fa/replayserver.py
+++ b/src/fa/replayserver.py
@@ -215,10 +215,10 @@ def doListen(self) -> bool:
"likely)another program is listening on port "
"{}".format(self.serverPort())
),
- QtWidgets.QMessageBox.Retry,
- QtWidgets.QMessageBox.Abort,
+ QtWidgets.QMessageBox.StandardButton.Retry,
+ QtWidgets.QMessageBox.StandardButton.Abort,
)
- if answer == QtWidgets.QMessageBox.Abort:
+ if answer == QtWidgets.QMessageBox.StandardButton.Abort:
return False
return True
diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py
index 12f996216..72283fe59 100644
--- a/src/games/_gameswidget.py
+++ b/src/games/_gameswidget.py
@@ -367,9 +367,9 @@ def kickPlayerFromParty(self, playerId):
result = QtWidgets.QMessageBox.question(
self, "Kick Player: {}".format(login),
"Are you sure you want to kick {} from party?".format(login),
- QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No,
)
- if result == QtWidgets.QMessageBox.Yes:
+ if result == QtWidgets.QMessageBox.StandardButton.Yes:
self.stopSearch()
msg = {
'command': 'kick_player_from_party',
@@ -380,9 +380,9 @@ def kickPlayerFromParty(self, playerId):
def leave_party(self):
result = QtWidgets.QMessageBox.question(
self, "Leaving Party", "Are you sure you want to leave party?",
- QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No,
)
- if result == QtWidgets.QMessageBox.Yes:
+ if result == QtWidgets.QMessageBox.StandardButton.Yes:
msg = {
'command': 'leave_party',
}
diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py
index 752fa0f72..1fd325521 100644
--- a/src/mapGenerator/mapgenManager.py
+++ b/src/mapGenerator/mapgenManager.py
@@ -75,14 +75,14 @@ def generateMap(self, mapname=None, args=None):
"time.",
)
msgbox.setStandardButtons(
- QtWidgets.QMessageBox.Yes
- | QtWidgets.QMessageBox.YesToAll
- | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes
+ | QtWidgets.QMessageBox.StandardButton.YesToAll
+ | QtWidgets.QMessageBox.StandardButton.No,
)
result = msgbox.exec()
- if result == QtWidgets.QMessageBox.No:
+ if result == QtWidgets.QMessageBox.StandardButton.No:
return False
- elif result == QtWidgets.QMessageBox.YesToAll:
+ elif result == QtWidgets.QMessageBox.StandardButton.YesToAll:
Settings.set('mapGenerator/autostart', True)
mapsFolder = getUserMapsFolder()
diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py
index 377a2d4be..50acd3d57 100644
--- a/src/replays/_replayswidget.py
+++ b/src/replays/_replayswidget.py
@@ -964,9 +964,9 @@ def onlineTreeDoubleClicked(self, item):
client.instance,
"Live Game ended",
"Would you like to watch the replay from the vault?",
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
- ) == QtWidgets.QMessageBox.Yes:
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
+ ) == QtWidgets.QMessageBox.StandardButton.Yes:
req = QNetworkRequest(QtCore.QUrl(item.url))
self.replayDownload.get(req)
diff --git a/src/tourneys/_tournamentswidget.py b/src/tourneys/_tournamentswidget.py
index 1f653d1b6..96718dcca 100644
--- a/src/tourneys/_tournamentswidget.py
+++ b/src/tourneys/_tournamentswidget.py
@@ -64,9 +64,9 @@ def tourneyDoubleClicked(self, item):
self.client,
"Register",
"Do you want to register to this tournament ?",
- QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
)
- if reply == QtWidgets.QMessageBox.Yes:
+ if reply == QtWidgets.QMessageBox.StandardButton.Yes:
self.tourneyServer.send(
dict(
command="add_participant",
@@ -80,9 +80,9 @@ def tourneyDoubleClicked(self, item):
self.client,
"Register",
"Do you want to leave this tournament ?",
- QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
)
- if reply == QtWidgets.QMessageBox.Yes:
+ if reply == QtWidgets.QMessageBox.StandardButton.Yes:
self.tourneyServer.send(
dict(
command="remove_participant",
diff --git a/src/util/__init__.py b/src/util/__init__.py
index 139199884..91b3be673 100644
--- a/src/util/__init__.py
+++ b/src/util/__init__.py
@@ -259,13 +259,13 @@ def clearDirectory(directory, confirm=True):
"Are you sure you wish to clear the following directory:"
"
{}".format(directory)
),
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
else:
- result = QtWidgets.QMessageBox.Yes
+ result = QtWidgets.QMessageBox.StandardButton.Yes
- if result == QtWidgets.QMessageBox.Yes:
+ if result == QtWidgets.QMessageBox.StandardButton.Yes:
shutil.rmtree(directory)
return True
else:
diff --git a/src/util/theme.py b/src/util/theme.py
index fa0652469..563c6c3e0 100644
--- a/src/util/theme.py
+++ b/src/util/theme.py
@@ -264,17 +264,17 @@ def theme_changed():
)
b_yes = box.addButton(
"Apply this once",
- QtWidgets.QMessageBox.YesRole,
+ QtWidgets.QMessageBox.ButtonRole.YesRole,
)
b_always = box.addButton(
"Always apply for this FA version",
- QtWidgets.QMessageBox.YesRole,
+ QtWidgets.QMessageBox.ButtonRole.YesRole,
)
b_default = box.addButton(
"Use default theme",
- QtWidgets.QMessageBox.NoRole,
+ QtWidgets.QMessageBox.ButtonRole.NoRole,
)
- b_no = box.addButton("Abort", QtWidgets.QMessageBox.NoRole)
+ b_no = box.addButton("Abort", QtWidgets.QMessageBox.ButtonRole.NoRole)
box.exec()
result = box.clickedButton()
diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py
index db8dd12a6..aed416f2b 100644
--- a/src/vaults/mapvault/mapvault.py
+++ b/src/vaults/mapvault/mapvault.py
@@ -162,12 +162,12 @@ def uploadMap(self):
"{}\nDo you want to upload the map?"
.format(scenariolua.errorMsg)
),
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
else:
- uploadmap = QtWidgets.QMessageBox.Yes
- if uploadmap == QtWidgets.QMessageBox.Yes:
+ uploadmap = QtWidgets.QMessageBox.StandardButton.Yes
+ if uploadmap == QtWidgets.QMessageBox.StandardButton.Yes:
savelua = luaparser.luaParser(
os.path.join(mapDir, maps.getSaveFile(mapDir)),
)
@@ -241,10 +241,10 @@ def downloadMap(self, link):
"Seems like you already have that map!
Would you "
"like to see it?"
),
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
- if show == QtWidgets.QMessageBox.Yes:
+ if show == QtWidgets.QMessageBox.StandardButton.Yes:
util.showDirInFileBrowser(maps.folderForMap(avail_name))
@QtCore.pyqtSlot(str)
diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py
index b92f11536..4633eaa20 100644
--- a/src/vaults/mapvault/mapwidget.py
+++ b/src/vaults/mapvault/mapwidget.py
@@ -71,10 +71,10 @@ def download(self):
self.parent.client,
"Delete Map",
"Are you sure you want to delete this map?",
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
- if show == QtWidgets.QMessageBox.Yes:
+ if show == QtWidgets.QMessageBox.StandardButton.Yes:
self.parent.removeMap(self._map.folderName)
self.done(1)
diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py
index 620d9069d..7cc1f6ebe 100644
--- a/src/vaults/modvault/modvault.py
+++ b/src/vaults/modvault/modvault.py
@@ -154,12 +154,12 @@ def openUploadForm(self):
modinfofile.errorMsg
+ "\nDo you want to upload the mod?"
),
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
else:
- uploadmod = QtWidgets.QMessageBox.Yes
- if uploadmod == QtWidgets.QMessageBox.Yes:
+ uploadmod = QtWidgets.QMessageBox.StandardButton.Yes
+ if uploadmod == QtWidgets.QMessageBox.StandardButton.Yes:
modinfo = utils.ModInfo(**modinfo)
modinfo.setFolder(os.path.split(modDir)[1])
modinfo.update()
diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py
index 6ad0d2dd3..796dd600e 100644
--- a/src/vaults/modvault/modwidget.py
+++ b/src/vaults/modvault/modwidget.py
@@ -90,10 +90,10 @@ def download(self):
self.parent.client,
"Delete Mod",
"Are you sure you want to delete this mod?",
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
- if show == QtWidgets.QMessageBox.Yes:
+ if show == QtWidgets.QMessageBox.StandardButton.Yes:
self.parent.removeMod(self.mod)
self.done(1)
diff --git a/src/vaults/modvault/utils.py b/src/vaults/modvault/utils.py
index 15c8e37c9..886335817 100644
--- a/src/vaults/modvault/utils.py
+++ b/src/vaults/modvault/utils.py
@@ -438,10 +438,10 @@ def handle_exist(path, modname):
"already exists and contains {}. Do you want to "
"overwrite this mod?"
).format(modpath, oldmod.totalname),
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No,
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
)
- if result == QtWidgets.QMessageBox.No:
+ if result == QtWidgets.QMessageBox.StandardButton.No:
return False
removeMod(oldmod)
return True
From 776475f0069700cdad4cbfb500fd800fae87cd08 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 16 Apr 2024 18:22:06 +0300
Subject: [PATCH 034/123] Refactor downloadManager a little
* create a class for general-purpose downloading, not only previews
* make preview downloaders to be subclasses of general-purpose downloader
* take map preivew url from settings instead of doing the 'update_url_prefix' shenanigans
---
src/client/_clientwindow.py | 19 +---
src/config/__init__.py | 10 +-
src/config/production.py | 20 +---
src/downloadManager/__init__.py | 188 +++++++++++++++----------------
src/replays/_replayswidget.py | 4 +-
src/replays/replayToolbox.py | 6 +-
src/replays/replayitem.py | 2 +-
src/tutorials/tutorialitem.py | 2 +-
src/vaults/mapvault/mapitem.py | 7 +-
src/vaults/mapvault/mapwidget.py | 17 +--
src/vaults/modvault/moditem.py | 5 +-
src/vaults/vaultitem.py | 2 +
12 files changed, 131 insertions(+), 151 deletions(-)
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index ccba3c462..2a1426b05 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -44,9 +44,8 @@
from client.user import UserRelationTrackers
from connectivity.ConnectivityDialog import ConnectivityDialog
from coop import CoopWidget
-from downloadManager import MAP_PREVIEW_ROOT
from downloadManager import AvatarDownloader
-from downloadManager import PreviewDownloader
+from downloadManager import MapSmallPreviewDownloader
from fa.factions import Factions
from fa.game_runner import GameRunner
from fa.game_session import GameSession
@@ -236,21 +235,14 @@ def __init__(self, *args, **kwargs):
)
self.me.relations = self.user_relations
- self.map_downloader = PreviewDownloader(
- util.MAP_PREVIEW_SMALL_DIR,
- util.MAP_PREVIEW_LARGE_DIR,
- MAP_PREVIEW_ROOT,
- )
- self.mod_downloader = PreviewDownloader(
- util.MOD_PREVIEW_DIR, None, None,
- )
+ self.map_preview_downloader = MapSmallPreviewDownloader(util.MAP_PREVIEW_SMALL_DIR)
self.avatar_downloader = AvatarDownloader()
# Map generator
self.map_generator = MapGeneratorManager()
# Qt model for displaying active games.
- self.game_model = GameModel(self.me, self.map_downloader, self.gameset)
+ self.game_model = GameModel(self.me, self.map_preview_downloader, self.gameset)
self.gameset.added.connect(self.fill_in_session_info)
@@ -706,7 +698,7 @@ def setup(self):
self.game_launcher = build_launcher(
self.players, self.me,
self, self.gameview_builder,
- self.map_downloader,
+ self.map_preview_downloader,
)
self._avatar_widget_builder = AvatarWidget.builder(
parent_widget=self,
@@ -743,7 +735,7 @@ def setup(self):
me=self.me,
user_relations=self.user_relations,
power_tools=self.power_tools,
- map_preview_dler=self.map_downloader,
+ map_preview_dler=self.map_preview_downloader,
avatar_dler=self.avatar_downloader,
avatar_widget_builder=self._avatar_widget_builder,
alias_viewer=self._alias_viewer,
@@ -1560,7 +1552,6 @@ def on_widget_login_data(self, api_changed):
self._chatMVC.connection.setPortFromConfig()
if api_changed:
self.ladder.refreshLeaderboards()
- self.map_downloader.update_url_prefix()
self.news.updateNews()
self.games.refreshMods()
diff --git a/src/config/__init__.py b/src/config/__init__.py
index 08995b261..258749839 100644
--- a/src/config/__init__.py
+++ b/src/config/__init__.py
@@ -216,10 +216,18 @@ def is_beta():
if _settings.contains('client/force_environment'):
environment = _settings.value('client/force_environment', 'development')
+
+class FormatDefault(dict):
+ def __missing__(self, key: str) -> str:
+ # if key wasn't formatted leave it to format later
+ # "{foo}{bar}".format_map(FormatDefault(foo="FOO")) -> "FOO{bar}"
+ return f"{{{key}}}"
+
+
for defaults in [production_defaults, develop_defaults, testing_defaults]:
for key, value in defaults.items():
if isinstance(value, str):
- defaults[key] = value.format(host=Settings.get('host'))
+ defaults[key] = value.format_map(FormatDefault(host=Settings.get("host")))
if environment == 'production':
defaults = production_defaults
diff --git a/src/config/production.py b/src/config/production.py
index bdd5808bd..8fa983809 100644
--- a/src/config/production.py
+++ b/src/config/production.py
@@ -50,28 +50,18 @@
'replay_server/port': 15000,
'relay_server/host': 'lobby.{host}',
'relay_server/port': 8000,
+ 'vault/map_preview_url': 'https://content.{host}/maps/previews/{size}/{name}.png',
'FORUMS_URL': 'https://forums.faforever.com/',
'WEBSITE_URL': 'https://www.{host}',
# FIXME - temporary address below
# The base64 settings string disables expensive loading of all previews
- 'UNITDB_URL': (
- 'https://unitdb.faforever.com?'
- 'settings64=eyJwcmV2aWV3Q29ybmVyIjoiTm9uZSJ9'
- ),
+ 'UNITDB_URL': 'https://unitdb.faforever.com?settings64=eyJwcmV2aWV3Q29ybmVyIjoiTm9uZSJ9',
'UNITDB_SPOOKY_URL': 'https://spooky.github.io/unitdb/',
- 'MAPPOOL_URL': (
- 'https://forum.faforever.com/topic/148/matchmaker-pools-thread'
- ),
+ 'MAPPOOL_URL': 'https://forum.faforever.com/topic/148/matchmaker-pools-thread',
'GITHUB_URL': 'https://www.github.com/FAForever',
'WIKI_URL': 'https://wiki.faforever.com',
- 'SUPPORT_URL': (
- 'https://forum.faforever.com/category/9/'
- 'faf-support-client-and-account-issues'
- ),
- 'TICKET_URL': (
- 'https://forum.faforever.com/category/9/'
- 'faf-support-client-and-account-issues'
- ),
+ 'SUPPORT_URL': 'https://forum.faforever.com/category/9/faf-support-client-and-account-issues',
+ 'TICKET_URL': 'https://forum.faforever.com/category/9/faf-support-client-and-account-issues',
'CREATE_ACCOUNT_URL': 'https://faforever.com/account/register',
'STEAMLINK_URL': 'https://faforever.com/account/link',
'PASSWORD_RECOVERY_URL': 'https://faforever.com/account/password/reset',
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index 693f2b9b1..793a141f2 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -1,13 +1,16 @@
+from __future__ import annotations
+
import logging
import os
-import urllib.error
-import urllib.parse
-import urllib.request
+from io import BytesIO
-from PyQt6 import QtCore
from PyQt6 import QtGui
from PyQt6 import QtWidgets
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QFile
+from PyQt6.QtCore import QIODevice
from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QTimer
from PyQt6.QtCore import QUrl
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtNetwork import QNetworkAccessManager
@@ -30,7 +33,7 @@ def __init__(
self,
nam: QNetworkAccessManager,
addr: str,
- dest: QtCore.QIODevice,
+ dest: QFile | BytesIO,
destpath: str | None = None,
request_params: dict | None = None,
) -> None:
@@ -70,13 +73,9 @@ def cancel(self):
def _finish(self):
# check status code
- statusCode = self._dfile.attribute(
- QNetworkRequest.Attribute.HttpStatusCodeAttribute,
- )
+ statusCode = self._dfile.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
if statusCode != 200:
- logger.debug(
- 'Download failed: {} -> {}'.format(self.addr, statusCode),
- )
+ logger.debug(f"Download failed: {self.addr} -> {statusCode}")
self.error = True
self.finished.emit(self)
@@ -137,82 +136,80 @@ def succeeded(self):
return not self.error and not self.canceled
def waitForCompletion(self):
- waitFlag = QtCore.QEventLoop.ProcessEventsFlag.WaitForMoreEvents
+ waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
while self._running:
QtWidgets.QApplication.processEvents(waitFlag)
-MAP_PREVIEW_ROOT = "{}/maps/previews/small/"
-
-
-class PreviewDownload(QtCore.QObject):
- done = QtCore.pyqtSignal(object, object)
+class GeneralDownload(QObject):
+ done = pyqtSignal(object, object)
- def __init__(self, nam, name, url, target_dir, delay_timer=None):
- QtCore.QObject.__init__(self)
+ def __init__(
+ self,
+ nam: QNetworkAccessManager,
+ name: str,
+ url: str,
+ target_dir: str,
+ delay_timer: QTimer | None,
+ ) -> None:
+ super().__init__()
self.requests = set()
self.name = name
self._url = url
self._nam = nam
self._target_dir = target_dir
self._delay_timer = delay_timer
- self._dl = None
+ self._dl: FileDownload | None = None
if delay_timer is None:
self._start_download()
else:
delay_timer.timeout.connect(self._start_download)
- def _start_download(self):
+ def _start_download(self) -> None:
if self._delay_timer is not None:
self._delay_timer.disconnect(self._start_download)
self._dl = self._prepare_dl()
self._dl.run()
- def _prepare_dl(self):
- img, imgpath = self._get_cachefile(self.name + ".png.part")
- dl = FileDownload(self._nam, self._url, img, imgpath)
+ def _prepare_dl(self) -> FileDownload:
+ file_obj = self._get_cachefile(self.name + ".part")
+ dl = FileDownload(self._nam, self._url, file_obj)
dl.finished.connect(self._finished)
dl.blocksize = None
return dl
- def _get_cachefile(self, name):
- imgpath = os.path.join(self._target_dir, name)
- img = QtCore.QFile(imgpath)
- img.open(QtCore.QIODevice.OpenModeFlag.WriteOnly)
- return img, imgpath
+ def _get_cachefile(self, name: str) -> QFile:
+ filepath = os.path.join(self._target_dir, name)
+ file_obj = QFile(filepath)
+ file_obj.open(QIODevice.OpenModeFlag.WriteOnly)
+ return file_obj
- def remove_request(self, req):
+ def remove_request(self, req: DownloadRequest) -> None:
self.requests.remove(req)
- def add_request(self, req):
+ def add_request(self, req: DownloadRequest) -> None:
self.requests.add(req)
- def _finished(self, dl):
+ def _finished(self, dl: FileDownload) -> None:
dl.dest.close()
- logger.debug("Finished download from " + dl.addr)
+ destpath = dl.dest.fileName()
if self.failed():
- logger.debug("Web Preview failed for: {}".format(self.name))
- os.unlink(dl.destpath)
- filepath = "games/unknown_map.png"
- is_local = True
+ logger.debug(f"Download failed for: {self.name}")
+ os.unlink(destpath)
else:
- logger.debug("Web Preview used for: {}".format(self.name))
- # Remove '.part'
- partpath = dl.destpath
- filepath = partpath[:-5]
- QtCore.QDir().rename(partpath, filepath)
- is_local = False
- self.done.emit(self, (filepath, is_local))
+ logger.debug("Finished download from " + dl.addr)
+ dl.dest.rename(destpath.removesuffix(".part"))
+ self.done.emit(self, dl.dest.fileName())
def failed(self):
return not self._dl.succeeded()
-class DownloadRequest(QtCore.QObject):
- done = QtCore.pyqtSignal(object, object)
+class DownloadRequest(QObject):
+ done = pyqtSignal(object, object)
def __init__(self):
- QtCore.QObject.__init__(self)
+ QObject.__init__(self)
self._dl = None
@property
@@ -231,86 +228,83 @@ def finished(self, name, result):
self.done.emit(name, result)
-class PreviewDownloader(QtCore.QObject):
+class GeneralDownloader(QObject):
"""
- Class for downloading previews. Clients ask to download by giving download
+ Class for downloading. Clients ask to download by giving download
requests, which are stored by name. After download is complete, all
download requests get notified (neatly avoiding the 'requester died while
we were downloading' issue).
Requests can be resubmitted. That reclassifies them to a new name.
"""
- PREVIEW_REDOWNLOAD_TIMEOUT = 5 * 60 * 1000
- PREVIEW_DOWN_FAILS_TO_TIMEOUT = 3
+ REDOWNLOAD_TIMEOUT = 5 * 60 * 1000
+ DOWNLOAD_FAILS_TO_TIMEOUT = 3
- def __init__(self, target_dir, target_dir_large, route):
- QtCore.QObject.__init__(self)
+ def __init__(self, target_dir: str) -> None:
+ super().__init__()
self._nam = QNetworkAccessManager(self)
self._target_dir = target_dir
- self._target_dir_large = target_dir_large
- self._route = route
- self._default_url_prefix = None
- self._downloads = {}
- self._timeouts = DownloadTimeouts(
- self.PREVIEW_REDOWNLOAD_TIMEOUT,
- self.PREVIEW_DOWN_FAILS_TO_TIMEOUT,
- )
- self.update_url_prefix()
-
- def update_url_prefix(self):
- if self._route:
- self._default_url_prefix = self._route.format(
- Settings.get('content/host'),
- )
-
- def download_preview(self, name, req, url=None, large=None):
- target_url = self._target_url(name, url)
- if target_url is None:
- msg = "Missing url for a preview download {}".format(name)
- raise ValueError(msg)
- self._add_request(name, req, target_url, large)
-
- def _target_url(self, name, url):
- if url is not None:
- return url
- if self._default_url_prefix is None:
- return None
- return self._default_url_prefix + urllib.parse.quote(name) + ".png"
-
- def _add_request(self, name, req, url, large):
+ self._downloads: dict[str, GeneralDownload] = {}
+ self._timeouts = DownloadTimeouts(self.REDOWNLOAD_TIMEOUT, self.DOWNLOAD_FAILS_TO_TIMEOUT)
+
+ def set_target_dir(self, target_dir: str) -> None:
+ self._target_dir = target_dir
+
+ def download(self, name: str, request: DownloadRequest, url: str) -> None:
+ self._add_request(name, request, url)
+
+ def _add_request(self, name: str, req: DownloadRequest, url: str) -> None:
if name not in self._downloads:
- self._add_download(name, url, large)
+ self._add_download(name, url)
dl = self._downloads[name]
req.dl = dl
- def _add_download(self, name, url, large):
+ def _add_download(self, name: str, url: str) -> None:
if self._timeouts.on_timeout(name):
delay = self._timeouts.timer
else:
delay = None
-
- targetDir = self._target_dir
- if large:
- targetDir = self._target_dir_large
- dl = PreviewDownload(self._nam, name, url, targetDir, delay)
+ dl = GeneralDownload(self._nam, name, url, self._target_dir, delay)
dl.done.connect(self._finished_download)
self._downloads[name] = dl
- def _finished_download(self, dl, result):
- self._timeouts.update_fail_count(dl.name, dl.failed())
- requests = set(dl.requests) # Don't change it during iteration
+ def _finished_download(self, download: GeneralDownload, download_path: str) -> None:
+ self._timeouts.update_fail_count(download.name, download.failed())
+ requests = set(download.requests) # Don't change it during iteration
for req in requests:
req.dl = None
- del self._downloads[dl.name]
+ del self._downloads[download.name]
for req in requests:
- req.finished(dl.name, result)
+ req.finished(download.name, (download_path, download.failed()))
+
+
+class MapPreviewDownloader(GeneralDownloader):
+ def __init__(self, target_dir: str, size: str) -> None:
+ super().__init__(target_dir)
+ self.size = size
+
+ def download_preview(self, name: str, req: DownloadRequest) -> None:
+ self._add_request(f"{name}.png", req, self._target_url(name))
+
+ def _target_url(self, name: str) -> str:
+ return Settings.get("vault/map_preview_url").format(size=self.size, name=name)
+
+
+class MapSmallPreviewDownloader(MapPreviewDownloader):
+ def __init__(self, target_dir: str) -> None:
+ super().__init__(target_dir, "small")
+
+
+class MapLargePreviewDownloader(MapPreviewDownloader):
+ def __init__(self, target_dir: str) -> None:
+ super().__init__(target_dir, "large")
class DownloadTimeouts:
def __init__(self, timeout_interval, fail_count_to_timeout):
self._fail_count_to_timeout = fail_count_to_timeout
self._timed_out_items = {}
- self.timer = QtCore.QTimer()
+ self.timer = QTimer()
self.timer.setInterval(timeout_interval)
self.timer.timeout.connect(self._clear_timeouts)
diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py
index 50acd3d57..8eb0288ae 100644
--- a/src/replays/_replayswidget.py
+++ b/src/replays/_replayswidget.py
@@ -92,7 +92,7 @@ def _set_game_map_icon(self, game):
else:
icon = fa.maps.preview(game.mapname)
if not icon:
- dler = client.instance.map_downloader
+ dler = client.instance.map_preview_downloader
dler.download_preview(game.mapname, self._map_dl_request)
icon = util.THEME.icon("games/unknown_map.png")
self.setIcon(0, icon)
@@ -408,7 +408,7 @@ def _setup_complete_appearance(self):
if icon:
self.setIcon(0, icon)
else:
- dler = client.instance.map_downloader
+ dler = client.instance.map_preview_downloader
dler.download_preview(data['mapname'], self._map_dl_request)
self.setIcon(0, util.THEME.icon("games/unknown_map.png"))
diff --git a/src/replays/replayToolbox.py b/src/replays/replayToolbox.py
index 5eca2b94c..97d0a7b67 100644
--- a/src/replays/replayToolbox.py
+++ b/src/replays/replayToolbox.py
@@ -7,6 +7,7 @@
from config import Settings
from downloadManager import DownloadRequest
+from downloadManager import MapLargePreviewDownloader
from util import MAP_PREVIEW_LARGE_DIR
logger = logging.getLogger(__name__)
@@ -112,6 +113,7 @@ def __init__(
self._playerset = playerset
self.widgetHandler = wigetHandler
+ self._map_preview_dler = MapLargePreviewDownloader(MAP_PREVIEW_LARGE_DIR)
self._map_dl_request = DownloadRequest()
self._map_dl_request.done.connect(self._on_map_preview_downloaded)
@@ -356,11 +358,9 @@ def updateMapPreview(self):
preview.setPixmap(pix)
preview.currentMap = selectedReplay.mapname
else:
- self.client.map_downloader.download_preview(
+ self._map_preview_dler.download_preview(
selectedReplay.mapname,
self._map_dl_request,
- url=selectedReplay.previewUrlLarge,
- large=True,
)
def _on_map_preview_downloaded(self, mapname, result):
diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py
index f3d614ba6..b2263e5f0 100644
--- a/src/replays/replayitem.py
+++ b/src/replays/replayitem.py
@@ -229,7 +229,7 @@ def update(self, replay, client):
if not self.icon:
self.icon = util.THEME.icon("games/unknown_map.png")
if self.mapname != "unknown":
- self.client.map_downloader.download_preview(
+ self.client.map_preview_downloader.download_preview(
self.mapname, self._map_dl_request,
)
diff --git a/src/tutorials/tutorialitem.py b/src/tutorials/tutorialitem.py
index 853d76a80..9e6063275 100644
--- a/src/tutorials/tutorialitem.py
+++ b/src/tutorials/tutorialitem.py
@@ -126,7 +126,7 @@ def update(self, message, client):
icon = maps.preview(self.mapname)
if not icon:
icon = util.THEME.icon("games/unknown_map.png")
- self.client.map_downloader.download_preview(
+ self.client.map_preview_downloader.download_preview(
self.mapname, self._map_dl_request,
)
diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py
index 7468a0d69..d57c92003 100644
--- a/src/vaults/mapvault/mapitem.py
+++ b/src/vaults/mapvault/mapitem.py
@@ -20,6 +20,7 @@ def __init__(self, parent, folderName, *args, **kwargs):
self.folderName = folderName
self.thumbstrSmall = ""
self.thumbnailLarge = ""
+ self._preview_dler.set_target_dir(util.MAP_PREVIEW_SMALL_DIR)
def update(self, item_dict):
self.name = maps.getDisplayName(item_dict["folderName"])
@@ -49,8 +50,10 @@ def update(self, item_dict):
else:
self.setItemIcon("games/unknown_map.png")
else:
- self.parent.client.map_downloader.download_preview(
- self.folderName, self._item_dl_request, self.thumbstrSmall,
+ self._preview_dler.download(
+ f"{self.folderName}.png",
+ self._item_dl_request,
+ self.thumbstrSmall,
)
VaultItem.update(self)
diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py
index 4633eaa20..a73c86d01 100644
--- a/src/vaults/mapvault/mapwidget.py
+++ b/src/vaults/mapvault/mapwidget.py
@@ -5,8 +5,9 @@
from PyQt6 import QtGui
from PyQt6 import QtWidgets
-import downloadManager
import util
+from downloadManager import DownloadRequest
+from downloadManager import MapLargePreviewDownloader
from fa import maps
from mapGenerator import mapgenUtils
@@ -37,11 +38,8 @@ def __init__(self, parent, _map, *args, **kwargs):
self.Info.setText("{} Uploaded {}".format(maptext, str(_map.date)))
self.Players.setText("Maximum players: {}".format(_map.maxPlayers))
self.Size.setText("Size: {} x {} km".format(_map.width, _map.height))
- self.map_downloader = downloadManager.PreviewDownloader(
- util.MAP_PREVIEW_SMALL_DIR, util.MAP_PREVIEW_LARGE_DIR,
- downloadManager.MAP_PREVIEW_ROOT,
- )
- self._map_dl_request = downloadManager.DownloadRequest()
+ self._preview_dler = MapLargePreviewDownloader(util.MAP_PREVIEW_LARGE_DIR)
+ self._map_dl_request = DownloadRequest()
self._map_dl_request.done.connect(self._on_preview_downloaded)
# Ensure that pixmap is set
@@ -90,12 +88,7 @@ def updatePreview(self):
util.THEME.pixmap("games/generated_map.png"),
)
else:
- self.map_downloader.download_preview(
- self._map.folderName,
- self._map_dl_request,
- url=self._map.thumbnailLarge,
- large=True,
- )
+ self._preview_dler.download_preview(self._map.folderName, self._map_dl_request)
def _on_preview_downloaded(self, mapname, result):
filename, themed = result
diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py
index d5dc5517b..e3e505a0b 100644
--- a/src/vaults/modvault/moditem.py
+++ b/src/vaults/modvault/moditem.py
@@ -19,6 +19,7 @@ def __init__(self, parent, uid, *args, **kwargs):
self.thumbstr = ""
self.isuidmod = False
self.uploadedbyuser = False
+ self._preview_dler.set_target_dir(util.MOD_PREVIEW_DIR)
def shouldBeVisible(self):
p = self.parent
@@ -56,9 +57,7 @@ def update(self, item_dict):
if img:
self.setItemIcon(img, False)
else:
- self.parent.client.mod_downloader.download_preview(
- name[:-4], self._item_dl_request, self.thumbstr,
- )
+ self._preview_dler.download(name, self._item_dl_request, self.thumbstr)
VaultItem.update(self)
diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py
index 2405d5103..11c321491 100644
--- a/src/vaults/vaultitem.py
+++ b/src/vaults/vaultitem.py
@@ -4,6 +4,7 @@
import util
from downloadManager import DownloadRequest
+from downloadManager import GeneralDownloader
class VaultItem(QtWidgets.QListWidgetItem):
@@ -29,6 +30,7 @@ def __init__(self, parent, *args, **kwargs):
self.link = ""
self.setHidden(True)
+ self._preview_dler = GeneralDownloader(util.CACHE_DIR)
self._item_dl_request = DownloadRequest()
self._item_dl_request.done.connect(self._on_item_downloaded)
From 5394cfcb43571554ad6a62e67e8047ad79c10f35 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 17 Apr 2024 16:01:00 +0300
Subject: [PATCH 035/123] Refactor downloadManager a little bit more
* add classes that manage the process of creating-downloading-closing
buffers/files internally, one just needs to specify target directory and filename
so they can be reused to download anything
* fix sim mod downloading
---
src/api/sim_mod_updater.py | 14 ++--
src/config/production.py | 1 +
src/downloadManager/__init__.py | 96 ++++++++++++++++++++--
src/fa/game_runner.py | 12 +--
src/fa/maps.py | 23 +++---
src/fa/mods.py | 36 ++++-----
src/fa/updater.py | 56 +++++++------
src/mapGenerator/mapgenManager.py | 14 ++--
src/model/game.py | 4 +-
src/util/gameurl.py | 4 +-
src/vaults/dialogs.py | 130 ++++++++++++------------------
src/vaults/modvault/modvault.py | 4 +-
src/vaults/modvault/modwidget.py | 4 +-
src/vaults/modvault/utils.py | 14 +---
14 files changed, 221 insertions(+), 191 deletions(-)
diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py
index e8cae3158..a38b44456 100644
--- a/src/api/sim_mod_updater.py
+++ b/src/api/sim_mod_updater.py
@@ -8,16 +8,16 @@
class SimModFiles(DataApiAccessor):
def __init__(self) -> None:
super().__init__('/data/modVersion')
- self.simModUrl = ''
+ self.mod_url = ""
def requestData(self, queryDict: dict) -> None:
self.get_by_query(queryDict, self.handleData)
- def getUrlFromMessage(self, message):
- self.simModUrl = message[0]['downloadUrl']
+ def get_url_from_message(self, message: dict) -> str:
+ self.mod_url = message["data"][0]["downloadUrl"]
- def requestAndGetSimModUrlByUid(self, uid: int) -> str:
- queryDict = dict(filter='uid=={}'.format(uid))
- self.get_by_query(queryDict, self.getUrlFromMessage)
+ def request_and_get_sim_mod_url_by_id(self, uid: str) -> str:
+ query_dict = {"filter": f"uid=={uid}"}
+ self.get_by_query(query_dict, self.get_url_from_message)
self.waitForCompletion()
- return self.simModUrl
+ return self.mod_url
diff --git a/src/config/production.py b/src/config/production.py
index 8fa983809..dc872ba18 100644
--- a/src/config/production.py
+++ b/src/config/production.py
@@ -51,6 +51,7 @@
'relay_server/host': 'lobby.{host}',
'relay_server/port': 8000,
'vault/map_preview_url': 'https://content.{host}/maps/previews/{size}/{name}.png',
+ 'vault/map_download_url': "https://content.{host}/maps/{name}.zip",
'FORUMS_URL': 'https://forums.faforever.com/',
'WEBSITE_URL': 'https://www.{host}',
# FIXME - temporary address below
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index 793a141f2..afc006392 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -2,6 +2,7 @@
import logging
import os
+import zipfile
from io import BytesIO
from PyQt6 import QtGui
@@ -14,6 +15,7 @@
from PyQt6.QtCore import QUrl
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
from PyQt6.QtNetwork import QNetworkRequest
from config import Settings
@@ -21,7 +23,7 @@
logger = logging.getLogger(__name__)
-class FileDownload(QObject):
+class BaseDownload(QObject):
"""
A simple async one-shot file downloader.
"""
@@ -51,7 +53,7 @@ def __init__(
self.bytes_total = 0
self.bytes_progress = 0
- self._dfile = None
+ self._dfile: QNetworkReply | None = None
self._reading = False
self._running = False
@@ -61,6 +63,7 @@ def _stop(self):
ran = self._running
self._running = False
if ran:
+ self._about_to_finish()
self._finish()
def _error(self):
@@ -71,12 +74,17 @@ def cancel(self):
self.canceled = True
self._stop()
- def _finish(self):
+ def _handle_status(self) -> None:
# check status code
statusCode = self._dfile.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
if statusCode != 200:
logger.debug(f"Download failed: {self.addr} -> {statusCode}")
self.error = True
+
+ def _about_to_finish(self) -> None:
+ self._handle_status()
+
+ def _finish(self) -> None:
self.finished.emit(self)
def prepare_request(self) -> QNetworkRequest:
@@ -135,12 +143,88 @@ def _readloop(self):
def succeeded(self):
return not self.error and not self.canceled
+ def failed(self) -> bool:
+ return not self.succeeded()
+
def waitForCompletion(self):
waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
while self._running:
QtWidgets.QApplication.processEvents(waitFlag)
+class FileDownload(BaseDownload):
+ def __init__(
+ self,
+ target_path: str,
+ nam: QNetworkAccessManager,
+ addr: str,
+ request_params: dict | None = None,
+ ) -> None:
+ self._target_path = f"{target_path}.part"
+ self._output = QFile(target_path)
+ self._output.open(QIODevice.OpenModeFlag.WriteOnly)
+ super().__init__(nam, addr, self._output, request_params=request_params)
+
+ def _about_to_finish(self) -> None:
+ super()._about_to_finish()
+ self.cleanup()
+
+ def cleanup(self) -> None:
+ self._output.close()
+ if self.failed():
+ logger.debug(f"Download failed for: {self.name}")
+ os.unlink(self._target_path)
+ else:
+ logger.debug(f"Finished download from {self.addr}")
+ self._output.rename(self._target_path.removesuffix(".part"))
+
+
+class ZipDownloadExtract(BaseDownload):
+ """
+ Download a zip archive in-memory and extract it into target_dir
+ """
+
+ def __init__(
+ self,
+ target_dir: str,
+ nam: QNetworkAccessManager,
+ addr: str,
+ request_params: dict | None = None,
+ exist_ok: bool = False,
+ ) -> None:
+ self._target_dir = target_dir
+ self._output = BytesIO()
+ self._exist_ok = exist_ok
+ super().__init__(nam, addr, self._output, request_params=request_params)
+
+ def _about_to_finish(self) -> None:
+ super()._about_to_finish()
+ if self.succeeded():
+ self.extract_archive()
+ self.cleanup()
+
+ def extract_archive(self) -> None:
+ with zipfile.ZipFile(self._output) as zfile:
+ dirname = os.path.dirname(zfile.namelist()[0])
+ destpath = os.path.join(self._target_dir, dirname)
+ if os.path.exists(destpath):
+ if not self._exist_ok:
+ logger.warning(f"Cannot extract: {destpath!r} already exists")
+ self.error = True
+ return
+ try:
+ zfile.extractall(self._target_dir)
+ logger.debug(
+ f"Successfully downloaded and extracted to {destpath!r} from: {self.addr!r}",
+ )
+ except Exception as e:
+ logger.error(f"Extract error: {e}")
+ self.error = True
+
+ def cleanup(self) -> None:
+ self._output.close()
+
+
class GeneralDownload(QObject):
done = pyqtSignal(object, object)
@@ -171,9 +255,9 @@ def _start_download(self) -> None:
self._dl = self._prepare_dl()
self._dl.run()
- def _prepare_dl(self) -> FileDownload:
+ def _prepare_dl(self) -> BaseDownload:
file_obj = self._get_cachefile(self.name + ".part")
- dl = FileDownload(self._nam, self._url, file_obj)
+ dl = BaseDownload(self._nam, self._url, file_obj)
dl.finished.connect(self._finished)
dl.blocksize = None
return dl
@@ -190,7 +274,7 @@ def remove_request(self, req: DownloadRequest) -> None:
def add_request(self, req: DownloadRequest) -> None:
self.requests.add(req)
- def _finished(self, dl: FileDownload) -> None:
+ def _finished(self, dl: BaseDownload) -> None:
dl.dest.close()
destpath = dl.dest.fileName()
if self.failed():
diff --git a/src/fa/game_runner.py b/src/fa/game_runner.py
index 0ed860a7f..0ff0f6a60 100644
--- a/src/fa/game_runner.py
+++ b/src/fa/game_runner.py
@@ -1,9 +1,9 @@
-import json
import logging
import fa
from fa.replay import replay
from model.game import GameState
+from util.gameurl import GameUrl
logger = logging.getLogger(__name__)
@@ -29,16 +29,10 @@ def run_game_from_url(self, gurl):
elif game.state == GameState.PLAYING:
replay(gurl)
- def _join_game_from_url(self, gurl):
+ def _join_game_from_url(self, gurl: GameUrl) -> None:
logger.debug("Joining game from URL: " + gurl.to_url().toString())
if fa.instance.available():
- if gurl.mods is None:
- add_mods = []
- else:
- try:
- add_mods = json.loads(gurl.mods) # should be a list
- except (json.JSONDecodeError, TypeError):
- logger.info("Failed to decode game mods!")
+ add_mods = gurl.mods or {}
if fa.check.game(self):
if fa.check.check(gurl.mod, gurl.map, sim_mods=add_mods):
self._client_window.join_game(gurl.uid)
diff --git a/src/fa/maps.py b/src/fa/maps.py
index 1f23cc200..74896e365 100644
--- a/src/fa/maps.py
+++ b/src/fa/maps.py
@@ -7,10 +7,8 @@
import struct
import sys
import tempfile
-import urllib.error
-import urllib.parse
-import urllib.request
import zipfile
+from typing import Callable
from PyQt6 import QtCore
from PyQt6 import QtGui
@@ -26,9 +24,6 @@
logger = logging.getLogger(__name__)
route = Settings.get('content/host')
-VAULT_PREVIEW_ROOT = "{}/faf/vault/map_previews/small/".format(route)
-VAULT_DOWNLOAD_ROOT = "{}/maps/"
-VAULT_COUNTER_ROOT = "{}/faf/vault/map_vault/inc_downloads.php".format(route)
__exist_maps = None
@@ -63,12 +58,12 @@ def getDisplayName(filename):
return pretty
-def name2link(name):
+def name2link(name: str) -> str:
"""
Returns a quoted link for use with the VAULT_xxxx Urls
TODO: This could be cleaned up a little later.
"""
- return urllib.parse.quote(name + ".zip")
+ return Settings.get("vault/map_download_url").format(name=name)
def link2name(link):
@@ -462,11 +457,15 @@ def downloadMap(name, silent=False):
return True
-def _doDownloadMap(name, link, silent):
- url = VAULT_DOWNLOAD_ROOT.format(Settings.get('content/host')) + link
- logger.debug("Getting map from: " + url)
+def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None]]:
+ logger.debug(f"Getting map from: {link}")
return downloadVaultAssetNoMsg(
- url, getUserMapsFolder(), lambda m, d: True, name, "map", silent,
+ url=link,
+ target_dir=getUserMapsFolder(),
+ exist_handler=lambda m, d: True,
+ name=name,
+ category="map",
+ silent=silent,
)
diff --git a/src/fa/mods.py b/src/fa/mods.py
index d1b2a84c8..ff367ea60 100644
--- a/src/fa/mods.py
+++ b/src/fa/mods.py
@@ -4,27 +4,25 @@
import config
import fa
-import vaults.modvault.utils
+from vaults.modvault.utils import getInstalledMods
+from vaults.modvault.utils import setActiveMods
logger = logging.getLogger(__name__)
-def checkMods(mods): # mods is a dictionary of uid-name pairs
+def checkMods(mods: dict[str, str]) -> bool: # mods is a dictionary of uid-name pairs
"""
Assures that the specified mods are available in FA, or returns False.
Also sets the correct active mods in the ingame mod manager.
"""
logger.info("Updating FA for mods {}".format(", ".join(mods)))
- to_download = []
- inst = vaults.modvault.utils.getInstalledMods()
- uids = [mod.uid for mod in inst]
- for uid in mods:
- if uid not in uids:
- to_download.append(uid)
+
+ inst = set(mod.uid for mod in getInstalledMods())
+ to_download = {uid: name for uid, name in mods.items() if uid not in inst}
auto = config.Settings.get('mods/autodownload', default=False, type=bool)
if not auto:
- mod_names = ", ".join([mods[uid] for uid in mods])
+ mod_names = ", ".join(mods.values())
msgbox = QtWidgets.QMessageBox()
msgbox.setWindowTitle("Download Mod")
msgbox.setText(
@@ -46,32 +44,26 @@ def checkMods(mods): # mods is a dictionary of uid-name pairs
elif result == QtWidgets.QMessageBox.StandardButton.YesToAll:
config.Settings.set('mods/autodownload', True)
- for uid in to_download:
+ for item in to_download.items():
# Spawn an update for the required mod
- updater = fa.updater.Updater(uid, sim=True)
+ updater = fa.updater.Updater("sim", sim_mod=item)
result = updater.run()
if result != fa.updater.Updater.RESULT_SUCCESS:
- logger.warning("Failure getting {}: {}".format(uid, mods[uid]))
+ logger.warning(f"Failure getting {item}")
return False
actual_mods = []
- inst = vaults.modvault.utils.getInstalledMods()
- uids = {}
- for mod in inst:
- uids[mod.uid] = mod
- for uid in mods:
+ uids = {mod.uid: mod for mod in getInstalledMods()}
+ for uid, name in mods.items():
if uid not in uids:
QtWidgets.QMessageBox.warning(
None,
"Mod not Found",
- (
- "{} was apparently not installed correctly. Please check "
- "this.".format(mods[uid])
- ),
+ f"{name} was apparently not installed correctly. Please check this.",
)
return
actual_mods.append(uids[uid])
- if not vaults.modvault.utils.setActiveMods(actual_mods):
+ if not setActiveMods(actual_mods):
logger.warning("Couldn't set the active mods in the game.prefs file")
return False
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 570da0b98..77f2d7d9b 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -23,7 +23,7 @@
from api.featured_mod_updater import FeaturedModId
from api.sim_mod_updater import SimModFiles
from config import Settings
-from vaults.dialogs import downloadFile
+from vaults.dialogs import download_file
from vaults.modvault import utils
logger = logging.getLogger(__name__)
@@ -111,11 +111,11 @@ class Updater(QtCore.QObject):
def __init__(
self,
- featured_mod,
- version=None,
- modversions=None,
- sim=False,
- silent=False,
+ featured_mod: str,
+ version: int | None = None,
+ modversions: dict | None = None,
+ sim_mod: tuple[str, str] | None = None,
+ silent: bool = False,
*args,
**kwargs,
):
@@ -133,7 +133,7 @@ def __init__(
self.version = version
self.modversions = modversions
- self.sim = sim
+ self.sim_mod = sim_mod
self.modpath = None
self.result = self.RESULT_NONE
@@ -199,25 +199,24 @@ def getFilesToUpdate(self, id_, version):
def getFeaturedModIdByName(self, technicalName):
return FeaturedModId().requestAndGetFeaturedModIdByName(technicalName)
- def requestSimUrlByUid(self, uid):
- return SimModFiles().requestAndGetSimModUrlByUid(uid)
+ def request_sim_url_by_uid(self, uid: str) -> str:
+ return SimModFiles().request_and_get_sim_mod_url_by_id(uid)
- def fetchFile(self, _file: dict, filegroup: str) -> None:
- name = _file['name']
- targetDir = os.path.join(util.APPDATA_DIR, filegroup, name)
+ def fetch_file(self, file_info: dict, filegroup: str) -> None:
+ name = file_info["name"]
+ url = file_info["cacheableUrl"]
+ target_dir = os.path.join(util.APPDATA_DIR, filegroup)
- logger.info('Updater: Downloading {}'.format(_file['cacheableUrl']))
+ logger.info(f"Updater: Downloading {url}")
- downloaded = downloadFile(
- url=_file['cacheableUrl'],
- target_dir=targetDir,
- name=(
- 'Downloading FA file : {url} '
- .format(url=_file['cacheableUrl'])
- ),
- category='Update',
+ downloaded = download_file(
+ url=url,
+ target_dir=target_dir,
+ name=name,
+ category="Update",
silent=False,
- request_params={_file["hmacParameter"]: _file["hmacToken"]},
+ request_params={file_info["hmacParameter"]: file_info["hmacToken"]},
+ label=f"Downloading FA file : {url}
",
)
if not downloaded:
@@ -294,7 +293,7 @@ def updateFiles(self, filegroup, files):
if self.keep_cache or self.in_session_cache:
files_to_check.append(_file)
else:
- self.fetchFile(_file, filegroup)
+ self.fetch_file(_file, filegroup)
self.filesToUpdate.remove(_file)
self.updatedFiles.append(_file['name'])
@@ -305,7 +304,7 @@ def updateFiles(self, filegroup, files):
self.replaceFromCache(replaceable_files, filegroup)
for _file in need_to_download:
self.moveToCache([_file], filegroup)
- self.fetchFile(_file, filegroup)
+ self.fetch_file(_file, filegroup)
self.filesToUpdate.remove(_file)
self.updatedFiles.append(_file['name'])
@@ -375,13 +374,12 @@ def prepareBinFAF(self):
# need to patch them
os.chmod(dst_file, st.st_mode | stat.S_IWRITE)
- def doUpdate(self):
+ def doUpdate(self) -> None:
""" The core function that does most of the actual update work."""
try:
- if self.sim:
- if utils.downloadMod(
- self.requestSimUrlByUid(self.featured_mod),
- ):
+ if self.sim_mod:
+ uid, name = self.sim_mod
+ if utils.downloadMod(self.request_sim_url_by_uid(uid), name):
self.result = self.RESULT_SUCCESS
else:
self.result = self.RESULT_FAILURE
diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py
index 1fd325521..6bca9efb6 100644
--- a/src/mapGenerator/mapgenManager.py
+++ b/src/mapGenerator/mapgenManager.py
@@ -13,7 +13,7 @@
from fa.maps import getUserMapsFolder
from mapGenerator.mapgenProcess import MapGeneratorProcess
from mapGenerator.mapgenUtils import generatedMapPattern
-from vaults.dialogs import downloadFile
+from vaults.dialogs import download_file
logger = logging.getLogger(__name__)
@@ -132,21 +132,21 @@ def generateRandomMap(self):
return self.generateMap(mapName)
- def versionController(self, version):
+ def versionController(self, version: str) -> str:
name = GENERATOR_JAR_NAME.format(version)
- filePath = os.path.join(util.MAPGEN_DIR, name)
+ file_path = os.path.join(util.MAPGEN_DIR, name)
# Check if required version is already in folder
if os.path.isdir(util.MAPGEN_DIR):
for infile in os.listdir(util.MAPGEN_DIR):
if infile.lower() == name.lower():
- return filePath
+ return file_path
# Download from github if not
url = RELEASE_URL + RELEASE_VERSION_PATH.format(version=version)
- return downloadFile(
- url, filePath, name, "map generator", silent=False,
- )
+ if download_file(url, util.MAPGEN_DIR, name, "map generator", silent=False):
+ return file_path
+ return ""
def checkUpdates(self):
'''
diff --git a/src/model/game.py b/src/model/game.py
index 98c560a2e..0a575b869 100644
--- a/src/model/game.py
+++ b/src/model/game.py
@@ -167,9 +167,7 @@ def url(self, player_id):
else:
gtype = GameUrlType.LIVE_REPLAY
- return GameUrl(
- gtype, self.mapname, self.featured_mod, self.uid, player_id,
- )
+ return GameUrl(gtype, self.mapname, self.featured_mod, self.uid, player_id, self.sim_mods)
# Utility functions start here.
diff --git a/src/util/gameurl.py b/src/util/gameurl.py
index a7ed29cd5..0e653ae1a 100644
--- a/src/util/gameurl.py
+++ b/src/util/gameurl.py
@@ -28,8 +28,8 @@ def to_url(self):
query = QUrlQuery()
query.addQueryItem("map", self.map)
query.addQueryItem("mod", self.mod)
- if self.mods is not None:
- query.addQueryItem("mods", self.mods)
+ if self.mods:
+ query.addQueryItem("mods", ";".join(self.mods))
if self.game_type == GameUrlType.OPEN_GAME:
url.setPath("/{}".format(self.player))
diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py
index a75ef6f66..a73d49932 100644
--- a/src/vaults/dialogs.py
+++ b/src/vaults/dialogs.py
@@ -1,13 +1,13 @@
-import io
import logging
import os
-import zipfile
+from typing import Callable
from PyQt6 import QtCore
from PyQt6 import QtNetwork
from PyQt6 import QtWidgets
from downloadManager import FileDownload
+from downloadManager import ZipDownloadExtract
logger = logging.getLogger(__name__)
@@ -19,7 +19,13 @@ class VaultDownloadDialog(object):
DL_ERROR = 2
UNKNOWN_ERROR = 3
- def __init__(self, dler, title, label, silent=False):
+ def __init__(
+ self,
+ dler: FileDownload | ZipDownloadExtract,
+ title: str,
+ label: str,
+ silent: bool = False,
+ ) -> None:
self._silent = silent
self._result = None
@@ -122,8 +128,15 @@ def _finished(self, dler):
def downloadVaultAssetNoMsg(
- url, target_dir, exist_handler, name, category, silent,
-):
+ url: str,
+ target_dir: str,
+ exist_handler: Callable[[str, str], bool],
+ name: str,
+ category: str,
+ silent: bool,
+ request_params: dict | None = None,
+ label: str = "",
+) -> tuple[bool, Callable[[], None] | None]:
"""
Download and unpack a zip from the vault, interacting with the user and
logging things.
@@ -132,69 +145,39 @@ def downloadVaultAssetNoMsg(
msg = None
msg_title = ""
msg_text = ""
- output = io.BytesIO()
- capitCat = category[0].upper() + category[1:]
+ capit_cat = f"{category[0].upper()}{category[1:]}"
- dler = FileDownload(_global_nam, url, output)
- ddialog = VaultDownloadDialog(
- dler, "Downloading {}".format(category), name, silent,
- )
+ if os.path.exists(os.path.join(target_dir, name)):
+ proceed = exist_handler(target_dir, name)
+ if not proceed:
+ return False, msg
+
+ dler = ZipDownloadExtract(target_dir, _global_nam, url, request_params)
+ ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent)
result = ddialog.run()
if result == VaultDownloadDialog.CANCELED:
- logger.warning("{} Download canceled for: {}".format(capitCat, url))
+ logger.warning(f"{capit_cat} Download canceled for: {url}")
if result in [
VaultDownloadDialog.DL_ERROR,
VaultDownloadDialog.UNKNOWN_ERROR,
]:
- logger.warning(
- "Vault download failed, {} probably not in vault "
- "(or broken).".format(category),
- )
- msg_title = "{} not downloadable".format(capitCat)
+ logger.warning(f"Vault download failed, {category} probably not in vault (or broken).")
+ msg_title = "{} not downloadable".format(capit_cat)
msg_text = (
- "This {} was not found in the vault (or is broken)."
- "
You need to get it from somewhere else in order to "
- "use it.".format(category)
+ f"This {category} was not found in the vault (or is broken)."
+ f"
You need to get it from somewhere else in order to "
+ f"use it."
)
- if msg_title and msg_text:
def msg():
QtWidgets.QMessageBox.information(None, msg_title, msg_text)
if result != VaultDownloadDialog.SUCCESS:
return False, msg
- try:
- zfile = zipfile.ZipFile(output)
- # FIXME - nothing in python 2.7 that can do that
- dirname = zfile.namelist()[0].split(os.path.sep, 1)[0]
-
- if os.path.exists(os.path.join(target_dir, dirname)):
- proceed = exist_handler(target_dir, dirname)
- if not proceed:
- return False
- zfile.extractall(target_dir)
- logger.debug(
- "Successfully downloaded and extracted {} from: {}"
- .format(category, url),
- )
- return True, msg
-
- except BaseException:
- logger.error("Extract error")
-
- def msg():
- QtWidgets.QMessageBox.information(
- None,
- "{} installation failed".format(capitCat),
- (
- "This {} could not be installed (please report this {} "
- "or bug).".format(category, category)
- ),
- )
- return False, msg
+ return True, msg
def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent):
@@ -207,56 +190,43 @@ def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent):
return ret
-def downloadFile(
+def download_file(
url: str,
target_dir: str,
name: str,
category: str,
silent: bool,
request_params: dict | None = None,
-) -> None:
+ label: str = "",
+) -> bool:
"""
Basically a copy of downloadVaultAssetNoMsg without zip
"""
global _global_nam
- msg = None
- output = io.BytesIO()
- capitCat = category[0].upper() + category[1:]
+ capit_cat = f"{category[0].upper()}{category[1:]}"
- dler = FileDownload(_global_nam, url, output, request_params=request_params)
- ddialog = VaultDownloadDialog(
- dler, "Downloading {}".format(category), name, silent,
- )
+ os.makedirs(target_dir, exist_ok=True)
+
+ target_path = os.path.join(target_dir, name)
+ dler = FileDownload(target_path, _global_nam, url, request_params)
+ ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent)
result = ddialog.run()
if result == VaultDownloadDialog.CANCELED:
- logger.warning("{} Download canceled for: {}".format(capitCat, url))
+ logger.warning(f"{capit_cat} Download canceled for: {url}")
if result in [
VaultDownloadDialog.DL_ERROR,
VaultDownloadDialog.UNKNOWN_ERROR,
]:
- logger.warning("Download failed. {}".format(url))
-
- def msg():
- QtWidgets.QMessageBox.information(
- None,
- "{} not downloadable".format(capitCat),
- (
- "Failed to download {} from
"
- "{}".format(category, url)
- ),
- )
+ logger.warning(f"Download failed. {url}")
+ QtWidgets.QMessageBox.information(
+ None,
+ f"{capit_cat} not downloadable",
+ f"Failed to download {category} from
{url}",
+ )
if result != VaultDownloadDialog.SUCCESS:
- if msg:
- msg()
return False
- if not os.path.exists(os.path.dirname(target_dir)):
- os.makedirs(os.path.dirname(target_dir))
-
- with open(target_dir, "w+b") as f:
- f.write(output.getvalue())
-
- return target_dir
+ return True
diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py
index 7cc1f6ebe..3ffc0c738 100644
--- a/src/vaults/modvault/modvault.py
+++ b/src/vaults/modvault/modvault.py
@@ -172,8 +172,8 @@ def openUploadForm(self):
"This folder doesn't contain a mod_info.lua file",
)
- def downloadMod(self, mod):
- if utils.downloadMod(mod):
+ def downloadMod(self, link: str, name: str) -> bool:
+ if utils.downloadMod(link, name):
self.uids = [mod.uid for mod in utils.getInstalledMods()]
self.updateVisibilities()
return True
diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py
index 796dd600e..74b97b3a6 100644
--- a/src/vaults/modvault/modwidget.py
+++ b/src/vaults/modvault/modwidget.py
@@ -81,9 +81,9 @@ def load_stylesheet(self):
self.setStyleSheet(util.THEME.readstylesheet("client/client.css"))
@QtCore.pyqtSlot()
- def download(self):
+ def download(self) -> None:
if self.mod.uid not in self.parent.uids:
- self.parent.downloadMod(self.mod)
+ self.parent.downloadMod(self.mod.link, self.mod.name)
self.done(1)
else:
show = QtWidgets.QMessageBox.question(
diff --git a/src/vaults/modvault/utils.py b/src/vaults/modvault/utils.py
index 886335817..294741c91 100644
--- a/src/vaults/modvault/utils.py
+++ b/src/vaults/modvault/utils.py
@@ -420,14 +420,10 @@ def generateThumbnail(sourcename, destname):
return False
-def downloadMod(item):
- if isinstance(item, str):
- link = item
- else:
- link = item.link
- logger.debug("Getting mod from: {}".format(link))
+def downloadMod(link: str, name: str) -> bool:
+ logger.debug(f"Getting mod from: {link}")
- def handle_exist(path, modname):
+ def handle_exist(path: str, modname: str) -> bool:
modpath = os.path.join(path, modname)
oldmod = getModInfoFromFolder(modpath)
result = QtWidgets.QMessageBox.question(
@@ -446,9 +442,7 @@ def handle_exist(path, modname):
removeMod(oldmod)
return True
- return downloadVaultAsset(
- link, MODFOLDER, handle_exist, link, "mod", False,
- )
+ return downloadVaultAsset(link, MODFOLDER, handle_exist, name, "mod", silent=False)
def removeMod(mod):
From dbad8493fec69ea6e4635eb741f7cb94eef4b0f8 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 17 Apr 2024 19:50:53 +0300
Subject: [PATCH 036/123] Use FileDownload instead of BaseDownload in
DownloadWrapper
so that wrapper doesn't need to create and cleanup files
---
src/downloadManager/__init__.py | 46 ++++++++++++---------------------
src/vaults/vaultitem.py | 4 +--
2 files changed, 19 insertions(+), 31 deletions(-)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index afc006392..d5b0ad049 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -160,8 +160,10 @@ def __init__(
addr: str,
request_params: dict | None = None,
) -> None:
- self._target_path = f"{target_path}.part"
- self._output = QFile(target_path)
+ self._target_path = target_path
+ self._cache_path = f"{target_path}.part"
+
+ self._output = QFile(self._cache_path)
self._output.open(QIODevice.OpenModeFlag.WriteOnly)
super().__init__(nam, addr, self._output, request_params=request_params)
@@ -172,11 +174,11 @@ def _about_to_finish(self) -> None:
def cleanup(self) -> None:
self._output.close()
if self.failed():
- logger.debug(f"Download failed for: {self.name}")
- os.unlink(self._target_path)
+ logger.debug(f"Download failed for: {self._target_path}")
+ os.unlink(self._cache_path)
else:
logger.debug(f"Finished download from {self.addr}")
- self._output.rename(self._target_path.removesuffix(".part"))
+ self._output.rename(self._target_path)
class ZipDownloadExtract(BaseDownload):
@@ -225,7 +227,7 @@ def cleanup(self) -> None:
self._output.close()
-class GeneralDownload(QObject):
+class DownloadWrapper(QObject):
done = pyqtSignal(object, object)
def __init__(
@@ -255,34 +257,20 @@ def _start_download(self) -> None:
self._dl = self._prepare_dl()
self._dl.run()
- def _prepare_dl(self) -> BaseDownload:
- file_obj = self._get_cachefile(self.name + ".part")
- dl = BaseDownload(self._nam, self._url, file_obj)
+ def _prepare_dl(self) -> FileDownload:
+ filepath = os.path.join(self._target_dir, self.name)
+ dl = FileDownload(filepath, self._nam, self._url)
dl.finished.connect(self._finished)
dl.blocksize = None
return dl
- def _get_cachefile(self, name: str) -> QFile:
- filepath = os.path.join(self._target_dir, name)
- file_obj = QFile(filepath)
- file_obj.open(QIODevice.OpenModeFlag.WriteOnly)
- return file_obj
-
def remove_request(self, req: DownloadRequest) -> None:
self.requests.remove(req)
def add_request(self, req: DownloadRequest) -> None:
self.requests.add(req)
- def _finished(self, dl: BaseDownload) -> None:
- dl.dest.close()
- destpath = dl.dest.fileName()
- if self.failed():
- logger.debug(f"Download failed for: {self.name}")
- os.unlink(destpath)
- else:
- logger.debug("Finished download from " + dl.addr)
- dl.dest.rename(destpath.removesuffix(".part"))
+ def _finished(self, dl: FileDownload) -> None:
self.done.emit(self, dl.dest.fileName())
def failed(self):
@@ -312,7 +300,7 @@ def finished(self, name, result):
self.done.emit(name, result)
-class GeneralDownloader(QObject):
+class Downloader(QObject):
"""
Class for downloading. Clients ask to download by giving download
requests, which are stored by name. After download is complete, all
@@ -328,7 +316,7 @@ def __init__(self, target_dir: str) -> None:
super().__init__()
self._nam = QNetworkAccessManager(self)
self._target_dir = target_dir
- self._downloads: dict[str, GeneralDownload] = {}
+ self._downloads: dict[str, DownloadWrapper] = {}
self._timeouts = DownloadTimeouts(self.REDOWNLOAD_TIMEOUT, self.DOWNLOAD_FAILS_TO_TIMEOUT)
def set_target_dir(self, target_dir: str) -> None:
@@ -348,11 +336,11 @@ def _add_download(self, name: str, url: str) -> None:
delay = self._timeouts.timer
else:
delay = None
- dl = GeneralDownload(self._nam, name, url, self._target_dir, delay)
+ dl = DownloadWrapper(self._nam, name, url, self._target_dir, delay)
dl.done.connect(self._finished_download)
self._downloads[name] = dl
- def _finished_download(self, download: GeneralDownload, download_path: str) -> None:
+ def _finished_download(self, download: DownloadWrapper, download_path: str) -> None:
self._timeouts.update_fail_count(download.name, download.failed())
requests = set(download.requests) # Don't change it during iteration
for req in requests:
@@ -362,7 +350,7 @@ def _finished_download(self, download: GeneralDownload, download_path: str) -> N
req.finished(download.name, (download_path, download.failed()))
-class MapPreviewDownloader(GeneralDownloader):
+class MapPreviewDownloader(Downloader):
def __init__(self, target_dir: str, size: str) -> None:
super().__init__(target_dir)
self.size = size
diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py
index 11c321491..db04d38d5 100644
--- a/src/vaults/vaultitem.py
+++ b/src/vaults/vaultitem.py
@@ -3,8 +3,8 @@
from PyQt6 import QtWidgets
import util
+from downloadManager import Downloader
from downloadManager import DownloadRequest
-from downloadManager import GeneralDownloader
class VaultItem(QtWidgets.QListWidgetItem):
@@ -30,7 +30,7 @@ def __init__(self, parent, *args, **kwargs):
self.link = ""
self.setHidden(True)
- self._preview_dler = GeneralDownloader(util.CACHE_DIR)
+ self._preview_dler = Downloader(util.CACHE_DIR)
self._item_dl_request = DownloadRequest()
self._item_dl_request.done.connect(self._on_item_downloaded)
From 2ec6f51683b8722328e2d0469d39728340ddc963 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 17 Apr 2024 19:59:43 +0300
Subject: [PATCH 037/123] NewsWidget: Download news images into cache folder
---
src/news/_newswidget.py | 55 +++++++++++++++++++++--------------------
src/util/__init__.py | 5 +++-
2 files changed, 32 insertions(+), 28 deletions(-)
diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py
index 39604c2f5..e34836ee3 100644
--- a/src/news/_newswidget.py
+++ b/src/news/_newswidget.py
@@ -1,18 +1,18 @@
import logging
+import os.path
from PyQt6 import QtWidgets
-from PyQt6.QtCore import QByteArray
from PyQt6.QtCore import QPoint
from PyQt6.QtCore import QSize
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QImage
from PyQt6.QtGui import QTextDocument
from PyQt6.QtNetwork import QNetworkAccessManager
-from PyQt6.QtNetwork import QNetworkReply
-from PyQt6.QtNetwork import QNetworkRequest
import util
from config import Settings
+from downloadManager import Downloader
+from downloadManager import DownloadRequest
from .newsitem import NewsItem
from .newsitem import NewsItemDelegate
@@ -35,11 +35,12 @@ def __init__(self, *args, **kwargs) -> None:
self.setupUi(self)
self.nam = QNetworkAccessManager()
- self.reply: QNetworkReply | None = None
+ self._downloader = Downloader(util.NEWS_CACHE_DIR)
+ self._images_dl_request = DownloadRequest()
+ self._images_dl_request.done.connect(self.item_image_downloaded)
self.newsManager = NewsManager(self)
self.newsItems = []
- self.images = {}
# open all links in external browser
self.newsTextBrowser.setOpenExternalLinks(True)
@@ -65,37 +66,36 @@ def updateNews(self) -> None:
self.newsList.clear()
self.newsManager.WpApi.download()
- def download_image(self, img_url: QUrl) -> None:
- request = QNetworkRequest(img_url)
- self.reply = self.nam.get(request)
- self.reply.finished.connect(self.item_image_downloaded)
+ def download_image(self, img_url: str) -> None:
+ name = os.path.basename(img_url)
+ self._downloader.download(name, self._images_dl_request, img_url)
- def add_image_resource(self, img_url: QUrl, image_data: QByteArray) -> None:
- img = QImage()
- img.loadFromData(image_data)
+ def add_image_resource(self, image_name: str, image_path: str) -> None:
+ doc = self.newsTextBrowser.document()
+ if doc.resource(QTextDocument.ResourceType.ImageResource, QUrl(image_name)):
+ return
+ img = QImage(image_path)
scaled = img.scaled(QSize(900, 500))
+ doc.addResource(QTextDocument.ResourceType.ImageResource, QUrl(image_name), scaled)
- self.images[img_url] = scaled
- self.newsTextBrowser.document().addResource(
- QTextDocument.ResourceType.ImageResource,
- img_url,
- scaled,
- )
-
- def item_image_downloaded(self) -> None:
- if self.reply.error() is not self.reply.NetworkError.NoError:
- return
- self.add_image_resource(self.reply.request().url(), self.reply.readAll())
+ def item_image_downloaded(self, image_name: str, result: tuple[str, bool]) -> None:
+ image_path, download_failed = result
+ if not download_failed:
+ self.add_image_resource(image_name, image_path)
self.show_newspage()
def itemChanged(self, current: NewsItem | None, previous: NewsItem | None) -> None:
if current is None:
return
- url = QUrl(current.newsPost["img_url"])
- if url in self.images:
+
+ url = current.newsPost["img_url"]
+ image_name = os.path.basename(url)
+ image_path = os.path.join(util.NEWS_CACHE_DIR, image_name)
+ if os.path.isfile(image_path):
+ self.add_image_resource(image_name, image_path)
self.show_newspage()
else:
- self.download_image(url)
+ self._downloader.download(image_name, self._images_dl_request, url)
def show_newspage(self) -> None:
current = self.newsList.currentItem()
@@ -105,12 +105,13 @@ def show_newspage(self) -> None:
else:
external_link = current.newsPost['external_link']
+ image_name = os.path.basename(current.newsPost["img_url"])
content = current.newsPost["excerpt"].strip().removeprefix("
").removesuffix("
")
html = self.HTML.format(
style=self.CSS,
title=current.newsPost['title'],
content=content,
- img_source=current.newsPost["img_url"],
+ img_source=image_name,
external_link=external_link,
)
self.newsTextBrowser.setHtml(html)
diff --git a/src/util/__init__.py b/src/util/__init__.py
index 91b3be673..6586f2e04 100644
--- a/src/util/__init__.py
+++ b/src/util/__init__.py
@@ -55,6 +55,9 @@
MOD_PREVIEW_DIR = os.path.join(CACHE_DIR, "mod_previews")
+# Cache for news images
+NEWS_CACHE_DIR = os.path.join(CACHE_DIR, "news")
+
# This contains cached data downloaded for FA extras
EXTRA_DIR = os.path.join(APPDATA_DIR, "extra")
@@ -159,7 +162,7 @@ def setPersonalDir():
for data_dir in [
APPDATA_DIR, PERSONAL_DIR, LUA_DIR, CACHE_DIR,
MAP_PREVIEW_SMALL_DIR, MAP_PREVIEW_LARGE_DIR, MOD_PREVIEW_DIR,
- THEME_DIR, REPLAY_DIR, LOG_DIR, EXTRA_DIR,
+ THEME_DIR, REPLAY_DIR, LOG_DIR, EXTRA_DIR, NEWS_CACHE_DIR,
]:
if not os.path.isdir(data_dir):
os.makedirs(data_dir)
From 587bff73228b1e8b831f6d3c0522d33f585ea457 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 18 Apr 2024 18:38:19 +0300
Subject: [PATCH 038/123] vaults/dialogs: Extract some repeatable code into a
function
---
src/util/__init__.py | 7 +++
src/vaults/dialogs.py | 108 +++++++++++++++++++++++-------------------
2 files changed, 67 insertions(+), 48 deletions(-)
diff --git a/src/util/__init__.py b/src/util/__init__.py
index 6586f2e04..6d758ce27 100644
--- a/src/util/__init__.py
+++ b/src/util/__init__.py
@@ -501,3 +501,10 @@ def strtodate(s):
def datetostr(d):
return d.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def capitalize(string: str) -> str:
+ """
+ Capitalize the first letter only, leave the rest as it is
+ """
+ return f"{string[0].upper()}{string[1:]}"
diff --git a/src/vaults/dialogs.py b/src/vaults/dialogs.py
index a73d49932..b892759d6 100644
--- a/src/vaults/dialogs.py
+++ b/src/vaults/dialogs.py
@@ -1,5 +1,7 @@
import logging
import os
+from enum import Enum
+from enum import auto
from typing import Callable
from PyQt6 import QtCore
@@ -8,16 +10,19 @@
from downloadManager import FileDownload
from downloadManager import ZipDownloadExtract
+from util import capitalize
logger = logging.getLogger(__name__)
+class VaultDownloadResult(Enum):
+ SUCCESS = auto()
+ CANCELED = auto()
+ DL_ERROR = auto()
+ UNKNOWN_ERROR = auto()
+
+
class VaultDownloadDialog(object):
- # Result codes
- SUCCESS = 0
- CANCELED = 1
- DL_ERROR = 2
- UNKNOWN_ERROR = 3
def __init__(
self,
@@ -102,24 +107,25 @@ def _cont(self, dler):
QtWidgets.QApplication.processEvents()
- def _finished(self, dler):
+ def _finished(self, dler: FileDownload | ZipDownloadExtract) -> None:
self.timer.stop()
self._progress.reset()
+ self._set_result(dler)
- if not dler.succeeded():
+ def _set_result(self, dler: FileDownload | ZipDownloadExtract) -> None:
+ if dler.failed():
if dler.canceled:
- self._result = self.CANCELED
+ self._result = VaultDownloadResult.CANCELED
return
-
elif dler.error:
- self._result = self.DL_ERROR
+ self._result = VaultDownloadResult.DL_ERROR
return
else:
logger.error('Unknown download error')
- self._result = self.UNKNOWN_ERROR
+ self._result = VaultDownloadResult.UNKNOWN_ERROR
return
- self._result = self.SUCCESS
+ self._result = VaultDownloadResult.SUCCESS
return
@@ -127,6 +133,25 @@ def _finished(self, dler):
_global_nam = QtNetwork.QNetworkAccessManager()
+def _download_asset(
+ dler: FileDownload | ZipDownloadExtract,
+ category: str,
+ silent: bool,
+ label: str = "",
+) -> VaultDownloadResult:
+ ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label, silent)
+ result = ddialog.run()
+
+ if result == VaultDownloadResult.CANCELED:
+ logger.warning(f"{category} Download canceled for: {dler.addr}")
+ if result in [
+ VaultDownloadResult.DL_ERROR,
+ VaultDownloadResult.UNKNOWN_ERROR,
+ ]:
+ logger.warning(f"Download failed. {dler.addr}")
+ return result
+
+
def downloadVaultAssetNoMsg(
url: str,
target_dir: str,
@@ -142,28 +167,24 @@ def downloadVaultAssetNoMsg(
logging things.
"""
global _global_nam
+
msg = None
- msg_title = ""
- msg_text = ""
- capit_cat = f"{category[0].upper()}{category[1:]}"
+ capit_cat = capitalize(category)
if os.path.exists(os.path.join(target_dir, name)):
proceed = exist_handler(target_dir, name)
if not proceed:
return False, msg
+ os.makedirs(target_dir, exist_ok=True)
dler = ZipDownloadExtract(target_dir, _global_nam, url, request_params)
- ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent)
- result = ddialog.run()
-
- if result == VaultDownloadDialog.CANCELED:
- logger.warning(f"{capit_cat} Download canceled for: {url}")
+ result = _download_asset(dler, capit_cat, silent, label or name)
if result in [
- VaultDownloadDialog.DL_ERROR,
- VaultDownloadDialog.UNKNOWN_ERROR,
+ VaultDownloadResult.DL_ERROR,
+ VaultDownloadResult.UNKNOWN_ERROR,
]:
- logger.warning(f"Vault download failed, {category} probably not in vault (or broken).")
+ logger.warning(f"Vault download failed, {category} is probably not in vault (or broken).")
msg_title = "{} not downloadable".format(capit_cat)
msg_text = (
f"This {category} was not found in the vault (or is broken)."
@@ -174,19 +195,20 @@ def downloadVaultAssetNoMsg(
def msg():
QtWidgets.QMessageBox.information(None, msg_title, msg_text)
- if result != VaultDownloadDialog.SUCCESS:
- return False, msg
+ return result == VaultDownloadResult.SUCCESS, msg
- return True, msg
-
-def downloadVaultAsset(url, target_dir, exist_handler, name, category, silent):
- ret, dialog = downloadVaultAssetNoMsg(
- url, target_dir, exist_handler, name, category, silent,
- )
+def downloadVaultAsset(
+ url: str,
+ target_dir: str,
+ exist_handler: Callable[[str, str], bool],
+ name: str,
+ category: str,
+ silent: bool,
+) -> bool:
+ ret, dialog = downloadVaultAssetNoMsg(url, target_dir, exist_handler, name, category, silent)
if dialog is not None:
dialog()
-
return ret
@@ -202,31 +224,21 @@ def download_file(
"""
Basically a copy of downloadVaultAssetNoMsg without zip
"""
-
- global _global_nam
- capit_cat = f"{category[0].upper()}{category[1:]}"
+ capit_cat = capitalize(category)
os.makedirs(target_dir, exist_ok=True)
-
target_path = os.path.join(target_dir, name)
+
dler = FileDownload(target_path, _global_nam, url, request_params)
- ddialog = VaultDownloadDialog(dler, f"Downloading {category}", label or name, silent)
- result = ddialog.run()
+ result = _download_asset(dler, capit_cat, silent, label or name)
- if result == VaultDownloadDialog.CANCELED:
- logger.warning(f"{capit_cat} Download canceled for: {url}")
if result in [
- VaultDownloadDialog.DL_ERROR,
- VaultDownloadDialog.UNKNOWN_ERROR,
+ VaultDownloadResult.DL_ERROR,
+ VaultDownloadResult.UNKNOWN_ERROR,
]:
- logger.warning(f"Download failed. {url}")
QtWidgets.QMessageBox.information(
None,
f"{capit_cat} not downloadable",
f"Failed to download {category} from
{url}",
)
-
- if result != VaultDownloadDialog.SUCCESS:
- return False
-
- return True
+ return result == VaultDownloadResult.SUCCESS
From 00b50d80746c5835dbc90d64cbae34115b13c59d Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 18 Apr 2024 18:38:49 +0300
Subject: [PATCH 039/123] Do not try to call NoneType
---
src/fa/maps.py | 21 +++++++++------------
1 file changed, 9 insertions(+), 12 deletions(-)
diff --git a/src/fa/maps.py b/src/fa/maps.py
index 74896e365..fc49c8d4f 100644
--- a/src/fa/maps.py
+++ b/src/fa/maps.py
@@ -439,25 +439,22 @@ def preview(mapname, pixmap=False):
logger.debug("Map Preview Exception", exc_info=sys.exc_info())
-def downloadMap(name, silent=False):
+def downloadMap(name: str, silent: bool = False) -> bool:
"""
Download a map from the vault with the given name
"""
link = name2link(name)
ret, msg = _doDownloadMap(name, link, silent)
- if not ret:
- if msg is None:
- name = name.replace(" ", "_")
- link = name2link(name)
- ret, msg = _doDownloadMap(name, link, silent)
- if not ret:
- msg()
- return ret
+ if not ret and msg is None:
+ name = name.replace(" ", "_")
+ link = name2link(name)
+ ret, msg = _doDownloadMap(name, link, silent)
+ if not ret and msg is not None:
+ msg()
+ return ret
- return True
-
-def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None]]:
+def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None] | None]:
logger.debug(f"Getting map from: {link}")
return downloadVaultAssetNoMsg(
url=link,
From 65d478bc238c271ea5ed6ce40c9f83f62abc423a Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 19 Apr 2024 19:13:59 +0300
Subject: [PATCH 040/123] Add PyQt6-NetworkAuth to requirements
forgotten to add earlier
---
requirements.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/requirements.txt b/requirements.txt
index 7f4deedcb..299d0bd20 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,6 +2,7 @@ cx_Freeze
ipaddress
pathlib
pyqt6
+pyqt6-networkauth
pytest
pytest-cov
pytest-mock
From 94dec788b4ac0aac8e6751ac45550bf76ae49e3b Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 20 Apr 2024 19:31:57 +0300
Subject: [PATCH 041/123] Remove duplicate logger.debug call
this information is already logged in base class
---
src/downloadManager/__init__.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index d5b0ad049..9c67ffd0a 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -174,7 +174,6 @@ def _about_to_finish(self) -> None:
def cleanup(self) -> None:
self._output.close()
if self.failed():
- logger.debug(f"Download failed for: {self._target_path}")
os.unlink(self._cache_path)
else:
logger.debug(f"Finished download from {self.addr}")
From 6df2d082691be03c62e256a74c700810d3c2aa21 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 21 Apr 2024 23:20:09 +0300
Subject: [PATCH 042/123] Create models for api object used in vaults
so information about them is stored nicely in some data containers
and not spread out everywhere in class attributes
also, create parsers for them
hopefully this will ease the process of managing api data in the future
---
src/api/models/AbstractEntity.py | 8 +
src/api/models/GeneratedMapParams.py | 47 +++++
src/api/models/Map.py | 18 ++
src/api/models/MapPoolAssignment.py | 12 ++
src/api/models/MapType.py | 17 ++
src/api/models/MapVersion.py | 43 +++++
src/api/models/Mod.py | 16 ++
src/api/models/ModType.py | 16 ++
src/api/models/ModVersion.py | 16 ++
src/api/models/Player.py | 9 +
src/api/models/ReviewsSummary.py | 11 ++
src/api/models/__init__.py | 0
src/api/parsers/GeneratedMapParamsParser.py | 18 ++
src/api/parsers/MapParser.py | 45 +++++
src/api/parsers/MapPoolAssignmentParser.py | 46 +++++
src/api/parsers/MapVersionParser.py | 24 +++
src/api/parsers/ModParser.py | 25 +++
src/api/parsers/ModVersionParser.py | 21 +++
src/api/parsers/PlayerParser.py | 17 ++
src/api/parsers/ReviewsSummaryParser.py | 22 +++
src/api/parsers/__init__.py | 0
src/api/vaults_api.py | 197 +++++++-------------
src/vaults/mapvault/mapitem.py | 121 ++++++------
src/vaults/mapvault/mapvault.py | 49 ++---
src/vaults/mapvault/mapwidget.py | 61 +++---
src/vaults/modvault/moditem.py | 107 +++++------
src/vaults/modvault/modvault.py | 20 +-
src/vaults/modvault/modwidget.py | 35 ++--
src/vaults/vault.py | 41 ++--
src/vaults/vaultitem.py | 145 +++++++-------
30 files changed, 796 insertions(+), 411 deletions(-)
create mode 100644 src/api/models/AbstractEntity.py
create mode 100644 src/api/models/GeneratedMapParams.py
create mode 100644 src/api/models/Map.py
create mode 100644 src/api/models/MapPoolAssignment.py
create mode 100644 src/api/models/MapType.py
create mode 100644 src/api/models/MapVersion.py
create mode 100644 src/api/models/Mod.py
create mode 100644 src/api/models/ModType.py
create mode 100644 src/api/models/ModVersion.py
create mode 100644 src/api/models/Player.py
create mode 100644 src/api/models/ReviewsSummary.py
create mode 100644 src/api/models/__init__.py
create mode 100644 src/api/parsers/GeneratedMapParamsParser.py
create mode 100644 src/api/parsers/MapParser.py
create mode 100644 src/api/parsers/MapPoolAssignmentParser.py
create mode 100644 src/api/parsers/MapVersionParser.py
create mode 100644 src/api/parsers/ModParser.py
create mode 100644 src/api/parsers/ModVersionParser.py
create mode 100644 src/api/parsers/PlayerParser.py
create mode 100644 src/api/parsers/ReviewsSummaryParser.py
create mode 100644 src/api/parsers/__init__.py
diff --git a/src/api/models/AbstractEntity.py b/src/api/models/AbstractEntity.py
new file mode 100644
index 000000000..56b8dbb72
--- /dev/null
+++ b/src/api/models/AbstractEntity.py
@@ -0,0 +1,8 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class AbstractEntity:
+ uid: str
+ create_time: str
+ update_time: str
diff --git a/src/api/models/GeneratedMapParams.py b/src/api/models/GeneratedMapParams.py
new file mode 100644
index 000000000..cc12451c3
--- /dev/null
+++ b/src/api/models/GeneratedMapParams.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from api.models.Map import Map
+from api.models.MapType import MapType
+from api.models.MapVersion import MapSize
+from api.models.MapVersion import MapVersion
+
+
+@dataclass
+class GeneratedMapParams:
+ name: str
+ spawns: int
+ size: int
+ gen_version: str
+
+ def to_map(self) -> Map:
+ uid = f"neroxis_map_generator_{self.gen_version}_{self.name}_{self.spawns}_{self.size}"
+ version = MapVersion(
+ uid=uid,
+ create_time="",
+ update_time="",
+ folder_name=uid,
+ games_played=0,
+ description="Randomly Generated Map",
+ max_players=self.spawns,
+ size=MapSize(self.size, self.size),
+ version=self.gen_version,
+ hidden=False,
+ ranked=True,
+ download_url="",
+ thumbnail_url_small="",
+ thumbnail_url_large="",
+ )
+ return Map(
+ uid=uid,
+ create_time="",
+ update_time="",
+ display_name=self.name,
+ author=None,
+ recommended=False,
+ reviews_summary=None,
+ games_played=0,
+ maptype=MapType.SKIRMISH,
+ version=version,
+ )
diff --git a/src/api/models/Map.py b/src/api/models/Map.py
new file mode 100644
index 000000000..de6c053f0
--- /dev/null
+++ b/src/api/models/Map.py
@@ -0,0 +1,18 @@
+from dataclasses import dataclass
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.MapType import MapType
+from api.models.MapVersion import MapVersion
+from api.models.Player import Player
+from api.models.ReviewsSummary import ReviewsSummary
+
+
+@dataclass
+class Map(AbstractEntity):
+ display_name: str
+ recommended: int
+ author: Player | None
+ reviews_summary: ReviewsSummary | None
+ games_played: int
+ maptype: MapType
+ version: MapVersion | None = None
diff --git a/src/api/models/MapPoolAssignment.py b/src/api/models/MapPoolAssignment.py
new file mode 100644
index 000000000..9291b1f10
--- /dev/null
+++ b/src/api/models/MapPoolAssignment.py
@@ -0,0 +1,12 @@
+from dataclasses import dataclass
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.GeneratedMapParams import GeneratedMapParams
+from api.models.MapVersion import MapVersion
+
+
+@dataclass
+class MapPoolAssignment(AbstractEntity):
+ map_params: GeneratedMapParams | None
+ map_version: MapVersion | None
+ weight: int
diff --git a/src/api/models/MapType.py b/src/api/models/MapType.py
new file mode 100644
index 000000000..2262ea7f4
--- /dev/null
+++ b/src/api/models/MapType.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from enum import Enum
+
+
+class MapType(Enum):
+ SKIRMISH = "skirmish"
+ COOP = "campaign_coop"
+ OTHER = ""
+
+ @staticmethod
+ def from_string(map_type: str) -> MapType:
+ for mtype in list(MapType):
+ if mtype.value == map_type:
+ return mtype
+ else:
+ return MapType.OTHER
diff --git a/src/api/models/MapVersion.py b/src/api/models/MapVersion.py
new file mode 100644
index 000000000..a5377870e
--- /dev/null
+++ b/src/api/models/MapVersion.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from api.models.AbstractEntity import AbstractEntity
+
+
+@dataclass
+class MapSize:
+ height_px: int
+ width_px: int
+
+ @property
+ def width_km(self) -> int:
+ return self.width_px // 51.2
+
+ @property
+ def height_km(self) -> int:
+ return self.height_px // 51.2
+
+ def __lt__(self, other: MapSize) -> bool:
+ return self.height_px * self.width_px < other.height_px * other.width_px
+
+ def __ge__(self, other: MapSize) -> bool:
+ return not self.__lt__(other)
+
+ def __str__(self) -> str:
+ return f"{self.width_km} x {self.height_km} km"
+
+
+@dataclass
+class MapVersion(AbstractEntity):
+ folder_name: str
+ games_played: int
+ description: str
+ max_players: int
+ size: MapSize
+ version: int
+ hidden: bool
+ ranked: bool
+ download_url: str
+ thumbnail_url_small: str
+ thumbnail_url_large: str
diff --git a/src/api/models/Mod.py b/src/api/models/Mod.py
new file mode 100644
index 000000000..97708721b
--- /dev/null
+++ b/src/api/models/Mod.py
@@ -0,0 +1,16 @@
+from dataclasses import dataclass
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.ModVersion import ModVersion
+from api.models.Player import Player
+from api.models.ReviewsSummary import ReviewsSummary
+
+
+@dataclass
+class Mod(AbstractEntity):
+ display_name: str
+ recommended: bool
+ author: str
+ reviews_summary: ReviewsSummary | None
+ uploader: Player | None
+ version: ModVersion
diff --git a/src/api/models/ModType.py b/src/api/models/ModType.py
new file mode 100644
index 000000000..cbbe35573
--- /dev/null
+++ b/src/api/models/ModType.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from enum import Enum
+
+
+class ModType(Enum):
+ UI = "modType.ui"
+ SIM = "modType.sim"
+ OTHER = ""
+
+ @staticmethod
+ def from_string(string: str) -> ModType:
+ for modtype in list(ModType):
+ if modtype.value == string:
+ return modtype
+ return ModType.OTHER
diff --git a/src/api/models/ModVersion.py b/src/api/models/ModVersion.py
new file mode 100644
index 000000000..b1e6bf29e
--- /dev/null
+++ b/src/api/models/ModVersion.py
@@ -0,0 +1,16 @@
+from dataclasses import dataclass
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.ModType import ModType
+
+
+@dataclass
+class ModVersion(AbstractEntity):
+ description: str
+ download_url: str
+ filename: str
+ hidden: bool
+ ranked: bool
+ thumbnail_url: str
+ modtype: ModType
+ version: str
diff --git a/src/api/models/Player.py b/src/api/models/Player.py
new file mode 100644
index 000000000..509d9a1ca
--- /dev/null
+++ b/src/api/models/Player.py
@@ -0,0 +1,9 @@
+from dataclasses import dataclass
+
+from api.models.AbstractEntity import AbstractEntity
+
+
+@dataclass
+class Player(AbstractEntity):
+ login: str
+ user_agent: str
diff --git a/src/api/models/ReviewsSummary.py b/src/api/models/ReviewsSummary.py
new file mode 100644
index 000000000..ee305fb4b
--- /dev/null
+++ b/src/api/models/ReviewsSummary.py
@@ -0,0 +1,11 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class ReviewsSummary:
+ positive: float
+ negative: float
+ score: float
+ average_score: float
+ num_reviews: int
+ lower_bound: float
diff --git a/src/api/models/__init__.py b/src/api/models/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/api/parsers/GeneratedMapParamsParser.py b/src/api/parsers/GeneratedMapParamsParser.py
new file mode 100644
index 000000000..7e0c5fa41
--- /dev/null
+++ b/src/api/parsers/GeneratedMapParamsParser.py
@@ -0,0 +1,18 @@
+from api.models.Map import Map
+from src.api.models.GeneratedMapParams import GeneratedMapParams
+
+
+class GeneratedMapParamsParser:
+
+ @staticmethod
+ def parse(params_info: dict) -> GeneratedMapParams:
+ return GeneratedMapParams(
+ name=params_info["type"],
+ spawns=params_info["spawns"],
+ size=params_info["size"],
+ gen_version=params_info["version"],
+ )
+
+ @staticmethod
+ def parse_to_map(params_info: dict) -> Map:
+ return GeneratedMapParamsParser.parse(params_info).to_map()
diff --git a/src/api/parsers/MapParser.py b/src/api/parsers/MapParser.py
new file mode 100644
index 000000000..0b50b5cc7
--- /dev/null
+++ b/src/api/parsers/MapParser.py
@@ -0,0 +1,45 @@
+from api.models.Map import Map
+from api.models.MapType import MapType
+from api.parsers.MapVersionParser import MapVersionParser
+from api.parsers.PlayerParser import PlayerParser
+from api.parsers.ReviewsSummaryParser import ReviewsSummaryParser
+
+
+class MapParser:
+
+ @staticmethod
+ def parse(api_result: dict) -> Map:
+ return Map(
+ uid=api_result["id"],
+ create_time=api_result["createTime"],
+ update_time=api_result["updateTime"],
+ display_name=api_result["displayName"],
+ recommended=api_result["recommended"],
+ author=PlayerParser.parse(api_result["author"]),
+ reviews_summary=ReviewsSummaryParser.parse(api_result["reviewsSummary"]),
+ games_played=api_result["gamesPlayed"],
+ maptype=MapType.from_string(api_result["mapType"]),
+ )
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[Map]:
+ return [
+ MapParser.parse_version(info, info["latestVersion"])
+ for info in api_result
+ ]
+
+ @staticmethod
+ def parse_version(map_info: dict, version_info: dict) -> Map:
+ version = MapVersionParser.parse(version_info)
+ return Map(
+ uid=map_info["id"],
+ create_time=map_info["createTime"],
+ update_time=map_info["updateTime"],
+ display_name=map_info["displayName"],
+ recommended=map_info["recommended"],
+ author=PlayerParser.parse(map_info["author"]),
+ reviews_summary=ReviewsSummaryParser.parse(map_info["reviewsSummary"]),
+ games_played=map_info["gamesPlayed"],
+ maptype=MapType.from_string(map_info["mapType"]),
+ version=version,
+ )
diff --git a/src/api/parsers/MapPoolAssignmentParser.py b/src/api/parsers/MapPoolAssignmentParser.py
new file mode 100644
index 000000000..4cac1d90a
--- /dev/null
+++ b/src/api/parsers/MapPoolAssignmentParser.py
@@ -0,0 +1,46 @@
+from api.models.Map import Map
+from api.models.MapPoolAssignment import MapPoolAssignment
+from api.parsers.GeneratedMapParamsParser import GeneratedMapParamsParser
+from api.parsers.MapParser import MapParser
+from api.parsers.MapVersionParser import MapVersionParser
+
+
+class MapPoolAssignmentParser:
+
+ @staticmethod
+ def parse(assignment_info: dict) -> MapPoolAssignment:
+ if assignment_info["mapVersion"]:
+ map_version = MapVersionParser.parse(assignment_info["mapVersion"])
+ map_params = None
+ elif assignment_info["mapParams"]:
+ map_version = None
+ map_params = GeneratedMapParamsParser.parse(assignment_info["mapParams"])
+
+ return MapPoolAssignment(
+ uid=assignment_info["id"],
+ create_time=assignment_info["createTime"],
+ update_time=assignment_info["updateTime"],
+ map_version=map_version,
+ map_params=map_params,
+ weight=assignment_info["weight"],
+ )
+
+ @staticmethod
+ def parse_many(assignment_info: list[dict]) -> list[MapPoolAssignment]:
+ return [MapPoolAssignmentParser.parse(info) for info in assignment_info]
+
+ @staticmethod
+ def parse_to_map(assignment_info: dict) -> Map:
+ pool = MapPoolAssignmentParser.parse(assignment_info)
+ if pool.map_params is not None:
+ return pool.map_params.to_map()
+ if pool.map_version is not None:
+ return MapParser.parse_version(
+ assignment_info["mapVersion"]["map"],
+ assignment_info["mapVersion"],
+ )
+ raise ValueError("MapPoolAssignment info does not contain mapVersion or mapParams")
+
+ @staticmethod
+ def parse_many_to_maps(assignment_info: list[dict]) -> list[Map]:
+ return [MapPoolAssignmentParser.parse_to_map(info) for info in assignment_info]
diff --git a/src/api/parsers/MapVersionParser.py b/src/api/parsers/MapVersionParser.py
new file mode 100644
index 000000000..a9d07d865
--- /dev/null
+++ b/src/api/parsers/MapVersionParser.py
@@ -0,0 +1,24 @@
+from api.models.MapVersion import MapSize
+from api.models.MapVersion import MapVersion
+
+
+class MapVersionParser:
+
+ @staticmethod
+ def parse(version_info: dict) -> MapVersion:
+ return MapVersion(
+ uid=version_info["id"],
+ create_time=version_info["createTime"],
+ update_time=version_info["updateTime"],
+ folder_name=version_info["folderName"],
+ games_played=version_info["gamesPlayed"],
+ description=version_info["description"],
+ max_players=version_info["maxPlayers"],
+ size=MapSize(version_info["height"], version_info["width"]),
+ version=version_info["version"],
+ hidden=version_info["hidden"],
+ ranked=version_info["ranked"],
+ download_url=version_info["downloadUrl"],
+ thumbnail_url_small=version_info["thumbnailUrlSmall"],
+ thumbnail_url_large=version_info["thumbnailUrlLarge"],
+ )
diff --git a/src/api/parsers/ModParser.py b/src/api/parsers/ModParser.py
new file mode 100644
index 000000000..ba8930eda
--- /dev/null
+++ b/src/api/parsers/ModParser.py
@@ -0,0 +1,25 @@
+from api.models.Mod import Mod
+from api.parsers.ModVersionParser import ModVersionParser
+from api.parsers.PlayerParser import PlayerParser
+from api.parsers.ReviewsSummaryParser import ReviewsSummaryParser
+
+
+class ModParser:
+
+ @staticmethod
+ def parse(mod_info: dict) -> Mod:
+ return Mod(
+ uid=mod_info["id"],
+ create_time=mod_info["createTime"],
+ update_time=mod_info["updateTime"],
+ display_name=mod_info["displayName"],
+ recommended=mod_info["recommended"],
+ author=mod_info["author"],
+ reviews_summary=ReviewsSummaryParser.parse(mod_info["reviewsSummary"]),
+ uploader=PlayerParser.parse(mod_info["uploader"]),
+ version=ModVersionParser.parse(mod_info["latestVersion"]),
+ )
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[Mod]:
+ return [ModParser.parse(mod_info) for mod_info in api_result]
diff --git a/src/api/parsers/ModVersionParser.py b/src/api/parsers/ModVersionParser.py
new file mode 100644
index 000000000..5dbde04e2
--- /dev/null
+++ b/src/api/parsers/ModVersionParser.py
@@ -0,0 +1,21 @@
+from api.models.ModVersion import ModType
+from api.models.ModVersion import ModVersion
+
+
+class ModVersionParser:
+
+ @staticmethod
+ def parse(api_result: dict) -> ModVersion:
+ return ModVersion(
+ uid=api_result["uid"],
+ create_time=api_result["createTime"],
+ update_time=api_result["updateTime"],
+ description=api_result["description"],
+ download_url=api_result["downloadUrl"],
+ filename=api_result["filename"],
+ hidden=api_result["hidden"],
+ ranked=api_result["ranked"],
+ thumbnail_url=api_result["thumbnailUrl"],
+ modtype=ModType.from_string(api_result["type"]),
+ version=api_result["version"],
+ )
diff --git a/src/api/parsers/PlayerParser.py b/src/api/parsers/PlayerParser.py
new file mode 100644
index 000000000..3f32588d1
--- /dev/null
+++ b/src/api/parsers/PlayerParser.py
@@ -0,0 +1,17 @@
+from api.models.Player import Player
+
+
+class PlayerParser:
+
+ @staticmethod
+ def parse(player_info: dict) -> Player | None:
+ if not player_info:
+ return None
+
+ return Player(
+ uid=player_info["id"],
+ create_time=player_info["createTime"],
+ update_time=player_info["updateTime"],
+ login=player_info["login"],
+ user_agent=player_info["userAgent"],
+ )
diff --git a/src/api/parsers/ReviewsSummaryParser.py b/src/api/parsers/ReviewsSummaryParser.py
new file mode 100644
index 000000000..797c89864
--- /dev/null
+++ b/src/api/parsers/ReviewsSummaryParser.py
@@ -0,0 +1,22 @@
+from api.models.ReviewsSummary import ReviewsSummary
+
+
+def _avoid_none(value: float | int | None) -> float | int:
+ return value or 0
+
+
+class ReviewsSummaryParser:
+
+ @staticmethod
+ def parse(reviews_info: dict) -> ReviewsSummary | None:
+ if not reviews_info:
+ return None
+
+ return ReviewsSummary(
+ positive=_avoid_none(reviews_info["positive"]),
+ negative=_avoid_none(reviews_info["negative"]),
+ score=_avoid_none(reviews_info["score"]),
+ average_score=_avoid_none(reviews_info["averageScore"]),
+ num_reviews=_avoid_none(reviews_info["reviews"]),
+ lower_bound=_avoid_none(reviews_info["lowerBound"]),
+ )
diff --git a/src/api/parsers/__init__.py b/src/api/parsers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py
index ab8af345d..ba69edc93 100644
--- a/src/api/vaults_api.py
+++ b/src/api/vaults_api.py
@@ -1,6 +1,9 @@
import logging
from api.ApiAccessors import DataApiAccessor
+from api.parsers.MapParser import MapParser
+from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser
+from api.parsers.ModParser import ModParser
from client.connection import Dispatcher
logger = logging.getLogger(__name__)
@@ -13,82 +16,61 @@ def __init__(self, dispatch: Dispatcher) -> None:
def requestData(self, params: dict | None = None) -> None:
params = params or {}
- self.get_by_query(params, self.handleData)
-
- def handleData(self, message: dict) -> None:
- preparedData = dict(
- command='modvault_info',
- values=[],
- meta=message['meta'],
- )
- for mod in message['data']:
- preparedMod = dict(
- name=mod['displayName'],
- uid=mod['latestVersion']['uid'],
- link=mod['latestVersion']['downloadUrl'],
- description=mod['latestVersion']['description'],
- author=mod['author'],
- version=mod['latestVersion']['version'],
- ui=mod['latestVersion']['type'] == 'UI',
- thumbnail=mod['latestVersion']['thumbnailUrl'],
- date=mod['latestVersion']['updateTime'],
- rating=0,
- reviews=0,
- )
- if len(mod['reviewsSummary']) > 0:
- score = mod['reviewsSummary']['score']
- reviews = mod['reviewsSummary']['reviews']
- if reviews > 0:
- preparedMod['rating'] = float(
- '{:1.2f}'.format(score / reviews),
- )
- preparedMod['reviews'] = reviews
- preparedData["values"].append(preparedMod)
- self.dispatch.dispatch(preparedData)
+ self._add_default_include(params)
+ self._extend_filters(params)
+ self.get_by_query(params, self.handle_data)
+
+ def _add_default_include(self, params: dict) -> dict:
+ params["include"] = ",".join(("latestVersion", "reviewsSummary", "uploader"))
+ return params
+
+ def _extend_filters(self, params: dict) -> dict:
+ additional_filter = "latestVersion.hidden=='false'"
+ if cur_filters := params.get("filter", ""):
+ params["filter"] = f"{cur_filters};{additional_filter}"
+ else:
+ params["filter"] = additional_filter
+ return params
+
+ def handle_data(self, message: dict) -> None:
+ parsed_data = {
+ "command": "modvault_info",
+ "values": ModParser.parse_many(message["data"]),
+ "meta": message["meta"],
+ }
+ self.dispatch.dispatch(parsed_data)
class MapApiConnector(DataApiAccessor):
def __init__(self, dispatch: Dispatcher) -> None:
- super().__init__('/data/map')
+ super().__init__("/data/map")
self.dispatch = dispatch
def requestData(self, params: dict | None = None) -> None:
params = params or {}
- self.get_by_query(params, self.handleData)
-
- def handleData(self, message: dict) -> None:
- preparedData = dict(
- command='mapvault_info',
- values=[],
- meta=message['meta'],
- )
- for _map in message['data']:
- preparedMap = dict(
- name=_map['displayName'],
- folderName=_map['latestVersion']['folderName'],
- link=_map['latestVersion']['downloadUrl'],
- description=_map['latestVersion']['description'],
- maxPlayers=_map['latestVersion']['maxPlayers'],
- version=_map['latestVersion']['version'],
- ranked=_map['latestVersion']['ranked'],
- thumbnailSmall=_map['latestVersion']['thumbnailUrlSmall'],
- thumbnailLarge=_map['latestVersion']['thumbnailUrlLarge'],
- date=_map['latestVersion']['updateTime'],
- height=_map['latestVersion']['height'],
- width=_map['latestVersion']['width'],
- rating=0,
- reviews=0,
- )
- if len(_map['reviewsSummary']) > 0:
- score = _map['reviewsSummary']['score']
- reviews = _map['reviewsSummary']['reviews']
- if reviews > 0:
- preparedMap['rating'] = float(
- '{:1.2f}'.format(score / reviews),
- )
- preparedMap['reviews'] = reviews
- preparedData['values'].append(preparedMap)
- self.dispatch.dispatch(preparedData)
+ self._add_default_include(params)
+ self._extend_filters(params)
+ self.get_by_query(params, self.parse_data)
+
+ def _extend_filters(self, params: dict) -> dict:
+ additional_filter = "latestVersion.hidden=='false'"
+ if cur_filters := params.get("filter", ""):
+ params["filter"] = f"{cur_filters};{additional_filter}"
+ else:
+ params["filter"] = additional_filter
+ return params
+
+ def _add_default_include(self, params: dict) -> dict:
+ params["include"] = ",".join(("latestVersion", "reviewsSummary", "author"))
+ return params
+
+ def parse_data(self, message: dict) -> None:
+ prepared_data = {
+ "command": "mapvault_info",
+ "values": MapParser.parse_many(message["data"]),
+ "meta": message["meta"],
+ }
+ self.dispatch.dispatch(prepared_data)
class MapPoolApiConnector(DataApiAccessor):
@@ -98,64 +80,21 @@ def __init__(self, dispatch: Dispatcher) -> None:
def requestData(self, params: dict | None) -> None:
params = params or {}
- self.get_by_query(params, self.handleData)
-
- def handleData(self, message: dict) -> None:
- preparedData = dict(
- command='mapvault_info',
- values=[],
- meta=message['meta'],
- )
- for data in message['data']:
- if len(data['mapVersion']) > 0:
- _map = data['mapVersion']
- preparedMap = dict(
- name=_map['map']['displayName'],
- folderName=_map['folderName'],
- link=_map['downloadUrl'],
- description=_map['description'],
- maxPlayers=_map['maxPlayers'],
- version=_map['version'],
- ranked=_map['ranked'],
- thumbnailSmall=_map['thumbnailUrlSmall'],
- thumbnailLarge=_map['thumbnailUrlLarge'],
- date=_map['updateTime'],
- height=_map['height'],
- width=_map['width'],
- rating=0,
- reviews=0,
- )
- if len(_map['reviewsSummary']) > 0:
- score = _map['reviewsSummary']['score']
- reviews = _map['reviewsSummary']['reviews']
- if reviews > 0:
- preparedMap['rating'] = float(
- '{:1.2f}'.format(score / reviews),
- )
- preparedMap['reviews'] = reviews
- elif data['mapParams'] is not None:
- _map = data['mapParams']
- preparedMap = dict(
- name="Neroxis Map Generator",
- folderName=(
- 'neroxis_map_generator_{}_size={}km_spawns={}'.format(
- _map['version'],
- int(_map['size'] / 51.2),
- _map['spawns'],
- )
- ),
- link='',
- description='Randomly generated map',
- maxPlayers=_map['spawns'],
- version='1',
- ranked=True,
- thumbnailSmall='',
- thumbnailLarge='',
- date='',
- height=_map['size'],
- width=_map['size'],
- rating=0,
- reviews=0,
- )
- preparedData['values'].append(preparedMap)
- self.dispatch.dispatch(preparedData)
+ self.get_by_query(self._add_default_include(params), self.parse_data)
+
+ def _add_default_include(self, params: dict) -> dict:
+ params["include"] = ",".join((
+ "mapVersion",
+ "mapVersion.map",
+ "mapVersion.map.author",
+ "mapVersion.map.reviewsSummary",
+ ))
+ return params
+
+ def parse_data(self, message: dict) -> None:
+ prepared_data = {
+ "command": "mapvault_info",
+ "values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]),
+ "meta": message["meta"],
+ }
+ self.dispatch.dispatch(prepared_data)
diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py
index d57c92003..4e4aedbb9 100644
--- a/src/vaults/mapvault/mapitem.py
+++ b/src/vaults/mapvault/mapitem.py
@@ -1,96 +1,89 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
import util
+from api.models.Map import Map
from fa import maps
from mapGenerator import mapgenUtils
-from vaults.vaultitem import VaultItem
+from vaults.vaultitem import VaultListItem
+if TYPE_CHECKING:
+ from vaults.mapvault.mapvault import MapVault
-class MapItem(VaultItem):
- def __init__(self, parent, folderName, *args, **kwargs):
- VaultItem.__init__(self, parent, *args, **kwargs)
- self.formatterItem = str(
- util.THEME.readfile("vaults/mapvault/mapinfo.qthtml"),
- )
-
- self.height = 0
- self.width = 0
- self.maxPlayers = 0
- self.thumbnail = None
- self.unranked = False
- self.folderName = folderName
- self.thumbstrSmall = ""
- self.thumbnailLarge = ""
+class MapListItem(VaultListItem):
+ def __init__(self, parent: MapVault, item_info: Map, *args, **kwargs) -> None:
+ super().__init__(parent, item_info, *args, **kwargs)
+ self.html = str(util.THEME.readfile("vaults/mapvault/mapinfo.qthtml"))
self._preview_dler.set_target_dir(util.MAP_PREVIEW_SMALL_DIR)
+ self.update()
- def update(self, item_dict):
- self.name = maps.getDisplayName(item_dict["folderName"])
- self.description = item_dict["description"]
- self.version = item_dict["version"]
- self.rating = item_dict["rating"]
- self.reviews = item_dict["reviews"]
-
- self.maxPlayers = item_dict["maxPlayers"]
- self.height = int(item_dict["height"] / 51.2)
- self.width = int(item_dict["width"] / 51.2)
-
- self.folderName = item_dict["folderName"]
- self.date = item_dict['date'][:10]
- self.unranked = not item_dict["ranked"]
- self.link = item_dict["link"]
- self.thumbstrSmall = item_dict["thumbnailSmall"]
- self.thumbnailLarge = item_dict["thumbnailLarge"]
-
- self.thumbnail = maps.preview(self.folderName)
- if self.thumbnail:
- self.setIcon(self.thumbnail)
+ def update(self) -> None:
+ if thumbnail := maps.preview(self.item_version.folder_name):
+ self.setIcon(thumbnail)
else:
- if self.thumbstrSmall == "":
- if mapgenUtils.isGeneratedMap(self.folderName):
+ if self.item_version.thumbnail_url_small == "":
+ if mapgenUtils.isGeneratedMap(self.item_version.folder_name):
self.setItemIcon("games/generated_map.png")
else:
self.setItemIcon("games/unknown_map.png")
else:
self._preview_dler.download(
- f"{self.folderName}.png",
+ f"{self.item_version.folder_name}.png",
self._item_dl_request,
- self.thumbstrSmall,
+ self.item_version.thumbnail_url_small,
)
- VaultItem.update(self)
+ VaultListItem.update(self)
- def shouldBeVisible(self):
+ def should_be_visible(self) -> bool:
p = self.parent
if p.showType == "all":
return True
elif p.showType == "unranked":
- return self.unranked
+ return not self.item_version.ranked
elif p.showType == "ranked":
- return not self.unranked
+ return self.item_version.ranked
elif p.showType == "installed":
- return maps.isMapAvailable(self.folderName)
+ return maps.isMapAvailable(self.item_version.folder_name)
else:
return True
- def updateVisibility(self):
- if self.unranked:
- self.itemType_ = "Unranked map"
- if maps.isMapAvailable(self.folderName):
- self.color = "green"
+ def update_visibility(self):
+ if maps.isMapAvailable(self.item_version.folder_name):
+ color = "green"
else:
- self.color = "white"
+ color = "white"
+
+ maptype = "" if self.item_version.ranked else "Unranked map"
+ if self.item_info.reviews_summary is None:
+ score = reviews = "-"
+ else:
+ score = round(self.item_info.reviews_summary.average_score, 1)
+ reviews = self.item_info.reviews_summary.num_reviews
self.setText(
- self.formatterItem.format(
- color=self.color,
- version=self.version,
- title=self.name,
- description=self.trimmedDescription,
- rating=self.rating,
- reviews=self.reviews,
- date=self.date,
- modtype=self.itemType_,
- height=self.height,
- width=self.width,
+ self.html.format(
+ color=color,
+ version=self.item_version.version,
+ title=self.item_info.display_name,
+ description=self.item_version.description,
+ rating=score,
+ reviews=reviews,
+ date=self.item_version.create_time,
+ modtype=maptype,
+ height=self.item_version.size.height_km,
+ width=self.item_version.size.width_km,
),
)
+ super().update_visibility()
+
+ def __lt__(self, other: MapListItem) -> bool:
+ if self.parent.sortType == "size":
+ return self._lt_size(other)
+ return super().__lt__(other)
- VaultItem.updateVisibility(self)
+ def _lt_size(self, other: MapListItem) -> bool:
+ if self.item_version.size == other.item_version.size:
+ return self._lt_alphabetical(other)
+ return self.item_version.size < other.item_version.size
diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py
index aed416f2b..ba56e90da 100644
--- a/src/vaults/mapvault/mapvault.py
+++ b/src/vaults/mapvault/mapvault.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import logging
import os
import shutil
@@ -5,25 +7,30 @@
import urllib.parse
import urllib.request
from stat import S_IWRITE
+from typing import TYPE_CHECKING
from PyQt6 import QtCore
from PyQt6 import QtWidgets
import util
+from api.models.Map import Map
from api.vaults_api import MapApiConnector
from api.vaults_api import MapPoolApiConnector
from fa import maps
from vaults import luaparser
-from vaults.mapvault.mapitem import MapItem
+from vaults.mapvault.mapitem import MapListItem
from vaults.vault import Vault
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
+
from .mapwidget import MapWidget
logger = logging.getLogger(__name__)
class MapVault(Vault):
- def __init__(self, client, *args, **kwargs):
+ def __init__(self, client: ClientWindow, *args, **kwargs) -> None:
QtCore.QObject.__init__(self, *args, **kwargs)
Vault.__init__(self, client, *args, **kwargs)
@@ -52,12 +59,12 @@ def __init__(self, client, *args, **kwargs):
self.UIButton.hide()
self.uploadButton.hide()
- def createItem(self, item_key: str) -> MapItem:
- return MapItem(self, item_key)
+ def create_item(self, item: Map) -> MapListItem:
+ return MapListItem(self, item)
@QtCore.pyqtSlot(dict)
def mapInfo(self, message: dict) -> None:
- super().itemsInfo(message)
+ super().items_info(message)
@QtCore.pyqtSlot(int)
def sortChanged(self, index):
@@ -69,7 +76,7 @@ def sortChanged(self, index):
self.sortType = "rating"
elif index == 3:
self.sortType = "size"
- self.updateVisibilities()
+ self.update_visibilities()
@QtCore.pyqtSlot(int)
def showChanged(self, index):
@@ -81,28 +88,24 @@ def showChanged(self, index):
self.showType = "ranked"
elif index == 3:
self.showType = "installed"
- self.updateVisibilities()
+ self.update_visibilities()
@QtCore.pyqtSlot(QtWidgets.QListWidgetItem)
- def itemClicked(self, item):
+ def itemClicked(self, item: MapListItem) -> None:
widget = MapWidget(self, item)
widget.exec()
def requestMapPool(self, queueName, minRating):
self.apiConnector = self.mapPoolApiConnector
- self.searchQuery = dict(
- include=(
- 'mapVersion,mapVersion.map.latestVersion,'
- 'mapVersion.reviewsSummary'
- ),
- filter=(
- 'mapPool.matchmakerQueueMapPool.matchmakerQueue.'
- 'technicalName=="{}";'
- '(mapPool.matchmakerQueueMapPool.minRating=le="{}",'
- 'mapPool.matchmakerQueueMapPool.minRating=isnull="true")'
- .format(queueName, minRating)
- ),
- )
+ self.searchQuery = {
+ "filter": ";".join((
+ f"mapPool.matchmakerQueueMapPool.matchmakerQueue.technicalName=={queueName}",
+ (
+ f"(mapPool.matchmakerQueueMapPool.minRating=le={minRating!r},"
+ "mapPool.matchmakerQueueMapPool.minRating=isnull='true')"
+ ),
+ )),
+ }
self.goToPage(1)
self.apiConnector = self.mapApiConnector
@@ -232,7 +235,7 @@ def downloadMap(self, link):
if avail_name is None:
maps.downloadMap(name)
self.installed_maps.append(name)
- self.updateVisibilities()
+ self.update_visibilities()
else:
show = QtWidgets.QMessageBox.question(
self.client,
@@ -253,4 +256,4 @@ def removeMap(self, folder):
if os.path.exists(maps_folder):
shutil.rmtree(maps_folder)
self.installed_maps.remove(folder)
- self.updateVisibilities()
+ self.update_visibilities()
diff --git a/src/vaults/mapvault/mapwidget.py b/src/vaults/mapvault/mapwidget.py
index a73c86d01..a9178b2f5 100644
--- a/src/vaults/mapvault/mapwidget.py
+++ b/src/vaults/mapvault/mapwidget.py
@@ -1,5 +1,7 @@
+from __future__ import annotations
import os
+from typing import TYPE_CHECKING
from PyQt6 import QtCore
from PyQt6 import QtGui
@@ -10,6 +12,10 @@
from downloadManager import MapLargePreviewDownloader
from fa import maps
from mapGenerator import mapgenUtils
+from vaults.mapvault.mapitem import MapListItem
+
+if TYPE_CHECKING:
+ from vaults.mapvault.mapvault import MapVault
FormClass, BaseClass = util.THEME.loadUiType("vaults/mapvault/map.ui")
@@ -17,7 +23,7 @@
class MapWidget(FormClass, BaseClass):
ICONSIZE = QtCore.QSize(256, 256)
- def __init__(self, parent, _map, *args, **kwargs):
+ def __init__(self, parent: MapVault, list_item: MapListItem, *args, **kwargs) -> None:
BaseClass.__init__(self, *args, **kwargs)
self.setupUi(self)
@@ -26,32 +32,32 @@ def __init__(self, parent, _map, *args, **kwargs):
util.THEME.stylesheets_reloaded.connect(self.load_stylesheet)
self.load_stylesheet()
- self.setWindowTitle(_map.name)
-
- self._map = _map
+ self._map = list_item.item_info
+ self.map_version = list_item.item_version
+ self.setWindowTitle(self._map.display_name)
- self.Title.setText(_map.name)
- self.Description.setText(_map.description)
+ self.Title.setText(self._map.display_name)
+ self.Description.setText(self.map_version.description)
maptext = ""
- if _map.unranked:
+ if not self.map_version.ranked:
maptext = "Unranked map\n"
- self.Info.setText("{} Uploaded {}".format(maptext, str(_map.date)))
- self.Players.setText("Maximum players: {}".format(_map.maxPlayers))
- self.Size.setText("Size: {} x {} km".format(_map.width, _map.height))
+ self.Info.setText(f"{maptext} Uploaded {self.map_version.create_time}")
+ self.Players.setText(f"Maximum players: {self.map_version.max_players}")
+ self.Size.setText(f"Size: {self.map_version.size}")
self._preview_dler = MapLargePreviewDownloader(util.MAP_PREVIEW_LARGE_DIR)
self._map_dl_request = DownloadRequest()
self._map_dl_request.done.connect(self._on_preview_downloaded)
# Ensure that pixmap is set
self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png"))
- self.updatePreview()
+ self.update_preview()
- if maps.isBase(self._map.folderName):
+ if maps.isBase(self.map_version.folder_name):
self.DownloadButton.setText("This is a base map")
self.DownloadButton.setEnabled(False)
- elif mapgenUtils.isGeneratedMap(self._map.folderName):
+ elif mapgenUtils.isGeneratedMap(self.map_version.folder_name):
self.DownloadButton.setEnabled(False)
- elif maps.isMapAvailable(self._map.folderName):
+ elif maps.isMapAvailable(self.map_version.folder_name):
self.DownloadButton.setText("Remove Map")
self.DownloadButton.clicked.connect(self.download)
@@ -60,9 +66,9 @@ def load_stylesheet(self):
self.setStyleSheet(util.THEME.readstylesheet("client/client.css"))
@QtCore.pyqtSlot()
- def download(self):
- if not maps.isMapAvailable(self._map.folderName):
- self.parent.downloadMap(self._map.link)
+ def download(self) -> None:
+ if not maps.isMapAvailable(self.map_version.folder_name):
+ self.parent.downloadMap(self.map_version.download_url)
self.done(1)
else:
show = QtWidgets.QMessageBox.question(
@@ -73,24 +79,23 @@ def download(self):
QtWidgets.QMessageBox.StandardButton.No,
)
if show == QtWidgets.QMessageBox.StandardButton.Yes:
- self.parent.removeMap(self._map.folderName)
+ self.parent.removeMap(self.map_version.folder_name)
self.done(1)
- def updatePreview(self):
- imgPath = os.path.join(
- util.MAP_PREVIEW_LARGE_DIR, self._map.folderName + ".png",
- )
+ def update_preview(self) -> None:
+ imgPath = os.path.join(util.MAP_PREVIEW_LARGE_DIR, f"{self.map_version.folder_name}.png")
if os.path.isfile(imgPath):
pix = QtGui.QPixmap(imgPath).scaled(self.ICONSIZE)
self.Picture.setPixmap(pix)
- elif mapgenUtils.isGeneratedMap(self._map.folderName):
- self.Picture.setPixmap(
- util.THEME.pixmap("games/generated_map.png"),
- )
+ elif mapgenUtils.isGeneratedMap(self.map_version.folder_name):
+ self.Picture.setPixmap(util.THEME.pixmap("games/generated_map.png"))
else:
- self._preview_dler.download_preview(self._map.folderName, self._map_dl_request)
+ self._preview_dler.download_preview(
+ self.map_version.folder_name,
+ self._map_dl_request,
+ )
- def _on_preview_downloaded(self, mapname, result):
+ def _on_preview_downloaded(self, mapname, result: tuple[str, bool]) -> None:
filename, themed = result
pixmap = util.THEME.pixmap(filename, themed)
if themed:
diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py
index e3e505a0b..a9fefc767 100644
--- a/src/vaults/modvault/moditem.py
+++ b/src/vaults/modvault/moditem.py
@@ -1,86 +1,81 @@
+from __future__ import annotations
+
import os
import urllib
+from typing import TYPE_CHECKING
import util
+from api.models.Mod import Mod
+from api.models.ModVersion import ModType
from vaults.modvault import utils
-from vaults.vaultitem import VaultItem
-
+from vaults.vaultitem import VaultListItem
-class ModItem(VaultItem):
- def __init__(self, parent, uid, *args, **kwargs):
- VaultItem.__init__(self, parent, *args, **kwargs)
+if TYPE_CHECKING:
+ from vaults.modvault.modvault import ModVault
- self.formatterItem = str(
- util.THEME.readfile("vaults/modvault/modinfo.qthtml"),
- )
- self.uid = uid
- self.author = ""
- self.thumbstr = ""
- self.isuidmod = False
- self.uploadedbyuser = False
+class ModListItem(VaultListItem):
+ def __init__(self, parent: ModVault, item_info: Mod, *args, **kwargs) -> None:
+ super().__init__(parent, item_info, *args, **kwargs)
+ self.html = str(util.THEME.readfile("vaults/modvault/modinfo.qthtml"))
self._preview_dler.set_target_dir(util.MOD_PREVIEW_DIR)
+ self.update()
- def shouldBeVisible(self):
+ def should_be_visible(self) -> bool:
p = self.parent
if p.showType == "all":
return True
elif p.showType == "ui":
- return self.isuimod
+ return self.item_version.modtype == ModType.UI
elif p.showType == "sim":
- return not self.isuimod
+ return self.item_version.modtype == ModType.SIM
elif p.showType == "yours":
- return self.uploadedbyuser
+ return self.item_info.author == self.parent.client.login
elif p.showType == "installed":
- return self.uid in self.parent.uids
+ return self.item_version.uid in self.parent.uids
else:
return True
- def update(self, item_dict):
- self.name = item_dict["name"]
- self.description = item_dict["description"]
- self.version = item_dict["version"]
- self.author = item_dict["author"]
- self.rating = item_dict["rating"]
- self.reviews = item_dict["reviews"]
- self.date = item_dict['date'][:10]
- self.isuimod = item_dict["ui"]
- self.link = item_dict["link"]
- self.thumbstr = item_dict["thumbnail"]
- self.uploadedbyuser = (self.author == self.parent.client.login)
-
- if self.thumbstr == "":
- self.setItemIcon("games/unknown_map.png")
- else:
- name = os.path.basename(urllib.parse.unquote(self.thumbstr))
+ def update(self) -> None:
+ if thumbstr := self.item_version.thumbnail_url:
+ name = os.path.basename(urllib.parse.unquote(thumbstr))
img = utils.getIcon(name)
if img:
- self.setItemIcon(img, False)
+ self.set_item_icon(img, False)
else:
- self._preview_dler.download(name, self._item_dl_request, self.thumbstr)
+ self._preview_dler.download(name, self._item_dl_request, thumbstr)
+ else:
+ self.set_item_icon("games/unknown_map.png")
+ super().update()
- VaultItem.update(self)
+ def update_visibility(self) -> None:
+ if self.item_version.modtype == ModType.UI:
+ modtype = "UI mod"
+ else:
+ modtype = ""
- def updateVisibility(self):
- if self.isuimod:
- self.itemType_ = "UI mod"
- if self.uid in self.parent.uids:
- self.color = "green"
+ if self.item_version.uid in self.parent.uids:
+ color = "green"
else:
- self.color = "white"
+ color = "white"
+
+ if self.item_info.reviews_summary is None:
+ score = reviews = "-"
+ else:
+ score = round(self.item_info.reviews_summary.average_score, 1)
+ reviews = self.item_info.reviews_summary.num_reviews
self.setText(
- self.formatterItem.format(
- color=self.color,
- version=self.version,
- title=self.name,
- description=self.trimmedDescription,
- rating=self.rating,
- reviews=self.reviews,
- date=self.date,
- modtype=self.itemType_,
- author=self.author,
+ self.html.format(
+ color=color,
+ version=self.item_version.version,
+ title=self.item_info.display_name,
+ description=self.item_version.description,
+ rating=score,
+ reviews=reviews,
+ date=self.item_version.create_time,
+ modtype=modtype,
+ author=self.item_info.author,
),
)
-
- VaultItem.updateVisibility(self)
+ super().update_visibility()
diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py
index 3ffc0c738..6a435da9d 100644
--- a/src/vaults/modvault/modvault.py
+++ b/src/vaults/modvault/modvault.py
@@ -46,7 +46,8 @@
from api.vaults_api import ModApiConnector
from vaults.modvault import utils
-from vaults.modvault.moditem import ModItem
+from vaults.modvault.moditem import ModListItem
+from vaults.modvault.utils import ModInfo
from vaults.vault import Vault
from .modwidget import ModWidget
@@ -78,12 +79,12 @@ def __init__(self, client, *args, **kwargs):
self.uploadButton.hide()
- def createItem(self, item_key: str) -> ModItem:
- return ModItem(self, item_key)
+ def create_item(self, item_key: str) -> ModListItem:
+ return ModListItem(self, item_key)
@QtCore.pyqtSlot(dict)
def modInfo(self, message: dict) -> None:
- super().itemsInfo(message)
+ super().items_info(message)
@QtCore.pyqtSlot(int)
def sortChanged(self, index):
@@ -93,7 +94,7 @@ def sortChanged(self, index):
self.sortType = "date"
elif index == 2:
self.sortType = "rating"
- self.updateVisibilities()
+ self.update_visibilities()
@QtCore.pyqtSlot(int)
def showChanged(self, index):
@@ -107,7 +108,7 @@ def showChanged(self, index):
self.showType = "yours"
elif index == 4:
self.showType = "installed"
- self.updateVisibilities()
+ self.update_visibilities()
@QtCore.pyqtSlot(QtWidgets.QListWidgetItem)
def modClicked(self, item):
@@ -175,12 +176,13 @@ def openUploadForm(self):
def downloadMod(self, link: str, name: str) -> bool:
if utils.downloadMod(link, name):
self.uids = [mod.uid for mod in utils.getInstalledMods()]
- self.updateVisibilities()
+ self.update_visibilities()
return True
else:
return False
- def removeMod(self, mod):
+ def removeMod(self, name: str, uid: str) -> None:
+ mod = ModInfo(name=name, uid=uid)
if utils.removeMod(mod):
self.uids = [m.uid for m in utils.installedMods]
- mod.updateVisibility()
+ self.update_visibilities()
diff --git a/src/vaults/modvault/modwidget.py b/src/vaults/modvault/modwidget.py
index 74b97b3a6..c7dcc8b1b 100644
--- a/src/vaults/modvault/modwidget.py
+++ b/src/vaults/modvault/modwidget.py
@@ -7,7 +7,9 @@
from PyQt6 import QtWidgets
import util
+from api.models.ModType import ModType
from util import strtodate
+from vaults.modvault.moditem import ModListItem
from .modvault import utils
@@ -17,7 +19,7 @@
class ModWidget(FormClass, BaseClass):
ICONSIZE = QtCore.QSize(100, 100)
- def __init__(self, parent, mod, *args, **kwargs):
+ def __init__(self, parent: QtWidgets.QWidget, mod_item: ModListItem, *args, **kwargs) -> None:
BaseClass.__init__(self, *args, **kwargs)
self.setupUi(self)
@@ -26,25 +28,26 @@ def __init__(self, parent, mod, *args, **kwargs):
util.THEME.stylesheets_reloaded.connect(self.load_stylesheet)
self.load_stylesheet()
- self.setWindowTitle(mod.name)
+ self.mod = mod_item.item_info
+ self.mod_version = mod_item.item_version
- self.mod = mod
+ self.setWindowTitle(self.mod.display_name)
- self.Title.setText(mod.name)
- self.Description.setText(mod.description)
+ self.Title.setText(self.mod.display_name)
+ self.Description.setText(self.mod_version.description)
modtext = ""
- if mod.isuimod:
- modtext = "UI mod\n"
+ if self.mod_version.modtype == ModType.UI:
+ modtext = "UI mod"
self.Info.setText(
- modtext + "By {}\nUploaded {}".format(mod.author, str(mod.date)),
+ f"{modtext}\nBy {self.mod.author}\nUploaded {self.mod_version.create_time}",
)
- mod.thumbnail = utils.getIcon(
- os.path.basename(urllib.parse.unquote(mod.thumbstr)),
+ thumbnail = utils.getIcon(
+ os.path.basename(urllib.parse.unquote(self.mod_version.thumbnail_url)),
)
- if mod.thumbnail is None:
+ if thumbnail is None:
self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png"))
else:
- pixmap = util.THEME.pixmap(mod.thumbnail, False)
+ pixmap = util.THEME.pixmap(thumbnail, False)
self.Picture.setPixmap(pixmap.scaled(self.ICONSIZE))
# ensure that pixmap is set
@@ -56,7 +59,7 @@ def __init__(self, parent, mod, *args, **kwargs):
self.tabWidget.setEnabled(False)
- if self.mod.uid in self.parent.uids:
+ if self.mod_version.uid in self.parent.uids:
self.DownloadButton.setText("Remove Mod")
self.DownloadButton.clicked.connect(self.download)
@@ -82,8 +85,8 @@ def load_stylesheet(self):
@QtCore.pyqtSlot()
def download(self) -> None:
- if self.mod.uid not in self.parent.uids:
- self.parent.downloadMod(self.mod.link, self.mod.name)
+ if self.mod_version.uid not in self.parent.uids:
+ self.parent.downloadMod(self.mod_version.download_url, self.mod.display_name)
self.done(1)
else:
show = QtWidgets.QMessageBox.question(
@@ -94,7 +97,7 @@ def download(self) -> None:
QtWidgets.QMessageBox.StandardButton.No,
)
if show == QtWidgets.QMessageBox.StandardButton.Yes:
- self.parent.removeMod(self.mod)
+ self.parent.removeMod(self.mod.display_name, self.mod_version.uid)
self.done(1)
@QtCore.pyqtSlot()
diff --git a/src/vaults/vault.py b/src/vaults/vault.py
index f7c5a577b..9b621f571 100644
--- a/src/vaults/vault.py
+++ b/src/vaults/vault.py
@@ -1,11 +1,17 @@
+from __future__ import annotations
+
import logging
+from typing import TYPE_CHECKING
from PyQt6 import QtCore
import util
from ui.busy_widget import BusyWidget
-from vaults.vaultitem import VaultItem
from vaults.vaultitem import VaultItemDelegate
+from vaults.vaultitem import VaultListItem
+
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
logger = logging.getLogger(__name__)
@@ -14,7 +20,7 @@
class Vault(FormClass, BaseClass, BusyWidget):
- def __init__(self, client, *args, **kwargs):
+ def __init__(self, client: ClientWindow, *args, **kwargs) -> None:
QtCore.QObject.__init__(self, *args, **kwargs)
self.setupUi(self)
self.client = client
@@ -31,7 +37,7 @@ def __init__(self, client, *args, **kwargs):
self.sortType = "alphabetical"
self.showType = "all"
self.searchString = ""
- self.searchQuery = dict(include='latestVersion,reviewsSummary')
+ self.searchQuery = {}
self.apiConnector = None
self.pageSize = self.quantityBox.value()
@@ -87,22 +93,21 @@ def goToPage(self, page: int) -> None:
self.pageNumber = self.pageBox.value()
self.updateQuery(self.pageNumber)
self.apiConnector.requestData(self.searchQuery)
- self.updateVisibilities()
+ self.update_visibilities()
- def createItem(self, item_key: str) -> VaultItem:
- return VaultItem(self, item_key)
+ def create_item(self, item_key: str) -> VaultListItem:
+ return VaultListItem(self, item_key)
@QtCore.pyqtSlot(dict)
- def itemsInfo(self, message: dict) -> None:
+ def items_info(self, message: dict) -> None:
for value in message["values"]:
- item_key = value[self.items_uid]
+ item_key = value.uid
if item_key in self._items:
item = self._items[item_key]
else:
- item = self.createItem(item_key)
+ item = self.create_item(value)
self._items[item_key] = item
self.itemList.addItem(item)
- item.update(value)
self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder)
self.processMeta(message["meta"])
@@ -117,7 +122,7 @@ def processMeta(self, message: dict) -> None:
def resetSearch(self):
self.searchString = ''
self.searchInput.clear()
- self.searchQuery = dict(include='latestVersion,reviewsSummary')
+ self.searchQuery.clear()
self.goToPage(1)
def search(self):
@@ -126,10 +131,7 @@ def search(self):
self.resetSearch()
else:
self.searchString = self.searchString.strip()
- self.searchQuery = dict(
- include='latestVersion,reviewsSummary',
- filter='displayName=="*{}*"'.format(self.searchString),
- )
+ self.searchQuery = {"filter": f"displayName=='*{self.searchString}*'"}
self.goToPage(1)
@QtCore.pyqtSlot()
@@ -137,11 +139,10 @@ def busy_entered(self):
if not self._items:
self.goToPage(self.pageNumber)
- def updateVisibilities(self):
+ def update_visibilities(self) -> None:
logger.debug(
- "Updating visibilities with sort '{}' and visibility '{}'"
- .format(self.sortType, self.showType),
+ f"Updating visibilities with sort {self.sortType!r} and visibility {self.showType!r}",
)
- for _item in self._items:
- self._items[_item].updateVisibility()
+ for item in self._items.values():
+ item.update_visibility()
self.itemList.sortItems(QtCore.Qt.SortOrder.DescendingOrder)
diff --git a/src/vaults/vaultitem.py b/src/vaults/vaultitem.py
index db04d38d5..4e5cbcf00 100644
--- a/src/vaults/vaultitem.py
+++ b/src/vaults/vaultitem.py
@@ -1,102 +1,116 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
from PyQt6 import QtCore
from PyQt6 import QtGui
-from PyQt6 import QtWidgets
+from PyQt6.QtWidgets import QListWidgetItem
+from PyQt6.QtWidgets import QStyle
+from PyQt6.QtWidgets import QStyledItemDelegate
import util
+from api.models.Map import Map
+from api.models.Mod import Mod
from downloadManager import Downloader
from downloadManager import DownloadRequest
+if TYPE_CHECKING:
+ from vaults.vault import Vault
-class VaultItem(QtWidgets.QListWidgetItem):
+
+class VaultListItem(QListWidgetItem):
TEXTWIDTH = 230
ICONSIZE = 100
PADDING = 10
- def __init__(self, parent, *args, **kwargs):
- QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs)
+ def __init__(self, parent: Vault, item_info: Mod | Map, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
self.parent = parent
-
- self.name = ""
- self.description = ""
- self.trimmedDescription = ""
- self.version = 0
- self.rating = 0
- self.reviews = 0
- self.date = None
-
- self.itemType_ = ""
- self.color = "white"
-
- self.link = ""
self.setHidden(True)
+ self.item_info = item_info
+ self.item_version = item_info.version
+
self._preview_dler = Downloader(util.CACHE_DIR)
self._item_dl_request = DownloadRequest()
self._item_dl_request.done.connect(self._on_item_downloaded)
def update(self):
- self.ensureIcon()
- self.updateVisibility()
+ self.ensure_icon()
+ self.update_visibility()
- def setItemIcon(self, filename, themed=True):
+ def set_item_icon(self, filename: str, themed: bool = True) -> None:
icon = util.THEME.icon(filename)
if not themed:
pixmap = QtGui.QPixmap(filename)
if not pixmap.isNull():
- icon.addPixmap(
- pixmap.scaled(
- QtCore.QSize(self.ICONSIZE, self.ICONSIZE),
- ),
- )
+ scaled_pixmap = pixmap.scaled(QtCore.QSize(self.ICONSIZE, self.ICONSIZE))
+ icon.addPixmap(scaled_pixmap)
self.setIcon(icon)
- def ensureIcon(self):
+ def ensure_icon(self):
if self.icon() is None or self.icon().isNull():
- self.setItemIcon("games/unknown_map.png")
+ self.set_item_icon("games/unknown_map.png")
- def _on_item_downloaded(self, mapname, result):
+ def _on_item_downloaded(self, mapname: str, result: tuple[str, bool]) -> None:
filename, themed = result
- self.setItemIcon(filename, themed)
- self.ensureIcon()
+ self.set_item_icon(filename, themed)
+ self.ensure_icon()
- def updateVisibility(self):
- self.setHidden(not self.shouldBeVisible())
- if len(self.description) < 200:
- self.trimmedDescription = self.description
- else:
- self.trimmedDescription = self.description[:197] + "..."
+ def should_be_hidden(self) -> bool:
+ return not self.should_be_visible()
- self.setToolTip('{}
'.format(self.description))
+ def should_be_visible(self) -> bool:
+ return True
- def __ge__(self, other):
+ def update_visibility(self) -> None:
+ self.setHidden(self.should_be_hidden())
+ if len(self.item_version.description) < 200:
+ trimmed_description = self.item_version.description
+ else:
+ trimmed_description = f"{self.item_version.description[:197]}..."
+ self.setToolTip('{}
'.format(trimmed_description))
+
+ def __ge__(self, other: VaultListItem) -> bool:
return not self.__lt__(self, other)
- def __lt__(self, other):
+ def __lt__(self, other: VaultListItem) -> bool:
if self.parent.sortType == "alphabetical":
- return self.name.lower() > other.name.lower()
+ return self._lt_alphabetical(other)
elif self.parent.sortType == "rating":
- if self.rating == other.rating:
- if self.reviews == other.reviews:
- return self.name.lower() > other.name.lower()
- return self.reviews < other.reviews
- return self.rating < other.rating
- elif self.parent.sortType == "size":
- if self.height * self.width == other.height * other.width:
- return self.name.lower() > other.name.lower()
- return self.height * self.width < other.height * other.width
+ return self._lt_rating(other)
elif self.parent.sortType == "date":
- if self.date is None:
- return other.date is not None
- if self.date == other.date:
- return self.name.lower() > other.name.lower()
- return self.date < other.date
+ return self._lt_date(other)
+ return True
+
+ def _lt_date(self, other: VaultListItem) -> bool:
+ if self.item_version.create_time == other.item_version.create_time:
+ if self.item_version.update_time == other.item_version.update_time:
+ return self._lt_alphabetical(other)
+ return self.item_version.update_time < other.item_version.update_time
+ return self.item_version.create_time < other.item_version.create_time
+
+ def _lt_alphabetical(self, other: VaultListItem) -> bool:
+ return self.item_info.display_name.lower() > other.item_info.display_name.lower()
+ def _lt_rating(self, other: VaultListItem) -> bool:
+ review = self.item_info.reviews_summary
+ other_review = other.item_info.reviews_summary
-class VaultItemDelegate(QtWidgets.QStyledItemDelegate):
+ if review is None:
+ return other_review is not None
+ if other_review is None:
+ return review is None
- def __init__(self, *args, **kwargs):
- QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
+ if review.average_score == other_review.average_score:
+ if review.num_reviews == other_review.num_reviews:
+ return self._lt_alphabetical(other)
+ return review.num_reviews < other_review.num_reviews
+ return review.average_score < other_review.average_score
+
+
+class VaultItemDelegate(QStyledItemDelegate):
def paint(self, painter, option, index, *args, **kwargs):
self.initStyleOption(option, index)
@@ -106,15 +120,14 @@ def paint(self, painter, option, index, *args, **kwargs):
html.setHtml(option.text)
icon = QtGui.QIcon(option.icon)
- iconsize = QtCore.QSize(VaultItem.ICONSIZE, VaultItem.ICONSIZE)
+ iconsize = QtCore.QSize(VaultListItem.ICONSIZE, VaultListItem.ICONSIZE)
# clear icon and text before letting the control draw itself because
# we're rendering these parts ourselves
option.icon = QtGui.QIcon()
option.text = ""
- option.widget.style().drawControl(
- QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
- )
+ control_element = QStyle.ControlElement.CE_ItemViewItem
+ option.widget.style().drawControl(control_element, option, painter, option.widget)
# Shadow
painter.fillRect(
@@ -158,12 +171,12 @@ def sizeHint(self, option, index, *args, **kwargs):
html = QtGui.QTextDocument()
html.setHtml(option.text)
- html.setTextWidth(VaultItem.TEXTWIDTH)
+ html.setTextWidth(VaultListItem.TEXTWIDTH)
return QtCore.QSize(
(
- VaultItem.ICONSIZE
- + VaultItem.TEXTWIDTH
- + VaultItem.PADDING
+ VaultListItem.ICONSIZE
+ + VaultListItem.TEXTWIDTH
+ + VaultListItem.PADDING
),
- VaultItem.ICONSIZE + VaultItem.PADDING,
+ VaultListItem.ICONSIZE + VaultListItem.PADDING,
)
From 0e297cf321afca573f9367f4d8bd620ed99ea68f Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Mon, 22 Apr 2024 21:00:06 +0300
Subject: [PATCH 043/123] FileDownload: Except possible permission error
when deleting cache file
---
src/downloadManager/__init__.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index 9c67ffd0a..2f475f7c7 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -174,7 +174,10 @@ def _about_to_finish(self) -> None:
def cleanup(self) -> None:
self._output.close()
if self.failed():
- os.unlink(self._cache_path)
+ try:
+ os.unlink(self._cache_path)
+ except OSError as e:
+ logger.warning(f"Couldn't remove {self._cache_path}: {e}")
else:
logger.debug(f"Finished download from {self.addr}")
self._output.rename(self._target_path)
From e148c51d9bb2e7e3bb7d69d0c7335c6aa424df86 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 23 Apr 2024 22:12:18 +0300
Subject: [PATCH 044/123] Factor out a superclass for vault api connectors
---
src/api/vaults_api.py | 105 +++++++++++++++++++++++-------------------
src/vaults/vault.py | 2 +-
2 files changed, 58 insertions(+), 49 deletions(-)
diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py
index ba69edc93..2fff9d01a 100644
--- a/src/api/vaults_api.py
+++ b/src/api/vaults_api.py
@@ -1,4 +1,5 @@
import logging
+from collections.abc import Sequence
from api.ApiAccessors import DataApiAccessor
from api.parsers.MapParser import MapParser
@@ -9,30 +10,55 @@
logger = logging.getLogger(__name__)
-class ModApiConnector(DataApiAccessor):
- def __init__(self, dispatch: Dispatcher) -> None:
- super().__init__('/data/mod')
- self.dispatch = dispatch
+class VaultsApiConnector(DataApiAccessor):
+ def __init__(self, route: str) -> None:
+ super().__init__(route)
+ self._includes = ("latestVersion", "reviewsSummary")
+
+ def _extend_query_options(self, query_options: dict) -> dict:
+ self._add_default_includes(query_options)
+ self._apply_default_filters(query_options)
+ return query_options
+
+ def _copy_query_options(self, query_options: dict | None) -> dict:
+ query_options = query_options or {}
+ return query_options.copy()
+
+ def request_data(self, query_options: dict | None = None) -> None:
+ query = self._copy_query_options(query_options)
+ self._extend_query_options(query)
+ self.get_by_query(query, self.parse_data)
- def requestData(self, params: dict | None = None) -> None:
- params = params or {}
- self._add_default_include(params)
- self._extend_filters(params)
- self.get_by_query(params, self.handle_data)
+ def _add_default_includes(self, query_options: dict) -> dict:
+ return self._extend_includes(query_options, self._includes)
- def _add_default_include(self, params: dict) -> dict:
- params["include"] = ",".join(("latestVersion", "reviewsSummary", "uploader"))
- return params
+ def _extend_includes(self, query_options: dict, to_include: Sequence[str]) -> dict:
+ cur_includes = query_options.get("include", "")
+ to_include_str = ",".join((cur_includes, *to_include)).removeprefix(",")
+ query_options["include"] = to_include_str
+ return query_options
- def _extend_filters(self, params: dict) -> dict:
+ def _apply_default_filters(self, query_options: dict) -> dict:
+ cur_filters = query_options.get("filter", "")
additional_filter = "latestVersion.hidden=='false'"
- if cur_filters := params.get("filter", ""):
- params["filter"] = f"{cur_filters};{additional_filter}"
- else:
- params["filter"] = additional_filter
- return params
+ query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";")
+ return query_options
- def handle_data(self, message: dict) -> None:
+ def parse_data(self, message: dict) -> None:
+ raise NotImplementedError
+
+
+class ModApiConnector(VaultsApiConnector):
+ def __init__(self, dispatch: Dispatcher) -> None:
+ super().__init__("/data/mod")
+ self.dispatch = dispatch
+
+ def _extend_query_options(self, query_options: dict) -> dict:
+ super()._extend_query_options(query_options)
+ self._extend_includes(query_options, ["uploader"])
+ return query_options
+
+ def parse_data(self, message: dict) -> None:
parsed_data = {
"command": "modvault_info",
"values": ModParser.parse_many(message["data"]),
@@ -41,28 +67,14 @@ def handle_data(self, message: dict) -> None:
self.dispatch.dispatch(parsed_data)
-class MapApiConnector(DataApiAccessor):
+class MapApiConnector(VaultsApiConnector):
def __init__(self, dispatch: Dispatcher) -> None:
super().__init__("/data/map")
self.dispatch = dispatch
- def requestData(self, params: dict | None = None) -> None:
- params = params or {}
- self._add_default_include(params)
- self._extend_filters(params)
- self.get_by_query(params, self.parse_data)
-
- def _extend_filters(self, params: dict) -> dict:
- additional_filter = "latestVersion.hidden=='false'"
- if cur_filters := params.get("filter", ""):
- params["filter"] = f"{cur_filters};{additional_filter}"
- else:
- params["filter"] = additional_filter
- return params
-
- def _add_default_include(self, params: dict) -> dict:
- params["include"] = ",".join(("latestVersion", "reviewsSummary", "author"))
- return params
+ def _extend_query_options(self, query_options: dict) -> dict:
+ super()._extend_query_options(query_options)
+ self._extend_includes(query_options, ["author"])
def parse_data(self, message: dict) -> None:
prepared_data = {
@@ -73,23 +85,20 @@ def parse_data(self, message: dict) -> None:
self.dispatch.dispatch(prepared_data)
-class MapPoolApiConnector(DataApiAccessor):
+class MapPoolApiConnector(VaultsApiConnector):
def __init__(self, dispatch: Dispatcher) -> None:
- super().__init__('/data/mapPoolAssignment')
+ super().__init__("/data/mapPoolAssignment")
self.dispatch = dispatch
-
- def requestData(self, params: dict | None) -> None:
- params = params or {}
- self.get_by_query(self._add_default_include(params), self.parse_data)
-
- def _add_default_include(self, params: dict) -> dict:
- params["include"] = ",".join((
+ self._includes = (
"mapVersion",
"mapVersion.map",
"mapVersion.map.author",
"mapVersion.map.reviewsSummary",
- ))
- return params
+ )
+
+ def _extend_query_options(self, query_options: dict) -> dict:
+ self._add_default_includes(query_options)
+ return query_options
def parse_data(self, message: dict) -> None:
prepared_data = {
diff --git a/src/vaults/vault.py b/src/vaults/vault.py
index 9b621f571..55792e318 100644
--- a/src/vaults/vault.py
+++ b/src/vaults/vault.py
@@ -92,7 +92,7 @@ def goToPage(self, page: int) -> None:
self.pageBox.setValue(page)
self.pageNumber = self.pageBox.value()
self.updateQuery(self.pageNumber)
- self.apiConnector.requestData(self.searchQuery)
+ self.apiConnector.request_data(self.searchQuery)
self.update_visibilities()
def create_item(self, item_key: str) -> VaultListItem:
From ad040107c289bda7daf10a4a1072446bec2a079b Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 24 Apr 2024 18:46:23 +0300
Subject: [PATCH 045/123] Do not use Dispatcher to transmit vaults' signals
use bare pyqtSignal for that
---
src/api/vaults_api.py | 37 +++++++++++++++------------------
src/client/connection.py | 24 ++++-----------------
src/vaults/mapvault/mapvault.py | 10 ++++-----
src/vaults/modvault/modvault.py | 4 ++--
4 files changed, 28 insertions(+), 47 deletions(-)
diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py
index 2fff9d01a..5ff34089b 100644
--- a/src/api/vaults_api.py
+++ b/src/api/vaults_api.py
@@ -1,16 +1,19 @@
import logging
from collections.abc import Sequence
+from PyQt6.QtCore import pyqtSignal
+
from api.ApiAccessors import DataApiAccessor
from api.parsers.MapParser import MapParser
from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser
from api.parsers.ModParser import ModParser
-from client.connection import Dispatcher
logger = logging.getLogger(__name__)
class VaultsApiConnector(DataApiAccessor):
+ data_ready = pyqtSignal(dict)
+
def __init__(self, route: str) -> None:
super().__init__(route)
self._includes = ("latestVersion", "reviewsSummary")
@@ -27,7 +30,7 @@ def _copy_query_options(self, query_options: dict | None) -> dict:
def request_data(self, query_options: dict | None = None) -> None:
query = self._copy_query_options(query_options)
self._extend_query_options(query)
- self.get_by_query(query, self.parse_data)
+ self.get_by_query(query, self.handle_response)
def _add_default_includes(self, query_options: dict) -> dict:
return self._extend_includes(query_options, self._includes)
@@ -44,51 +47,47 @@ def _apply_default_filters(self, query_options: dict) -> dict:
query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";")
return query_options
- def parse_data(self, message: dict) -> None:
- raise NotImplementedError
+ def parse_data(self, message: dict) -> dict:
+ return message
+
+ def handle_response(self, message: dict) -> None:
+ self.data_ready.emit(self.parse_data(message))
class ModApiConnector(VaultsApiConnector):
- def __init__(self, dispatch: Dispatcher) -> None:
+ def __init__(self) -> None:
super().__init__("/data/mod")
- self.dispatch = dispatch
def _extend_query_options(self, query_options: dict) -> dict:
super()._extend_query_options(query_options)
self._extend_includes(query_options, ["uploader"])
return query_options
- def parse_data(self, message: dict) -> None:
- parsed_data = {
- "command": "modvault_info",
+ def parse_data(self, message: dict) -> dict:
+ return {
"values": ModParser.parse_many(message["data"]),
"meta": message["meta"],
}
- self.dispatch.dispatch(parsed_data)
class MapApiConnector(VaultsApiConnector):
- def __init__(self, dispatch: Dispatcher) -> None:
+ def __init__(self) -> None:
super().__init__("/data/map")
- self.dispatch = dispatch
def _extend_query_options(self, query_options: dict) -> dict:
super()._extend_query_options(query_options)
self._extend_includes(query_options, ["author"])
def parse_data(self, message: dict) -> None:
- prepared_data = {
- "command": "mapvault_info",
+ return {
"values": MapParser.parse_many(message["data"]),
"meta": message["meta"],
}
- self.dispatch.dispatch(prepared_data)
class MapPoolApiConnector(VaultsApiConnector):
- def __init__(self, dispatch: Dispatcher) -> None:
+ def __init__(self) -> None:
super().__init__("/data/mapPoolAssignment")
- self.dispatch = dispatch
self._includes = (
"mapVersion",
"mapVersion.map",
@@ -101,9 +100,7 @@ def _extend_query_options(self, query_options: dict) -> dict:
return query_options
def parse_data(self, message: dict) -> None:
- prepared_data = {
- "command": "mapvault_info",
+ return {
"values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]),
"meta": message["meta"],
}
- self.dispatch.dispatch(prepared_data)
diff --git a/src/client/connection.py b/src/client/connection.py
index f8928169b..b4b3b63f1 100644
--- a/src/client/connection.py
+++ b/src/client/connection.py
@@ -392,13 +392,11 @@ class LobbyInfo(QtCore.QObject):
coopInfo = QtCore.pyqtSignal(dict)
tutorialsInfo = QtCore.pyqtSignal(dict)
modInfo = QtCore.pyqtSignal(dict)
- modVaultInfo = QtCore.pyqtSignal(dict)
replayVault = QtCore.pyqtSignal(dict)
coopLeaderBoard = QtCore.pyqtSignal(dict)
avatarList = QtCore.pyqtSignal(list)
social = QtCore.pyqtSignal(dict)
serverSession = QtCore.pyqtSignal(dict)
- mapVaultInfo = QtCore.pyqtSignal(dict)
aliasInfo = QtCore.pyqtSignal(dict)
matchmakerQueueInfo = QtCore.pyqtSignal(dict)
@@ -418,24 +416,10 @@ def __init__(self, dispatcher, gameset, playerset):
self._dispatcher["session"] = self._simple_emit(self.serverSession)
self._dispatcher["alias_info"] = self._simple_emit(self.aliasInfo)
self._dispatcher["modvault_list_info"] = self.handle_modvault_list_info
- self._dispatcher["modvault_info"] = self._simple_emit(
- self.modVaultInfo,
- )
- self._dispatcher["mapvault_info"] = self._simple_emit(
- self.mapVaultInfo,
- )
- self._dispatcher["updated_achievements"] = (
- self.handle_updated_achievements
- )
- self._dispatcher["coop_leaderboard"] = self._simple_emit(
- self.coopLeaderBoard,
- )
- self._dispatcher["tutorials_info"] = self._simple_emit(
- self.tutorialsInfo,
- )
- self._dispatcher["matchmaker_queue_info"] = self._simple_emit(
- self.matchmakerQueueInfo,
- )
+ self._dispatcher["updated_achievements"] = (self.handle_updated_achievements)
+ self._dispatcher["coop_leaderboard"] = self._simple_emit(self.coopLeaderBoard)
+ self._dispatcher["tutorials_info"] = self._simple_emit(self.tutorialsInfo)
+ self._dispatcher["matchmaker_queue_info"] = self._simple_emit(self.matchmakerQueueInfo)
self._gameset = gameset
self._playerset = playerset
diff --git a/src/vaults/mapvault/mapvault.py b/src/vaults/mapvault/mapvault.py
index ba56e90da..8ae503926 100644
--- a/src/vaults/mapvault/mapvault.py
+++ b/src/vaults/mapvault/mapvault.py
@@ -37,7 +37,6 @@ def __init__(self, client: ClientWindow, *args, **kwargs) -> None:
logger.debug("Map Vault tab instantiating")
self.itemList.itemDoubleClicked.connect(self.itemClicked)
- self.client.lobby_info.mapVaultInfo.connect(self.mapInfo)
self.client.authorized.connect(self.busy_entered)
self.installed_maps = maps.getUserMaps()
@@ -47,10 +46,11 @@ def __init__(self, client: ClientWindow, *args, **kwargs) -> None:
for type_ in ["Unranked Only", "Ranked Only", "Installed"]:
self.ShowTypeList.addItem(type_)
- self.mapApiConnector = MapApiConnector(self.client.lobby_dispatch)
- self.mapPoolApiConnector = MapPoolApiConnector(
- self.client.lobby_dispatch,
- )
+ self.mapApiConnector = MapApiConnector()
+ self.mapPoolApiConnector = MapPoolApiConnector()
+ self.mapApiConnector.data_ready.connect(self.mapInfo)
+ self.mapPoolApiConnector.data_ready.connect(self.mapInfo)
+
self.apiConnector = self.mapApiConnector
self.items_uid = "folderName"
diff --git a/src/vaults/modvault/modvault.py b/src/vaults/modvault/modvault.py
index 6a435da9d..1d3e8c0bb 100644
--- a/src/vaults/modvault/modvault.py
+++ b/src/vaults/modvault/modvault.py
@@ -66,14 +66,14 @@ def __init__(self, client, *args, **kwargs):
self.itemList.itemDoubleClicked.connect(self.modClicked)
self.UIButton.clicked.connect(self.openUIModForm)
- self.client.lobby_info.modVaultInfo.connect(self.modInfo)
self.uids = [mod.uid for mod in utils.getInstalledMods()]
for type_ in ["UI Only", "Sim Only", "Uploaded by You", "Installed"]:
self.ShowTypeList.addItem(type_)
- self.apiConnector = ModApiConnector(self.client.lobby_dispatch)
+ self.apiConnector = ModApiConnector()
+ self.apiConnector.data_ready.connect(self.modInfo)
self.items_uid = "uid"
From 1db3a0232d15848f32838098e4656bfd591d4437 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 25 Apr 2024 22:20:35 +0300
Subject: [PATCH 046/123] Do not use Dispatcher to dispatch data from API
i think it was originally supposed to dispatch server messages
also, move some common parts of ApiAcessors into their base class
---
src/api/ApiAccessors.py | 14 ++++
src/api/featured_mod_api.py | 21 ++----
src/api/featured_mod_updater.py | 9 +--
src/api/matchmaker_queue_api.py | 18 ++---
src/api/player_api.py | 34 ++-------
src/api/replaysapi.py | 22 +-----
src/api/sim_mod_updater.py | 3 -
src/api/stats_api.py | 38 ++--------
src/api/vaults_api.py | 18 +----
src/client/aliasviewer.py | 6 +-
src/client/connection.py | 13 ----
src/games/_gameswidget.py | 5 +-
src/games/automatchframe.py | 12 +--
src/replays/_replayswidget.py | 40 +++++-----
src/stats/_statswidget.py | 90 ++++-------------------
src/stats/leaderboard_widget.py | 39 ++++------
src/stats/models/leaderboardtablemodel.py | 6 +-
17 files changed, 103 insertions(+), 285 deletions(-)
diff --git a/src/api/ApiAccessors.py b/src/api/ApiAccessors.py
index 874a02cb4..bc17ed6c5 100644
--- a/src/api/ApiAccessors.py
+++ b/src/api/ApiAccessors.py
@@ -1,5 +1,7 @@
import logging
+from PyQt6.QtCore import pyqtSignal
+
from api.ApiBase import ApiBase
logger = logging.getLogger(__name__)
@@ -18,6 +20,8 @@ def __init__(self, route: str = "") -> None:
class DataApiAccessor(ApiAccessor):
+ data_ready = pyqtSignal(dict)
+
def parse_message(self, message: dict) -> dict:
included = self.parseIncluded(message)
result = {}
@@ -87,3 +91,13 @@ def parseMeta(self, message: dict) -> dict:
if "meta" in message:
return message["meta"]
return {}
+
+ def requestData(self, query_dict: dict | None = None) -> None:
+ query_dict = query_dict or {}
+ self.get_by_query(query_dict, self.handle_response)
+
+ def prepare_data(self, message: dict) -> dict:
+ return message
+
+ def handle_response(self, message: dict) -> None:
+ self.data_ready.emit(self.prepare_data(message))
diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py
index 203aad324..1577fc4cb 100644
--- a/src/api/featured_mod_api.py
+++ b/src/api/featured_mod_api.py
@@ -1,27 +1,18 @@
import logging
from api.ApiAccessors import DataApiAccessor
-from client.connection import Dispatcher
logger = logging.getLogger(__name__)
class FeaturedModApiConnector(DataApiAccessor):
- def __init__(self, dispatch: Dispatcher) -> None:
+ def __init__(self) -> None:
super().__init__('/data/featuredMod')
- self.dispatch = dispatch
- def requestData(self) -> None:
- self.get_by_query({}, self.handleData)
-
- def handleData(self, message: dict) -> None:
- preparedData = {
- "command": "mod_info_api",
- "values": [],
- }
+ def prepare_data(self, message: dict) -> None:
+ values = []
for mod in message["data"]:
- preparedMod = {
- "command": "mod_info_api",
+ prepared_mod = {
"name": mod["technicalName"],
"fullname": mod["displayName"],
"publish": mod.get("visible", False),
@@ -31,5 +22,5 @@ def handleData(self, message: dict) -> None:
"No description provided",
),
}
- preparedData["values"].append(preparedMod)
- self.dispatch.dispatch(preparedData)
+ values.append(prepared_mod)
+ return {"values": values}
diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py
index e1d2cbd6e..3a9b96163 100644
--- a/src/api/featured_mod_updater.py
+++ b/src/api/featured_mod_updater.py
@@ -10,10 +10,7 @@ def __init__(self, mod_id: int, version: str) -> None:
super().__init__('/featuredMods/{}/files/{}'.format(mod_id, version))
self.featuredModFiles = []
- def requestData(self) -> None:
- self.get_by_query({}, self.handleData)
-
- def handleData(self, message):
+ def handle_response(self, message):
self.featuredModFiles = message["data"]
def getFiles(self):
@@ -27,10 +24,6 @@ def __init__(self) -> None:
super().__init__('/data/featuredMod')
self.featuredModId = 0
- def requestData(self, queryDict: dict | None = None) -> None:
- queryDict = queryDict or {}
- self.get_by_query(queryDict, self.handleData)
-
def handleFeaturedModId(self, message):
self.featuredModId = message['data'][0]['id']
diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py
index e4ca62d43..576a85a31 100644
--- a/src/api/matchmaker_queue_api.py
+++ b/src/api/matchmaker_queue_api.py
@@ -1,22 +1,16 @@
import logging
from api.ApiAccessors import DataApiAccessor
-from client.connection import Dispatcher
logger = logging.getLogger(__name__)
-class matchmakerQueueApiConnector(DataApiAccessor):
- def __init__(self, dispatch: Dispatcher) -> None:
+class MatchmakerQueueApiConnector(DataApiAccessor):
+ def __init__(self) -> None:
super().__init__('/data/matchmakerQueue')
- self.dispatch = dispatch
- def requestData(self, queryDict: dict | None = None) -> None:
- queryDict = queryDict or {}
- self.get_by_query(queryDict, self.handleData)
-
- def handleData(self, message: dict) -> None:
- preparedData = {
+ def prepare_data(self, message: dict) -> None:
+ prepared_data = {
"command": "matchmaker_queue_info",
"values": [],
"meta": message["meta"],
@@ -28,5 +22,5 @@ def handleData(self, message: dict) -> None:
"id": queue["id"],
"leaderboardId": queue["leaderboard"]["id"],
}
- preparedData["values"].append(preparedQueue)
- self.dispatch.dispatch(preparedData)
+ prepared_data["values"].append(preparedQueue)
+ return prepared_data
diff --git a/src/api/player_api.py b/src/api/player_api.py
index 235a22638..5253c8854 100644
--- a/src/api/player_api.py
+++ b/src/api/player_api.py
@@ -1,34 +1,17 @@
import logging
+from PyQt6.QtCore import pyqtSignal
+
from api.ApiAccessors import DataApiAccessor
-from client.connection import Dispatcher
logger = logging.getLogger(__name__)
class PlayerApiConnector(DataApiAccessor):
- def __init__(self, dispatch: Dispatcher) -> None:
+ alias_info = pyqtSignal(dict)
+
+ def __init__(self) -> None:
super().__init__('/data/player')
- self.dispatch = dispatch
-
- def requestDataForLeaderboard(
- self,
- leaderboardName: str,
- queryDict: dict | None = None,
- ) -> None:
- queryDict = queryDict or {}
- self.leaderboardName = leaderboardName
- self.get_by_query(queryDict, self.handleDataForLeaderboard)
-
- def handleDataForLeaderboard(self, message: dict) -> None:
- preparedData = dict(
- command='stats',
- type='player',
- leaderboardName=self.leaderboardName,
- values=message['data'],
- meta=message['meta'],
- )
- self.dispatch.dispatch(preparedData)
def requestDataForAliasViewer(self, nameToFind: str) -> None:
queryDict = {
@@ -42,9 +25,4 @@ def requestDataForAliasViewer(self, nameToFind: str) -> None:
self.get_by_query(queryDict, self.handleDataForAliasViewer)
def handleDataForAliasViewer(self, message: dict) -> None:
- preparedData = dict(
- command='alias_info',
- values=message['data'],
- meta=message['meta'],
- )
- self.dispatch.dispatch(preparedData)
+ self.alias_info.emit(message)
diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py
index f76ebd17e..a9a8e99b7 100644
--- a/src/api/replaysapi.py
+++ b/src/api/replaysapi.py
@@ -1,30 +1,10 @@
import logging
from api.ApiAccessors import DataApiAccessor
-from client.connection import Dispatcher
logger = logging.getLogger(__name__)
class ReplaysApiConnector(DataApiAccessor):
- def __init__(self, dispatch: Dispatcher) -> None:
+ def __init__(self) -> None:
super().__init__('/data/game')
- self.dispatch = dispatch
-
- def requestData(self, params: dict) -> None:
- self.get_by_query(params, self.handleData)
-
- def handleData(self, message):
- preparedData = dict(
- command="replay_vault",
- action="search_result",
- replays={},
- featuredMods={},
- maps={},
- players={},
- playerStats={},
- )
-
- preparedData["replays"] = message["data"]
-
- self.dispatch.dispatch(preparedData)
diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py
index a38b44456..9b5bacbd2 100644
--- a/src/api/sim_mod_updater.py
+++ b/src/api/sim_mod_updater.py
@@ -10,9 +10,6 @@ def __init__(self) -> None:
super().__init__('/data/modVersion')
self.mod_url = ""
- def requestData(self, queryDict: dict) -> None:
- self.get_by_query(queryDict, self.handleData)
-
def get_url_from_message(self, message: dict) -> str:
self.mod_url = message["data"][0]["downloadUrl"]
diff --git a/src/api/stats_api.py b/src/api/stats_api.py
index ad8f1fc0c..af4301ab1 100644
--- a/src/api/stats_api.py
+++ b/src/api/stats_api.py
@@ -1,46 +1,20 @@
import logging
from api.ApiAccessors import DataApiAccessor
-from client.connection import Dispatcher
logger = logging.getLogger(__name__)
class LeaderboardRatingApiConnector(DataApiAccessor):
- def __init__(self, dispatch: Dispatcher, leaderboardName: str) -> None:
+ def __init__(self, leaderboard_name: str) -> None:
super().__init__('/data/leaderboardRating')
- self.dispatch = dispatch
- self.leadeboardName = leaderboardName
+ self.leaderboard_name = leaderboard_name
- def requestData(self, queryDict: dict | None = None) -> None:
- queryDict = queryDict or {}
- self.get_by_query(queryDict, self.handleData)
-
- def handleData(self, message: dict) -> None:
- preparedData = dict(
- command='stats',
- type='leaderboardRating',
- leaderboardName=self.leadeboardName,
- values=message["data"],
- meta=message["meta"],
- )
- self.dispatch.dispatch(preparedData)
+ def prepare_data(self, message: dict) -> None:
+ message["leaderboard"] = self.leaderboard_name
+ return message
class LeaderboardApiConnector(DataApiAccessor):
- def __init__(self, dispatch: Dispatcher | None = None) -> None:
+ def __init__(self) -> None:
super().__init__('/data/leaderboard')
- self.dispatch = dispatch
-
- def requestData(self, queryDict: dict | None = None) -> None:
- queryDict = queryDict or {}
- self.get_by_query(queryDict, self.handleData)
-
- def handleData(self, message: dict) -> None:
- preparedData = dict(
- command='stats',
- type='leaderboard',
- values=message["data"],
- meta=message["meta"],
- )
- self.dispatch.dispatch(preparedData)
diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py
index 5ff34089b..c1cf6504a 100644
--- a/src/api/vaults_api.py
+++ b/src/api/vaults_api.py
@@ -1,8 +1,6 @@
import logging
from collections.abc import Sequence
-from PyQt6.QtCore import pyqtSignal
-
from api.ApiAccessors import DataApiAccessor
from api.parsers.MapParser import MapParser
from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser
@@ -12,8 +10,6 @@
class VaultsApiConnector(DataApiAccessor):
- data_ready = pyqtSignal(dict)
-
def __init__(self, route: str) -> None:
super().__init__(route)
self._includes = ("latestVersion", "reviewsSummary")
@@ -47,12 +43,6 @@ def _apply_default_filters(self, query_options: dict) -> dict:
query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";")
return query_options
- def parse_data(self, message: dict) -> dict:
- return message
-
- def handle_response(self, message: dict) -> None:
- self.data_ready.emit(self.parse_data(message))
-
class ModApiConnector(VaultsApiConnector):
def __init__(self) -> None:
@@ -63,9 +53,9 @@ def _extend_query_options(self, query_options: dict) -> dict:
self._extend_includes(query_options, ["uploader"])
return query_options
- def parse_data(self, message: dict) -> dict:
+ def prepare_data(self, message: dict) -> dict:
return {
- "values": ModParser.parse_many(message["data"]),
+ "values": ModParser.parse_many(message["data"]),
"meta": message["meta"],
}
@@ -78,7 +68,7 @@ def _extend_query_options(self, query_options: dict) -> dict:
super()._extend_query_options(query_options)
self._extend_includes(query_options, ["author"])
- def parse_data(self, message: dict) -> None:
+ def prepare_data(self, message: dict) -> None:
return {
"values": MapParser.parse_many(message["data"]),
"meta": message["meta"],
@@ -99,7 +89,7 @@ def _extend_query_options(self, query_options: dict) -> dict:
self._add_default_includes(query_options)
return query_options
- def parse_data(self, message: dict) -> None:
+ def prepare_data(self, message: dict) -> None:
return {
"values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]),
"meta": message["meta"],
diff --git a/src/client/aliasviewer.py b/src/client/aliasviewer.py
index 58a2d1355..6996511ce 100644
--- a/src/client/aliasviewer.py
+++ b/src/client/aliasviewer.py
@@ -14,8 +14,8 @@ class AliasViewer:
def __init__(self, client, alias_formatter):
self.client = client
self.formatter = alias_formatter
- self.api_connector = PlayerApiConnector(self.client.lobby_dispatch)
- self.client.lobby_info.aliasInfo.connect(self.process_alias_info)
+ self.api_connector = PlayerApiConnector()
+ self.api_connector.alias_info.connect(self.process_alias_info)
self.name_to_find = ""
self.searching = False
self.timer = QTimer()
@@ -37,7 +37,7 @@ def process_alias_info(self, message):
self.stop_alias_search()
player_aliases, other_users = [], []
- for player in message["values"]:
+ for player in message["data"]:
if player["login"].lower() == self.name_to_find.lower():
player_aliases.append({
"name": player["login"],
diff --git a/src/client/connection.py b/src/client/connection.py
index b4b3b63f1..e1257b76e 100644
--- a/src/client/connection.py
+++ b/src/client/connection.py
@@ -391,14 +391,11 @@ class LobbyInfo(QtCore.QObject):
statsInfo = QtCore.pyqtSignal(dict)
coopInfo = QtCore.pyqtSignal(dict)
tutorialsInfo = QtCore.pyqtSignal(dict)
- modInfo = QtCore.pyqtSignal(dict)
replayVault = QtCore.pyqtSignal(dict)
coopLeaderBoard = QtCore.pyqtSignal(dict)
avatarList = QtCore.pyqtSignal(list)
social = QtCore.pyqtSignal(dict)
serverSession = QtCore.pyqtSignal(dict)
- aliasInfo = QtCore.pyqtSignal(dict)
- matchmakerQueueInfo = QtCore.pyqtSignal(dict)
def __init__(self, dispatcher, gameset, playerset):
QtCore.QObject.__init__(self)
@@ -406,20 +403,15 @@ def __init__(self, dispatcher, gameset, playerset):
self._dispatcher = dispatcher
self._dispatcher["stats"] = self._simple_emit(self.statsInfo)
self._dispatcher["coop_info"] = self._simple_emit(self.coopInfo)
- self._dispatcher["mod_info_api"] = self._simple_emit(self.modInfo)
- self._dispatcher["mod_info"] = lambda _: None
self._dispatcher["game_info"] = self.handle_game_info
self._dispatcher["replay_vault"] = self._simple_emit(self.replayVault)
self._dispatcher["avatar"] = self.handle_avatar
self._dispatcher["admin"] = self.handle_admin
self._dispatcher["social"] = self._simple_emit(self.social)
self._dispatcher["session"] = self._simple_emit(self.serverSession)
- self._dispatcher["alias_info"] = self._simple_emit(self.aliasInfo)
- self._dispatcher["modvault_list_info"] = self.handle_modvault_list_info
self._dispatcher["updated_achievements"] = (self.handle_updated_achievements)
self._dispatcher["coop_leaderboard"] = self._simple_emit(self.coopLeaderBoard)
self._dispatcher["tutorials_info"] = self._simple_emit(self.tutorialsInfo)
- self._dispatcher["matchmaker_queue_info"] = self._simple_emit(self.matchmakerQueueInfo)
self._gameset = gameset
self._playerset = playerset
@@ -454,11 +446,6 @@ def _update_game(self, m):
else:
self._gameset[uid].update(**m)
- def handle_modvault_list_info(self, message):
- modList = message["modList"]
- for mod in modList:
- self.modVaultInfo.emit(mod)
-
def handle_avatar(self, message):
if "avatarlist" in message:
self.avatarList.emit(message["avatarlist"])
diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py
index 72283fe59..650778b8c 100644
--- a/src/games/_gameswidget.py
+++ b/src/games/_gameswidget.py
@@ -97,7 +97,8 @@ def __init__(
self._game_model = CustomGameFilterModel(self._me, game_model)
self._game_launcher = game_launcher
- self.apiConnector = FeaturedModApiConnector(self.client.lobby_dispatch)
+ self.apiConnector = FeaturedModApiConnector()
+ self.apiConnector.data_ready.connect(self.processModInfo)
self.gameview = gameview_builder(self._game_model, self.gameList)
self.gameview.game_double_clicked.connect(self.gameDoubleClicked)
@@ -106,8 +107,6 @@ def __init__(
self.ispassworded = False
self.party = None
- self.client.lobby_info.modInfo.connect(self.processModInfo)
-
self.client.matchmaker_info.connect(self.handleMatchmakerInfo)
self.client.game_enter.connect(self.stopSearch)
self.client.viewing_replay.connect(self.stopSearch)
diff --git a/src/games/automatchframe.py b/src/games/automatchframe.py
index 2b8f6cec5..2a2aa75ea 100644
--- a/src/games/automatchframe.py
+++ b/src/games/automatchframe.py
@@ -10,7 +10,7 @@
import fa
import util
-from api.matchmaker_queue_api import matchmakerQueueApiConnector
+from api.matchmaker_queue_api import MatchmakerQueueApiConnector
from config import Settings
from fa.factions import Factions
@@ -89,13 +89,9 @@ def __init__(
self.secondsToAutomatch = 0
self.ratingType = ""
- self.client.lobby_info.matchmakerQueueInfo.connect(
- self.handleApiQueueInfo,
- )
- self.apiConnector = matchmakerQueueApiConnector(
- self.client.lobby_dispatch,
- )
- self.apiConnector.requestData(queryDict=dict(include="leaderboard"))
+ self.apiConnector = MatchmakerQueueApiConnector()
+ self.apiConnector.data_ready.connect(self.handleApiQueueInfo)
+ self.apiConnector.requestData({"include": "leaderboard"})
title = self.queueName.replace("_", " ").capitalize()
self.automatchTitle.setText(title)
diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py
index 8eb0288ae..d7ad42e63 100644
--- a/src/replays/_replayswidget.py
+++ b/src/replays/_replayswidget.py
@@ -683,8 +683,8 @@ def __init__(self, widget, dispatcher, client, gameset, playerset):
self.onlineReplays = {}
self.selectedReplay = None
- self.apiConnector = ReplaysApiConnector(self._dispatcher)
- self.client.lobby_info.replayVault.connect(self.replayVault)
+ self.apiConnector = ReplaysApiConnector()
+ self.apiConnector.data_ready.connect(self.process_replays_data)
self.replayDownload = QNetworkAccessManager()
self.replayDownload.finished.connect(self.onDownloadFinished)
self.toolboxHandler = ReplayToolboxHandler(
@@ -1038,27 +1038,25 @@ def onDownloadFinished(self, reply):
faf_replay.close()
replay(os.path.join(util.CACHE_DIR, "temp.fafreplay"))
- def replayVault(self, message):
- action = message["action"]
+ def process_replays_data(self, message: dict) -> None:
self.stopSearchVault()
self._w.replayInfos.clear()
- if action == "search_result":
- self.onlineReplays = {}
- replays = message["replays"]
- for replay_item in replays:
- uid = int(replay_item["id"])
- if uid not in self.onlineReplays:
- self.onlineReplays[uid] = ReplayItem(uid, self._w)
- self.onlineReplays[uid].update(replay_item, self.client)
- self.updateOnlineTree()
-
- if len(message["replays"]) == 0:
- self._w.searchInfoLabel.setText(
- "No replays found",
- )
- self._w.advSearchInfoLabel.setText(
- "No replays found",
- )
+ self.onlineReplays = {}
+ replays = message["data"]
+ for replay_item in replays:
+ uid = int(replay_item["id"])
+ if uid not in self.onlineReplays:
+ self.onlineReplays[uid] = ReplayItem(uid, self._w)
+ self.onlineReplays[uid].update(replay_item, self.client)
+ self.updateOnlineTree()
+
+ if len(message["data"]) == 0:
+ self._w.searchInfoLabel.setText(
+ "No replays found",
+ )
+ self._w.advSearchInfoLabel.setText(
+ "No replays found",
+ )
def updateOnlineTree(self):
self.selectedReplay = None # clear, it won't be part of the new tree
diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py
index b8f7f494e..a13bef632 100644
--- a/src/stats/_statswidget.py
+++ b/src/stats/_statswidget.py
@@ -29,8 +29,6 @@ def __init__(self, client):
self.client = client
- self.client.lobby_info.statsInfo.connect(self.processStatsInfos)
-
self.selected_player = None
self.selected_player_loaded = False
self.leagues.currentChanged.connect(self.leagueUpdate)
@@ -56,8 +54,9 @@ def __init__(self, client):
# setup other tabs
- self.apiConnector = LeaderboardApiConnector(self.client.lobby_dispatch)
- self.apiConnector.requestData(dict(sort="id"))
+ self.apiConnector = LeaderboardApiConnector()
+ self.apiConnector.data_ready.connect(self.process_leaderboards_info)
+ self.apiConnector.requestData({"sort": "id"})
# hiding some non-functional tabs
self.removeTab(self.indexOf(self.ladderTab))
@@ -242,78 +241,19 @@ def leaderboardsTabChanged(self, curr):
if self.leaderboards.widget(curr) is not None:
self.leaderboards.widget(curr).entered()
- @QtCore.pyqtSlot(dict)
- def processStatsInfos(self, message):
-
- typeStat = message["type"]
- if typeStat == "divisions":
- self.currentLeague = message["league"]
- tab = self.currentLeague - 1
-
- if tab not in self.pagesDivisions:
- self.pagesDivisions[tab] = self.createDivisionsTabs(
- message["values"],
- )
- leagueTab = self.leagues.widget(tab).findChild(
- QtWidgets.QTabWidget, "league" + str(tab),
- )
- leagueTab.widget(1).layout().addWidget(
- self.pagesDivisions[tab],
- )
-
- elif typeStat == "division_table":
- self.currentLeague = message["league"]
- self.currentDivision = message["division"]
-
- if self.currentLeague in self.pagesDivisionsResults:
- if (
- self.currentDivision
- in self.pagesDivisionsResults[self.currentLeague]
- ):
- self.createResults(
- message["values"],
- (
- self.pagesDivisionsResults[self.currentLeague]
- [self.currentDivision]
- ),
- )
-
- elif typeStat == "league_table":
- self.currentLeague = message["league"]
- tab = self.currentLeague - 1
- if tab not in self.pagesAllLeagues:
- table = QtWidgets.QTextBrowser()
- self.pagesAllLeagues[tab] = self.createResults(
- message["values"], table,
- )
- leagueTab = self.leagues.widget(tab).findChild(
- QtWidgets.QTabWidget, "league" + str(tab),
- )
- leagueTab.currentChanged.connect(self.divisionsUpdate)
- leagueTab.widget(0).layout().addWidget(
- self.pagesAllLeagues[tab],
- )
-
- elif typeStat == "leaderboard":
- self.leaderboardNames.clear()
- for value in message["values"]:
- self.leaderboardNames.append(value["technicalName"])
- for i in range(len(self.leaderboardNames)):
- self.leaderboards.insertTab(
- i,
- LeaderboardWidget(
- self.client, self, self.leaderboardNames[i],
- ),
- self.leaderboardNames[i].capitalize().replace("_", " "),
- )
- self.client.replays.leaderboardList.addItem(
- self.leaderboardNames[i],
- )
-
- self.leaderboards.setCurrentIndex(1)
- self.leaderboards.currentChanged.connect(
- self.leaderboardsTabChanged,
+ def process_leaderboards_info(self, message: dict) -> None:
+ self.leaderboardNames.clear()
+ for value in message["data"]:
+ self.leaderboardNames.append(value["technicalName"])
+ for index, name in enumerate(self.leaderboardNames):
+ self.leaderboards.insertTab(
+ index,
+ LeaderboardWidget(self.client, self, name),
+ name.capitalize().replace("_", " "),
)
+ self.client.replays.leaderboardList.addItem(name)
+ self.leaderboards.setCurrentIndex(1)
+ self.leaderboards.currentChanged.connect(self.leaderboardsTabChanged)
@QtCore.pyqtSlot()
def busy_entered(self):
diff --git a/src/stats/leaderboard_widget.py b/src/stats/leaderboard_widget.py
index a8a8d1636..a10dc476d 100644
--- a/src/stats/leaderboard_widget.py
+++ b/src/stats/leaderboard_widget.py
@@ -41,12 +41,9 @@ def __init__(
self.client = client
self.parent = parent
self.leaderboardName = leaderboardName
- self.apiConnector = LeaderboardRatingApiConnector(
- self.client.lobby_dispatch, self.leaderboardName,
- )
- self.playerApiConnector = PlayerApiConnector(
- self.client.lobby_dispatch,
- )
+ self.apiConnector = LeaderboardRatingApiConnector(self.leaderboardName)
+ self.apiConnector.data_ready.connect(self.process_rating_info)
+ self.playerApiConnector = PlayerApiConnector()
self.onlyActive = True
self.pageNumber = 1
self.totalPages = 1
@@ -77,7 +74,6 @@ def __init__(
self.pageBox.valueChanged.connect(self.checkTotalPages)
self.refreshButton.clicked.connect(self.refreshLeaderboard)
- self.client.lobby_info.statsInfo.connect(self.processStatsInfos)
self.findInPageLine.textChanged.connect(self.findEntry)
self.findInPageLine.returnPressed.connect(
lambda: self.findEntry(self.findInPageLine.text()),
@@ -169,16 +165,12 @@ def showColumns(self):
self.showColumnCheckBoxes[index].blockSignals(False)
- def processStatsInfos(self, message):
- if message["type"] == "leaderboardRating":
- if message["leaderboardName"] == self.leaderboardName:
- self.createLeaderboard(message)
- self.processMeta(message["meta"])
- self.resetLoading()
- self.timer.stop()
- elif message["type"] == "player":
- if message["leaderboardName"] == self.leaderboardName:
- self.createPlayerCompleter(message)
+ def process_rating_info(self, message: dict) -> None:
+ if message["leaderboard"] == self.leaderboardName:
+ self.createLeaderboard(message)
+ self.processMeta(message["meta"])
+ self.resetLoading()
+ self.timer.stop()
def createLeaderboard(self, data):
self.model = LeaderboardTableModel(data)
@@ -220,20 +212,15 @@ def findEntry(self, text):
self.tableView.selectRow(self.model.logins.index(row))
break
- def searchPlayer(self):
+ def searchPlayer(self) -> None:
query = {
"filter": 'login=="{}*"'.format(self.searchPlayerLine.text()),
"page[size]": 10,
}
- self.playerApiConnector.requestDataForLeaderboard(
- self.leaderboardName, query,
- )
-
- def createPlayerCompleter(self, message):
- logins = []
- for value in message["values"]:
- logins.append(value["login"])
+ self.playerApiConnector.get_by_query(query, self.createPlayerCompleter)
+ def createPlayerCompleter(self, message: dict) -> None:
+ logins = [player["login"] for player in message["data"]]
self.searchPlayerLine.set_completion_list(logins)
completer = QtWidgets.QCompleter(
sorted(logins, key=lambda login: login.lower()),
diff --git a/src/stats/models/leaderboardtablemodel.py b/src/stats/models/leaderboardtablemodel.py
index 18b04dd69..c01c95d06 100644
--- a/src/stats/models/leaderboardtablemodel.py
+++ b/src/stats/models/leaderboardtablemodel.py
@@ -9,14 +9,14 @@ def __init__(self, data=None):
QAbstractTableModel.__init__(self)
self.load_data(data)
- def load_data(self, data):
- self.values = data["values"]
+ def load_data(self, data: dict) -> None:
+ self.values = data["data"]
self.meta = data["meta"]
self.logins = []
for value in self.values:
self.logins.append(value["player"]["login"])
self.column_count = 9
- self.row_count = len(data["values"])
+ self.row_count = len(data["data"])
def rowCount(self, parent=QModelIndex()):
return self.row_count
From 53bdebcd36d6aeeca7ab265809af6def82e50ade Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 25 Apr 2024 22:28:49 +0300
Subject: [PATCH 047/123] pre-commit autoupdate
---
.pre-commit-config.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e0eb7f6ad..2a40de6c3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.5.0
+ rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
From 4426540b88de2559b9e585bcaa21fdc99f843efe Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 26 Apr 2024 23:05:31 +0300
Subject: [PATCH 048/123] Create api model for FeaturedMod
---
src/api/featured_mod_api.py | 16 ++--------------
src/api/models/FeaturedMod.py | 10 ++++++++++
src/api/parsers/FeaturedModParser.py | 21 +++++++++++++++++++++
src/games/_gameswidget.py | 14 +++++++-------
src/games/moditem.py | 18 +++++++++---------
5 files changed, 49 insertions(+), 30 deletions(-)
create mode 100644 src/api/models/FeaturedMod.py
create mode 100644 src/api/parsers/FeaturedModParser.py
diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py
index 1577fc4cb..164e9332d 100644
--- a/src/api/featured_mod_api.py
+++ b/src/api/featured_mod_api.py
@@ -1,6 +1,7 @@
import logging
from api.ApiAccessors import DataApiAccessor
+from api.parsers.FeaturedModParser import FeaturedModParser
logger = logging.getLogger(__name__)
@@ -10,17 +11,4 @@ def __init__(self) -> None:
super().__init__('/data/featuredMod')
def prepare_data(self, message: dict) -> None:
- values = []
- for mod in message["data"]:
- prepared_mod = {
- "name": mod["technicalName"],
- "fullname": mod["displayName"],
- "publish": mod.get("visible", False),
- "order": mod.get("order", 0),
- "desc": mod.get(
- "description",
- "No description provided",
- ),
- }
- values.append(prepared_mod)
- return {"values": values}
+ return {"values": FeaturedModParser.parse_many(message["data"])}
diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py
new file mode 100644
index 000000000..abb8f9030
--- /dev/null
+++ b/src/api/models/FeaturedMod.py
@@ -0,0 +1,10 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class FeaturedMod:
+ name: str
+ fullname: str
+ visible: bool
+ order: int
+ description: str
diff --git a/src/api/parsers/FeaturedModParser.py b/src/api/parsers/FeaturedModParser.py
new file mode 100644
index 000000000..2ebfcdd9a
--- /dev/null
+++ b/src/api/parsers/FeaturedModParser.py
@@ -0,0 +1,21 @@
+from api.models.FeaturedMod import FeaturedMod
+
+
+class FeaturedModParser:
+
+ @staticmethod
+ def parse(data: dict) -> FeaturedMod:
+ return FeaturedMod(
+ name=data["technicalName"],
+ fullname=data["displayName"],
+ visible=data.get("visible", False),
+ order=data.get("order", 0),
+ description=data.get(
+ "description",
+ "No description provided",
+ ),
+ )
+
+ @staticmethod
+ def parse_many(data: list[dict]) -> list[FeaturedMod]:
+ return [FeaturedModParser.parse(info) for info in data]
diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py
index 650778b8c..9d1a2b057 100644
--- a/src/games/_gameswidget.py
+++ b/src/games/_gameswidget.py
@@ -98,7 +98,7 @@ def __init__(
self._game_launcher = game_launcher
self.apiConnector = FeaturedModApiConnector()
- self.apiConnector.data_ready.connect(self.processModInfo)
+ self.apiConnector.data_ready.connect(self.process_mod_info)
self.gameview = gameview_builder(self._game_model, self.gameList)
self.gameview.game_double_clicked.connect(self.gameDoubleClicked)
@@ -172,14 +172,14 @@ def onLogOut(self):
self.matchmakerFramesInitialized = False
@pyqtSlot(dict)
- def processModInfo(self, message):
+ def process_mod_info(self, message: dict) -> None:
"""
Slot that interprets and propagates mod_info messages into the mod list
"""
- for value in message["values"]:
- mod = value['name']
+ for featured_mod in message["values"]:
+ mod = featured_mod.name
old_mod = self.mods.get(mod, None)
- self.mods[mod] = ModItem(value)
+ self.mods[mod] = ModItem(featured_mod)
if old_mod:
if mod in mod_invisible:
@@ -191,12 +191,12 @@ def processModInfo(self, message):
if self.client.replays.modList.itemText(i) == old_mod.mod:
self.client.replays.modList.removeItem(i)
- if value["publish"]:
+ if featured_mod.visible:
self.modList.addItem(self.mods[mod])
else:
mod_invisible[mod] = self.mods[mod]
- self.client.replays.modList.addItem(value["name"])
+ self.client.replays.modList.addItem(mod)
@pyqtSlot(int)
def togglePrivateGames(self, state):
diff --git a/src/games/moditem.py b/src/games/moditem.py
index 93fd64680..7142083f1 100644
--- a/src/games/moditem.py
+++ b/src/games/moditem.py
@@ -5,6 +5,7 @@
import client
import util
+from api.models.FeaturedMod import FeaturedMod
# Maps names of featured mods to ModItem objects.
mods = {}
@@ -18,18 +19,17 @@
class ModItem(QtWidgets.QListWidgetItem):
- def __init__(self, message, *args, **kwargs):
- QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs)
+ def __init__(self, mod_info: FeaturedMod, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
- self.mod = message["name"]
- self.order = message.get("order", 0)
- self.name = message["fullname"]
+ self.mod = mod_info.name
+ self.order = mod_info.order
+ self.name = mod_info.fullname
# Load Icon and Tooltip
- tip = message["desc"]
- self.setToolTip(tip)
+ self.setToolTip(mod_info.description)
- icon = util.THEME.icon(os.path.join("games/mods/", self.mod + ".png"))
+ icon = util.THEME.icon(os.path.join("games/mods/", f"{self.mod}.png"))
if icon.isNull():
icon = util.THEME.icon("games/mods/default.png")
self.setIcon(icon)
@@ -56,6 +56,6 @@ def __lt__(self, other):
if self.order == other.order:
# Default: Alphabetical
- return self.name.lower() < other.mod.lower()
+ return self.name.lower() < other.name.lower()
return self.order < other.order
From ab20f214128d482dfb9faef6ced40b567d2f4ba4 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 27 Apr 2024 21:47:24 +0300
Subject: [PATCH 049/123] Clean up updater a little
* remove unused code from Updater class
* fix duplications in updating logic
* do not redownload ForgedAlliance.exe file -- patch it if needed
So we take files of specific version from API and update all
except ForgedAlliance.exe -- versions for this file are broken
(at least for fafbeta and fafdevelop featured mods) -- we patch
it ourselves: patching is just writing version to special addresses
---
src/config/production.py | 2 +
src/fa/check.py | 14 +-
src/fa/mods.py | 2 +-
src/fa/replayparser.py | 2 +-
src/fa/updater.py | 444 +++++++++++++++------------------------
5 files changed, 176 insertions(+), 288 deletions(-)
diff --git a/src/config/production.py b/src/config/production.py
index dc872ba18..e0f805a5a 100644
--- a/src/config/production.py
+++ b/src/config/production.py
@@ -28,6 +28,8 @@
'game/logs/path': join(APPDATA_DIR, 'logs'),
'game/mods/path': join(join(APPDATA_DIR, 'repo'), 'mods'),
'game/maps/path': join(join(APPDATA_DIR, 'repo'), 'maps'),
+ 'game/exe-url': 'https://content.{host}/faf/updaterNew/updates_faf_files/ForgedAlliance.exe',
+ 'game/exe-name': "ForgedAlliance.exe",
'host': 'faforever.com',
'proxy/host': 'proxy.{host}',
'proxy/port': 9124,
diff --git a/src/fa/check.py b/src/fa/check.py
index 166f5eec5..6e9275d8d 100644
--- a/src/fa/check.py
+++ b/src/fa/check.py
@@ -150,12 +150,12 @@ def checkMovies(files):
def check(
- featured_mod,
- mapname=None,
- version=None,
- modVersions=None,
- sim_mods=None,
- silent=False,
+ featured_mod: str,
+ mapname: str | None = None,
+ version: int | None = None,
+ modVersions: dict | None = None,
+ sim_mods: dict[str, str] | None = None,
+ silent: bool = False,
):
"""
This checks whether the mods are properly updated and player has the
@@ -182,7 +182,7 @@ def check(
)
result = game_updater.run()
- if result != fa.updater.Updater.RESULT_SUCCESS:
+ if result != fa.updater.UpdaterResult.SUCCESS:
return False
try:
diff --git a/src/fa/mods.py b/src/fa/mods.py
index ff367ea60..a05b78afd 100644
--- a/src/fa/mods.py
+++ b/src/fa/mods.py
@@ -48,7 +48,7 @@ def checkMods(mods: dict[str, str]) -> bool: # mods is a dictionary of uid-name
# Spawn an update for the required mod
updater = fa.updater.Updater("sim", sim_mod=item)
result = updater.run()
- if result != fa.updater.Updater.RESULT_SUCCESS:
+ if result != fa.updater.UpdaterResult.SUCCESS:
logger.warning(f"Failure getting {item}")
return False
diff --git a/src/fa/replayparser.py b/src/fa/replayparser.py
index a6d647da8..6bc708e1b 100644
--- a/src/fa/replayparser.py
+++ b/src/fa/replayparser.py
@@ -35,7 +35,7 @@ def getVersion(self):
if not supcomVersion.startswith("Supreme Commander v1"):
return None
else:
- return supcomVersion.split(".")[-1]
+ return int(supcomVersion.split(".")[-1])
def getMapName(self):
with open(self.file, 'rb') as f:
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 77f2d7d9b..950388034 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -13,11 +13,11 @@
import shutil
import stat
import time
+from enum import Enum
from PyQt6 import QtCore
from PyQt6 import QtWidgets
-import config
import util
from api.featured_mod_updater import FeaturedModFiles
from api.featured_mod_updater import FeaturedModId
@@ -93,21 +93,20 @@ class UpdaterTimeout(Exception):
pass
+class UpdaterResult(Enum):
+ SUCCESS = 0 # Update successful
+ NONE = -1 # Update operation is still ongoing
+ FAILURE = 1 # An error occured during updating
+ CANCEL = 2 # User cancelled the download process
+ ILLEGAL = 3 # User has the wrong version of FA
+ BUSY = 4 # Server is currently busy
+ PASS = 5 # User refuses to update by canceling the wizard
+
+
class Updater(QtCore.QObject):
"""
This is the class that does the actual installation work.
"""
- # Network configuration
- TIMEOUT = 20 # seconds
-
- # Return codes to expect from run()
- RESULT_SUCCESS = 0 # Update successful
- RESULT_NONE = -1 # Update operation is still ongoing
- RESULT_FAILURE = 1 # An error occured during updating
- RESULT_CANCEL = 2 # User cancelled the download process
- RESULT_ILLEGAL = 3 # User has the wrong version of FA
- RESULT_BUSY = 4 # Server is currently busy
- RESULT_PASS = 5 # User refuses to update by canceling the wizard
def __init__(
self,
@@ -124,19 +123,13 @@ def __init__(
"""
QtCore.QObject.__init__(self, *args, **kwargs)
- self.filesToUpdate = []
- self.updatedFiles = []
-
- self.lastData = time.time()
-
self.featured_mod = featured_mod
self.version = version
self.modversions = modversions
-
self.sim_mod = sim_mod
- self.modpath = None
+ self.silent = silent
- self.result = self.RESULT_NONE
+ self.result = UpdaterResult.NONE
self.keep_cache = not Settings.get(
'cache/do_not_keep', type=bool, default=True,
@@ -144,12 +137,7 @@ def __init__(
self.in_session_cache = Settings.get(
'cache/in_session', type=bool, default=False,
)
- self.fmod_cache_dir = os.path.join(util.CACHE_DIR, 'featured_mod')
- if self.keep_cache or self.in_session_cache:
- if not os.path.exists(self.fmod_cache_dir):
- os.mkdir(self.fmod_cache_dir)
- self.silent = silent
self.progress = QtWidgets.QProgressDialog()
if self.silent:
self.progress.setCancelButton(None)
@@ -161,11 +149,7 @@ def __init__(
self.progress.setAutoClose(False)
self.progress.setAutoReset(False)
self.progress.setModal(1)
- self.progress.setWindowTitle(
- "Updating {}".format(self.featured_mod.upper()),
- )
-
- self.bytesToSend = 0
+ self.progress.setWindowTitle(f"Updating {self.featured_mod.upper()}")
def run(self, *args, **kwargs):
clearLog()
@@ -181,29 +165,41 @@ def run(self, *args, **kwargs):
if not self.progress.wasCanceled():
log("Connected to update server at {}".format(timestamp()))
- self.doUpdate()
+ self.do_update()
self.progress.setLabelText("Cleaning up.")
self.progress.close()
else:
log("Cancelled connecting to server.")
- self.result = self.RESULT_CANCEL
+ self.result = UpdaterResult.CANCEL
log("Update finished at {}".format(timestamp()))
return self.result
- def getFilesToUpdate(self, id_, version):
- return FeaturedModFiles(id_, version).getFiles()
+ def get_files_to_update(self, mod_id: int, version: str) -> list[dict]:
+ return FeaturedModFiles(mod_id, version).getFiles()
- def getFeaturedModIdByName(self, technicalName):
- return FeaturedModId().requestAndGetFeaturedModIdByName(technicalName)
+ def get_featured_mod_id_by_name(self, technical_name: str) -> int:
+ return FeaturedModId().requestAndGetFeaturedModIdByName(technical_name)
def request_sim_url_by_uid(self, uid: str) -> str:
return SimModFiles().request_and_get_sim_mod_url_by_id(uid)
- def fetch_file(self, file_info: dict, filegroup: str) -> None:
+ @staticmethod
+ def _file_needs_update(file_info: dict) -> bool:
+ filegroup = file_info["group"]
+ filename = file_info["name"]
+ filepath = os.path.join(util.APPDATA_DIR, filegroup, filename)
+ return filename != Settings.get("game/exe-name") and util.md5(filepath) != file_info["md5"]
+
+ def fetch_files(self, files: list[dict]) -> None:
+ for file in files:
+ self.fetch_single_file(file)
+
+ def fetch_single_file(self, file_info: dict) -> None:
name = file_info["name"]
+ filegroup = file_info["group"]
url = file_info["cacheableUrl"]
target_dir = os.path.join(util.APPDATA_DIR, filegroup)
@@ -226,122 +222,84 @@ def fetch_file(self, file_info: dict, filegroup: str) -> None:
"Operation aborted while waiting for data.",
)
- def moveFromCache(self, files, filegroup):
- src_dir = os.path.join(util.APPDATA_DIR, filegroup)
- cache_dir = os.path.join(self.fmod_cache_dir, filegroup)
- for _file in files:
- if os.path.exists(os.path.join(cache_dir, _file['md5'])):
- shutil.move(
- os.path.join(cache_dir, _file['md5']),
- os.path.join(src_dir, _file['name']),
- )
-
- def moveToCache(self, files, filegroup):
- src_dir = os.path.join(util.APPDATA_DIR, filegroup)
- cache_dir = os.path.join(self.fmod_cache_dir, filegroup)
- for _file in files:
- if os.path.exists(os.path.join(src_dir, _file['name'])):
- md5 = util.md5(os.path.join(src_dir, _file['name']))
- shutil.move(
- os.path.join(src_dir, _file['name']),
- os.path.join(cache_dir, md5),
- )
- util.setAccessTime(os.path.join(cache_dir, md5))
-
- def replaceFromCache(self, files, filegroup):
- self.moveToCache(files, filegroup)
- self.moveFromCache(files, filegroup)
-
- def checkCache(self, filegroup, files_to_check):
- cache_dir = os.path.join(self.fmod_cache_dir, filegroup)
- if not os.path.exists(cache_dir):
- os.mkdir(cache_dir)
- for src_dir, _, _files in os.walk(cache_dir):
- files_in_cache = _files
- replaceable_files, need_to_download = [], []
- for _file in files_to_check:
- if _file['md5'] in files_in_cache:
- replaceable_files.append(_file)
- self.filesToUpdate.remove(_file)
- else:
- need_to_download.append(_file)
- return replaceable_files, need_to_download
-
- def updateFiles(self, filegroup, files):
- """
- Updates the files in a given file group, in the destination
- subdirectory of the Forged Alliance path.
- """
- QtWidgets.QApplication.processEvents()
+ def move_many_from_cache(self, files: list[dict]) -> None:
+ for file in files:
+ self.move_from_cache(file)
+
+ def move_from_cache(self, file_info: dict) -> None:
+ src_dir = os.path.join(util.APPDATA_DIR, file_info["group"])
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"])
+ if os.path.exists(os.path.join(cache_dir, file_info["md5"])):
+ shutil.move(
+ os.path.join(cache_dir, file_info["md5"]),
+ os.path.join(src_dir, file_info["name"]),
+ )
- self.progress.setLabelText("Updating files: " + filegroup)
+ def move_many_to_cache(self, files: list[dict]) -> None:
+ for file in files:
+ self.move_to_cache(file)
+
+ def move_to_cache(self, file_info: dict) -> None:
+ src_dir = os.path.join(util.APPDATA_DIR, file_info["group"])
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"])
+ if os.path.exists(os.path.join(src_dir, file_info["name"])):
+ md5 = util.md5(os.path.join(src_dir, file_info["name"]))
+ shutil.move(
+ os.path.join(src_dir, file_info["name"]),
+ os.path.join(cache_dir, md5),
+ )
+ util.setAccessTime(os.path.join(cache_dir, md5))
- targetdir = os.path.join(util.APPDATA_DIR, filegroup)
- if not os.path.exists(targetdir):
- os.makedirs(targetdir)
+ def replace_from_cache(self, file: dict) -> None:
+ self.move_to_cache(file)
+ self.move_from_cache(file)
- files_to_check = []
+ def replace_many_from_cache(self, files: list[dict]) -> None:
+ for file in files:
+ self.replace_from_cache(file)
- for _file in files:
- md5File = util.md5(
- os.path.join(util.APPDATA_DIR, filegroup, _file['name']),
- )
- md5NewFile = _file['md5']
- if md5File == md5NewFile:
- self.filesToUpdate.remove(_file)
+ def check_cache(self, files_to_check: list[dict]) -> None:
+ replaceable_files, need_to_download = [], []
+ for file in files_to_check:
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file["group"])
+ os.makedirs(cache_dir, exist_ok=True)
+ if self._is_cached(file):
+ replaceable_files.append(file)
else:
- if self.keep_cache or self.in_session_cache:
- files_to_check.append(_file)
- else:
- self.fetch_file(_file, filegroup)
- self.filesToUpdate.remove(_file)
- self.updatedFiles.append(_file['name'])
+ need_to_download.append(file)
+ return replaceable_files, need_to_download
- if len(files_to_check) > 0:
- replaceable_files, need_to_download = (
- self.checkCache(filegroup, files_to_check)
- )
- self.replaceFromCache(replaceable_files, filegroup)
- for _file in need_to_download:
- self.moveToCache([_file], filegroup)
- self.fetch_file(_file, filegroup)
- self.filesToUpdate.remove(_file)
- self.updatedFiles.append(_file['name'])
+ @staticmethod
+ def _is_cached(file_info: dict) -> bool:
+ cached_file = os.path.join(util.GAME_CACHE_DIR, file_info["group"], file_info["name"])
+ return os.path.isfile(cached_file)
- self.waitUntilFilesAreUpdated()
+ def create_cache_subdirs(self, files: list[dict]) -> None:
+ for file in files:
+ target = os.path.join(util.GAME_CACHE_DIR, file["group"])
+ os.makedirs(target, exist_ok=True)
- def waitUntilFilesAreUpdated(self):
+ def update_files(self, files: list[dict]) -> None:
"""
- A simple loop that updates the progress bar while the server sends
- actual file data
+ Updates the files in the destination
+ subdirectory of the Forged Alliance path.
"""
- self.lastData = time.time()
-
- self.progress.setValue(0)
- self.progress.setMinimum(0)
- self.progress.setMaximum(0)
-
- while len(self.filesToUpdate) > 0:
- if self.progress.wasCanceled():
- raise UpdaterCancellation(
- "Operation aborted while waiting for data.",
- )
+ self.create_cache_subdirs(files)
+ self.patch_fa_exe_if_needed(files)
- if self.result != self.RESULT_NONE:
- raise UpdaterFailure(
- "Operation failed while waiting for data.",
- )
+ to_update = list(filter(self._file_needs_update, files))
+ replacable_files, need_to_download = self.check_cache(to_update)
- if time.time() - self.lastData > self.TIMEOUT:
- raise UpdaterTimeout(
- "Connection timed out while waiting for data.",
- )
-
- QtWidgets.QApplication.processEvents()
+ if self.keep_cache or self.in_session_cache:
+ self.replace_many_from_cache(replacable_files)
+ self.move_many_to_cache(need_to_download)
+ else:
+ self.move_many_from_cache(replacable_files)
+ self.fetch_files(need_to_download)
log("Updates applied successfully.")
- def prepareBinFAF(self):
+ def prepare_bin_FAF(self) -> None:
"""
Creates all necessary files in the binFAF folder, which contains
a modified copy of all that is in the standard bin folder of
@@ -350,9 +308,7 @@ def prepareBinFAF(self):
self.progress.setLabelText("Preparing binFAF...")
# now we check if we've got a binFAF folder
- FABindir = os.path.join(
- config.Settings.get("ForgedAlliance/app/path"), 'bin',
- )
+ FABindir = os.path.join(Settings.get("ForgedAlliance/app/path"), "bin")
FAFdir = util.BIN_DIR
# Try to copy without overwriting, but fill in any missing files,
@@ -374,170 +330,100 @@ def prepareBinFAF(self):
# need to patch them
os.chmod(dst_file, st.st_mode | stat.S_IWRITE)
- def doUpdate(self) -> None:
+ self.download_fa_executable()
+
+ def download_fa_executable(self) -> bool:
+ fa_exe_name = Settings.get("game/exe-name")
+ fa_exe = os.path.join(util.BIN_DIR, fa_exe_name)
+
+ if os.path.isfile(fa_exe):
+ return True
+
+ url = Settings.get("game/exe-url")
+ return download_file(
+ url=url,
+ target_dir=util.BIN_DIR,
+ name=fa_exe_name,
+ category="Update",
+ silent=False,
+ label=f"Downloading FA file : {url}",
+ )
+
+ def patch_fa_executable(self, version: int) -> None:
+ exe_path = os.path.join(util.BIN_DIR, "ForgedAlliance.exe")
+ version_addresses = (0xd3d40, 0x47612d, 0x476666)
+ with open(exe_path, "rb+") as file:
+ for address in version_addresses:
+ file.seek(address)
+ file.write(version.to_bytes(4, "little"))
+
+ def patch_fa_exe_if_needed(self, files: list[dict]) -> None:
+ for file in files:
+ if file["name"] == Settings.get("game/exe-name"):
+ version = int(self._resolve_base_version(file))
+ self.patch_fa_executable(version)
+ return
+
+ def update_featured_mod(self, modname: str, modversion: str) -> list[dict]:
+ fmod_id = self.get_featured_mod_id_by_name(modname)
+ files = self.get_files_to_update(fmod_id, modversion)
+ self.update_files(files)
+ return files
+
+ def _resolve_modversion(self) -> str:
+ if self.modversions:
+ return str(max(self.modversions.values()))
+ return "latest"
+
+ def _resolve_base_version(self, base_info: dict | None) -> str:
+ if self.version:
+ return str(self.version)
+ if base_info:
+ return str(base_info["version"])
+ return "latest"
+
+ def do_update(self) -> None:
""" The core function that does most of the actual update work."""
try:
if self.sim_mod:
uid, name = self.sim_mod
if utils.downloadMod(self.request_sim_url_by_uid(uid), name):
- self.result = self.RESULT_SUCCESS
+ self.result = UpdaterResult.SUCCESS
else:
- self.result = self.RESULT_FAILURE
+ self.result = UpdaterResult.FAILURE
else:
# Prepare FAF directory & all necessary files
- self.prepareBinFAF()
-
- toUpdate = []
- filesToUpdateInBin = []
- filesToUpdateInGamedata = []
-
+ self.prepare_bin_FAF()
# Update the mod if it's requested
- if (
- self.featured_mod == "faf"
- # HACK - ladder1v1 "is" FAF. :-)
- or self.featured_mod == "ladder1v1"
- ):
- if self.version:
- # id for faf (or ladder1v1) is 0
- toUpdate = self.getFilesToUpdate('0', self.version)
- else:
- toUpdate = self.getFilesToUpdate('0', 'latest')
-
- for _file in toUpdate:
- if _file['group'] == 'bin':
- filesToUpdateInBin.append(_file)
- else:
- filesToUpdateInGamedata.append(_file)
-
- self.filesToUpdate = filesToUpdateInBin.copy()
- self.updateFiles("bin", filesToUpdateInBin)
- self.filesToUpdate = filesToUpdateInGamedata.copy()
- self.updateFiles("gamedata", filesToUpdateInGamedata)
-
- elif (
- self.featured_mod == 'fafbeta'
- or self.featured_mod == 'fafdevelop'
- ):
- # no need to update faf first for these mods
- id_ = self.getFeaturedModIdByName(self.featured_mod)
- if self.modversions:
- modversion = sorted(
- self.modversions.items(),
- key=lambda item: item[1],
- reverse=True,
- )[0][1]
- else:
- modversion = 'latest'
-
- toUpdate = self.getFilesToUpdate(id_, modversion)
- toUpdate.sort(
- key=lambda item: item['version'],
- reverse=True,
- )
-
- # file lists for fafbeta and fafdevelop contain wrong
- # version of ForgedAlliance.exe
- if self.version:
- faf_version = self.version
- else:
- faf_version = toUpdate[0]['version']
-
- for _file in toUpdate:
- if _file['group'] == 'bin':
- if _file['name'] != 'ForgedAlliance.exe':
- filesToUpdateInBin.append(_file)
- else:
- filesToUpdateInGamedata.append(_file)
-
- self.filesToUpdate = filesToUpdateInBin.copy()
- self.updateFiles('bin', filesToUpdateInBin)
- self.filesToUpdate = filesToUpdateInGamedata.copy()
- self.updateFiles('gamedata', filesToUpdateInGamedata)
-
- filesToUpdateInBin.clear()
- filesToUpdateInGamedata.clear()
-
- # update proper version of bin
- toUpdate = self.getFilesToUpdate('0', faf_version)
-
- for _file in toUpdate:
- if _file['group'] == 'bin':
- filesToUpdateInBin.append(_file)
-
- self.filesToUpdate = filesToUpdateInBin.copy()
- self.updateFiles('bin', filesToUpdateInBin)
-
+ if self.featured_mod in ("faf", "fafbeta", "fafdevelop", "ladder1v1"):
+ self.update_featured_mod(self.featured_mod, self._resolve_base_version())
else:
# update faf first
- # id for faf (or ladder1v1) is 0
- if self.version:
- toUpdate = self.getFilesToUpdate('0', self.version)
- else:
- toUpdate = self.getFilesToUpdate('0', 'latest')
-
- for _file in toUpdate:
- if _file['group'] == 'bin':
- filesToUpdateInBin.append(_file)
- else:
- filesToUpdateInGamedata.append(_file)
-
- self.filesToUpdate = filesToUpdateInBin.copy()
- self.updateFiles("bin", filesToUpdateInBin)
- self.filesToUpdate = filesToUpdateInGamedata.copy()
- self.updateFiles("gamedata", filesToUpdateInGamedata)
-
- filesToUpdateInBin.clear()
- filesToUpdateInGamedata.clear()
-
- # update featuredMod then
- id_ = self.getFeaturedModIdByName(self.featured_mod)
- if self.modversions:
- modversion = sorted(
- self.modversions.items(),
- key=lambda item: item[1],
- reverse=True,
- )[0][1]
- else:
- modversion = 'latest'
-
- toUpdate = self.getFilesToUpdate(id_, modversion)
-
- for _file in toUpdate:
- if _file['group'] == 'bin':
- filesToUpdateInBin.append(_file)
- else:
- filesToUpdateInGamedata.append(_file)
-
- self.filesToUpdate = filesToUpdateInBin.copy()
- self.updateFiles("bin", filesToUpdateInBin)
- self.filesToUpdate = filesToUpdateInGamedata.copy()
- self.updateFiles("gamedata", filesToUpdateInGamedata)
-
- except UpdaterTimeout as e:
- log("TIMEOUT: {}".format(e))
- self.result = self.RESULT_FAILURE
+ self.update_featured_mod("faf", self._resolve_base_version())
+ # update featured mod then
+ self.update_featured_mod(self.featured_mod, self._resolve_modversion())
except UpdaterCancellation as e:
log("CANCELLED: {}".format(e))
- self.result = self.RESULT_CANCEL
+ self.result = UpdaterResult.CANCEL
except BaseException as e:
log("EXCEPTION: {}".format(e))
- self.result = self.RESULT_FAILURE
+ self.result = UpdaterResult.FAILURE
else:
- self.result = self.RESULT_SUCCESS
+ self.result = UpdaterResult.SUCCESS
# Hide progress dialog if it's still showing.
self.progress.close()
# Integrated handlers for the various things that could go wrong
- if self.result == self.RESULT_CANCEL:
+ if self.result == UpdaterResult.CANCEL:
pass # The user knows damn well what happened here.
- elif self.result == self.RESULT_PASS:
+ elif self.result == UpdaterResult.PASS:
QtWidgets.QMessageBox.information(
QtWidgets.QApplication.activeWindow(),
"Installation Required",
"You can't play without a legal version of Forged Alliance.",
)
- elif self.result == self.RESULT_BUSY:
+ elif self.result == UpdaterResult.BUSY:
QtWidgets.QMessageBox.information(
QtWidgets.QApplication.activeWindow(),
"Server Busy",
@@ -546,7 +432,7 @@ def doUpdate(self) -> None:
"again later."
),
)
- elif self.result == self.RESULT_FAILURE:
+ elif self.result == UpdaterResult.FAILURE:
failureDialog()
# If nothing terribly bad happened until now,
From e0f4d78c68794ffb9f0b8e7964e56b99ca17daa2 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 28 Apr 2024 10:11:00 +0300
Subject: [PATCH 050/123] Updater: Be more specific with Qt imports
---
src/fa/updater.py | 35 ++++++++++++++++++++---------------
1 file changed, 20 insertions(+), 15 deletions(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 950388034..78d716c40 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -15,8 +15,13 @@
import time
from enum import Enum
-from PyQt6 import QtCore
-from PyQt6 import QtWidgets
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import pyqtSlot
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QDialog
+from PyQt6.QtWidgets import QMessageBox
+from PyQt6.QtWidgets import QProgressDialog
import util
from api.featured_mod_updater import FeaturedModFiles
@@ -43,22 +48,22 @@ def __init__(self, parent):
self.adjustSize()
self.watches = []
- @QtCore.pyqtSlot(str)
+ @pyqtSlot(str)
def appendLog(self, text):
self.logPlainTextEdit.appendPlainText(text)
- @QtCore.pyqtSlot(QtCore.QObject)
+ @pyqtSlot(QObject)
def addWatch(self, watch):
self.watches.append(watch)
watch.finished.connect(self.watchFinished)
- @QtCore.pyqtSlot()
+ @pyqtSlot()
def watchFinished(self) -> None:
for watch in self.watches:
if not watch.isFinished():
return
# equivalent to self.accept(), but clearer
- self.done(QtWidgets.QDialog.DialogCode.Accepted)
+ self.done(QDialog.DialogCode.Accepted)
def clearLog():
@@ -103,7 +108,7 @@ class UpdaterResult(Enum):
PASS = 5 # User refuses to update by canceling the wizard
-class Updater(QtCore.QObject):
+class Updater(QObject):
"""
This is the class that does the actual installation work.
"""
@@ -121,7 +126,7 @@ def __init__(
"""
Constructor
"""
- QtCore.QObject.__init__(self, *args, **kwargs)
+ super().__init__(*args, **kwargs)
self.featured_mod = featured_mod
self.version = version
@@ -138,13 +143,13 @@ def __init__(
'cache/in_session', type=bool, default=False,
)
- self.progress = QtWidgets.QProgressDialog()
+ self.progress = QProgressDialog()
if self.silent:
self.progress.setCancelButton(None)
else:
self.progress.setCancelButtonText("Cancel")
self.progress.setWindowFlags(
- QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint,
+ Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint,
)
self.progress.setAutoClose(False)
self.progress.setAutoReset(False)
@@ -157,7 +162,7 @@ def run(self, *args, **kwargs):
log("Using appdata: " + util.APPDATA_DIR)
self.progress.show()
- QtWidgets.QApplication.processEvents()
+ QApplication.processEvents()
# Actual network code adapted from previous version
self.progress.setLabelText("Connecting to update server...")
@@ -418,14 +423,14 @@ def do_update(self) -> None:
if self.result == UpdaterResult.CANCEL:
pass # The user knows damn well what happened here.
elif self.result == UpdaterResult.PASS:
- QtWidgets.QMessageBox.information(
- QtWidgets.QApplication.activeWindow(),
+ QMessageBox.information(
+ QApplication.activeWindow(),
"Installation Required",
"You can't play without a legal version of Forged Alliance.",
)
elif self.result == UpdaterResult.BUSY:
- QtWidgets.QMessageBox.information(
- QtWidgets.QApplication.activeWindow(),
+ QMessageBox.information(
+ QApplication.activeWindow(),
"Server Busy",
(
"The Server is busy preparing new patch files.
Try "
From 638ab1b135cb08ed4be02541652b5ee9a7e6855d Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Mon, 29 Apr 2024 19:57:41 +0300
Subject: [PATCH 051/123] Do not require optional argument
---
src/fa/updater.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 78d716c40..e58d10802 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -380,7 +380,7 @@ def _resolve_modversion(self) -> str:
return str(max(self.modversions.values()))
return "latest"
- def _resolve_base_version(self, base_info: dict | None) -> str:
+ def _resolve_base_version(self, base_info: dict | None = None) -> str:
if self.version:
return str(self.version)
if base_info:
From 2c36817ecc0af2ade61220d94c07504383c42c9f Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 30 Apr 2024 19:44:27 +0300
Subject: [PATCH 052/123] Unpack movies inside Updater
because it has the information about files being updated
---
src/fa/check.py | 57 -----------------------------------------------
src/fa/updater.py | 3 +++
src/fa/utils.py | 54 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 57 insertions(+), 57 deletions(-)
create mode 100644 src/fa/utils.py
diff --git a/src/fa/check.py b/src/fa/check.py
index 6e9275d8d..7bf6465cc 100644
--- a/src/fa/check.py
+++ b/src/fa/check.py
@@ -1,9 +1,6 @@
from __future__ import annotations
-import binascii
import logging
-import os
-import zipfile
from typing import TYPE_CHECKING
from PyQt6 import QtWidgets
@@ -102,53 +99,6 @@ def game(parent):
return True
-def crc32(fname):
- try:
- with open(fname) as stream:
- return binascii.crc32(stream.read())
- except BaseException:
- logger.exception('CRC check fail!')
- return None
-
-
-def checkMovies(files):
- """
- Unpacks movies (based on path in zipfile) to the movies folder.
- Movies must be unpacked for FA to be able to play them.
- This is a hack needed because the game updater can only handle bin and
- gamedata.
- """
-
- logger.info('checking updated files: {}'.format(files))
-
- # construct dirs
- gd = os.path.join(util.APPDATA_DIR, 'gamedata')
-
- for fname in files:
- origpath = os.path.join(gd, fname)
-
- if os.path.exists(origpath) and zipfile.is_zipfile(origpath):
- try:
- zf = zipfile.ZipFile(origpath)
- except BaseException:
- logger.exception(
- 'Failed to open Game File {}'.format(origpath),
- )
- continue
-
- for zi in zf.infolist():
- if zi.filename.startswith('movies'):
- tgtpath = os.path.join(util.APPDATA_DIR, zi.filename)
- # copy only if file is different - check first if file
- # exists, then if size is changed, then crc
- if (
- not os.path.exists(tgtpath)
- or os.stat(tgtpath).st_size != zi.file_size
- or crc32(tgtpath) != zi.CRC
- ):
- zf.extract(zi, util.APPDATA_DIR)
-
-
def check(
featured_mod: str,
mapname: str | None = None,
@@ -185,13 +135,6 @@ def check(
if result != fa.updater.UpdaterResult.SUCCESS:
return False
- try:
- if len(game_updater.updatedFiles) > 0:
- checkMovies(game_updater.updatedFiles)
- except BaseException:
- logger.exception('Error checking game files for movies')
- return False
-
# Now it's down to having the right map
if mapname:
if not map_(mapname, silent=silent):
diff --git a/src/fa/updater.py b/src/fa/updater.py
index e58d10802..755759f83 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -28,6 +28,7 @@
from api.featured_mod_updater import FeaturedModId
from api.sim_mod_updater import SimModFiles
from config import Settings
+from fa.utils import unpack_movies
from vaults.dialogs import download_file
from vaults.modvault import utils
@@ -302,6 +303,8 @@ def update_files(self, files: list[dict]) -> None:
self.move_many_from_cache(replacable_files)
self.fetch_files(need_to_download)
+
+ unpack_movies(to_update)
log("Updates applied successfully.")
def prepare_bin_FAF(self) -> None:
diff --git a/src/fa/utils.py b/src/fa/utils.py
new file mode 100644
index 000000000..9cd0a59e7
--- /dev/null
+++ b/src/fa/utils.py
@@ -0,0 +1,54 @@
+import binascii
+import logging
+import os
+import zipfile
+
+from util import APPDATA_DIR
+
+logger = logging.getLogger(__name__)
+
+
+def crc32(filepath: str) -> int | None:
+ try:
+ with open(filepath) as stream:
+ return binascii.crc32(stream.read())
+ except Exception as e:
+ logger.exception(f"CRC check fail! Details: {e}")
+ return None
+
+
+def unpack_movies(files: list[dict]) -> None:
+ """
+ Unpacks movies (based on path in zipfile) to the movies folder.
+ Movies must be unpacked for FA to be able to play them.
+ This is a hack needed because the game updater can only handle bin and
+ gamedata.
+ """
+
+ logger.info(f"checking updated files: {files}")
+
+ # construct dirs
+ gd = os.path.join(APPDATA_DIR, "gamedata")
+
+ for file in files:
+ fname = file["name"]
+ origpath = os.path.join(gd, fname)
+
+ if os.path.exists(origpath) and zipfile.is_zipfile(origpath):
+ try:
+ zf = zipfile.ZipFile(origpath)
+ except Exception as e:
+ logger.exception(f"Failed to open Game File {origpath!r}: {e}")
+ continue
+
+ for zi in zf.infolist():
+ if zi.filename.startswith("movies"):
+ tgtpath = os.path.join(APPDATA_DIR, zi.filename)
+ # copy only if file is different - check first if file
+ # exists, then if size is changed, then crc
+ if (
+ not os.path.exists(tgtpath)
+ or os.stat(tgtpath).st_size != zi.file_size
+ or crc32(tgtpath) != zi.CRC
+ ):
+ zf.extract(zi, APPDATA_DIR)
From 8628d66e9a5e84a305325d0d1a8033e27f549a7b Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 1 May 2024 03:50:48 +0300
Subject: [PATCH 053/123] Use correct PyQt namespace in SecondaryServer
this server doesn't work anyway and was deprecated ages ago
and shoudl be pruned together with unused widgets
---
src/secondaryServer/secondaryserver.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/secondaryServer/secondaryserver.py b/src/secondaryServer/secondaryserver.py
index d51557a2f..49da8bd26 100644
--- a/src/secondaryServer/secondaryserver.py
+++ b/src/secondaryServer/secondaryserver.py
@@ -164,19 +164,19 @@ def handleServerError(self, socketError):
Simple error handler that flags the whole operation as failed, not
very graceful but what can you do...
"""
- if socketError == QtNetwork.QAbstractSocket.RemoteHostClosedError:
+ if socketError == QtNetwork.QAbstractSocket.SocketError.RemoteHostClosedError:
log(
"FA Server down: The server is down for maintenance, please "
"try later.",
)
- elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError:
+ elif socketError == QtNetwork.QAbstractSocket.SocketError.HostNotFoundError:
log(
"Connection to Host lost. Please check the host name and port "
"settings.",
)
- elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError:
+ elif socketError == QtNetwork.QAbstractSocket.SocketError.ConnectionRefusedError:
log("The connection was refused by the peer.")
else:
log(
From 67f237fe560db48fe7e79526538d9b5cdf2a8cd7 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 1 May 2024 20:03:53 +0300
Subject: [PATCH 054/123] Prune featured_mod_updater
remove FeaturedModId class -- use existing FeaturedModApiConnector
---
src/api/featured_mod_api.py | 31 +++++++++++++++++++++--
src/api/featured_mod_updater.py | 37 ----------------------------
src/api/models/FeaturedMod.py | 1 +
src/api/parsers/FeaturedModParser.py | 1 +
src/fa/updater.py | 17 +++++++------
5 files changed, 40 insertions(+), 47 deletions(-)
delete mode 100644 src/api/featured_mod_updater.py
diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py
index 164e9332d..c779c9c2a 100644
--- a/src/api/featured_mod_api.py
+++ b/src/api/featured_mod_api.py
@@ -1,6 +1,7 @@
import logging
from api.ApiAccessors import DataApiAccessor
+from api.models.FeaturedMod import FeaturedMod
from api.parsers.FeaturedModParser import FeaturedModParser
logger = logging.getLogger(__name__)
@@ -8,7 +9,33 @@
class FeaturedModApiConnector(DataApiAccessor):
def __init__(self) -> None:
- super().__init__('/data/featuredMod')
+ super().__init__("/data/featuredMod")
- def prepare_data(self, message: dict) -> None:
+ def prepare_data(self, message: dict) -> dict[str, list[FeaturedMod]]:
return {"values": FeaturedModParser.parse_many(message["data"])}
+
+ def handle_featured_mod(self, message: dict) -> None:
+ self.featured_mod = FeaturedModParser.parse(message["data"][0])
+
+ def request_fmod_by_name(self, technical_name: str) -> None:
+ queryDict = {"filter": f"technicalName=={technical_name}"}
+ self.get_by_query(queryDict, self.handle_featured_mod)
+
+ def request_and_get_fmod_by_name(self, technicalName) -> FeaturedMod:
+ self.request_fmod_by_name(technicalName)
+ self.waitForCompletion()
+ return self.featured_mod
+
+
+class FeaturedModFiles(DataApiAccessor):
+ def __init__(self, mod_id: str, version: str) -> None:
+ super().__init__(f"/featuredMods/{mod_id}/files/{version}")
+ self.featuredModFiles = []
+
+ def handle_response(self, message):
+ self.featuredModFiles = message["data"]
+
+ def get_files(self):
+ self.requestData()
+ self.waitForCompletion()
+ return self.featuredModFiles
diff --git a/src/api/featured_mod_updater.py b/src/api/featured_mod_updater.py
deleted file mode 100644
index 3a9b96163..000000000
--- a/src/api/featured_mod_updater.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import logging
-
-from api.ApiAccessors import DataApiAccessor
-
-logger = logging.getLogger(__name__)
-
-
-class FeaturedModFiles(DataApiAccessor):
- def __init__(self, mod_id: int, version: str) -> None:
- super().__init__('/featuredMods/{}/files/{}'.format(mod_id, version))
- self.featuredModFiles = []
-
- def handle_response(self, message):
- self.featuredModFiles = message["data"]
-
- def getFiles(self):
- self.requestData()
- self.waitForCompletion()
- return self.featuredModFiles
-
-
-class FeaturedModId(DataApiAccessor):
- def __init__(self) -> None:
- super().__init__('/data/featuredMod')
- self.featuredModId = 0
-
- def handleFeaturedModId(self, message):
- self.featuredModId = message['data'][0]['id']
-
- def requestFeaturedModIdByName(self, technicalName: str) -> None:
- queryDict = dict(filter='technicalName=={}'.format(technicalName))
- self.get_by_query(queryDict, self.handleFeaturedModId)
-
- def requestAndGetFeaturedModIdByName(self, technicalName):
- self.requestFeaturedModIdByName(technicalName)
- self.waitForCompletion()
- return self.featuredModId
diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py
index abb8f9030..0d08d37b8 100644
--- a/src/api/models/FeaturedMod.py
+++ b/src/api/models/FeaturedMod.py
@@ -3,6 +3,7 @@
@dataclass
class FeaturedMod:
+ uid: str
name: str
fullname: str
visible: bool
diff --git a/src/api/parsers/FeaturedModParser.py b/src/api/parsers/FeaturedModParser.py
index 2ebfcdd9a..11fb83f09 100644
--- a/src/api/parsers/FeaturedModParser.py
+++ b/src/api/parsers/FeaturedModParser.py
@@ -6,6 +6,7 @@ class FeaturedModParser:
@staticmethod
def parse(data: dict) -> FeaturedMod:
return FeaturedMod(
+ uid=data["id"],
name=data["technicalName"],
fullname=data["displayName"],
visible=data.get("visible", False),
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 755759f83..f864e284a 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -24,8 +24,9 @@
from PyQt6.QtWidgets import QProgressDialog
import util
-from api.featured_mod_updater import FeaturedModFiles
-from api.featured_mod_updater import FeaturedModId
+from api.featured_mod_api import FeaturedModApiConnector
+from api.featured_mod_api import FeaturedModFiles
+from api.models.FeaturedMod import FeaturedMod
from api.sim_mod_updater import SimModFiles
from config import Settings
from fa.utils import unpack_movies
@@ -183,11 +184,11 @@ def run(self, *args, **kwargs):
log("Update finished at {}".format(timestamp()))
return self.result
- def get_files_to_update(self, mod_id: int, version: str) -> list[dict]:
- return FeaturedModFiles(mod_id, version).getFiles()
+ def get_files_to_update(self, mod_id: str, version: str) -> list[dict]:
+ return FeaturedModFiles(mod_id, version).get_files()
- def get_featured_mod_id_by_name(self, technical_name: str) -> int:
- return FeaturedModId().requestAndGetFeaturedModIdByName(technical_name)
+ def get_featured_mod_by_name(self, technical_name: str) -> FeaturedMod:
+ return FeaturedModApiConnector().request_and_get_fmod_by_name(technical_name)
def request_sim_url_by_uid(self, uid: str) -> str:
return SimModFiles().request_and_get_sim_mod_url_by_id(uid)
@@ -373,8 +374,8 @@ def patch_fa_exe_if_needed(self, files: list[dict]) -> None:
return
def update_featured_mod(self, modname: str, modversion: str) -> list[dict]:
- fmod_id = self.get_featured_mod_id_by_name(modname)
- files = self.get_files_to_update(fmod_id, modversion)
+ fmod = self.get_featured_mod_by_name(modname)
+ files = self.get_files_to_update(fmod.uid, modversion)
self.update_files(files)
return files
From 7df55f17c2440c3cb5b033f7c3077102eb4dfa01 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 2 May 2024 22:17:17 +0300
Subject: [PATCH 055/123] CoopWidget: Fix sim_mods being incorrectly checked on
game join
---
src/coop/_coopwidget.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py
index 035cc8620..de70bd265 100644
--- a/src/coop/_coopwidget.py
+++ b/src/coop/_coopwidget.py
@@ -13,6 +13,7 @@
from coop.coopmapitem import CoopMapItemDelegate
from coop.coopmodel import CoopGameFilterModel
from fa.replay import replay
+from model.game import Game
from ui.busy_widget import BusyWidget
logger = logging.getLogger(__name__)
@@ -52,7 +53,7 @@ def __init__(
self.coopList.setItemDelegate(CoopMapItemDelegate(self))
self.gameview = self._gameview_builder(self._game_model, self.gameList)
- self.gameview.game_double_clicked.connect(self.gameDoubleClicked)
+ self.gameview.game_double_clicked.connect(self.game_double_clicked)
self.coopList.itemDoubleClicked.connect(self.coopListDoubleClicked)
self.coopList.itemClicked.connect(self.coopListClicked)
@@ -278,16 +279,14 @@ def processCoopInfo(self, message):
self.coop[uid] = itemCoop
- def gameDoubleClicked(self, game):
+ def game_double_clicked(self, game: Game) -> None:
"""
Slot that attempts to join a game.
"""
if not fa.instance.available():
return
- if not fa.check.check(
- game.featured_mod, game.mapname, None, game.sim_mods,
- ):
+ if not fa.check.check(game.featured_mod, game.mapname, sim_mods=game.sim_mods):
return
if game.password_protected:
From 62177ca65d75e64bf7f1a656857465c5e5ebcdc4 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 3 May 2024 17:06:37 +0300
Subject: [PATCH 056/123] Make checking md5 and unpacking movies less
UI-blocking
spawn/use QProgressDialog to block other windows from receving inputs
and call setValue() on progress done (which calls processEvents interally)
---
src/fa/updater.py | 32 +++++++++++++++++----
src/fa/utils.py | 73 +++++++++++++++++++++++++++++------------------
2 files changed, 72 insertions(+), 33 deletions(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index f864e284a..27a7392d1 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -195,10 +195,29 @@ def request_sim_url_by_uid(self, uid: str) -> str:
@staticmethod
def _file_needs_update(file_info: dict) -> bool:
- filegroup = file_info["group"]
- filename = file_info["name"]
- filepath = os.path.join(util.APPDATA_DIR, filegroup, filename)
- return filename != Settings.get("game/exe-name") and util.md5(filepath) != file_info["md5"]
+ return (
+ file_info["name"] != Settings.get("game/exe-name")
+ and file_info["old_md5"] != file_info["md5"]
+ )
+
+ def _calc_md5s(self, files: list[dict]) -> None:
+ self.progress.setMaximum(len(files))
+
+ for index, file_info in enumerate(files, start=1):
+
+ if self.progress.wasCanceled():
+ raise UpdaterCancellation()
+
+ filegroup = file_info["group"]
+ filename = file_info["name"]
+ filepath = os.path.join(util.APPDATA_DIR, filegroup, filename)
+
+ self.progress.setLabelText(f"Calculating md5 for {filename}...")
+
+ file_info["old_md5"] = util.md5(filepath)
+
+ self.progress.setValue(index)
+ self.progress.setMaximum(0)
def fetch_files(self, files: list[dict]) -> None:
for file in files:
@@ -293,6 +312,9 @@ def update_files(self, files: list[dict]) -> None:
"""
self.create_cache_subdirs(files)
self.patch_fa_exe_if_needed(files)
+ self._calc_md5s(files)
+
+ self.progress.setLabelText("Updating files...")
to_update = list(filter(self._file_needs_update, files))
replacable_files, need_to_download = self.check_cache(to_update)
@@ -305,7 +327,7 @@ def update_files(self, files: list[dict]) -> None:
self.fetch_files(need_to_download)
- unpack_movies(to_update)
+ unpack_movies(files)
log("Updates applied successfully.")
def prepare_bin_FAF(self) -> None:
diff --git a/src/fa/utils.py b/src/fa/utils.py
index 9cd0a59e7..5167601c0 100644
--- a/src/fa/utils.py
+++ b/src/fa/utils.py
@@ -3,6 +3,9 @@
import os
import zipfile
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QProgressDialog
+
from util import APPDATA_DIR
logger = logging.getLogger(__name__)
@@ -10,45 +13,59 @@
def crc32(filepath: str) -> int | None:
try:
- with open(filepath) as stream:
+ with open(filepath, "rb") as stream:
return binascii.crc32(stream.read())
except Exception as e:
- logger.exception(f"CRC check fail! Details: {e}")
+ logger.exception(f"CRC check for {filepath!r} fail! Details: {e}")
return None
-def unpack_movies(files: list[dict]) -> None:
+def unpack_movies_from_file(file_info: dict) -> None:
"""
Unpacks movies (based on path in zipfile) to the movies folder.
Movies must be unpacked for FA to be able to play them.
This is a hack needed because the game updater can only handle bin and
gamedata.
"""
-
- logger.info(f"checking updated files: {files}")
-
# construct dirs
gd = os.path.join(APPDATA_DIR, "gamedata")
- for file in files:
- fname = file["name"]
- origpath = os.path.join(gd, fname)
-
- if os.path.exists(origpath) and zipfile.is_zipfile(origpath):
- try:
- zf = zipfile.ZipFile(origpath)
- except Exception as e:
- logger.exception(f"Failed to open Game File {origpath!r}: {e}")
- continue
-
- for zi in zf.infolist():
- if zi.filename.startswith("movies"):
- tgtpath = os.path.join(APPDATA_DIR, zi.filename)
- # copy only if file is different - check first if file
- # exists, then if size is changed, then crc
- if (
- not os.path.exists(tgtpath)
- or os.stat(tgtpath).st_size != zi.file_size
- or crc32(tgtpath) != zi.CRC
- ):
- zf.extract(zi, APPDATA_DIR)
+ fname = file_info["name"]
+ origpath = os.path.join(gd, fname)
+
+ if os.path.exists(origpath) and zipfile.is_zipfile(origpath):
+ try:
+ zf = zipfile.ZipFile(origpath)
+ except Exception as e:
+ logger.exception(f"Failed to open Game File {origpath!r}: {e}")
+ return
+
+ for zi in zf.infolist():
+ if zi.filename.startswith("movies") and not zi.is_dir():
+ tgtpath = os.path.join(APPDATA_DIR, zi.filename)
+ # copy only if file is different - check first if file
+ # exists, then if size is changed, then crc
+ if (
+ not os.path.exists(tgtpath)
+ or os.stat(tgtpath).st_size != zi.file_size
+ or crc32(tgtpath) != zi.CRC
+ ):
+ zf.extract(zi, APPDATA_DIR)
+
+
+def unpack_movies(files: list[dict]) -> None:
+ logger.info("Checking files for movies")
+
+ progress = QProgressDialog()
+ progress.setWindowTitle("Updating Movies")
+ progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint)
+ progress.setModal(True)
+ progress.setCancelButton(None)
+ progress.setMaximum(len(files))
+ progress.setValue(0)
+
+ for index, file in enumerate(files, start=1):
+ filename = file["name"]
+ progress.setLabelText(f"Checking for movies in {filename}...")
+ unpack_movies_from_file(file)
+ progress.setValue(index)
From c25637d1ab2ce72f0147b3cb4b0ea0d123df0489 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 3 May 2024 18:03:33 +0300
Subject: [PATCH 057/123] Unpack sounds along with movies from gamefiles
---
src/fa/updater.py | 4 ++--
src/fa/utils.py | 22 +++++++++++++---------
2 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 27a7392d1..e3d012c73 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -29,7 +29,7 @@
from api.models.FeaturedMod import FeaturedMod
from api.sim_mod_updater import SimModFiles
from config import Settings
-from fa.utils import unpack_movies
+from fa.utils import unpack_movies_and_sounds
from vaults.dialogs import download_file
from vaults.modvault import utils
@@ -327,7 +327,7 @@ def update_files(self, files: list[dict]) -> None:
self.fetch_files(need_to_download)
- unpack_movies(files)
+ unpack_movies_and_sounds(files)
log("Updates applied successfully.")
def prepare_bin_FAF(self) -> None:
diff --git a/src/fa/utils.py b/src/fa/utils.py
index 5167601c0..33500a777 100644
--- a/src/fa/utils.py
+++ b/src/fa/utils.py
@@ -20,10 +20,10 @@ def crc32(filepath: str) -> int | None:
return None
-def unpack_movies_from_file(file_info: dict) -> None:
+def unpack_movies_and_sounds_from_file(file_info: dict) -> None:
"""
- Unpacks movies (based on path in zipfile) to the movies folder.
- Movies must be unpacked for FA to be able to play them.
+ Unpacks movies and sounds (based on path in zipfile) to the corresponding
+ folder. Movies must be unpacked for FA to be able to play them.
This is a hack needed because the game updater can only handle bin and
gamedata.
"""
@@ -41,7 +41,11 @@ def unpack_movies_from_file(file_info: dict) -> None:
return
for zi in zf.infolist():
- if zi.filename.startswith("movies") and not zi.is_dir():
+ movie_or_sound = (
+ zi.filename.startswith("movies")
+ or zi.filename.startswith("sounds")
+ )
+ if movie_or_sound and not zi.is_dir():
tgtpath = os.path.join(APPDATA_DIR, zi.filename)
# copy only if file is different - check first if file
# exists, then if size is changed, then crc
@@ -53,11 +57,11 @@ def unpack_movies_from_file(file_info: dict) -> None:
zf.extract(zi, APPDATA_DIR)
-def unpack_movies(files: list[dict]) -> None:
- logger.info("Checking files for movies")
+def unpack_movies_and_sounds(files: list[dict]) -> None:
+ logger.info("Checking files for movies and sounds")
progress = QProgressDialog()
- progress.setWindowTitle("Updating Movies")
+ progress.setWindowTitle("Updating Movies and Sounds")
progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint)
progress.setModal(True)
progress.setCancelButton(None)
@@ -66,6 +70,6 @@ def unpack_movies(files: list[dict]) -> None:
for index, file in enumerate(files, start=1):
filename = file["name"]
- progress.setLabelText(f"Checking for movies in {filename}...")
- unpack_movies_from_file(file)
+ progress.setLabelText(f"Checking for movies and sounds in {filename}...")
+ unpack_movies_and_sounds_from_file(file)
progress.setValue(index)
From 4c81a0d42e4ad246a4e17df3929d3964634dd16a Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 3 May 2024 18:16:13 +0300
Subject: [PATCH 058/123] Updater: Do not close progress prematurely
---
src/fa/updater.py | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index e3d012c73..2e2758a5f 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -442,9 +442,6 @@ def do_update(self) -> None:
else:
self.result = UpdaterResult.SUCCESS
- # Hide progress dialog if it's still showing.
- self.progress.close()
-
# Integrated handlers for the various things that could go wrong
if self.result == UpdaterResult.CANCEL:
pass # The user knows damn well what happened here.
From d1e795f076bd213340d3f79e50274fc5eb3b99ee Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 4 May 2024 09:41:36 +0300
Subject: [PATCH 059/123] Create api model for FeaturedModFile
---
src/api/featured_mod_api.py | 14 ++--
src/api/models/FeaturedModFile.py | 14 ++++
src/api/parsers/FeaturedModFileParser.py | 21 ++++++
src/fa/updater.py | 93 ++++++++++++------------
src/fa/utils.py | 11 ++-
5 files changed, 93 insertions(+), 60 deletions(-)
create mode 100644 src/api/models/FeaturedModFile.py
create mode 100644 src/api/parsers/FeaturedModFileParser.py
diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py
index c779c9c2a..9d1f67b7c 100644
--- a/src/api/featured_mod_api.py
+++ b/src/api/featured_mod_api.py
@@ -2,6 +2,8 @@
from api.ApiAccessors import DataApiAccessor
from api.models.FeaturedMod import FeaturedMod
+from api.models.FeaturedModFile import FeaturedModFile
+from api.parsers.FeaturedModFileParser import FeaturedModFileParser
from api.parsers.FeaturedModParser import FeaturedModParser
logger = logging.getLogger(__name__)
@@ -27,15 +29,15 @@ def request_and_get_fmod_by_name(self, technicalName) -> FeaturedMod:
return self.featured_mod
-class FeaturedModFiles(DataApiAccessor):
+class FeaturedModFilesApiConnector(DataApiAccessor):
def __init__(self, mod_id: str, version: str) -> None:
super().__init__(f"/featuredMods/{mod_id}/files/{version}")
- self.featuredModFiles = []
+ self.featured_mod_files = []
- def handle_response(self, message):
- self.featuredModFiles = message["data"]
+ def handle_response(self, message: dict) -> None:
+ self.featured_mod_files = FeaturedModFileParser.parse_many(message["data"])
- def get_files(self):
+ def get_files(self) -> list[FeaturedModFile]:
self.requestData()
self.waitForCompletion()
- return self.featuredModFiles
+ return self.featured_mod_files
diff --git a/src/api/models/FeaturedModFile.py b/src/api/models/FeaturedModFile.py
new file mode 100644
index 000000000..8162c8540
--- /dev/null
+++ b/src/api/models/FeaturedModFile.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class FeaturedModFile:
+ uid: str
+ version: int
+ group: str
+ name: str
+ md5: str
+ url: str
+ cacheable_url: str
+ hmac_token: str
+ hmac_parameter: str
diff --git a/src/api/parsers/FeaturedModFileParser.py b/src/api/parsers/FeaturedModFileParser.py
new file mode 100644
index 000000000..ee2acecc7
--- /dev/null
+++ b/src/api/parsers/FeaturedModFileParser.py
@@ -0,0 +1,21 @@
+from api.models.FeaturedModFile import FeaturedModFile
+
+
+class FeaturedModFileParser:
+ @staticmethod
+ def parse(api_result: dict) -> FeaturedModFile:
+ return FeaturedModFile(
+ uid=api_result["id"],
+ version=api_result["version"],
+ group=api_result["group"],
+ name=api_result["name"],
+ md5=api_result["md5"],
+ url=api_result["url"],
+ cacheable_url=api_result["cacheableUrl"],
+ hmac_token=api_result["hmacToken"],
+ hmac_parameter=api_result["hmacParameter"],
+ )
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[FeaturedModFile]:
+ return [FeaturedModFileParser.parse(file_info) for file_info in api_result]
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 2e2758a5f..2b9293a3c 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -25,8 +25,9 @@
import util
from api.featured_mod_api import FeaturedModApiConnector
-from api.featured_mod_api import FeaturedModFiles
+from api.featured_mod_api import FeaturedModFilesApiConnector
from api.models.FeaturedMod import FeaturedMod
+from api.models.FeaturedModFile import FeaturedModFile
from api.sim_mod_updater import SimModFiles
from config import Settings
from fa.utils import unpack_movies_and_sounds
@@ -185,7 +186,7 @@ def run(self, *args, **kwargs):
return self.result
def get_files_to_update(self, mod_id: str, version: str) -> list[dict]:
- return FeaturedModFiles(mod_id, version).get_files()
+ return FeaturedModFilesApiConnector(mod_id, version).get_files()
def get_featured_mod_by_name(self, technical_name: str) -> FeaturedMod:
return FeaturedModApiConnector().request_and_get_fmod_by_name(technical_name)
@@ -194,50 +195,46 @@ def request_sim_url_by_uid(self, uid: str) -> str:
return SimModFiles().request_and_get_sim_mod_url_by_id(uid)
@staticmethod
- def _file_needs_update(file_info: dict) -> bool:
+ def _file_needs_update(file: FeaturedModFile) -> bool:
return (
- file_info["name"] != Settings.get("game/exe-name")
- and file_info["old_md5"] != file_info["md5"]
+ file.name != Settings.get("game/exe-name")
+ and file.old_md5 != file.md5
)
- def _calc_md5s(self, files: list[dict]) -> None:
+ def _calc_md5s(self, files: list[FeaturedModFile]) -> None:
self.progress.setMaximum(len(files))
- for index, file_info in enumerate(files, start=1):
+ for index, file in enumerate(files, start=1):
if self.progress.wasCanceled():
raise UpdaterCancellation()
- filegroup = file_info["group"]
- filename = file_info["name"]
- filepath = os.path.join(util.APPDATA_DIR, filegroup, filename)
+ filepath = os.path.join(util.APPDATA_DIR, file.group, file.name)
- self.progress.setLabelText(f"Calculating md5 for {filename}...")
+ self.progress.setLabelText(f"Calculating md5 for {file.name}...")
- file_info["old_md5"] = util.md5(filepath)
+ file.old_md5 = util.md5(filepath)
self.progress.setValue(index)
self.progress.setMaximum(0)
- def fetch_files(self, files: list[dict]) -> None:
+ def fetch_files(self, files: list[FeaturedModFile]) -> None:
for file in files:
self.fetch_single_file(file)
- def fetch_single_file(self, file_info: dict) -> None:
- name = file_info["name"]
- filegroup = file_info["group"]
- url = file_info["cacheableUrl"]
- target_dir = os.path.join(util.APPDATA_DIR, filegroup)
+ def fetch_single_file(self, file: FeaturedModFile) -> None:
+ target_dir = os.path.join(util.APPDATA_DIR, file.group)
+ url = file.cacheable_url
logger.info(f"Updater: Downloading {url}")
downloaded = download_file(
url=url,
target_dir=target_dir,
- name=name,
+ name=file.name,
category="Update",
silent=False,
- request_params={file_info["hmacParameter"]: file_info["hmacToken"]},
+ request_params={file.hmac_parameter: file.hmac_token},
label=f"Downloading FA file : {url}
",
)
@@ -248,46 +245,46 @@ def fetch_single_file(self, file_info: dict) -> None:
"Operation aborted while waiting for data.",
)
- def move_many_from_cache(self, files: list[dict]) -> None:
+ def move_many_from_cache(self, files: list[FeaturedModFile]) -> None:
for file in files:
self.move_from_cache(file)
- def move_from_cache(self, file_info: dict) -> None:
- src_dir = os.path.join(util.APPDATA_DIR, file_info["group"])
- cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"])
- if os.path.exists(os.path.join(cache_dir, file_info["md5"])):
+ def move_from_cache(self, file: FeaturedModFile) -> None:
+ src_dir = os.path.join(util.APPDATA_DIR, file.group)
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
+ if os.path.exists(os.path.join(cache_dir, file.md5)):
shutil.move(
- os.path.join(cache_dir, file_info["md5"]),
- os.path.join(src_dir, file_info["name"]),
+ os.path.join(cache_dir, file.md5),
+ os.path.join(src_dir, file.name),
)
def move_many_to_cache(self, files: list[dict]) -> None:
for file in files:
self.move_to_cache(file)
- def move_to_cache(self, file_info: dict) -> None:
- src_dir = os.path.join(util.APPDATA_DIR, file_info["group"])
- cache_dir = os.path.join(util.GAME_CACHE_DIR, file_info["group"])
- if os.path.exists(os.path.join(src_dir, file_info["name"])):
- md5 = util.md5(os.path.join(src_dir, file_info["name"]))
+ def move_to_cache(self, file: FeaturedModFile) -> None:
+ src_dir = os.path.join(util.APPDATA_DIR, file.group)
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
+ if os.path.exists(os.path.join(src_dir, file.name)):
+ md5 = util.md5(os.path.join(src_dir, file.name))
shutil.move(
- os.path.join(src_dir, file_info["name"]),
+ os.path.join(src_dir, file.name),
os.path.join(cache_dir, md5),
)
util.setAccessTime(os.path.join(cache_dir, md5))
- def replace_from_cache(self, file: dict) -> None:
+ def replace_from_cache(self, file: FeaturedModFile) -> None:
self.move_to_cache(file)
self.move_from_cache(file)
- def replace_many_from_cache(self, files: list[dict]) -> None:
+ def replace_many_from_cache(self, files: list[FeaturedModFile]) -> None:
for file in files:
self.replace_from_cache(file)
- def check_cache(self, files_to_check: list[dict]) -> None:
+ def check_cache(self, files_to_check: list[FeaturedModFile]) -> None:
replaceable_files, need_to_download = [], []
for file in files_to_check:
- cache_dir = os.path.join(util.GAME_CACHE_DIR, file["group"])
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
os.makedirs(cache_dir, exist_ok=True)
if self._is_cached(file):
replaceable_files.append(file)
@@ -296,16 +293,16 @@ def check_cache(self, files_to_check: list[dict]) -> None:
return replaceable_files, need_to_download
@staticmethod
- def _is_cached(file_info: dict) -> bool:
- cached_file = os.path.join(util.GAME_CACHE_DIR, file_info["group"], file_info["name"])
+ def _is_cached(file: FeaturedModFile) -> bool:
+ cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.name)
return os.path.isfile(cached_file)
- def create_cache_subdirs(self, files: list[dict]) -> None:
+ def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None:
for file in files:
- target = os.path.join(util.GAME_CACHE_DIR, file["group"])
+ target = os.path.join(util.GAME_CACHE_DIR, file.group)
os.makedirs(target, exist_ok=True)
- def update_files(self, files: list[dict]) -> None:
+ def update_files(self, files: list[FeaturedModFile]) -> None:
"""
Updates the files in the destination
subdirectory of the Forged Alliance path.
@@ -388,14 +385,14 @@ def patch_fa_executable(self, version: int) -> None:
file.seek(address)
file.write(version.to_bytes(4, "little"))
- def patch_fa_exe_if_needed(self, files: list[dict]) -> None:
+ def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None:
for file in files:
- if file["name"] == Settings.get("game/exe-name"):
+ if file.name == Settings.get("game/exe-name"):
version = int(self._resolve_base_version(file))
self.patch_fa_executable(version)
return
- def update_featured_mod(self, modname: str, modversion: str) -> list[dict]:
+ def update_featured_mod(self, modname: str, modversion: str) -> list[FeaturedModFile]:
fmod = self.get_featured_mod_by_name(modname)
files = self.get_files_to_update(fmod.uid, modversion)
self.update_files(files)
@@ -406,11 +403,11 @@ def _resolve_modversion(self) -> str:
return str(max(self.modversions.values()))
return "latest"
- def _resolve_base_version(self, base_info: dict | None = None) -> str:
+ def _resolve_base_version(self, exe_info: FeaturedModFile | None = None) -> str:
if self.version:
return str(self.version)
- if base_info:
- return str(base_info["version"])
+ if exe_info:
+ return str(exe_info.version)
return "latest"
def do_update(self) -> None:
diff --git a/src/fa/utils.py b/src/fa/utils.py
index 33500a777..78355342a 100644
--- a/src/fa/utils.py
+++ b/src/fa/utils.py
@@ -6,6 +6,7 @@
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QProgressDialog
+from api.models.FeaturedModFile import FeaturedModFile
from util import APPDATA_DIR
logger = logging.getLogger(__name__)
@@ -20,7 +21,7 @@ def crc32(filepath: str) -> int | None:
return None
-def unpack_movies_and_sounds_from_file(file_info: dict) -> None:
+def unpack_movies_and_sounds_from_file(file: FeaturedModFile) -> None:
"""
Unpacks movies and sounds (based on path in zipfile) to the corresponding
folder. Movies must be unpacked for FA to be able to play them.
@@ -30,8 +31,7 @@ def unpack_movies_and_sounds_from_file(file_info: dict) -> None:
# construct dirs
gd = os.path.join(APPDATA_DIR, "gamedata")
- fname = file_info["name"]
- origpath = os.path.join(gd, fname)
+ origpath = os.path.join(gd, file.name)
if os.path.exists(origpath) and zipfile.is_zipfile(origpath):
try:
@@ -57,7 +57,7 @@ def unpack_movies_and_sounds_from_file(file_info: dict) -> None:
zf.extract(zi, APPDATA_DIR)
-def unpack_movies_and_sounds(files: list[dict]) -> None:
+def unpack_movies_and_sounds(files: list[FeaturedModFile]) -> None:
logger.info("Checking files for movies and sounds")
progress = QProgressDialog()
@@ -69,7 +69,6 @@ def unpack_movies_and_sounds(files: list[dict]) -> None:
progress.setValue(0)
for index, file in enumerate(files, start=1):
- filename = file["name"]
- progress.setLabelText(f"Checking for movies and sounds in {filename}...")
+ progress.setLabelText(f"Checking for movies and sounds in {file.name}...")
unpack_movies_and_sounds_from_file(file)
progress.setValue(index)
From 6773cc56fbda12234ecf9ea6a78aeb84119bda52 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 4 May 2024 09:44:29 +0300
Subject: [PATCH 060/123] Updater: Get fa exe name from settings everywhere
---
src/fa/updater.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 2b9293a3c..81cd8eb06 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -378,7 +378,7 @@ def download_fa_executable(self) -> bool:
)
def patch_fa_executable(self, version: int) -> None:
- exe_path = os.path.join(util.BIN_DIR, "ForgedAlliance.exe")
+ exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name"))
version_addresses = (0xd3d40, 0x47612d, 0x476666)
with open(exe_path, "rb+") as file:
for address in version_addresses:
From d30ba146f39ca9c84fe3041af95314846e0fbe6a Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 4 May 2024 11:32:27 +0300
Subject: [PATCH 061/123] Do not add attributes to FeaturedModFile after it is
initialized
and keep the ability of it (and other api models)
to become immutable (frozen) in the future
---
src/fa/updater.py | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 81cd8eb06..2c8b94378 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -14,6 +14,7 @@
import stat
import time
from enum import Enum
+from functools import partial
from PyQt6.QtCore import QObject
from PyQt6.QtCore import Qt
@@ -195,15 +196,15 @@ def request_sim_url_by_uid(self, uid: str) -> str:
return SimModFiles().request_and_get_sim_mod_url_by_id(uid)
@staticmethod
- def _file_needs_update(file: FeaturedModFile) -> bool:
- return (
- file.name != Settings.get("game/exe-name")
- and file.old_md5 != file.md5
- )
+ def _file_needs_update(file: FeaturedModFile, md5s: dict[str, str]) -> bool:
+ incoming_md5 = file.md5
+ current_md5 = md5s[file.md5]
+ return file.name != Settings.get("game/exe-name") and current_md5 != incoming_md5
- def _calc_md5s(self, files: list[FeaturedModFile]) -> None:
+ def _calc_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]:
self.progress.setMaximum(len(files))
+ result = {}
for index, file in enumerate(files, start=1):
if self.progress.wasCanceled():
@@ -213,10 +214,11 @@ def _calc_md5s(self, files: list[FeaturedModFile]) -> None:
self.progress.setLabelText(f"Calculating md5 for {file.name}...")
- file.old_md5 = util.md5(filepath)
+ result[file.md5] = util.md5(filepath)
self.progress.setValue(index)
self.progress.setMaximum(0)
+ return result
def fetch_files(self, files: list[FeaturedModFile]) -> None:
for file in files:
@@ -309,11 +311,11 @@ def update_files(self, files: list[FeaturedModFile]) -> None:
"""
self.create_cache_subdirs(files)
self.patch_fa_exe_if_needed(files)
- self._calc_md5s(files)
+ md5s = self._calc_md5s(files)
self.progress.setLabelText("Updating files...")
- to_update = list(filter(self._file_needs_update, files))
+ to_update = list(filter(partial(self._file_needs_update, md5s=md5s), files))
replacable_files, need_to_download = self.check_cache(to_update)
if self.keep_cache or self.in_session_cache:
From 2764bf7c65bee214f5477bcd6100789bdc0feef0 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 5 May 2024 13:04:14 +0300
Subject: [PATCH 062/123] Fail update if sim mod wasn't downloaded
download function doesn't (normally) raise exceptions and
'result' attribute gets assigned to UpdaterResult.SUCCESS
thanks to try..else block
---
src/fa/updater.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 2c8b94378..dbbb2e53c 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -417,10 +417,8 @@ def do_update(self) -> None:
try:
if self.sim_mod:
uid, name = self.sim_mod
- if utils.downloadMod(self.request_sim_url_by_uid(uid), name):
- self.result = UpdaterResult.SUCCESS
- else:
- self.result = UpdaterResult.FAILURE
+ if not utils.downloadMod(self.request_sim_url_by_uid(uid), name):
+ raise UpdaterFailure("Sim mod wasn't downloaded")
else:
# Prepare FAF directory & all necessary files
self.prepare_bin_FAF()
From 817425a01fb61a06849e8a2fe3f9d51ac571f5b1 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Mon, 6 May 2024 22:12:25 +0300
Subject: [PATCH 063/123] Check possible map preview files more thoroughly
coop missions preview files do not contain version (e.g. '.v0039')
and also can be in different case
Windows doesn't care about cases, but Linux does
although client most likely doesn't work on Linux at the moment
we don't want to break Linux compatibility even further
---
src/fa/maps.py | 29 ++++++++++++++++++++++-------
1 file changed, 22 insertions(+), 7 deletions(-)
diff --git a/src/fa/maps.py b/src/fa/maps.py
index fc49c8d4f..c973e9b87 100644
--- a/src/fa/maps.py
+++ b/src/fa/maps.py
@@ -218,7 +218,10 @@ def genPrevFromDDS(sourcename: str, destname: str, small: bool = False) -> None:
raise
-def exportPreviewFromMap(mapname, positions=None):
+def export_preview_from_map(
+ mapname: str | None,
+ positions: dict | None = None,
+) -> None | dict[str, None | str | list[str]]:
"""
This method auto-upgrades the maps to have small and large preview images
"""
@@ -240,10 +243,11 @@ def exportPreviewFromMap(mapname, positions=None):
return previews
mapname = os.path.basename(mapdir).lower()
+ mapname_no_version, *_ = mapname.partition(".")
if isGeneratedMap(mapname):
mapfilename = os.path.join(mapdir, mapname + ".scmap")
else:
- mapfilename = os.path.join(mapdir, mapname.split(".")[0] + ".scmap")
+ mapfilename = os.path.join(mapdir, f"{mapname_no_version}.scmap")
mode = os.stat(mapdir)[0]
if not (mode and stat.S_IWRITE):
@@ -253,9 +257,20 @@ def exportPreviewFromMap(mapname, positions=None):
if not os.path.isdir(mapdir):
os.mkdir(mapdir)
- previewsmallname = os.path.join(mapdir, mapname + ".small.png")
- previewlargename = os.path.join(mapdir, mapname + ".large.png")
- previewddsname = os.path.join(mapdir, mapname + ".dds")
+ def plausible_mapname_preview_name(suffix: str) -> str:
+ casefold_names = (
+ f"{mapname}{suffix}".casefold(),
+ f"{mapname_no_version}{suffix}".casefold(),
+ )
+ for entry in os.listdir(mapdir):
+ plausible_preview = os.path.join(mapdir, entry)
+ if os.path.isfile(plausible_preview) and entry.casefold() in casefold_names:
+ return plausible_preview
+ return suffix
+
+ previewsmallname = plausible_mapname_preview_name(".small.png")
+ previewlargename = plausible_mapname_preview_name(".large.png")
+ previewddsname = plausible_mapname_preview_name(".dds")
cachepngname = os.path.join(util.MAP_PREVIEW_SMALL_DIR, mapname + ".png")
logger.debug("Generating preview from user maps for: " + mapname)
@@ -419,7 +434,7 @@ def preview(mapname, pixmap=False):
return util.THEME.icon(img, False, pixmap)
# Try to find in local map folder
- img = exportPreviewFromMap(mapname)
+ img = export_preview_from_map(mapname)
if (
img
@@ -471,7 +486,7 @@ def processMapFolderForUpload(mapDir, positions):
Zipping the file and creating thumbnails
"""
# creating thumbnail
- files = exportPreviewFromMap(mapDir, positions)["tozip"]
+ files = export_preview_from_map(mapDir, positions)["tozip"]
# abort zipping if there is insufficient previews
if len(files) != 3:
logger.debug("Insufficient previews for making an archive.")
From a50f36b4c53cc8cd165997156d7e3a2a3fc2ef4c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 7 May 2024 13:17:26 +0300
Subject: [PATCH 064/123] Do not ping server constantly while in game
ping server only if there was no message from it within a minute
currently 'keepalive' functionality is only enabled during game
and it was constantly pinging server every 3 seconds
which sometimes caused infinite reconnect loop
when client sends 'ping', doesn't get 'pong' within those 3 secs
and forces disonnect to start over again
the main version of why this happens is:
1. There is some connection error for whatever reason
2. Client tries to reconnect
- Reconnecting means exchanging 'startup' messages
between client and server, which includes some long messages
(e.g. player_info, which contains a list of all players
and is so long that actually requires several messages
to be fully sent) or messages that could be delayed
(e.g. ask_session)
- Keepalive timer continues to ping server every 3 seconds
as it stops only when game closes
3. The above connection/authentication process takes more
than 3 seconds, or catches bad timing
4. Client forces disconnect
and the loop repeats
probably this current keepalive logic should be reviewed
and checked, because there are 2 timers: _keepalive_timer
and _reconnect_timer, and _keepalive_timer doesn't
stop on disconnect (maybe it should)
anyway, looks like increasing 'ping' interval and pinging
only when there is no apparent conversation between client
and server is the right thing to do
---
src/client/connection.py | 34 +++++++++++++++++++---------------
1 file changed, 19 insertions(+), 15 deletions(-)
diff --git a/src/client/connection.py b/src/client/connection.py
index e1257b76e..620ec307d 100644
--- a/src/client/connection.py
+++ b/src/client/connection.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import json
import logging
import sys
@@ -27,11 +29,11 @@ class ConnectionState(IntEnum):
class ServerReconnecter(QtCore.QObject):
- def __init__(self, connection):
+ def __init__(self, connection: ServerConnection) -> None:
QtCore.QObject.__init__(self)
self._connection = connection
connection.state_changed.connect(self.on_state_changed)
- connection.received_pong.connect(self._receive_pong)
+ connection.message_received.connect(self._receive_message)
self._connection_attempts = 0
self._reconnect_timer = QtCore.QTimer(self)
@@ -44,8 +46,8 @@ def __init__(self, connection):
self._keepalive = False
self._keepalive_timer = QtCore.QTimer(self)
self._keepalive_timer.timeout.connect(self._ping_connection)
- self.keepalive_interval = 3 * 1000
- self._waiting_for_pong = False
+ self.keepalive_interval = 60 * 1000
+ self._waiting_for_message = False
@property
def enabled(self):
@@ -71,7 +73,7 @@ def keepalive(self, value):
def _disable_keepalive(self):
self._keepalive_timer.stop()
- self._waiting_for_pong = False
+ self._waiting_for_message = False
def _enable_keepalive(self):
if not self._keepalive_timer.isActive():
@@ -126,25 +128,27 @@ def _ping_connection(self):
not self._enabled
or self._connection.state != ConnectionState.CONNECTED
):
- self._waiting_for_pong = False
+ self._waiting_for_message = False
return
# Prepare to reconnect immediately
self._connection_attempts = 0
- if self._waiting_for_pong:
- self._waiting_for_pong = False
+ if self._waiting_for_message:
+ self._waiting_for_message = False
# Force disconnect
# Note that it will force disconnect and reconnect if we
# reconnected on our own since last ping!
self._connection.disconnect_()
else:
- self._waiting_for_pong = True
- self._connection.send(dict(command="ping"))
+ self._waiting_for_message = True
+ self._connection.send({"command": "ping"})
- def _receive_pong(self):
- self._waiting_for_pong = False
+ def _receive_message(self):
+ self._waiting_for_message = False
+ if self.keepalive:
+ self._keepalive_timer.start() # restart
class ServerConnection(QtCore.QObject):
@@ -154,13 +158,14 @@ class ServerConnection(QtCore.QObject):
state_changed = QtCore.pyqtSignal(object)
connected = QtCore.pyqtSignal()
disconnected = QtCore.pyqtSignal()
- received_pong = QtCore.pyqtSignal()
+ message_received = QtCore.pyqtSignal()
access_url_ready = QtCore.pyqtSignal(QtCore.QUrl)
def __init__(self, host, port, dispatch):
QtCore.QObject.__init__(self)
self.socket = QWebSocket()
self.socket.binaryMessageReceived.connect(self.on_binary_message_received)
+ self.socket.binaryMessageReceived.connect(lambda: self.message_received.emit())
self.socket.errorOccurred.connect(self.socketError)
self.socket.stateChanged.connect(self.on_socket_state_change)
@@ -267,7 +272,7 @@ def set_upnp(self, port):
self.socket.localAddress().toString(), port, "UDP",
)
- def processDataFromServer(self, data):
+ def processDataFromServer(self, data: str) -> None:
self._data = ""
for line in data.splitlines():
action = json.loads(line)
@@ -277,7 +282,6 @@ def processDataFromServer(self, data):
self.send(dict(command="pong"))
elif command == "pong":
logger.debug("Server: PONG")
- self.received_pong.emit()
else:
try:
self._dispatch(action)
From aae49fa564df0f04fffbb3b12b0f60577179ece3 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 7 May 2024 14:18:15 +0300
Subject: [PATCH 065/123] Stop reconnect_timer when connection is established
so it doesn't continue to call connection.do_connect()
after successful reconnect
---
src/client/connection.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/client/connection.py b/src/client/connection.py
index 620ec307d..697581390 100644
--- a/src/client/connection.py
+++ b/src/client/connection.py
@@ -100,6 +100,7 @@ def on_state_changed(self, state):
def handle_connected(self):
self._connection_attempts = 0
+ self._reconnect_timer.stop()
def handle_reconnecting(self):
self._connection_attempts += 1
@@ -110,7 +111,6 @@ def handle_disconnected(self):
if self._connection_attempts < 3:
logger.info("Reconnecting immediately")
- self._reconnect_timer.stop()
self._connection.do_connect()
elif self._reconnect_timer.isActive():
return
From 4773a966bb8e600b9103a21bcfaca25a83197955 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 7 May 2024 20:36:56 +0300
Subject: [PATCH 066/123] util: Add GAME_CACHE_DIR constant
which is needed since ab20f214128d482dfb9faef6ced40b567d2f4ba4
ridiculous!
---
src/util/__init__.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/util/__init__.py b/src/util/__init__.py
index 6d758ce27..5ee297d77 100644
--- a/src/util/__init__.py
+++ b/src/util/__init__.py
@@ -58,6 +58,9 @@
# Cache for news images
NEWS_CACHE_DIR = os.path.join(CACHE_DIR, "news")
+# This contains cached game files
+GAME_CACHE_DIR = os.path.join(CACHE_DIR, "featured_mod")
+
# This contains cached data downloaded for FA extras
EXTRA_DIR = os.path.join(APPDATA_DIR, "extra")
@@ -163,6 +166,7 @@ def setPersonalDir():
APPDATA_DIR, PERSONAL_DIR, LUA_DIR, CACHE_DIR,
MAP_PREVIEW_SMALL_DIR, MAP_PREVIEW_LARGE_DIR, MOD_PREVIEW_DIR,
THEME_DIR, REPLAY_DIR, LOG_DIR, EXTRA_DIR, NEWS_CACHE_DIR,
+ GAME_CACHE_DIR,
]:
if not os.path.isdir(data_dir):
os.makedirs(data_dir)
From 35e5f96f6b046f37821060b0c6bc27fe1a83239a Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Wed, 8 May 2024 15:08:38 +0300
Subject: [PATCH 067/123] replayserver: Fix PyQt's namespaces
---
src/fa/replayserver.py | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py
index d315e9cb5..02eceea7c 100644
--- a/src/fa/replayserver.py
+++ b/src/fa/replayserver.py
@@ -1,3 +1,4 @@
+from __future__ import annotations
import json
import logging
@@ -26,13 +27,17 @@ class ReplayRecorder(QtCore.QObject):
"""
__logger = logging.getLogger(__name__)
- def __init__(self, parent, local_socket, *args, **kwargs):
+ def __init__(
+ self,
+ parent: ReplayServer,
+ local_socket: QtNetwork.QTcpSocket,
+ *args,
+ **kwargs,
+ ) -> None:
QtCore.QObject.__init__(self, *args, **kwargs)
self.parent = parent
self.inputSocket = local_socket
- self.inputSocket.setSocketOption(
- QtNetwork.QTcpSocket.KeepAliveOption, 1,
- )
+ self.inputSocket.setSocketOption(QtNetwork.QTcpSocket.SocketOption.KeepAliveOption, 1)
self.inputSocket.readyRead.connect(self.readDatas)
self.inputSocket.disconnected.connect(self.inputDisconnected)
self.__logger.info("FA connected locally.")
@@ -168,7 +173,7 @@ def writeReplayFile(self):
)
replay = QtCore.QFile(filename)
- replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.Text)
+ replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.OpenModeFlag.Text)
replay.write(json.dumps(self.replayInfo).encode('utf-8'))
replay.write(b'\n')
replay.write(QtCore.qCompress(self.replayData).toBase64())
From 4e96ec3713caa51dfcf28fd70abe4f26a24acae8 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Thu, 9 May 2024 20:30:38 +0300
Subject: [PATCH 068/123] Remove Qt ANGLE workaround
as it is not supported and was removed in Qt6
---
src/__main__.py | 16 ----------------
1 file changed, 16 deletions(-)
diff --git a/src/__main__.py b/src/__main__.py
index 8e3d3d06a..df9860c0a 100644
--- a/src/__main__.py
+++ b/src/__main__.py
@@ -36,24 +36,8 @@
cmd_parser = argparse.ArgumentParser(
description='FAF client commandline arguments.',
)
-cmd_parser.add_argument(
- '--qt-angle-workaround',
- action='store_true',
- help=(
- 'Use Qt5 ANGLE backend. Enable if some client '
- 'tabs appear frozen. On by default.'
- ),
-)
-cmd_parser.add_argument(
- '--no-qt-angle-workaround',
- action='store_true',
- help='Do not use Qt5 ANGLE backend.',
-)
args, trailing_args = cmd_parser.parse_known_args()
-if sys.platform == 'win32' and not args.no_qt_angle_workaround:
- os.environ.setdefault('QT_OPENGL', 'angle')
- os.environ.setdefault('QT_ANGLE_PLATFORM', 'd3d9')
path = os.path.join(os.path.dirname(sys.argv[0]), "PyQt6.uic.widget-plugins")
From 453fe7042e1cfd67fb06b9167328709b6ddaa5ba Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 10 May 2024 15:13:06 +0300
Subject: [PATCH 069/123] Refactor updater
* run Updater's tasks in a separate thread
to not block the GUI thread
* make use of UpdaterProgressDialog which was in the code
since like project creation, but was never used
* use QEventLoop to processEvents in Updater (it is still undesirable
behaviour, but preferred to using QApplication.processEvents())
* factor out downloading sim mods from updater -- it doesn't fit
into updater, and also spawns GUI windows (and Updater runs in non-GUI thread)
now updater doesn't block GUI at all!
---
res/fa/updater/updater.ui | 397 +++++++++++++++------------
src/fa/mods.py | 14 +-
src/fa/update_processor.py | 285 ++++++++++++++++++++
src/fa/updater.py | 532 ++++++++++---------------------------
src/fa/updater_misc.py | 75 ++++++
src/fa/utils.py | 22 +-
6 files changed, 732 insertions(+), 593 deletions(-)
create mode 100644 src/fa/update_processor.py
create mode 100644 src/fa/updater_misc.py
diff --git a/res/fa/updater/updater.ui b/res/fa/updater/updater.ui
index ea13a733a..7c7ade422 100644
--- a/res/fa/updater/updater.ui
+++ b/res/fa/updater/updater.ui
@@ -6,159 +6,273 @@
0
0
- 258
- 305
+ 360
+ 364
-
+
0
0
+
+
+ 360
+ 0
+
+
- Dialog
+ Updater
-
-
- QLayout::SetMinimumSize
-
-
- 0
-
-
- 0
-
+
-
-
-
- 0
-
-
- 2
-
-
-
-
-
- Updating Game Data
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Abort
-
-
-
-
-
- -
-
+
0
0
+
+
+ 0
+ 180
+
+
+
+
+ 16777215
+ 180
+
+
- QFrame::StyledPanel
+ QFrame::Shape::StyledPanel
- QFrame::Raised
+ QFrame::Shadow::Raised
-
- 0
-
-
+
0
-
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
0
-
-
-
- 100
-
-
- 24
+
+
+
+ 0
+ 0
+
-
- Qt::AlignCenter
+
+
+ Courier
+
-
+
true
-
- Qt::Horizontal
-
-
- false
-
-
- Game
-
- -
-
-
- 24
-
-
- Qt::AlignCenter
-
-
- Featured Mod
-
-
-
- -
-
-
- 24
-
-
- Qt::AlignCenter
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 16777215
+ 160
+
+
+
+ QFrame::Shape::StyledPanel
+
+
+ QFrame::Shadow::Raised
+
+
+ 0
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 2
+
+
-
+
+
+ 0
-
- Map
+
+ 0
-
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 16777215
+ 40
+
+
+
+ Updating Game Data
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+ QSizePolicy::Policy::Expanding
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 16777215
+ 40
+
+
+
+ Abort
+
+
+
+
- -
-
-
- Details
+
-
+
+
+ 2
-
- false
-
-
+ -
+
+
+ 100
+
+
+ 24
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ true
+
+
+ Qt::Orientation::Horizontal
+
+
+ false
+
+
+ Game
+
+
+
+ -
+
+
+ 24
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ md5
+
+
+
+ -
+
+
+ 24
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ Featured Mod
+
+
+
+ -
+
+
+ 24
+
+
+ Qt::AlignmentFlag::AlignCenter
+
+
+ Movies and Sounds
+
+
+
+
- -
-
+
-
+
+
-
+
+
+ Details
+
+
+ false
+
+
+
+
@@ -166,54 +280,5 @@
-
-
- abortButton
- clicked()
- Dialog
- reject()
-
-
- 236
- 12
-
-
- 137
- 46
-
-
-
-
- detailsButton
- clicked()
- detailsButton
- hide()
-
-
- 128
- 100
-
-
- 128
- 100
-
-
-
-
- detailsButton
- clicked()
- logPlainTextEdit
- show()
-
-
- 128
- 100
-
-
- 128
- 196
-
-
-
-
+
diff --git a/src/fa/mods.py b/src/fa/mods.py
index a05b78afd..773ba4d1d 100644
--- a/src/fa/mods.py
+++ b/src/fa/mods.py
@@ -3,7 +3,8 @@
from PyQt6 import QtWidgets
import config
-import fa
+from api.sim_mod_updater import SimModFiles
+from vaults.modvault.utils import downloadMod
from vaults.modvault.utils import getInstalledMods
from vaults.modvault.utils import setActiveMods
@@ -44,12 +45,11 @@ def checkMods(mods: dict[str, str]) -> bool: # mods is a dictionary of uid-name
elif result == QtWidgets.QMessageBox.StandardButton.YesToAll:
config.Settings.set('mods/autodownload', True)
- for item in to_download.items():
- # Spawn an update for the required mod
- updater = fa.updater.Updater("sim", sim_mod=item)
- result = updater.run()
- if result != fa.updater.UpdaterResult.SUCCESS:
- logger.warning(f"Failure getting {item}")
+ api_accessor = SimModFiles()
+ for uid, name in to_download.items():
+ url = api_accessor.request_and_get_sim_mod_url_by_id(uid)
+ if not downloadMod(url, name):
+ logger.warning(f"Failure getting {name!r} with uid {uid!r}")
return False
actual_mods = []
diff --git a/src/fa/update_processor.py b/src/fa/update_processor.py
new file mode 100644
index 000000000..8a2228265
--- /dev/null
+++ b/src/fa/update_processor.py
@@ -0,0 +1,285 @@
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import stat
+
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkAccessManager
+
+import util
+from api.featured_mod_api import FeaturedModApiConnector
+from api.featured_mod_api import FeaturedModFilesApiConnector
+from api.models.FeaturedMod import FeaturedMod
+from api.models.FeaturedModFile import FeaturedModFile
+from config import Settings
+from downloadManager import FileDownload
+from fa.updater_misc import ProgressInfo
+from fa.updater_misc import UpdaterCancellation
+from fa.updater_misc import UpdaterFailure
+from fa.updater_misc import UpdaterResult
+from fa.updater_misc import log
+from fa.utils import unpack_movies_and_sounds
+from vaults.dialogs import download_file
+
+logger = logging.getLogger(__name__)
+
+
+class UpdateProcessor(QObject):
+ done = pyqtSignal(UpdaterResult)
+ md5_progress = pyqtSignal(ProgressInfo)
+ movies_progress = pyqtSignal(ProgressInfo)
+ game_progress = pyqtSignal(ProgressInfo)
+ featured_mod_progress = pyqtSignal(ProgressInfo)
+
+ download_started = pyqtSignal(FileDownload)
+ download_progress = pyqtSignal(FileDownload)
+ download_finished = pyqtSignal(FileDownload)
+
+ def __init__(
+ self,
+ featured_mod: str,
+ version: int | None,
+ modversions: dict | None,
+ silent: bool = False,
+ ) -> None:
+ super().__init__()
+ self.featured_mod = featured_mod
+ self.version = version
+ self.modversions = modversions
+ self.silent = silent
+
+ self.nam = QNetworkAccessManager(self)
+ self.result = UpdaterResult.NONE
+
+ keep_cache = not Settings.get("cache/do_not_keep", type=bool, default=True)
+ in_session_cache = Settings.get("cache/in_session", type=bool, default=False)
+ self.cache_enabled = keep_cache or in_session_cache
+
+ def get_files_to_update(self, mod_id: str, version: str) -> list[dict]:
+ return FeaturedModFilesApiConnector(mod_id, version).get_files()
+
+ def get_featured_mod_by_name(self, technical_name: str) -> FeaturedMod:
+ return FeaturedModApiConnector().request_and_get_fmod_by_name(technical_name)
+
+ @staticmethod
+ def _filter_files_to_update(
+ files: list[FeaturedModFile],
+ precalculated_md5s: dict[str, str],
+ ) -> list[FeaturedModFile]:
+ exe_name = Settings.get("game/exe-name")
+ return [
+ file for file in files
+ if precalculated_md5s[file.md5] != file.md5 and file.name != exe_name
+ ]
+
+ def _calculate_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]:
+ total = len(files)
+ result = {}
+ for index, file in enumerate(files, start=1):
+ filepath = os.path.join(util.APPDATA_DIR, file.group, file.name)
+ self.md5_progress.emit(ProgressInfo(index, total, file.name))
+ result[file.md5] = util.md5(filepath)
+ return result
+
+ def fetch_file(self, file: FeaturedModFile) -> None:
+ target_path = os.path.join(util.APPDATA_DIR, file.group, file.name)
+ url = file.cacheable_url
+ logger.info(f"Updater: Downloading {url}")
+
+ dler = FileDownload(
+ target_path=target_path,
+ nam=self.nam,
+ addr=url,
+ request_params={file.hmac_parameter: file.hmac_token},
+ )
+ dler.progress.connect(lambda: self.download_progress.emit(dler))
+ dler.start.connect(lambda: self.download_started.emit(dler))
+ dler.finished.connect(lambda: self.download_finished.emit(dler))
+ dler.run()
+ dler.waitForCompletion()
+ if dler.failed():
+ raise UpdaterFailure()
+
+ def check_download_failure(self, dler: FileDownload) -> None:
+ if dler.failed():
+ raise UpdaterFailure(f"Failed to download from {dler.addr!r}")
+
+ def move_from_cache(self, file: FeaturedModFile) -> None:
+ src_dir = os.path.join(util.APPDATA_DIR, file.group)
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
+ if os.path.exists(os.path.join(cache_dir, file.md5)):
+ shutil.move(
+ os.path.join(cache_dir, file.md5),
+ os.path.join(src_dir, file.name),
+ )
+
+ def move_to_cache(
+ self,
+ file: FeaturedModFile,
+ precalculated_md5s: dict[str, str] | None = None,
+ ) -> None:
+ precalculated_md5s = precalculated_md5s or {}
+ src_dir = os.path.join(util.APPDATA_DIR, file.group)
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
+ if os.path.exists(os.path.join(src_dir, file.name)):
+ md5 = precalculated_md5s.get(file.md5, util.md5(os.path.join(src_dir, file.name)))
+ shutil.move(
+ os.path.join(src_dir, file.name),
+ os.path.join(cache_dir, md5),
+ )
+ util.setAccessTime(os.path.join(cache_dir, md5))
+
+ @staticmethod
+ def _is_cached(file: FeaturedModFile) -> bool:
+ cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.name)
+ return os.path.isfile(cached_file)
+
+ def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None:
+ for file in files:
+ target = os.path.join(util.GAME_CACHE_DIR, file.group)
+ os.makedirs(target, exist_ok=True)
+
+ def _update_file(
+ self,
+ file: FeaturedModFile,
+ precalculated_md5s: dict[str, str] | None = None,
+ ) -> None:
+ if self._is_cached(file):
+ if self.cache_enabled:
+ self.move_to_cache(file, precalculated_md5s)
+ self.move_from_cache(file)
+ else:
+ self.fetch_file(file)
+
+ def update_files(self, files: list[FeaturedModFile]) -> None:
+ """
+ Updates the files in the destination
+ subdirectory of the Forged Alliance path.
+ """
+ self.create_cache_subdirs(files)
+ self.patch_fa_exe_if_needed(files)
+ md5s = self._calculate_md5s(files)
+
+ to_update = self._filter_files_to_update(files, md5s)
+ total = len(to_update)
+
+ for index, file in enumerate(to_update, start=1):
+ self.featured_mod_progress.emit(ProgressInfo(index, total, file.name))
+ self._update_file(file, md5s)
+
+ def unpack_movies_and_sounds(self, files: list[FeaturedModFile]) -> None:
+ logger.info("Checking files for movies and sounds")
+
+ total = len(files)
+ for index, file in enumerate(files, start=1):
+ self.movies_progress.emit(ProgressInfo(index, total, file.name))
+ unpack_movies_and_sounds(file)
+
+ def prepare_bin_FAF(self) -> None:
+ """
+ Creates all necessary files in the binFAF folder, which contains
+ a modified copy of all that is in the standard bin folder of
+ Forged Alliance
+ """
+ # now we check if we've got a binFAF folder
+ FABindir = os.path.join(Settings.get("ForgedAlliance/app/path"), "bin")
+ FAFdir = util.BIN_DIR
+
+ # Try to copy without overwriting, but fill in any missing files,
+ # otherwise it might miss some files to update
+ root_src_dir = FABindir
+ root_dst_dir = FAFdir
+
+ for src_dir, _, files in os.walk(root_src_dir):
+ dst_dir = src_dir.replace(root_src_dir, root_dst_dir)
+ os.makedirs(dst_dir, exist_ok=True)
+ total_files = len(files)
+ for index, file in enumerate(files, start=1):
+ self.game_progress.emit(ProgressInfo(index, total_files, file))
+ src_file = os.path.join(src_dir, file)
+ dst_file = os.path.join(dst_dir, file)
+ if not os.path.exists(dst_file):
+ shutil.copy(src_file, dst_dir)
+ st = os.stat(dst_file)
+ # make all files we were considering writable, because we may
+ # need to patch them
+ os.chmod(dst_file, st.st_mode | stat.S_IWRITE)
+
+ self.download_fa_executable()
+
+ def download_fa_executable(self) -> bool:
+ fa_exe_name = Settings.get("game/exe-name")
+ fa_exe = os.path.join(util.BIN_DIR, fa_exe_name)
+
+ if os.path.isfile(fa_exe):
+ return True
+
+ url = Settings.get("game/exe-url")
+ return download_file(
+ url=url,
+ target_dir=util.BIN_DIR,
+ name=fa_exe_name,
+ category="Update",
+ silent=False,
+ label=f"Downloading FA file : {url}",
+ )
+
+ def patch_fa_executable(self, version: int) -> None:
+ exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name"))
+ version_addresses = (0xd3d40, 0x47612d, 0x476666)
+ with open(exe_path, "rb+") as file:
+ for address in version_addresses:
+ file.seek(address)
+ file.write(version.to_bytes(4, "little"))
+
+ def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None:
+ for file in files:
+ if file.name == Settings.get("game/exe-name"):
+ version = int(self._resolve_base_version(file))
+ self.patch_fa_executable(version)
+ return
+
+ def update_featured_mod(self, modname: str, modversion: str) -> list[FeaturedModFile]:
+ fmod = self.get_featured_mod_by_name(modname)
+ files = self.get_files_to_update(fmod.uid, modversion)
+ self.update_files(files)
+ return files
+
+ def _resolve_modversion(self) -> str:
+ if self.modversions:
+ return str(max(self.modversions.values()))
+ return "latest"
+
+ def _resolve_base_version(self, exe_info: FeaturedModFile | None = None) -> str:
+ if self.version:
+ return str(self.version)
+ if exe_info:
+ return str(exe_info.version)
+ return "latest"
+
+ def do_update(self) -> None:
+ """ The core function that does most of the actual update work."""
+ try:
+ # Prepare FAF directory & all necessary files
+ self.prepare_bin_FAF()
+ # Update the mod if it's requested
+ if self.featured_mod in ("faf", "fafbeta", "fafdevelop", "ladder1v1"):
+ self.update_featured_mod(self.featured_mod, self._resolve_base_version())
+ else:
+ # update faf first
+ self.update_featured_mod("faf", self._resolve_base_version())
+ # update featured mod then
+ self.update_featured_mod(self.featured_mod, self._resolve_modversion())
+ except UpdaterCancellation as e:
+ log("CANCELLED: {}".format(e), logger)
+ self.result = UpdaterResult.CANCEL
+ except Exception as e:
+ log("EXCEPTION: {}".format(e), logger)
+ logger.exception(f"EXCEPTION: {e}")
+ self.result = UpdaterResult.FAILURE
+ else:
+ self.result = UpdaterResult.SUCCESS
+ self.done.emit(self.result)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index dbbb2e53c..402e64c2c 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -8,471 +8,205 @@
@author thygrrr
"""
+from __future__ import annotations
+
import logging
-import os
-import shutil
-import stat
-import time
-from enum import Enum
-from functools import partial
+from PyQt6.QtCore import QEventLoop
from PyQt6.QtCore import QObject
-from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QThread
+from PyQt6.QtCore import pyqtSignal
from PyQt6.QtCore import pyqtSlot
-from PyQt6.QtWidgets import QApplication
+from PyQt6.QtGui import QTextCursor
from PyQt6.QtWidgets import QDialog
-from PyQt6.QtWidgets import QMessageBox
-from PyQt6.QtWidgets import QProgressDialog
import util
-from api.featured_mod_api import FeaturedModApiConnector
-from api.featured_mod_api import FeaturedModFilesApiConnector
-from api.models.FeaturedMod import FeaturedMod
-from api.models.FeaturedModFile import FeaturedModFile
-from api.sim_mod_updater import SimModFiles
-from config import Settings
-from fa.utils import unpack_movies_and_sounds
-from vaults.dialogs import download_file
-from vaults.modvault import utils
+from downloadManager import FileDownload
+from fa.updater_misc import ProgressInfo
+from fa.updater_misc import UpdaterResult
+from fa.updater_misc import clear_log
+from fa.updater_misc import failure_dialog
+from fa.updater_misc import log
+from fa.updater_misc import timestamp
+from src.fa.update_processor import UpdateProcessor
logger = logging.getLogger(__name__)
-# This contains a complete dump of everything that was supplied to logOutput
-debugLog = []
-
FormClass, BaseClass = util.THEME.loadUiType("fa/updater/updater.ui")
class UpdaterProgressDialog(FormClass, BaseClass):
- def __init__(self, parent):
+ aborted = pyqtSignal()
+
+ def __init__(self, parent, abortable: bool) -> None:
BaseClass.__init__(self, parent)
self.setupUi(self)
- self.logPlainTextEdit.setVisible(False)
+ self.setModal(True)
+ self.logPlainTextEdit.setLineWrapMode(self.logPlainTextEdit.LineWrapMode.NoWrap)
+ self.logFrame.setVisible(False)
self.adjustSize()
self.watches = []
+ if not abortable:
+ self.abortButton.hide()
+
+ self.rejected.connect(self.abort)
+ self.abortButton.clicked.connect(self.reject)
+ self.detailsButton.clicked.connect(self.change_details_visibility)
+
+ def change_details_visibility(self) -> None:
+ visible = self.logFrame.isVisible()
+ self.logFrame.setVisible(not visible)
+ self.adjustSize()
+
+ def abort(self) -> None:
+ self.aborted.emit()
+
@pyqtSlot(str)
- def appendLog(self, text):
+ def append_log(self, text: str) -> None:
self.logPlainTextEdit.appendPlainText(text)
+ def replace_last_log_line(self, text: str) -> None:
+ self.logPlainTextEdit.moveCursor(
+ QTextCursor.MoveOperation.StartOfLine,
+ QTextCursor.MoveMode.KeepAnchor,
+ )
+ self.logPlainTextEdit.textCursor().removeSelectedText()
+ self.logPlainTextEdit.insertPlainText(text)
+
@pyqtSlot(QObject)
- def addWatch(self, watch):
+ def add_watch(self, watch: QObject) -> None:
self.watches.append(watch)
- watch.finished.connect(self.watchFinished)
+ watch.finished.connect(self.watch_finished)
@pyqtSlot()
- def watchFinished(self) -> None:
+ def watch_finished(self) -> None:
for watch in self.watches:
- if not watch.isFinished():
+ if not watch.is_finished():
return
# equivalent to self.accept(), but clearer
self.done(QDialog.DialogCode.Accepted)
-def clearLog():
- global debugLog
- debugLog = []
-
-
-def log(string):
- logger.debug(string)
- debugLog.append(str(string))
-
-
-def dumpPlainText():
- return "\n".join(debugLog)
-
-
-def dumpHTML():
- return "
".join(debugLog)
-
-
-# A set of exceptions we use to see what goes wrong during asynchronous data
-# transfer waits
-class UpdaterCancellation(Exception):
- pass
-
-
-class UpdaterFailure(Exception):
- pass
-
-
-class UpdaterTimeout(Exception):
- pass
-
-
-class UpdaterResult(Enum):
- SUCCESS = 0 # Update successful
- NONE = -1 # Update operation is still ongoing
- FAILURE = 1 # An error occured during updating
- CANCEL = 2 # User cancelled the download process
- ILLEGAL = 3 # User has the wrong version of FA
- BUSY = 4 # Server is currently busy
- PASS = 5 # User refuses to update by canceling the wizard
-
-
class Updater(QObject):
"""
This is the class that does the actual installation work.
"""
+ finished = pyqtSignal()
+
def __init__(
- self,
- featured_mod: str,
- version: int | None = None,
- modversions: dict | None = None,
- sim_mod: tuple[str, str] | None = None,
- silent: bool = False,
- *args,
- **kwargs,
+ self,
+ featured_mod: str,
+ version: int | None = None,
+ modversions: dict | None = None,
+ silent: bool = False,
+ *args,
+ **kwargs,
):
"""
Constructor
"""
super().__init__(*args, **kwargs)
- self.featured_mod = featured_mod
- self.version = version
- self.modversions = modversions
- self.sim_mod = sim_mod
- self.silent = silent
-
- self.result = UpdaterResult.NONE
-
- self.keep_cache = not Settings.get(
- 'cache/do_not_keep', type=bool, default=True,
- )
- self.in_session_cache = Settings.get(
- 'cache/in_session', type=bool, default=False,
- )
-
- self.progress = QProgressDialog()
- if self.silent:
- self.progress.setCancelButton(None)
- else:
- self.progress.setCancelButtonText("Cancel")
- self.progress.setWindowFlags(
- Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint,
- )
- self.progress.setAutoClose(False)
- self.progress.setAutoReset(False)
- self.progress.setModal(1)
- self.progress.setWindowTitle(f"Updating {self.featured_mod.upper()}")
-
- def run(self, *args, **kwargs):
- clearLog()
- log("Update started at " + timestamp())
- log("Using appdata: " + util.APPDATA_DIR)
-
- self.progress.show()
- QApplication.processEvents()
-
- # Actual network code adapted from previous version
- self.progress.setLabelText("Connecting to update server...")
-
- if not self.progress.wasCanceled():
- log("Connected to update server at {}".format(timestamp()))
-
- self.do_update()
-
- self.progress.setLabelText("Cleaning up.")
-
- self.progress.close()
- else:
- log("Cancelled connecting to server.")
- self.result = UpdaterResult.CANCEL
-
- log("Update finished at {}".format(timestamp()))
- return self.result
-
- def get_files_to_update(self, mod_id: str, version: str) -> list[dict]:
- return FeaturedModFilesApiConnector(mod_id, version).get_files()
-
- def get_featured_mod_by_name(self, technical_name: str) -> FeaturedMod:
- return FeaturedModApiConnector().request_and_get_fmod_by_name(technical_name)
-
- def request_sim_url_by_uid(self, uid: str) -> str:
- return SimModFiles().request_and_get_sim_mod_url_by_id(uid)
-
- @staticmethod
- def _file_needs_update(file: FeaturedModFile, md5s: dict[str, str]) -> bool:
- incoming_md5 = file.md5
- current_md5 = md5s[file.md5]
- return file.name != Settings.get("game/exe-name") and current_md5 != incoming_md5
+ self.worker_thread = QThread()
+ self.update_processor = UpdateProcessor(featured_mod, version, modversions, silent)
+ self.update_processor.moveToThread(self.worker_thread)
- def _calc_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]:
- self.progress.setMaximum(len(files))
+ self.update_processor.done.connect(self.on_update_done)
+ self.update_processor.md5_progress.connect(self.on_md5_progress)
+ self.update_processor.movies_progress.connect(self.on_movies_progress)
+ self.update_processor.game_progress.connect(self.on_game_progress)
+ self.update_processor.featured_mod_progress.connect(self.on_feat_mod_progress)
+ self.update_processor.download_progress.connect(self.on_download_progress)
+ self.update_processor.download_finished.connect(self.on_download_finished)
+ self.update_processor.download_started.connect(self.on_download_started)
+ self.worker_thread.started.connect(self.update_processor.do_update)
- result = {}
- for index, file in enumerate(files, start=1):
+ self.progress = UpdaterProgressDialog(None, silent)
+ self.progress.aborted.connect(self.abort)
- if self.progress.wasCanceled():
- raise UpdaterCancellation()
-
- filepath = os.path.join(util.APPDATA_DIR, file.group, file.name)
+ self.result = UpdaterResult.NONE
- self.progress.setLabelText(f"Calculating md5 for {file.name}...")
+ def on_movies_progress(self, info: ProgressInfo) -> None:
+ self.progress.moviesProgress.setMaximum(info.total)
+ self.progress.moviesProgress.setValue(info.progress)
+ self.progress.append_log(f"Checking for movies and sounds: {info.description}")
- result[file.md5] = util.md5(filepath)
+ def on_md5_progress(self, info: ProgressInfo) -> None:
+ self.progress.md5Progress.setMaximum(info.total)
+ self.progress.md5Progress.setValue(info.progress)
+ self.progress.append_log(f"Calculating md5: {info.description}")
- self.progress.setValue(index)
- self.progress.setMaximum(0)
- return result
+ def on_game_progress(self, info: ProgressInfo) -> None:
+ self.progress.gameProgress.setMaximum(info.total)
+ self.progress.gameProgress.setValue(info.progress)
+ self.progress.append_log(f"Copying game files: {info.description}")
- def fetch_files(self, files: list[FeaturedModFile]) -> None:
- for file in files:
- self.fetch_single_file(file)
+ def on_feat_mod_progress(self, info: ProgressInfo) -> None:
+ self.progress.modProgress.setMaximum(info.total)
+ self.progress.modProgress.setValue(info.progress)
+ self.progress.append_log(f"Processing something: {info.description}")
- def fetch_single_file(self, file: FeaturedModFile) -> None:
- target_dir = os.path.join(util.APPDATA_DIR, file.group)
+ def on_download_progress(self, dler: FileDownload) -> None:
+ if dler.bytes_total == 0:
+ return
- url = file.cacheable_url
- logger.info(f"Updater: Downloading {url}")
+ total = dler.bytes_total
+ ready = dler.bytes_progress
- downloaded = download_file(
- url=url,
- target_dir=target_dir,
- name=file.name,
- category="Update",
- silent=False,
- request_params={file.hmac_parameter: file.hmac_token},
- label=f"Downloading FA file : {url}
",
- )
+ total_mb = round(total / (1024 ** 2), 2)
+ ready_mb = round(ready / (1024 ** 2), 2)
- if not downloaded:
- # FIXME: the information about the reason is already given in the
- # dowloadFile function, need to come up with better way probably
- raise UpdaterCancellation(
- "Operation aborted while waiting for data.",
- )
-
- def move_many_from_cache(self, files: list[FeaturedModFile]) -> None:
- for file in files:
- self.move_from_cache(file)
-
- def move_from_cache(self, file: FeaturedModFile) -> None:
- src_dir = os.path.join(util.APPDATA_DIR, file.group)
- cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
- if os.path.exists(os.path.join(cache_dir, file.md5)):
- shutil.move(
- os.path.join(cache_dir, file.md5),
- os.path.join(src_dir, file.name),
- )
-
- def move_many_to_cache(self, files: list[dict]) -> None:
- for file in files:
- self.move_to_cache(file)
-
- def move_to_cache(self, file: FeaturedModFile) -> None:
- src_dir = os.path.join(util.APPDATA_DIR, file.group)
- cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
- if os.path.exists(os.path.join(src_dir, file.name)):
- md5 = util.md5(os.path.join(src_dir, file.name))
- shutil.move(
- os.path.join(src_dir, file.name),
- os.path.join(cache_dir, md5),
- )
- util.setAccessTime(os.path.join(cache_dir, md5))
-
- def replace_from_cache(self, file: FeaturedModFile) -> None:
- self.move_to_cache(file)
- self.move_from_cache(file)
-
- def replace_many_from_cache(self, files: list[FeaturedModFile]) -> None:
- for file in files:
- self.replace_from_cache(file)
-
- def check_cache(self, files_to_check: list[FeaturedModFile]) -> None:
- replaceable_files, need_to_download = [], []
- for file in files_to_check:
- cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
- os.makedirs(cache_dir, exist_ok=True)
- if self._is_cached(file):
- replaceable_files.append(file)
- else:
- need_to_download.append(file)
- return replaceable_files, need_to_download
-
- @staticmethod
- def _is_cached(file: FeaturedModFile) -> bool:
- cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.name)
- return os.path.isfile(cached_file)
-
- def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None:
- for file in files:
- target = os.path.join(util.GAME_CACHE_DIR, file.group)
- os.makedirs(target, exist_ok=True)
-
- def update_files(self, files: list[FeaturedModFile]) -> None:
- """
- Updates the files in the destination
- subdirectory of the Forged Alliance path.
- """
- self.create_cache_subdirs(files)
- self.patch_fa_exe_if_needed(files)
- md5s = self._calc_md5s(files)
+ def construct_bar(blockchar: str = "=", fillchar: str = " ") -> str:
+ num_blocks = round(20 * ready / total)
+ empty_blocks = 20 - num_blocks
+ return f"[{blockchar * num_blocks}{fillchar * empty_blocks}]"
- self.progress.setLabelText("Updating files...")
+ bar = construct_bar()
+ percent_text = f"{100 * ready / total:.1f}%"
+ text = f"{bar} {percent_text} ({ready_mb} MB / {total_mb} MB)"
+ self.progress.replace_last_log_line(text)
- to_update = list(filter(partial(self._file_needs_update, md5s=md5s), files))
- replacable_files, need_to_download = self.check_cache(to_update)
+ def on_download_finished(self, dler: FileDownload) -> None:
+ self.progress.append_log("Finished downloading.")
- if self.keep_cache or self.in_session_cache:
- self.replace_many_from_cache(replacable_files)
- self.move_many_to_cache(need_to_download)
- else:
- self.move_many_from_cache(replacable_files)
+ def on_download_started(self, dler: FileDownload) -> None:
+ self.progress.append_log(f"Downloading file from {dler.addr}\n")
- self.fetch_files(need_to_download)
+ def run(self) -> UpdaterResult:
+ clear_log()
+ log(f"Update started at {timestamp()}", logger)
+ log(f"Using appdata: {util.APPDATA_DIR}", logger)
- unpack_movies_and_sounds(files)
- log("Updates applied successfully.")
+ self.progress.show()
+ self.worker_thread.start()
- def prepare_bin_FAF(self) -> None:
- """
- Creates all necessary files in the binFAF folder, which contains
- a modified copy of all that is in the standard bin folder of
- Forged Alliance
- """
- self.progress.setLabelText("Preparing binFAF...")
-
- # now we check if we've got a binFAF folder
- FABindir = os.path.join(Settings.get("ForgedAlliance/app/path"), "bin")
- FAFdir = util.BIN_DIR
-
- # Try to copy without overwriting, but fill in any missing files,
- # otherwise it might miss some files to update
- root_src_dir = FABindir
- root_dst_dir = FAFdir
-
- for src_dir, _, files in os.walk(root_src_dir):
- dst_dir = src_dir.replace(root_src_dir, root_dst_dir)
- if not os.path.exists(dst_dir):
- os.mkdir(dst_dir)
- for file_ in files:
- src_file = os.path.join(src_dir, file_)
- dst_file = os.path.join(dst_dir, file_)
- if not os.path.exists(dst_file):
- shutil.copy(src_file, dst_dir)
- st = os.stat(dst_file)
- # make all files we were considering writable, because we may
- # need to patch them
- os.chmod(dst_file, st.st_mode | stat.S_IWRITE)
-
- self.download_fa_executable()
-
- def download_fa_executable(self) -> bool:
- fa_exe_name = Settings.get("game/exe-name")
- fa_exe = os.path.join(util.BIN_DIR, fa_exe_name)
-
- if os.path.isfile(fa_exe):
- return True
-
- url = Settings.get("game/exe-url")
- return download_file(
- url=url,
- target_dir=util.BIN_DIR,
- name=fa_exe_name,
- category="Update",
- silent=False,
- label=f"Downloading FA file : {url}
",
- )
+ loop = QEventLoop()
+ self.worker_thread.finished.connect(loop.quit)
+ loop.exec()
- def patch_fa_executable(self, version: int) -> None:
- exe_path = os.path.join(util.BIN_DIR, Settings.get("game/exe-name"))
- version_addresses = (0xd3d40, 0x47612d, 0x476666)
- with open(exe_path, "rb+") as file:
- for address in version_addresses:
- file.seek(address)
- file.write(version.to_bytes(4, "little"))
-
- def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None:
- for file in files:
- if file.name == Settings.get("game/exe-name"):
- version = int(self._resolve_base_version(file))
- self.patch_fa_executable(version)
- return
+ self.progress.accept()
+ log(f"Update finished at {timestamp()}", logger)
+ return self.result
- def update_featured_mod(self, modname: str, modversion: str) -> list[FeaturedModFile]:
- fmod = self.get_featured_mod_by_name(modname)
- files = self.get_files_to_update(fmod.uid, modversion)
- self.update_files(files)
- return files
-
- def _resolve_modversion(self) -> str:
- if self.modversions:
- return str(max(self.modversions.values()))
- return "latest"
-
- def _resolve_base_version(self, exe_info: FeaturedModFile | None = None) -> str:
- if self.version:
- return str(self.version)
- if exe_info:
- return str(exe_info.version)
- return "latest"
-
- def do_update(self) -> None:
- """ The core function that does most of the actual update work."""
- try:
- if self.sim_mod:
- uid, name = self.sim_mod
- if not utils.downloadMod(self.request_sim_url_by_uid(uid), name):
- raise UpdaterFailure("Sim mod wasn't downloaded")
- else:
- # Prepare FAF directory & all necessary files
- self.prepare_bin_FAF()
- # Update the mod if it's requested
- if self.featured_mod in ("faf", "fafbeta", "fafdevelop", "ladder1v1"):
- self.update_featured_mod(self.featured_mod, self._resolve_base_version())
- else:
- # update faf first
- self.update_featured_mod("faf", self._resolve_base_version())
- # update featured mod then
- self.update_featured_mod(self.featured_mod, self._resolve_modversion())
- except UpdaterCancellation as e:
- log("CANCELLED: {}".format(e))
- self.result = UpdaterResult.CANCEL
- except BaseException as e:
- log("EXCEPTION: {}".format(e))
- self.result = UpdaterResult.FAILURE
- else:
- self.result = UpdaterResult.SUCCESS
+ def on_update_done(self, result: UpdaterResult) -> None:
+ self.result = result
+ self.handle_result_if_needed(result)
+ self.stop_thread()
+ def handle_result_if_needed(self, result: UpdaterResult) -> None:
# Integrated handlers for the various things that could go wrong
- if self.result == UpdaterResult.CANCEL:
+ if result == UpdaterResult.CANCEL:
pass # The user knows damn well what happened here.
- elif self.result == UpdaterResult.PASS:
- QMessageBox.information(
- QApplication.activeWindow(),
- "Installation Required",
- "You can't play without a legal version of Forged Alliance.",
- )
- elif self.result == UpdaterResult.BUSY:
- QMessageBox.information(
- QApplication.activeWindow(),
- "Server Busy",
- (
- "The Server is busy preparing new patch files.
Try "
- "again later."
- ),
- )
- elif self.result == UpdaterResult.FAILURE:
- failureDialog()
-
- # If nothing terribly bad happened until now,
- # the operation is a success and/or the client can display what's up.
- return self.result
+ elif result == UpdaterResult.FAILURE:
+ failure_dialog()
+ def abort(self) -> None:
+ self.result = UpdaterResult.CANCEL
+ self.stop_thread()
-def timestamp():
- return time.strftime("%Y-%m-%d %H:%M:%S")
-
-
-# This is a pretty rough port of the old installer wizard. It works, but will
-# need some work later
-def failureDialog():
- """
- The dialog that shows the user the log if something went wrong.
- """
- raise Exception(dumpPlainText())
+ def stop_thread(self) -> None:
+ self.worker_thread.quit()
+ self.worker_thread.wait(1000)
diff --git a/src/fa/updater_misc.py b/src/fa/updater_misc.py
new file mode 100644
index 000000000..09774e5a9
--- /dev/null
+++ b/src/fa/updater_misc.py
@@ -0,0 +1,75 @@
+import logging
+import time
+from enum import Enum
+from typing import NamedTuple
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QMessageBox
+
+
+# A set of exceptions we use to see what goes wrong during asynchronous data
+# transfer waits
+class UpdaterCancellation(Exception):
+ pass
+
+
+class UpdaterFailure(Exception):
+ pass
+
+
+class UpdaterTimeout(Exception):
+ pass
+
+
+class UpdaterResult(Enum):
+ SUCCESS = 0 # Update successful
+ NONE = -1 # Update operation is still ongoing
+ FAILURE = 1 # An error occured during updating
+ CANCEL = 2 # User cancelled the download process
+
+
+class ProgressInfo(NamedTuple):
+ progress: int
+ total: int
+ description: str = ""
+
+
+# This contains a complete dump of everything that was supplied to logOutput
+debug_log = []
+
+
+def clear_log() -> None:
+ global debug_log
+ debug_log = []
+
+
+def log(string: str, loger: logging.Logger) -> None:
+ loger.debug(string)
+ debug_log.append(str(string))
+
+
+def dump_plain_text() -> str:
+ return "\n".join(debug_log)
+
+
+def dump_HTML() -> str:
+ return "
".join(debug_log)
+
+
+def timestamp() -> str:
+ return time.strftime("%Y-%m-%d %H:%M:%S")
+
+
+# It works, but will need some work later
+def failure_dialog() -> None:
+ """
+ The dialog that shows the user the log if something went wrong.
+ """
+ mbox = QMessageBox()
+ mbox.setParent(QApplication.activeWindow())
+ mbox.setWindowFlags(Qt.WindowType.Dialog)
+ mbox.setWindowTitle("Update Failed")
+ mbox.setText("An error occurred during downloading/copying/moving files")
+ mbox.setDetailedText(dump_plain_text())
+ mbox.exec()
diff --git a/src/fa/utils.py b/src/fa/utils.py
index 78355342a..f45e1ef52 100644
--- a/src/fa/utils.py
+++ b/src/fa/utils.py
@@ -3,9 +3,6 @@
import os
import zipfile
-from PyQt6.QtCore import Qt
-from PyQt6.QtWidgets import QProgressDialog
-
from api.models.FeaturedModFile import FeaturedModFile
from util import APPDATA_DIR
@@ -21,7 +18,7 @@ def crc32(filepath: str) -> int | None:
return None
-def unpack_movies_and_sounds_from_file(file: FeaturedModFile) -> None:
+def unpack_movies_and_sounds(file: FeaturedModFile) -> None:
"""
Unpacks movies and sounds (based on path in zipfile) to the corresponding
folder. Movies must be unpacked for FA to be able to play them.
@@ -55,20 +52,3 @@ def unpack_movies_and_sounds_from_file(file: FeaturedModFile) -> None:
or crc32(tgtpath) != zi.CRC
):
zf.extract(zi, APPDATA_DIR)
-
-
-def unpack_movies_and_sounds(files: list[FeaturedModFile]) -> None:
- logger.info("Checking files for movies and sounds")
-
- progress = QProgressDialog()
- progress.setWindowTitle("Updating Movies and Sounds")
- progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint)
- progress.setModal(True)
- progress.setCancelButton(None)
- progress.setMaximum(len(files))
- progress.setValue(0)
-
- for index, file in enumerate(files, start=1):
- progress.setLabelText(f"Checking for movies and sounds in {file.name}...")
- unpack_movies_and_sounds_from_file(file)
- progress.setValue(index)
From 20af1d525ded73c9b167e7771d84212e720b72ae Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Fri, 10 May 2024 15:47:49 +0300
Subject: [PATCH 070/123] Fix up BaseDownload
* use QEventLoop to wait for finish (looks like it works
from within non-GUI thread, while QApplication.processEvents()
does not)
* emit progress signal on QNetworkReply's progress,
not on every 8 KB of data written to disk, which caused an
enormous signal spam
---
src/downloadManager/__init__.py | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index 2f475f7c7..ba4be5a27 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -6,7 +6,6 @@
from io import BytesIO
from PyQt6 import QtGui
-from PyQt6 import QtWidgets
from PyQt6.QtCore import QEventLoop
from PyQt6.QtCore import QFile
from PyQt6.QtCore import QIODevice
@@ -113,9 +112,10 @@ def _atFinished(self):
self._sock_finished = True
self._kick_read()
- def _atProgress(self, recv, total):
+ def _atProgress(self, recv: int, total: int) -> None:
self.bytes_progress = recv
self.bytes_total = total
+ self.progress.emit(self)
def _kick_read(self): # Don't run the read loop more than once at a time
if self._reading:
@@ -138,7 +138,6 @@ def _readloop(self):
else:
bs = self.blocksize
self.dest.write(self._dfile.read(bs))
- self.progress.emit(self)
def succeeded(self):
return not self.error and not self.canceled
@@ -146,10 +145,14 @@ def succeeded(self):
def failed(self) -> bool:
return not self.succeeded()
- def waitForCompletion(self):
- waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
- while self._running:
- QtWidgets.QApplication.processEvents(waitFlag)
+ def waitForCompletion(self) -> None:
+ if not self._running:
+ return
+
+ wait_flag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
+ loop = QEventLoop()
+ self.finished.connect(loop.quit)
+ loop.exec(wait_flag)
class FileDownload(BaseDownload):
From b0889877bed244b01764189abfa2de83d1aa8d92 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sat, 11 May 2024 16:56:11 +0300
Subject: [PATCH 071/123] Provide clearer info about update process
* add label, which tells about current and total featured mods to be updated
---
res/fa/updater/updater.ui | 27 ++++++++++-------
src/fa/update_processor.py | 32 ++++++++++++--------
src/fa/updater.py | 61 +++++++++++++++++++++++---------------
3 files changed, 73 insertions(+), 47 deletions(-)
diff --git a/res/fa/updater/updater.ui b/res/fa/updater/updater.ui
index 7c7ade422..415cd055f 100644
--- a/res/fa/updater/updater.ui
+++ b/res/fa/updater/updater.ui
@@ -200,7 +200,7 @@
100
- 24
+ 0
Qt::AlignmentFlag::AlignCenter
@@ -215,46 +215,53 @@
false
- Game
+ Game (%v/%m)
-
-
+
+
+ Updating FAF... (1/1)
+
+
+
+ -
+
- 24
+ 0
Qt::AlignmentFlag::AlignCenter
- md5
+ Check hash (%v/%m)
-
- 24
+ 0
Qt::AlignmentFlag::AlignCenter
- Featured Mod
+ Update (%v/%m)
-
-
+
- 24
+ 0
Qt::AlignmentFlag::AlignCenter
- Movies and Sounds
+ Check Movies and Sounds (%v/%m)
diff --git a/src/fa/update_processor.py b/src/fa/update_processor.py
index 8a2228265..b51ccf8e9 100644
--- a/src/fa/update_processor.py
+++ b/src/fa/update_processor.py
@@ -27,12 +27,14 @@
logger = logging.getLogger(__name__)
-class UpdateProcessor(QObject):
+class UpdaterWorker(QObject):
done = pyqtSignal(UpdaterResult)
- md5_progress = pyqtSignal(ProgressInfo)
- movies_progress = pyqtSignal(ProgressInfo)
+
+ current_mod = pyqtSignal(ProgressInfo)
+ hash_progress = pyqtSignal(ProgressInfo)
+ extras_progress = pyqtSignal(ProgressInfo)
game_progress = pyqtSignal(ProgressInfo)
- featured_mod_progress = pyqtSignal(ProgressInfo)
+ mod_progress = pyqtSignal(ProgressInfo)
download_started = pyqtSignal(FileDownload)
download_progress = pyqtSignal(FileDownload)
@@ -80,7 +82,7 @@ def _calculate_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]:
result = {}
for index, file in enumerate(files, start=1):
filepath = os.path.join(util.APPDATA_DIR, file.group, file.name)
- self.md5_progress.emit(ProgressInfo(index, total, file.name))
+ self.hash_progress.emit(ProgressInfo(index, total, file.name))
result[file.md5] = util.md5(filepath)
return result
@@ -103,10 +105,6 @@ def fetch_file(self, file: FeaturedModFile) -> None:
if dler.failed():
raise UpdaterFailure()
- def check_download_failure(self, dler: FileDownload) -> None:
- if dler.failed():
- raise UpdaterFailure(f"Failed to download from {dler.addr!r}")
-
def move_from_cache(self, file: FeaturedModFile) -> None:
src_dir = os.path.join(util.APPDATA_DIR, file.group)
cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
@@ -142,7 +140,7 @@ def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None:
target = os.path.join(util.GAME_CACHE_DIR, file.group)
os.makedirs(target, exist_ok=True)
- def _update_file(
+ def update_file(
self,
file: FeaturedModFile,
precalculated_md5s: dict[str, str] | None = None,
@@ -166,16 +164,21 @@ def update_files(self, files: list[FeaturedModFile]) -> None:
to_update = self._filter_files_to_update(files, md5s)
total = len(to_update)
+ if total == 0:
+ self.mod_progress.emit(ProgressInfo(0, 0, ""))
+
for index, file in enumerate(to_update, start=1):
- self.featured_mod_progress.emit(ProgressInfo(index, total, file.name))
- self._update_file(file, md5s)
+ self.mod_progress.emit(ProgressInfo(index, total, file.name))
+ self.update_file(file, md5s)
+
+ self.unpack_movies_and_sounds(files)
def unpack_movies_and_sounds(self, files: list[FeaturedModFile]) -> None:
logger.info("Checking files for movies and sounds")
total = len(files)
for index, file in enumerate(files, start=1):
- self.movies_progress.emit(ProgressInfo(index, total, file.name))
+ self.extras_progress.emit(ProgressInfo(index, total, file.name))
unpack_movies_and_sounds(file)
def prepare_bin_FAF(self) -> None:
@@ -267,11 +270,14 @@ def do_update(self) -> None:
self.prepare_bin_FAF()
# Update the mod if it's requested
if self.featured_mod in ("faf", "fafbeta", "fafdevelop", "ladder1v1"):
+ self.current_mod.emit(ProgressInfo(1, 1, self.featured_mod))
self.update_featured_mod(self.featured_mod, self._resolve_base_version())
else:
# update faf first
+ self.current_mod.emit(ProgressInfo(1, 2, "FAF"))
self.update_featured_mod("faf", self._resolve_base_version())
# update featured mod then
+ self.current_mod.emit(ProgressInfo(2, 2, self.featured_mod))
self.update_featured_mod(self.featured_mod, self._resolve_modversion())
except UpdaterCancellation as e:
log("CANCELLED: {}".format(e), logger)
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 402e64c2c..892a04312 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -28,7 +28,7 @@
from fa.updater_misc import failure_dialog
from fa.updater_misc import log
from fa.updater_misc import timestamp
-from src.fa.update_processor import UpdateProcessor
+from src.fa.update_processor import UpdaterWorker
logger = logging.getLogger(__name__)
@@ -111,43 +111,56 @@ def __init__(
super().__init__(*args, **kwargs)
self.worker_thread = QThread()
- self.update_processor = UpdateProcessor(featured_mod, version, modversions, silent)
- self.update_processor.moveToThread(self.worker_thread)
-
- self.update_processor.done.connect(self.on_update_done)
- self.update_processor.md5_progress.connect(self.on_md5_progress)
- self.update_processor.movies_progress.connect(self.on_movies_progress)
- self.update_processor.game_progress.connect(self.on_game_progress)
- self.update_processor.featured_mod_progress.connect(self.on_feat_mod_progress)
- self.update_processor.download_progress.connect(self.on_download_progress)
- self.update_processor.download_finished.connect(self.on_download_finished)
- self.update_processor.download_started.connect(self.on_download_started)
- self.worker_thread.started.connect(self.update_processor.do_update)
+ self.worker = UpdaterWorker(featured_mod, version, modversions, silent)
+ self.worker.moveToThread(self.worker_thread)
+
+ self.worker.done.connect(self.on_update_done)
+ self.worker.current_mod.connect(self.on_processed_mod_changed)
+ self.worker.hash_progress.connect(self.on_hash_progress)
+ self.worker.extras_progress.connect(self.on_movies_progress)
+ self.worker.game_progress.connect(self.on_game_progress)
+ self.worker.mod_progress.connect(self.on_mod_progress)
+ self.worker.download_progress.connect(self.on_download_progress)
+ self.worker.download_finished.connect(self.on_download_finished)
+ self.worker.download_started.connect(self.on_download_started)
+ self.worker_thread.started.connect(self.worker.do_update)
self.progress = UpdaterProgressDialog(None, silent)
self.progress.aborted.connect(self.abort)
self.result = UpdaterResult.NONE
+ def on_processed_mod_changed(self, info: ProgressInfo) -> None:
+ text = f"Updating {info.description.upper()}... ({info.progress}/{info.total})"
+ self.progress.currentModLabel.setText(text)
+ self.progress.hashProgress.setValue(0)
+ self.progress.modProgress.setValue(0)
+ self.progress.extrasProgress.setValue(0)
+
def on_movies_progress(self, info: ProgressInfo) -> None:
- self.progress.moviesProgress.setMaximum(info.total)
- self.progress.moviesProgress.setValue(info.progress)
+ self.progress.extrasProgress.setMaximum(info.total)
+ self.progress.extrasProgress.setValue(info.progress)
self.progress.append_log(f"Checking for movies and sounds: {info.description}")
- def on_md5_progress(self, info: ProgressInfo) -> None:
- self.progress.md5Progress.setMaximum(info.total)
- self.progress.md5Progress.setValue(info.progress)
+ def on_hash_progress(self, info: ProgressInfo) -> None:
+ self.progress.hashProgress.setMaximum(info.total)
+ self.progress.hashProgress.setValue(info.progress)
self.progress.append_log(f"Calculating md5: {info.description}")
def on_game_progress(self, info: ProgressInfo) -> None:
self.progress.gameProgress.setMaximum(info.total)
self.progress.gameProgress.setValue(info.progress)
- self.progress.append_log(f"Copying game files: {info.description}")
-
- def on_feat_mod_progress(self, info: ProgressInfo) -> None:
- self.progress.modProgress.setMaximum(info.total)
- self.progress.modProgress.setValue(info.progress)
- self.progress.append_log(f"Processing something: {info.description}")
+ self.progress.append_log(f"Checking/copying game file: {info.description}")
+
+ def on_mod_progress(self, info: ProgressInfo) -> None:
+ if info.total == 0:
+ self.progress.modProgress.setMaximum(1)
+ self.progress.modProgress.setValue(1)
+ self.progress.append_log("Everything is up to date.")
+ else:
+ self.progress.append_log(f"Updating file: {info.description}")
+ self.progress.modProgress.setMaximum(info.total)
+ self.progress.modProgress.setValue(info.progress)
def on_download_progress(self, dler: FileDownload) -> None:
if dler.bytes_total == 0:
From fb80bb9a680b07665b5e7787a763c16c7aa83618 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Sun, 12 May 2024 21:35:54 +0300
Subject: [PATCH 072/123] Give user some chances to cancel game update
---
src/downloadManager/__init__.py | 7 +++++
src/fa/update_processor.py | 52 ++++++++++++++++++++++++---------
src/fa/updater.py | 7 ++---
3 files changed, 48 insertions(+), 18 deletions(-)
diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py
index ba4be5a27..22daafad8 100644
--- a/src/downloadManager/__init__.py
+++ b/src/downloadManager/__init__.py
@@ -71,6 +71,8 @@ def _error(self):
def cancel(self):
self.canceled = True
+ if not self._dfile.isFinished():
+ self._dfile.abort()
self._stop()
def _handle_status(self) -> None:
@@ -145,6 +147,11 @@ def succeeded(self):
def failed(self) -> bool:
return not self.succeeded()
+ def error_string(self) -> str:
+ if self._dfile is not None:
+ return self._dfile.errorString()
+ return ""
+
def waitForCompletion(self) -> None:
if not self._running:
return
diff --git a/src/fa/update_processor.py b/src/fa/update_processor.py
index b51ccf8e9..a9f603f39 100644
--- a/src/fa/update_processor.py
+++ b/src/fa/update_processor.py
@@ -4,6 +4,7 @@
import os
import shutil
import stat
+from functools import wraps
from PyQt6.QtCore import QObject
from PyQt6.QtCore import pyqtSignal
@@ -60,6 +61,17 @@ def __init__(
in_session_cache = Settings.get("cache/in_session", type=bool, default=False)
self.cache_enabled = keep_cache or in_session_cache
+ self.dler: FileDownload | None = None
+ self._interruption_requested = False
+
+ def _check_interruption(fn):
+ @wraps(fn)
+ def wrapper(self, *args, **kwargs):
+ if self._interruption_requested:
+ raise UpdaterCancellation("User aborted the update")
+ return fn(self, *args, **kwargs)
+ return wrapper
+
def get_files_to_update(self, mod_id: str, version: str) -> list[dict]:
return FeaturedModFilesApiConnector(mod_id, version).get_files()
@@ -77,13 +89,14 @@ def _filter_files_to_update(
if precalculated_md5s[file.md5] != file.md5 and file.name != exe_name
]
+ @_check_interruption
def _calculate_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]:
total = len(files)
result = {}
for index, file in enumerate(files, start=1):
filepath = os.path.join(util.APPDATA_DIR, file.group, file.name)
- self.hash_progress.emit(ProgressInfo(index, total, file.name))
result[file.md5] = util.md5(filepath)
+ self.hash_progress.emit(ProgressInfo(index, total, file.name))
return result
def fetch_file(self, file: FeaturedModFile) -> None:
@@ -91,19 +104,21 @@ def fetch_file(self, file: FeaturedModFile) -> None:
url = file.cacheable_url
logger.info(f"Updater: Downloading {url}")
- dler = FileDownload(
+ self.dler = FileDownload(
target_path=target_path,
nam=self.nam,
addr=url,
request_params={file.hmac_parameter: file.hmac_token},
)
- dler.progress.connect(lambda: self.download_progress.emit(dler))
- dler.start.connect(lambda: self.download_started.emit(dler))
- dler.finished.connect(lambda: self.download_finished.emit(dler))
- dler.run()
- dler.waitForCompletion()
- if dler.failed():
- raise UpdaterFailure()
+ self.dler.progress.connect(lambda: self.download_progress.emit(self.dler))
+ self.dler.start.connect(lambda: self.download_started.emit(self.dler))
+ self.dler.finished.connect(lambda: self.download_finished.emit(self.dler))
+ self.dler.run()
+ self.dler.waitForCompletion()
+ if self.dler.canceled:
+ raise UpdaterCancellation(self.dler.error_string())
+ elif self.dler.failed():
+ raise UpdaterFailure(f"Update failed: {self.dler.error_sring()}")
def move_from_cache(self, file: FeaturedModFile) -> None:
src_dir = os.path.join(util.APPDATA_DIR, file.group)
@@ -140,6 +155,7 @@ def create_cache_subdirs(self, files: list[FeaturedModFile]) -> None:
target = os.path.join(util.GAME_CACHE_DIR, file.group)
os.makedirs(target, exist_ok=True)
+ @_check_interruption
def update_file(
self,
file: FeaturedModFile,
@@ -152,6 +168,7 @@ def update_file(
else:
self.fetch_file(file)
+ @_check_interruption
def update_files(self, files: list[FeaturedModFile]) -> None:
"""
Updates the files in the destination
@@ -168,18 +185,19 @@ def update_files(self, files: list[FeaturedModFile]) -> None:
self.mod_progress.emit(ProgressInfo(0, 0, ""))
for index, file in enumerate(to_update, start=1):
- self.mod_progress.emit(ProgressInfo(index, total, file.name))
self.update_file(file, md5s)
+ self.mod_progress.emit(ProgressInfo(index, total, file.name))
self.unpack_movies_and_sounds(files)
+ @_check_interruption
def unpack_movies_and_sounds(self, files: list[FeaturedModFile]) -> None:
logger.info("Checking files for movies and sounds")
total = len(files)
for index, file in enumerate(files, start=1):
- self.extras_progress.emit(ProgressInfo(index, total, file.name))
unpack_movies_and_sounds(file)
+ self.extras_progress.emit(ProgressInfo(index, total, file.name))
def prepare_bin_FAF(self) -> None:
"""
@@ -201,7 +219,6 @@ def prepare_bin_FAF(self) -> None:
os.makedirs(dst_dir, exist_ok=True)
total_files = len(files)
for index, file in enumerate(files, start=1):
- self.game_progress.emit(ProgressInfo(index, total_files, file))
src_file = os.path.join(src_dir, file)
dst_file = os.path.join(dst_dir, file)
if not os.path.exists(dst_file):
@@ -210,6 +227,7 @@ def prepare_bin_FAF(self) -> None:
# make all files we were considering writable, because we may
# need to patch them
os.chmod(dst_file, st.st_mode | stat.S_IWRITE)
+ self.game_progress.emit(ProgressInfo(index, total_files, file))
self.download_fa_executable()
@@ -245,6 +263,7 @@ def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None:
self.patch_fa_executable(version)
return
+ @_check_interruption
def update_featured_mod(self, modname: str, modversion: str) -> list[FeaturedModFile]:
fmod = self.get_featured_mod_by_name(modname)
files = self.get_files_to_update(fmod.uid, modversion)
@@ -280,12 +299,17 @@ def do_update(self) -> None:
self.current_mod.emit(ProgressInfo(2, 2, self.featured_mod))
self.update_featured_mod(self.featured_mod, self._resolve_modversion())
except UpdaterCancellation as e:
- log("CANCELLED: {}".format(e), logger)
+ log(f"CANCELLED: {e}", logger)
self.result = UpdaterResult.CANCEL
except Exception as e:
- log("EXCEPTION: {}".format(e), logger)
+ log(f"EXCEPTION: {e}", logger)
logger.exception(f"EXCEPTION: {e}")
self.result = UpdaterResult.FAILURE
else:
self.result = UpdaterResult.SUCCESS
self.done.emit(self.result)
+
+ def abort(self) -> None:
+ if self.dler is not None:
+ self.dler.cancel()
+ self._interruption_requested = True
diff --git a/src/fa/updater.py b/src/fa/updater.py
index 892a04312..b05c847da 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -39,7 +39,7 @@
class UpdaterProgressDialog(FormClass, BaseClass):
aborted = pyqtSignal()
- def __init__(self, parent, abortable: bool) -> None:
+ def __init__(self, parent: QObject, silent: bool) -> None:
BaseClass.__init__(self, parent)
self.setupUi(self)
self.setModal(True)
@@ -48,7 +48,7 @@ def __init__(self, parent, abortable: bool) -> None:
self.adjustSize()
self.watches = []
- if not abortable:
+ if silent:
self.abortButton.hide()
self.rejected.connect(self.abort)
@@ -217,8 +217,7 @@ def handle_result_if_needed(self, result: UpdaterResult) -> None:
failure_dialog()
def abort(self) -> None:
- self.result = UpdaterResult.CANCEL
- self.stop_thread()
+ self.worker.abort()
def stop_thread(self) -> None:
self.worker_thread.quit()
From 35e7108dac97b8cc85d0a259980b3e6af534d308 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Mon, 13 May 2024 23:18:34 +0300
Subject: [PATCH 073/123] Fix luaparser's inability to parse lines with
multiple '='
coop missions create lobby presets with values
for some keys, that look like '======================'
workaround this by splitting assignments by ' = ', not by '=' --
this forces whitespaces around this operator though if one
wants the file to be parsed
also, improve readability of some parts, which i made worse back then
---
src/vaults/luaparser.py | 60 +++++++++++------------------------------
1 file changed, 16 insertions(+), 44 deletions(-)
diff --git a/src/vaults/luaparser.py b/src/vaults/luaparser.py
index 8d872b76a..a0f130c75 100644
--- a/src/vaults/luaparser.py
+++ b/src/vaults/luaparser.py
@@ -128,8 +128,9 @@ def __processLine(self, parent=""):
line = line.strip()
# if the string is not empty, proceed
if line != "":
- # split it by '='
- lineArray = line.split("=")
+ # split it by '=' -- actually, by ' = ', because
+ # there can be values with '=' inside, e.g. '=============',
+ lineArray = line.split(" = ")
# if result is one element list
if len(lineArray) == 1:
# this element is value
@@ -162,9 +163,7 @@ def __processLine(self, parent=""):
# add new item into the array: recursive function call
if self.__prevUnfinished:
self.__prevUnfinished = False
- lua[prevkey] = self.__processLine(
- parent + ">" + prevkey,
- )
+ lua[prevkey] = self.__processLine(parent + ">" + prevkey)
else:
lua[key] = self.__processLine(parent + ">" + key)
else:
@@ -199,9 +198,7 @@ def __processLine(self, parent=""):
else:
count = 0
if resultKey in self.__searchResult:
- resultVal = (
- self.__searchResult[resultKey] + count
- )
+ resultVal = self.__searchResult[resultKey] + count
else:
resultVal = count
else:
@@ -222,25 +219,15 @@ def __processLine(self, parent=""):
self.__searchResult[resultKey] = resultVal
else:
if keydst in self.__searchResult:
- if isinstance(
- self.__searchResult[keydst], dict,
- ):
- self.__searchResult[keydst][resultKey] = (
- resultVal
- )
+ if isinstance(self.__searchResult[keydst], dict):
+ self.__searchResult[keydst][resultKey] = resultVal
else:
self.__searchResult[keydst] = dict()
- self.__searchResult[keydst][resultKey] = (
- resultVal
- )
+ self.__searchResult[keydst][resultKey] = resultVal
if isinstance(resultVal, int):
- self.__foundItemsCount[searchKey] = (
- self.__foundItemsCount[searchKey] + resultVal
- )
+ self.__foundItemsCount[searchKey] += resultVal
else:
- self.__foundItemsCount[searchKey] = (
- self.__foundItemsCount[searchKey] + 1
- )
+ self.__foundItemsCount[searchKey] += 1
# increase counter
counter = counter + 1
# return resulting array
@@ -278,43 +265,28 @@ def __checkErrors(self):
if len(key.split(":")) == 2 or key.find("*") != -1:
if self.__foundItemsCount[key] == 0:
if resultKey in self.__defaultValues:
- self.__searchResult[resultKey] = (
- self.__defaultValues[resultKey]
- )
+ self.__searchResult[resultKey] = self.__defaultValues[resultKey]
else:
self.error = True
self.errors += 1
- self.errorMsg += (
- "Error: no matches for '{}' were "
- "found\n".format(key)
- )
+ self.errorMsg += f"Error: no matches for {key!r} were found\n"
else:
if self.__foundItemsCount[key] == 0:
if resultKey in self.__defaultValues:
- self.__searchResult[resultKey] = (
- self.__defaultValues[resultKey]
- )
+ self.__searchResult[resultKey] = self.__defaultValues[resultKey]
else:
self.error = True
self.errors += 1
- self.errorMsg += (
- "Error: no matches for '{}' were "
- "found\n".format(key)
- )
+ self.errorMsg += f"Error: no matches for {key!r} were found\n"
elif self.__foundItemsCount[key] > 1:
self.warning = True
self.warnings += 1
- self.errorMsg += (
- "Warning: there were duplicate "
- "occurrences for '{}'\n".format(key)
- )
+ self.errorMsg += f"Warning: there were duplicate occurrences for {key!r}\n"
def parse(self, luaSearch, defValues=dict()):
self.__searchPattern.update(luaSearch)
self.__defaultValues.update(defValues)
- self.__foundItemsCount = (
- {}.fromkeys(list(self.__searchPattern.keys()), 0)
- )
+ self.__foundItemsCount = {}.fromkeys(self.__searchPattern, 0)
self.__parsedData = self.__parseLua()
self.__checkErrors()
return self.__searchResult
From f0b0d75a96625ca66254dc272a6aaf9fdd6d004c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Mon, 13 May 2024 23:25:40 +0300
Subject: [PATCH 074/123] Fix maximizing/resizing window
---
src/client/_clientwindow.py | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 2a1426b05..917d037df 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -622,9 +622,7 @@ def mouseMoveEvent(self, event):
def resize_widget(self, mouse_position: QtCore.QRectF) -> None:
mouse_point = mouse_position.toPoint()
if mouse_point.y() == 0:
- self.rubber_band.setGeometry(
- QtWidgets.QDesktopWidget().availableGeometry(self),
- )
+ self.rubber_band.setGeometry(self.screen().availableGeometry())
self.rubber_band.show()
else:
self.rubber_band.hide()
@@ -1351,8 +1349,7 @@ def checkPlayerAliases(self):
def saveWindow(self):
util.settings.beginGroup("window")
util.settings.setValue("geometry", self.saveGeometry())
- if self.is_window_maximized:
- util.settings.setValue("maximized", True)
+ util.settings.setValue("maximized", self.is_window_maximized)
util.settings.endGroup()
def show_autojoin_settings_dialog(self):
@@ -1455,7 +1452,7 @@ def load_settings(self):
geometry = util.settings.value("geometry", None)
# FIXME: looks like bug in Qt: restoring from maximized geometry doesn't work
# see https://bugreports.qt.io/browse/QTBUG-123335 (?)
- maximized = util.settings.value("maximized", False)
+ maximized = util.settings.value("maximized", defaultValue=False, type=bool)
util.settings.endGroup()
if maximized:
self.setGeometry(self.screen().availableGeometry())
From 8c6ee76edd85fa53afc8be890c8cfedae6768d7c Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 04:22:43 +0300
Subject: [PATCH 075/123] Present vault items more concisely
---
res/vaults/mapvault/mapinfo.qthtml | 7 +++----
res/vaults/modvault/modinfo.qthtml | 7 +++----
src/util/__init__.py | 14 ++++++++++----
src/vaults/mapvault/mapitem.py | 2 +-
src/vaults/modvault/moditem.py | 2 +-
5 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/res/vaults/mapvault/mapinfo.qthtml b/res/vaults/mapvault/mapinfo.qthtml
index 554672dba..ef4444343 100644
--- a/res/vaults/mapvault/mapinfo.qthtml
+++ b/res/vaults/mapvault/mapinfo.qthtml
@@ -6,10 +6,9 @@
-Size: {width} x {height} km |
-Rating: {rating} |
-{reviews} Rated this |
-Uploaded {date} |
+Size: {width} x {height} km |
+Rating: {rating} ({reviews}) |
+Uploaded: {date} |
diff --git a/res/vaults/modvault/modinfo.qthtml b/res/vaults/modvault/modinfo.qthtml
index 626e71ea2..067884c52 100644
--- a/res/vaults/modvault/modinfo.qthtml
+++ b/res/vaults/modvault/modinfo.qthtml
@@ -6,10 +6,9 @@
-Author: {author} |
-Rating: {rating} |
-{reviews} Rated this |
-Uploaded {date} |
+Author: {author} |
+Rating: {rating} ({reviews}) |
+Uploaded {date} |
diff --git a/src/util/__init__.py b/src/util/__init__.py
index 5ee297d77..7de380d2f 100644
--- a/src/util/__init__.py
+++ b/src/util/__init__.py
@@ -9,7 +9,9 @@
import sys
from PyQt6 import QtWidgets
+from PyQt6.QtCore import QDateTime
from PyQt6.QtCore import QStandardPaths
+from PyQt6.QtCore import Qt
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QDesktopServices
from PyQt6.QtWidgets import QMessageBox
@@ -499,12 +501,16 @@ def uniqueID(session):
return None
-def strtodate(s):
- return datetime.datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
+def strtodate(s: str) -> QDateTime:
+ return QDateTime.fromString(s, Qt.DateFormat.ISODate).toLocalTime()
-def datetostr(d):
- return d.strftime("%Y-%m-%d %H:%M:%S")
+def datetostr(d: QDateTime) -> str:
+ return d.toString("yyyy-mm-dd HH:MM:ss")
+
+
+def utctolocal(s: str) -> str:
+ return datetostr(strtodate(s))
def capitalize(string: str) -> str:
diff --git a/src/vaults/mapvault/mapitem.py b/src/vaults/mapvault/mapitem.py
index 4e4aedbb9..ed4481d2e 100644
--- a/src/vaults/mapvault/mapitem.py
+++ b/src/vaults/mapvault/mapitem.py
@@ -70,7 +70,7 @@ def update_visibility(self):
description=self.item_version.description,
rating=score,
reviews=reviews,
- date=self.item_version.create_time,
+ date=util.utctolocal(self.item_version.create_time),
modtype=maptype,
height=self.item_version.size.height_km,
width=self.item_version.size.width_km,
diff --git a/src/vaults/modvault/moditem.py b/src/vaults/modvault/moditem.py
index a9fefc767..b510b63a9 100644
--- a/src/vaults/modvault/moditem.py
+++ b/src/vaults/modvault/moditem.py
@@ -73,7 +73,7 @@ def update_visibility(self) -> None:
description=self.item_version.description,
rating=score,
reviews=reviews,
- date=self.item_version.create_time,
+ date=util.utctolocal(self.item_version.create_time),
modtype=modtype,
author=self.item_info.author,
),
From b0a963fb95c61c903ae72fa763b2434a64f0a9e9 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 14:52:02 +0300
Subject: [PATCH 076/123] Set stylesheet for UpdaterProgressDialog
---
res/fa/updater/updater.ui | 129 +++++++++++++++++++++-----------------
src/fa/updater.py | 4 ++
2 files changed, 75 insertions(+), 58 deletions(-)
diff --git a/res/fa/updater/updater.ui b/res/fa/updater/updater.ui
index 415cd055f..419d4f9a1 100644
--- a/res/fa/updater/updater.ui
+++ b/res/fa/updater/updater.ui
@@ -43,7 +43,7 @@
16777215
- 180
+ 200
@@ -100,7 +100,7 @@
16777215
- 160
+ 180
@@ -131,59 +131,15 @@
2
- -
-
-
- 0
-
-
- 0
-
+
-
+
-
-
-
-
- 0
- 0
-
-
-
-
- 16777215
- 40
-
-
+
- Updating Game Data
-
-
-
- -
-
-
- Qt::Orientation::Horizontal
-
-
- QSizePolicy::Policy::Expanding
-
-
-
- 40
- 20
-
-
-
-
- -
-
-
-
- 16777215
- 40
-
+ Details
-
- Abort
+
+ false
@@ -267,20 +223,77 @@
- -
-
+
-
+
+
+ 0
+
+
+ 0
+
-
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 16777215
+ 40
+
+
- Details
+ Updating Game Data
-
- false
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+ QSizePolicy::Policy::Expanding
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 16777215
+ 40
+
+
+
+ Abort
+ -
+
+
+ Qt::Orientation::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
diff --git a/src/fa/updater.py b/src/fa/updater.py
index b05c847da..0a0a90742 100644
--- a/src/fa/updater.py
+++ b/src/fa/updater.py
@@ -54,6 +54,10 @@ def __init__(self, parent: QObject, silent: bool) -> None:
self.rejected.connect(self.abort)
self.abortButton.clicked.connect(self.reject)
self.detailsButton.clicked.connect(self.change_details_visibility)
+ self.load_stylesheet()
+
+ def load_stylesheet(self):
+ self.setStyleSheet(util.THEME.readstylesheet("client/client.css"))
def change_details_visibility(self) -> None:
visible = self.logFrame.isVisible()
From 1c80b2fbfade28260c9d06a33a902aa863c4d5db Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 14:54:27 +0300
Subject: [PATCH 077/123] Do not try to update coop mod twice
when trying to host a game -- game launcher already does that
---
src/coop/_coopwidget.py | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py
index de70bd265..a5025e0fc 100644
--- a/src/coop/_coopwidget.py
+++ b/src/coop/_coopwidget.py
@@ -231,7 +231,7 @@ def coopListClicked(self, item):
),
)
- def coopListDoubleClicked(self, item):
+ def coopListDoubleClicked(self, item: CoopMapItem) -> None:
"""
Hosting a coop event
"""
@@ -244,9 +244,6 @@ def coopListDoubleClicked(self, item):
self.client.games.stopSearch()
- if not fa.check.check("coop"):
- return
-
self._game_launcher.host_game(item.name, item.mod, mapname)
@QtCore.pyqtSlot(dict)
From 0f93ac576177ab0def9c6a13ca420baf48d488cd Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 18:30:01 +0300
Subject: [PATCH 078/123] Fix updating MapGenerator
---
src/mapGenerator/mapgenManager.py | 52 +++++++++++++++----------------
1 file changed, 26 insertions(+), 26 deletions(-)
diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py
index 6bca9efb6..baef51ea8 100644
--- a/src/mapGenerator/mapgenManager.py
+++ b/src/mapGenerator/mapgenManager.py
@@ -1,14 +1,18 @@
-# system imports
import logging
import os
import random
-from PyQt6 import QtCore
-from PyQt6 import QtNetwork
from PyQt6 import QtWidgets
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
import util
-# local imports
from config import Settings
from fa.maps import getUserMapsFolder
from mapGenerator.mapgenProcess import MapGeneratorProcess
@@ -22,10 +26,12 @@
GENERATOR_JAR_NAME = "MapGenerator_{}.jar"
-class MapGeneratorManager(object):
- def __init__(self):
+class MapGeneratorManager(QObject):
+ version_received = pyqtSignal()
+
+ def __init__(self) -> None:
+ super().__init__()
self.latestVersion = None
- self.response = None
self.currentVersion = Settings.get('mapGenerator/version', "0", str)
@@ -148,24 +154,20 @@ def versionController(self, version: str) -> str:
return file_path
return ""
- def checkUpdates(self):
+ def checkUpdates(self) -> None:
'''
Not downloading anything here.
Just requesting latest version and return the number
'''
- self.manager = QtNetwork.QNetworkAccessManager()
- self.manager.finished.connect(self.onRequestFinished)
+ self.manager = QNetworkAccessManager()
+ self.manager.finished.connect(self.on_request_finished)
- request = QtNetwork.QNetworkRequest(
- QtCore.QUrl(RELEASE_URL + "latest"),
- )
+ request = QNetworkRequest(QUrl(RELEASE_URL).resolved(QUrl("latest")))
self.manager.get(request)
progress = QtWidgets.QProgressDialog()
progress.setCancelButtonText("Cancel")
- progress.setWindowFlags(
- QtCore.Qt.WindowType.CustomizeWindowHint | QtCore.Qt.WindowType.WindowTitleHint,
- )
+ progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint)
progress.setAutoClose(False)
progress.setAutoReset(False)
progress.setMinimum(0)
@@ -175,15 +177,13 @@ def checkUpdates(self):
progress.setWindowTitle("Looking for updates")
progress.show()
- while not self.response:
- QtWidgets.QApplication.processEvents()
+ loop = QEventLoop()
+ self.version_received.connect(loop.quit)
+ loop.exec()
progress.close()
- def onRequestFinished(self, reply):
- redirectUrl = reply.attribute(2)
- if redirectUrl:
- redirectUrl = redirectUrl.toString()
- if "releases/tag/" in redirectUrl:
- self.latestVersion = redirectUrl.rsplit('/', 1)[1]
-
- self.response = True
+ def on_request_finished(self, reply: QNetworkReply) -> None:
+ redirect_url = reply.url()
+ if "releases/tag/" in redirect_url.toString():
+ self.latestVersion = redirect_url.fileName()
+ self.version_received.emit()
From 5946b6d1f9d7f87332d9a64209b732f26e14abfa Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 19:07:28 +0300
Subject: [PATCH 079/123] Move game updater files into a dedicated folder
---
src/fa/__init__.py | 10 ++++++++--
src/fa/check.py | 8 ++++----
src/fa/game_updater/__init__.py | 0
src/fa/{updater_misc.py => game_updater/misc.py} | 0
src/fa/{ => game_updater}/updater.py | 14 +++++++-------
.../worker.py} | 10 +++++-----
6 files changed, 24 insertions(+), 18 deletions(-)
create mode 100644 src/fa/game_updater/__init__.py
rename src/fa/{updater_misc.py => game_updater/misc.py} (100%)
rename src/fa/{ => game_updater}/updater.py (96%)
rename src/fa/{update_processor.py => game_updater/worker.py} (98%)
diff --git a/src/fa/__init__.py b/src/fa/__init__.py
index fbc8b0c23..523458cd9 100644
--- a/src/fa/__init__.py
+++ b/src/fa/__init__.py
@@ -3,7 +3,13 @@
# We only want one instance of Forged Alliance to run, so we use a singleton
# here (other modules may wish to connect to its signals so it needs
# persistence)
-from . import check, factions, maps, mods, replayserver, updater, wizards
+from . import check
+from . import factions
+from . import game_updater
+from . import maps
+from . import mods
+from . import replayserver
+from . import wizards
from .game_process import instance
from .play import run
from .replay import replay
@@ -14,7 +20,7 @@
"maps",
"mods",
"replayserver",
- "updater",
+ "game_updater",
"wizards",
"instance",
"run",
diff --git a/src/fa/check.py b/src/fa/check.py
index 7bf6465cc..d735ccc2f 100644
--- a/src/fa/check.py
+++ b/src/fa/check.py
@@ -8,6 +8,8 @@
import config
import fa
import util
+from fa.game_updater.misc import UpdaterResult
+from fa.game_updater.updater import Updater
from fa.mods import checkMods
from fa.path import validatePath
from fa.path import writeFAPathLua
@@ -127,12 +129,10 @@ def check(
return False
# Spawn an update for the required mod
- game_updater = fa.updater.Updater(
- featured_mod, version, modVersions, silent=silent,
- )
+ game_updater = Updater(featured_mod, version, modVersions, silent=silent)
result = game_updater.run()
- if result != fa.updater.UpdaterResult.SUCCESS:
+ if result != UpdaterResult.SUCCESS:
return False
# Now it's down to having the right map
diff --git a/src/fa/game_updater/__init__.py b/src/fa/game_updater/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/fa/updater_misc.py b/src/fa/game_updater/misc.py
similarity index 100%
rename from src/fa/updater_misc.py
rename to src/fa/game_updater/misc.py
diff --git a/src/fa/updater.py b/src/fa/game_updater/updater.py
similarity index 96%
rename from src/fa/updater.py
rename to src/fa/game_updater/updater.py
index 0a0a90742..6f85d8cdb 100644
--- a/src/fa/updater.py
+++ b/src/fa/game_updater/updater.py
@@ -22,13 +22,13 @@
import util
from downloadManager import FileDownload
-from fa.updater_misc import ProgressInfo
-from fa.updater_misc import UpdaterResult
-from fa.updater_misc import clear_log
-from fa.updater_misc import failure_dialog
-from fa.updater_misc import log
-from fa.updater_misc import timestamp
-from src.fa.update_processor import UpdaterWorker
+from fa.game_updater.misc import ProgressInfo
+from fa.game_updater.misc import UpdaterResult
+from fa.game_updater.misc import clear_log
+from fa.game_updater.misc import failure_dialog
+from fa.game_updater.misc import log
+from fa.game_updater.misc import timestamp
+from fa.game_updater.worker import UpdaterWorker
logger = logging.getLogger(__name__)
diff --git a/src/fa/update_processor.py b/src/fa/game_updater/worker.py
similarity index 98%
rename from src/fa/update_processor.py
rename to src/fa/game_updater/worker.py
index a9f603f39..e27fd65a7 100644
--- a/src/fa/update_processor.py
+++ b/src/fa/game_updater/worker.py
@@ -17,11 +17,11 @@
from api.models.FeaturedModFile import FeaturedModFile
from config import Settings
from downloadManager import FileDownload
-from fa.updater_misc import ProgressInfo
-from fa.updater_misc import UpdaterCancellation
-from fa.updater_misc import UpdaterFailure
-from fa.updater_misc import UpdaterResult
-from fa.updater_misc import log
+from fa.game_updater.misc import ProgressInfo
+from fa.game_updater.misc import UpdaterCancellation
+from fa.game_updater.misc import UpdaterFailure
+from fa.game_updater.misc import UpdaterResult
+from fa.game_updater.misc import log
from fa.utils import unpack_movies_and_sounds
from vaults.dialogs import download_file
From 75b937af65c08f589ab9f5d7d227f1bfc4b1ec71 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 19:17:32 +0300
Subject: [PATCH 080/123] Make UpdaterProgressDialog abortable by default
---
src/fa/game_updater/updater.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/fa/game_updater/updater.py b/src/fa/game_updater/updater.py
index 6f85d8cdb..5f6ad1561 100644
--- a/src/fa/game_updater/updater.py
+++ b/src/fa/game_updater/updater.py
@@ -39,7 +39,7 @@
class UpdaterProgressDialog(FormClass, BaseClass):
aborted = pyqtSignal()
- def __init__(self, parent: QObject, silent: bool) -> None:
+ def __init__(self, parent: QObject, silent: bool = False) -> None:
BaseClass.__init__(self, parent)
self.setupUi(self)
self.setModal(True)
From 5776ce4bde59a4029ef8ba41fefb3c679f88a814 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 19:32:06 +0300
Subject: [PATCH 081/123] Fix failing tests and remove duplicate ones
---
runtests.py | 2 -
src/fa/game_updater/updater.py | 2 +-
tests/fa/test_featured.py | 118 ---------------------------------
tests/fa/test_updater.py | 39 +++++------
4 files changed, 17 insertions(+), 144 deletions(-)
delete mode 100644 tests/fa/test_featured.py
diff --git a/runtests.py b/runtests.py
index fc148a245..3beb4c35d 100644
--- a/runtests.py
+++ b/runtests.py
@@ -1,5 +1,3 @@
-#! /usr/bin/env python3
-
import sys
import pytest
diff --git a/src/fa/game_updater/updater.py b/src/fa/game_updater/updater.py
index 5f6ad1561..47d314195 100644
--- a/src/fa/game_updater/updater.py
+++ b/src/fa/game_updater/updater.py
@@ -87,7 +87,7 @@ def add_watch(self, watch: QObject) -> None:
@pyqtSlot()
def watch_finished(self) -> None:
for watch in self.watches:
- if not watch.is_finished():
+ if not watch.isFinished():
return
# equivalent to self.accept(), but clearer
self.done(QDialog.DialogCode.Accepted)
diff --git a/tests/fa/test_featured.py b/tests/fa/test_featured.py
deleted file mode 100644
index 3e83739ed..000000000
--- a/tests/fa/test_featured.py
+++ /dev/null
@@ -1,118 +0,0 @@
-__author__ = 'Thygrrr'
-
-
-from typing import Callable
-
-import pytest
-from PyQt6 import QtCore
-from PyQt6 import QtWidgets
-
-from fa import updater
-
-
-class _TestObjectWithoutIsFinished(QtCore.QObject):
- finished = QtCore.pyqtSignal()
-
-
-class _TestThreadNoOp(QtCore.QThread):
- def run(self):
- self.yieldCurrentThread()
-
-
-def test_updater_is_a_dialog(application):
- assert isinstance(updater.UpdaterProgressDialog(None), QtWidgets.QDialog)
-
-
-def test_updater_has_progress_bar_game_progress(application):
- assert isinstance(
- updater.UpdaterProgressDialog(None).gameProgress,
- QtWidgets.QProgressBar,
- )
-
-
-def test_updater_has_progress_bar_map_progress(application):
- assert isinstance(
- updater.UpdaterProgressDialog(None).mapProgress,
- QtWidgets.QProgressBar,
- )
-
-
-def test_updater_has_progress_bar_mod_progress(application):
- assert isinstance(
- updater.UpdaterProgressDialog(None).mapProgress,
- QtWidgets.QProgressBar,
- )
-
-
-def test_updater_has_method_append_log(application):
- assert isinstance(
- updater.UpdaterProgressDialog(None).appendLog,
- Callable,
- )
-
-
-def test_updater_append_log_accepts_string(application):
- updater.UpdaterProgressDialog(None).appendLog("Hello Test")
-
-
-def test_updater_has_method_add_watch(application):
- assert isinstance(
- updater.UpdaterProgressDialog(None).addWatch,
- Callable,
- )
-
-
-def test_updater_append_log_accepts_qobject_with_signals_finished(application):
- updater.UpdaterProgressDialog(None).addWatch(QtCore.QThread())
-
-
-def test_updater_add_watch_raises_error_on_watch_without_signal_finished(
- application,
-):
- with pytest.raises(AttributeError):
- updater.UpdaterProgressDialog(None).addWatch(QtCore.QObject())
-
-
-def test_updater_watch_finished_raises_error_on_watch_without_method_finished(
- application,
-):
- u = updater.UpdaterProgressDialog(None)
- u.addWatch(_TestObjectWithoutIsFinished())
- with pytest.raises(AttributeError):
- u.watchFinished()
-
-
-def test_updater_hides_and_accepts_if_all_watches_are_finished(application):
- u = updater.UpdaterProgressDialog(None)
- t = _TestThreadNoOp()
-
- u.addWatch(t)
- u.show()
- t.start()
-
- while not t.isFinished():
- pass
-
- application.processEvents()
- assert not u.isVisible()
- assert u.result() == QtWidgets.QDialog.DialogCode.Accepted
-
-
-def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(
- application,
-):
- u = updater.UpdaterProgressDialog(None)
- t = _TestThreadNoOp()
- t_not_finished = QtCore.QThread()
-
- u.addWatch(t)
- u.addWatch(t_not_finished)
- u.show()
- t.start()
-
- while not t.isFinished():
- pass
-
- application.processEvents()
- assert u.isVisible()
- assert not u.result() == QtWidgets.QDialog.DialogCode.Accepted
diff --git a/tests/fa/test_updater.py b/tests/fa/test_updater.py
index c8bd8f20f..0e9b0757e 100644
--- a/tests/fa/test_updater.py
+++ b/tests/fa/test_updater.py
@@ -6,7 +6,7 @@
from PyQt6 import QtCore
from PyQt6 import QtWidgets
-from fa import updater
+from fa.game_updater.updater import UpdaterProgressDialog
class NoIsFinished(QtCore.QObject):
@@ -19,73 +19,66 @@ def run(self):
def test_updater_is_a_dialog(application):
- assert isinstance(updater.UpdaterProgressDialog(None), QtWidgets.QDialog)
+ assert isinstance(UpdaterProgressDialog(None), QtWidgets.QDialog)
def test_updater_has_progress_bar_game_progress(application):
assert isinstance(
- updater.UpdaterProgressDialog(None).gameProgress,
- QtWidgets.QProgressBar,
- )
-
-
-def test_updater_has_progress_bar_map_progress(application):
- assert isinstance(
- updater.UpdaterProgressDialog(None).mapProgress,
+ UpdaterProgressDialog(None).gameProgress,
QtWidgets.QProgressBar,
)
def test_updater_has_progress_bar_mod_progress(application):
assert isinstance(
- updater.UpdaterProgressDialog(None).mapProgress,
+ UpdaterProgressDialog(None).modProgress,
QtWidgets.QProgressBar,
)
def test_updater_has_method_append_log(application):
assert isinstance(
- updater.UpdaterProgressDialog(None).appendLog,
+ UpdaterProgressDialog(None).append_log,
Callable,
)
def test_updater_append_log_accepts_string(application):
- updater.UpdaterProgressDialog(None).appendLog("Hello Test")
+ UpdaterProgressDialog(None).append_log("Hello Test")
def test_updater_has_method_add_watch(application):
assert isinstance(
- updater.UpdaterProgressDialog(None).addWatch,
+ UpdaterProgressDialog(None).add_watch,
Callable,
)
def test_updater_append_log_accepts_qobject_with_signals_finished(application):
- updater.UpdaterProgressDialog(None).addWatch(QtCore.QThread())
+ UpdaterProgressDialog(None).add_watch(QtCore.QThread())
def test_updater_add_watch_raises_error_on_watch_without_signal_finished(
application,
):
with pytest.raises(AttributeError):
- updater.UpdaterProgressDialog(None).addWatch(QtCore.QObject())
+ UpdaterProgressDialog(None).add_watch(QtCore.QObject())
def test_updater_watch_finished_raises_error_on_watch_without_method_finished(
application,
):
- u = updater.UpdaterProgressDialog(None)
- u.addWatch(NoIsFinished())
+ u = UpdaterProgressDialog(None)
+ u.add_watch(NoIsFinished())
with pytest.raises(AttributeError):
u.watchFinished()
def test_updater_hides_and_accepts_if_all_watches_are_finished(application):
- u = updater.UpdaterProgressDialog(None)
+ u = UpdaterProgressDialog(None)
t = NoOpThread()
- u.addWatch(t)
+ u.add_watch(t)
u.show()
t.start()
@@ -100,12 +93,12 @@ def test_updater_hides_and_accepts_if_all_watches_are_finished(application):
def test_updater_does_not_hide_and_accept_before_all_watches_are_finished(
application,
):
- u = updater.UpdaterProgressDialog(None)
+ u = UpdaterProgressDialog(None)
t = NoOpThread()
t_not_finished = QtCore.QThread()
- u.addWatch(t)
- u.addWatch(t_not_finished)
+ u.add_watch(t)
+ u.add_watch(t_not_finished)
u.show()
t.start()
From 7e057d0ac7c033849e32e99c176bc19a96b3bad3 Mon Sep 17 00:00:00 2001
From: gatsik <74517072+Gatsik@users.noreply.github.com>
Date: Tue, 14 May 2024 20:31:57 +0300
Subject: [PATCH 082/123] Tweak setup and release scripts
use 'natives' dir as a directory for ice-adapter and jre
because cx_Freeze also writes packages into this folder
---
.github/workflows/release.yml | 52 +++++------
.gitignore | 2 +
lib/qt.conf | 3 -
lib/xdelta3.exe | Bin 314880 -> 0 bytes
setup.py | 166 ++++++++++++----------------------
src/fafpath.py | 4 +-
6 files changed, 83 insertions(+), 144 deletions(-)
delete mode 100644 lib/qt.conf
delete mode 100755 lib/xdelta3.exe
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c1d89f8e..2e4c78ac6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,51 +13,33 @@ jobs:
runs-on: windows-latest
env:
UID_VERSION: v4.0.4
- ICE_ADAPTER_VERSION: v3.1.2
- PYWHEEL_INFIX: "cp36"
+ ICE_ADAPTER_VERSION: v3.3.7
BUILD_VERSION: ${{ github.event.inputs.version }}
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- - name: Set up Python 3.6.7
- uses: actions/setup-python@v2
+ - name: Set up Python
+ uses: actions/setup-python@v5
with:
- python-version: 3.6.7
- architecture: x86
+ python-version: 3.12
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install sip==4.19.8
- pip install pyqt5==5.7.1
- pip install https://github.com/FAForever/python-wheels/releases/download/2.0.0/pywin32-221-${{ env.PYWHEEL_INFIX }}-${{ env.PYWHEEL_INFIX }}m-win32.whl
- pip install wheel
- pip install pytest
- pip install cx_Freeze==5.0.2
- pip install -r requirements.txt
-
- - name: Copy required files for packaging in setup.py
- run: |
- xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\plugins\\imageformats .\\imageformats /I
- xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\plugins\\platforms .\\platforms /I
- xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\plugins\\audio .\\audio /I
- xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\bin .
- xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\pywin32_system32 .
- xcopy ${{ env.pythonLocation }}\\lib\\site-packages\\PyQt5\\Qt\\resources . /I
+ python -m pip install -r requirements.txt
- name: Download ICE adapter and UID calculator
run: |
- mkdir lib\ice-adapter
- Invoke-WebRequest -Uri "https://github.com/FAForever/uid/releases/download/$($env:UID_VERSION)/faf-uid.exe" -OutFile ".\\lib\\faf-uid.exe"
+ mkdir build_setup\ice-adapter
+ Invoke-WebRequest -Uri "https://github.com/FAForever/uid/releases/download/$($env:UID_VERSION)/faf-uid.exe" -OutFile ".\\build_setup\\faf-uid.exe"
Invoke-WebRequest -Uri "https://github.com/FAForever/java-ice-adapter/releases/download/v1.0.0/faf-ice-adapter-jre-base.7z" -OutFile ".\\faf-ice-adapter-jre-base.7z"
- 7z x faf-ice-adapter-jre-base.7z -olib
- Remove-Item .\lib\ice-adapter\jre -Force -Recurse
- Remove-Item .\lib\ice-adapter\LICENSE.txt -Force -Recurse
- Invoke-WebRequest -Uri "https://content.faforever.com/jre/windows-amd64-15.0.1.tar.gz" -OutFile ".\\windows-amd64-15.0.1.tar.gz"
+ 7z x faf-ice-adapter-jre-base.7z -obuild_setup
+ Remove-Item .\build_setup\ice-adapter\jre -Force -Recurse
+ Invoke-WebRequest -Uri "https://content.faforever.com/build/jre/windows-amd64-21.0.1.tar.gz" -OutFile ".\\windows-amd64-15.0.1.tar.gz"
7z x windows-amd64-15.0.1.tar.gz
- 7z x windows-amd64-15.0.1.tar -olib/ice-adapter/jre
- Invoke-WebRequest -Uri "https://github.com/FAForever/java-ice-adapter/releases/download/$($env:ICE_ADAPTER_VERSION)/faf-ice-adapter.jar" -OutFile ".\\lib\\ice-adapter\\faf-ice-adapter.jar"
+ 7z x windows-amd64-15.0.1.tar -obuild_setup/ice-adapter/jre
+ Invoke-WebRequest -Uri "https://github.com/FAForever/java-ice-adapter/releases/download/$($env:ICE_ADAPTER_VERSION)/faf-ice-adapter.jar" -OutFile ".\\build_setup\\ice-adapter\\faf-ice-adapter.jar"
- name: Test with pytest
run: |
@@ -81,6 +63,13 @@ jobs:
echo "::set-output name=WINDOWS_MSI::${WINDOWS_MSI}"
echo "::set-output name=WINDOWS_MSI_NAME::${WINDOWS_MSI_NAME}"
+ - name: Calculate checksum
+ id: checksum
+ run: |
+ $MSI_SUM = $(CertUtil -hashfile ${{ steps.artifact_paths.outputs.WINDOWS_MSI }} SHA256)[1] -replace " ",""
+ Write-Host $MSI_SUM
+ echo "::set-output name=MSI_SUM::${MSI_SUM}"
+
- name: Create draft release
id: create_release
uses: actions/create-release@v1
@@ -89,6 +78,7 @@ jobs:
with:
tag_name: ${{ github.event.inputs.version }}
release_name: ${{ github.event.inputs.version }}
+ body: "SHA256: ${{ steps.checksum.outputs.MSI_SUM }}"
draft: true
prerelease: true
diff --git a/.gitignore b/.gitignore
index b4c72e31a..c2df4b965 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,7 +12,9 @@
*.vcproj
.idea/
lib/
+natives/
build/
+build_setup/
dist/
Thumbs.db
.DS_Store
diff --git a/lib/qt.conf b/lib/qt.conf
deleted file mode 100644
index d5ba6f1d1..000000000
--- a/lib/qt.conf
+++ /dev/null
@@ -1,3 +0,0 @@
-[Paths]
-Prefix = .
-Plugins = plugins
diff --git a/lib/xdelta3.exe b/lib/xdelta3.exe
deleted file mode 100755
index 2b4970de817b744be2ff67cf149811905442ba3f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 314880
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