This commit is contained in:
CommandMC 2026-04-16 11:16:56 +02:00 committed by GitHub
commit 29cbb80cc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 330 additions and 219 deletions

13
.github/workflows/lint-ruff.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Lint with Ruff
on:
push:
branches: [ '*' ]
pull_request:
branches: [ '*' ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/ruff-action@v3

View file

@ -1,16 +1,18 @@
# !/usr/bin/env python
# coding: utf-8
import logging
import urllib.parse
import requests
import requests.adapters
import logging
from requests.auth import HTTPBasicAuth
from legendary.models.exceptions import InvalidCredentialsError
from legendary.models.gql import *
from legendary.models.gql import (
uplay_claim_query,
uplay_codes_query,
uplay_redeem_query,
)
class EPCAPI:

View file

@ -1,10 +1,10 @@
# !/usr/bin/env python
# coding: utf-8
import logging
from platform import system
import requests
from platform import system
from legendary import __version__

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# coding: utf-8
import argparse
import csv
@ -10,25 +9,46 @@ import shlex
import subprocess
import time
import webbrowser
from collections import defaultdict, namedtuple
from logging.handlers import QueueListener
from multiprocessing import freeze_support, Queue as MPQueue
from multiprocessing import Queue as MPQueue
from multiprocessing import freeze_support
from platform import platform
from sys import exit, stdout, platform as sys_platform
from sys import exit, stdout
from sys import platform as sys_platform
from legendary import __version__, __codename__
from legendary import __codename__, __version__
from legendary.core import LegendaryCore
from legendary.lfs.crossover import (
mac_find_crossover_apps,
mac_get_bottle_path,
mac_get_crossover_bottles,
mac_get_crossover_version,
mac_is_crossover_running,
mac_is_valid_bottle,
)
from legendary.lfs.eos import (
add_registry_entries,
query_registry_entries,
remove_registry_entries,
)
from legendary.lfs.utils import clean_filename, validate_files
from legendary.lfs.wine_helpers import (
case_insensitive_file_search,
get_shell_folders,
read_registry,
)
from legendary.models.exceptions import InvalidCredentialsError
from legendary.models.game import SaveGameStatus, VerifyResult, Game
from legendary.utils.cli import get_boolean_choice, get_int_choice, sdl_prompt, strtobool
from legendary.lfs.crossover import *
from legendary.models.game import Game, SaveGameStatus, VerifyResult
from legendary.utils.cli import (
get_boolean_choice,
get_int_choice,
sdl_prompt,
strtobool,
)
from legendary.utils.custom_parser import HiddenAliasSubparsersAction
from legendary.utils.env import is_windows_mac_or_pyi
from legendary.lfs.eos import add_registry_entries, query_registry_entries, remove_registry_entries
from legendary.lfs.utils import validate_files, clean_filename
from legendary.utils.selective_dl import get_sdl_appname
from legendary.lfs.wine_helpers import read_registry, get_shell_folders, case_insensitive_file_search
# todo custom formatter for cli logger (clean info, highlighted error/warning)
logging.basicConfig(
@ -146,7 +166,10 @@ class LegendaryCLI:
auth_code = ''
if not args.auth_code and not args.session_id and not args.ex_token:
# only import here since pywebview import is slow
from legendary.utils.webview_login import webview_available, do_webview_login
from legendary.utils.webview_login import (
do_webview_login,
webview_available,
)
if not webview_available or args.no_webview or self.core.webview_killswitch:
# unfortunately the captcha stuff makes a complete CLI login flow kinda impossible right now...
@ -179,9 +202,7 @@ class LegendaryCLI:
logger.fatal('No exchange token/authorization code, cannot login.')
return
if exchange_token and self.core.auth_ex_token(exchange_token):
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}"')
elif auth_code and self.core.auth_code(auth_code):
if exchange_token and self.core.auth_ex_token(exchange_token) or auth_code and self.core.auth_code(auth_code):
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}"')
else:
logger.error('Login attempt failed, please see log for details.')
@ -211,7 +232,7 @@ class LegendaryCLI:
# sort games and dlc by name
games = sorted(games, key=lambda x: x.app_title.lower())
for citem_id in dlc_list.keys():
for citem_id in dlc_list:
if citem_id in na_dlcs:
dlc_list[citem_id].extend(na_dlcs[citem_id])
dlc_list[citem_id] = sorted(dlc_list[citem_id], key=lambda d: d.app_title.lower())
@ -543,7 +564,7 @@ class LegendaryCLI:
continue
if not args.yes and not args.force_download:
if not get_boolean_choice(f'Download cloud save?'):
if not get_boolean_choice('Download cloud save?'):
logger.info('Not downloading...')
continue
@ -564,7 +585,7 @@ class LegendaryCLI:
continue
if not args.yes and not args.force_upload:
if not get_boolean_choice(f'Upload local save?'):
if not get_boolean_choice('Upload local save?'):
logger.info('Not uploading...')
continue
logger.info('Uploading local savegame...')
@ -727,7 +748,7 @@ class LegendaryCLI:
return
if not game.is_origin_game:
logger.error(f'The specified game is not an Origin title.')
logger.error('The specified game is not an Origin title.')
return
# login is not required to launch the game, but linking does require it.
@ -791,8 +812,8 @@ class LegendaryCLI:
logger.info(f'Using CrossOver Bottle "{bottle_name}"')
if not command:
logger.error(f'In order to launch Origin correctly you must specify a prefix and wine binary or '
f'wrapper in the configuration file or command line. See the README for details.')
logger.error('In order to launch Origin correctly you must specify a prefix and wine binary or '
'wrapper in the configuration file or command line. See the README for details.')
return
# You cannot launch a URI without start.exe
@ -1087,7 +1108,7 @@ class LegendaryCLI:
install_dlcs = not args.skip_dlcs
if not args.yes and not args.with_dlcs and not args.skip_dlcs:
if not get_boolean_choice(f'Do you wish to automatically install DLCs?'):
if not get_boolean_choice('Do you wish to automatically install DLCs?'):
install_dlcs = False
if install_dlcs:
@ -1358,7 +1379,7 @@ class LegendaryCLI:
f'(App name: "{main_game_appname}") is not installed!')
return
else:
logger.fatal(f'Unable to get base game information for DLC, cannot continue.')
logger.fatal('Unable to get base game information for DLC, cannot continue.')
return
# get everything needed for import from core, then run additional checks.
@ -1410,7 +1431,7 @@ class LegendaryCLI:
logger.info(f'Found {len(dlcs)} items of DLC that could be imported.')
import_dlc = True
if not args.yes and not args.with_dlcs:
if not get_boolean_choice(f'Do you wish to automatically attempt to import all DLCs?'):
if not get_boolean_choice('Do you wish to automatically attempt to import all DLCs?'):
import_dlc = False
if import_dlc:
@ -1788,9 +1809,7 @@ class LegendaryCLI:
if igame := self.core.get_installed_game(app_name):
installed_dlc_json.append(dict(app_name=igame.app_name, title=igame.title,
install_size=igame.install_size))
installed_dlc_human.append('App name: {}, Title: "{}", Size: {:.02f} GiB'.format(
igame.app_name, igame.title, igame.install_size / 1024 / 1024 / 1024
))
installed_dlc_human.append(f'App name: {igame.app_name}, Title: "{igame.title}", Size: {igame.install_size / 1024 / 1024 / 1024:.02f} GiB')
installation_info.append(InfoItem('Installed DLC', 'installed_dlc',
installed_dlc_human or None,
installed_dlc_json))
@ -1863,11 +1882,11 @@ class LegendaryCLI:
manifest.chunk_data_list.count))
# total file size
total_size = sum(fm.file_size for fm in manifest.file_manifest_list.elements)
file_size = '{:.02f} GiB'.format(total_size / 1024 / 1024 / 1024)
file_size = f'{total_size / 1024 / 1024 / 1024:.02f} GiB'
manifest_info.append(InfoItem('Disk size (uncompressed)', 'disk_size', file_size, total_size))
# total chunk size
total_size = sum(c.file_size for c in manifest.chunk_data_list.elements)
chunk_size = '{:.02f} GiB'.format(total_size / 1024 / 1024 / 1024)
chunk_size = f'{total_size / 1024 / 1024 / 1024:.02f} GiB'
manifest_info.append(InfoItem('Download size (compressed)', 'download_size',
chunk_size, total_size))
@ -1885,7 +1904,7 @@ class LegendaryCLI:
(tag in fm.install_tags) or (not tag and not fm.install_tags)]
tag_file_size = sum(fm.file_size for fm in tag_files)
tag_disk_size.append(dict(tag=tag, size=tag_file_size, count=len(tag_files)))
tag_file_size_human = '{:.02f} GiB'.format(tag_file_size / 1024 / 1024 / 1024)
tag_file_size_human = f'{tag_file_size / 1024 / 1024 / 1024:.02f} GiB'
tag_disk_size_human.append(f'{human_tag.ljust(longest_tag)} - {tag_file_size_human} '
f'(Files: {len(tag_files)})')
# tag_disk_size_human.append(f'Size: {tag_file_size_human}, Files: {len(tag_files)}, Tag: "{tag}"')
@ -1898,7 +1917,7 @@ class LegendaryCLI:
tag_chunk_size = sum(c.file_size for c in manifest.chunk_data_list.elements
if c.guid_num in tag_chunk_guids)
tag_download_size.append(dict(tag=tag, size=tag_chunk_size, count=len(tag_chunk_guids)))
tag_chunk_size_human = '{:.02f} GiB'.format(tag_chunk_size / 1024 / 1024 / 1024)
tag_chunk_size_human = f'{tag_chunk_size / 1024 / 1024 / 1024:.02f} GiB'
tag_download_size_human.append(f'{human_tag.ljust(longest_tag)} - {tag_chunk_size_human} '
f'(Chunks: {len(tag_chunk_guids)})')
@ -2299,7 +2318,7 @@ class LegendaryCLI:
reg_paths = query_registry_entries(prefix)
if old_path := reg_paths["overlay_path"]:
if os.path.normpath(old_path) == args.path:
logger.info(f'Overlay already enabled, nothing to do.')
logger.info('Overlay already enabled, nothing to do.')
return
else:
logger.info(f'Updating overlay registry entries from "{old_path}" to "{args.path}"')
@ -2314,7 +2333,7 @@ class LegendaryCLI:
remove_registry_entries(prefix)
# if the install is not managed by legendary, specify the command including the path
if self.core.is_overlay_installed():
logger.info(f'To re-enable the overlay, run: legendary eos-overlay enable')
logger.info('To re-enable the overlay, run: legendary eos-overlay enable')
else:
logger.info(f'To re-enable the overlay, run: legendary eos-overlay enable --path "{old_path}"')
@ -2333,7 +2352,7 @@ class LegendaryCLI:
if os.name != 'nt' and not prefix:
logger.info('Registry entries in prefixes (if any) have not been removed. '
f'This shouldn\'t cause any issues as the overlay will simply fail to load.')
'This shouldn\'t cause any issues as the overlay will simply fail to load.')
else:
logger.info('Removing registry entries...')
remove_registry_entries(prefix)
@ -2345,7 +2364,7 @@ class LegendaryCLI:
elif args.action in {'install', 'update'}:
if args.action == 'update' and not self.core.is_overlay_installed():
logger.error(f'Overlay not installed, nothing to update.')
logger.error('Overlay not installed, nothing to update.')
return
logger.info('Preparing to start overlay install...')
dlm, ares, igame = self.core.prepare_overlay_install(args.path)
@ -2385,7 +2404,7 @@ class LegendaryCLI:
logger.info(f'Updating overlay registry entries from "{old_path}" to "{install_path}"')
remove_registry_entries(prefix)
else:
logger.info(f'Registry entries already exist. Done.')
logger.info('Registry entries already exist. Done.')
return
add_registry_entries(install_path, prefix)
logger.info('Done.')
@ -2420,7 +2439,7 @@ class LegendaryCLI:
if args.crossover_app:
cx_version = mac_get_crossover_version(args.crossover_app)
if not cx_version:
logger.error(f'No valid CrossOver install specified!')
logger.error('No valid CrossOver install specified!')
return
logger.info(f'Using CrossOver {cx_version} at {args.crossover_app}')
else:
@ -2431,9 +2450,9 @@ class LegendaryCLI:
for i, (ver, path) in enumerate(apps, start=1):
print(f' {i:2d}. {ver} ({path})')
print('')
choice = get_int_choice(f'Select a CrossOver install', 1, 1, len(apps))
choice = get_int_choice('Select a CrossOver install', 1, 1, len(apps))
if choice is None:
logger.error(f'No valid choice made, aborting.')
logger.error('No valid choice made, aborting.')
exit(1)
# empty line just to make the output look a little less crammed
print('')
@ -2442,8 +2461,8 @@ class LegendaryCLI:
cx_version, args.crossover_app = apps[0]
logger.info(f'Found CrossOver {cx_version} at {args.crossover_app}')
else:
logger.error(f'No CrossOver installs found, see https://legendary.gl/crossover-setup '
f'for setup instructions')
logger.error('No CrossOver installs found, see https://legendary.gl/crossover-setup '
'for setup instructions')
return
forced_selection = None
@ -2451,7 +2470,7 @@ class LegendaryCLI:
if args.crossover_bottle:
if args.crossover_bottle not in bottles:
logger.error(f'No valid CrossOver bottle specified!')
logger.error('No valid CrossOver bottle specified!')
return
logger.info(f'Using Bottle "{args.crossover_bottle}"')
forced_selection = args.crossover_bottle
@ -2476,8 +2495,8 @@ class LegendaryCLI:
f'(Total: {len(available_bottles)})')
if not usable_bottles:
logger.info(f'No usable bottles found, see https://legendary.gl/crossover-setup for '
f'manual setup instructions.')
logger.info('No usable bottles found, see https://legendary.gl/crossover-setup for '
'manual setup instructions.')
install_candidate = None
else:
print('\nFound available bottle(s), please select one:')
@ -2502,11 +2521,11 @@ class LegendaryCLI:
print(f' {i:2d}. {bottle["name"]} ({bottle["description"]})')
print('')
choice = get_int_choice(f'Select a bottle (CTRL+C to abort)',
choice = get_int_choice('Select a bottle (CTRL+C to abort)',
default_choice, 1, len(usable_bottles),
return_on_invalid=True)
if choice is None:
logger.error(f'No valid choice made, aborting.')
logger.error('No valid choice made, aborting.')
return
# empty line just to make the output look a little less crammed
print('')
@ -2517,7 +2536,7 @@ class LegendaryCLI:
logger.info(f'Preparing to download "{bottle_name}" ({install_candidate["description"]})...')
if bottle_name in bottles:
logger.warning(f'Bottle with the same name already exists!')
logger.warning('Bottle with the same name already exists!')
new_name = input('Please provide a new name for the bottle [CTRL-C or empty to abort]: ')
if not new_name:
logger.error('No new name provided, aborting.')
@ -2569,9 +2588,9 @@ class LegendaryCLI:
print(f' {i:2d}. {bottle}')
print('')
choice = get_int_choice(f'Select a bottle', default_choice, 1, len(bottles))
choice = get_int_choice('Select a bottle', default_choice, 1, len(bottles))
if choice is None:
logger.error(f'No valid choice made, aborting.')
logger.error('No valid choice made, aborting.')
exit(1)
# empty line just to make the output look a little less crammed
print('')
@ -2635,7 +2654,7 @@ class LegendaryCLI:
f'"legendary move {app_name} "{args.new_path}" --skip-move"')
return
else:
logger.info(f'Not moving, just rewriting legendary metadata...')
logger.info('Not moving, just rewriting legendary metadata...')
igame.install_path = new_path
self.core.install_game(igame)
@ -3143,7 +3162,7 @@ def main():
# show note if update is available
if not disable_update_message and cli.core.update_available and cli.core.update_notice_enabled():
if update_info := cli.core.get_update_info():
print(f'\nLegendary update available!')
print('\nLegendary update available!')
print(f'- New version: {update_info["version"]} - "{update_info["name"]}"')
print(f'- Release summary:\n{update_info["summary"]}\n- Release URL: {update_info["gh_url"]}')
if update_info['critical']:

View file

@ -1,46 +1,73 @@
# coding: utf-8
import contextlib
import json
import logging
import os
import shlex
import shutil
from base64 import b64decode
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from datetime import timezone
from datetime import datetime, timezone
from hashlib import sha1
from locale import getdefaultlocale
from multiprocessing import Queue
from platform import system
from requests import session
from requests.exceptions import HTTPError, ConnectionError
from sys import platform as sys_platform
from urllib.parse import parse_qsl, urlencode, urlparse
from uuid import uuid4
from urllib.parse import urlencode, parse_qsl, urlparse
from requests import session
from requests.exceptions import ConnectionError, HTTPError
from legendary import __version__
from legendary.api.egs import EPCAPI
from legendary.api.lgd import LGDAPI
from legendary.downloader.mp.manager import DLManager
from legendary.lfs.crossover import (
mac_find_crossover_apps,
mac_get_bottle_path,
mac_get_crossover_version,
mac_is_valid_bottle,
)
from legendary.lfs.egl import EPCLFS
from legendary.lfs.eos import EOSOverlayApp, query_registry_entries
from legendary.lfs.lgndry import LGDLFS
from legendary.lfs.utils import clean_filename, delete_folder, delete_filelist, get_dir_size
from legendary.lfs.utils import (
clean_filename,
delete_filelist,
delete_folder,
get_dir_size,
)
from legendary.lfs.wine_helpers import (
case_insensitive_path_search,
get_shell_folders,
read_registry,
)
from legendary.models.chunk import Chunk
from legendary.models.downloading import AnalysisResult, ConditionCheckResult
from legendary.models.egl import EGLManifest
from legendary.models.exceptions import *
from legendary.models.game import *
from legendary.models.exceptions import InvalidCredentialsError
from legendary.models.game import (
Game,
GameAsset,
InstalledGame,
LaunchParameters,
SaveGameFile,
SaveGameStatus,
Sidecar,
)
from legendary.models.json_manifest import JSONManifest
from legendary.models.manifest import Manifest, ManifestMeta
from legendary.models.chunk import Chunk
from legendary.lfs.crossover import *
from legendary.utils.egl_crypt import decrypt_epic_data
from legendary.utils.env import is_windows_mac_or_pyi
from legendary.lfs.eos import EOSOverlayApp, query_registry_entries
from legendary.utils.game_workarounds import is_opt_enabled, update_workarounds, get_exe_override
from legendary.utils.game_workarounds import (
get_exe_override,
is_opt_enabled,
update_workarounds,
)
from legendary.utils.savegame_helper import SaveGameHelper
from legendary.utils.selective_dl import games as sdl_games
from legendary.lfs.wine_helpers import read_registry, get_shell_folders, case_insensitive_path_search
# ToDo: instead of true/false return values for success/failure actually raise an exception that the CLI/GUI
# can handle to give the user more details. (Not required yet since there's no GUI so log output is fine)
@ -295,7 +322,7 @@ class LegendaryCore:
update_workarounds(game_overrides)
if sdl_config := game_overrides.get('sdl_config'):
# add placeholder for games to fetch from API that aren't hardcoded
for app_name in sdl_config.keys():
for app_name in sdl_config:
if app_name not in sdl_games:
sdl_games[app_name] = None
if lgd_config := version_info.get('legendary_config'):
@ -344,7 +371,7 @@ class LegendaryCore:
if _aliases_enabled and (force or not self.lgd.aliases):
self.lgd.generate_aliases()
def get_assets(self, update_assets=False, platform='Windows') -> List[GameAsset]:
def get_assets(self, update_assets=False, platform='Windows') -> list[GameAsset]:
# do not save and always fetch list when platform is overridden
if not self.lgd.assets or update_assets or platform not in self.lgd.assets:
# if not logged in, return empty list
@ -372,8 +399,8 @@ class LegendaryCore:
try:
return next(i for i in self.lgd.assets[platform] if i.app_name == app_name)
except StopIteration:
raise ValueError
except StopIteration as e:
raise ValueError from e
def asset_valid(self, app_name) -> bool:
# EGL sync is only supported for Windows titles so this is fine
@ -395,11 +422,11 @@ class LegendaryCore:
self.get_game_list(True, platform=platform)
return self.lgd.get_game_meta(app_name)
def get_game_list(self, update_assets=True, platform='Windows') -> List[Game]:
def get_game_list(self, update_assets=True, platform='Windows') -> list[Game]:
return self.get_game_and_dlc_list(update_assets=update_assets, platform=platform)[0]
def get_game_and_dlc_list(self, update_assets=True, platform='Windows',
force_refresh=False, skip_ue=True) -> (List[Game], Dict[str, List[Game]]):
force_refresh=False, skip_ue=True) -> tuple[list[Game], dict[str, list[Game]]]:
_ret = []
_dlc = defaultdict(list)
meta_updated = False
@ -433,7 +460,7 @@ class LegendaryCore:
game = self.lgd.get_game_meta(app_name)
asset_updated = sidecar_updated = False
if game:
asset_updated = any(game.app_version(_p) != app_assets[_p].build_version for _p in app_assets.keys())
asset_updated = any(game.app_version(_p) != app_assets[_p].build_version for _p in app_assets)
# assuming sidecar data is the same for all platforms, just check the baseline (Windows) for updates.
sidecar_updated = (app_assets['Windows'].sidecar_rev > 0 and
(not game.sidecar or game.sidecar.rev != app_assets['Windows'].sidecar_rev))
@ -467,10 +494,8 @@ class LegendaryCore:
sidecar=sidecar)
self.lgd.set_game_meta(game.app_name, game)
games[app_name] = game
try:
with contextlib.suppress(KeyError):
still_needs_update.remove(app_name)
except KeyError:
pass
# setup and teardown of thread pool takes some time, so only do it when it makes sense.
still_needs_update = {e[0] for e in fetch_list}
@ -525,7 +550,7 @@ class LegendaryCore:
self.lgd.delete_game_meta(app_name)
def get_non_asset_library_items(self, force_refresh=False,
skip_ue=True) -> (List[Game], Dict[str, List[Game]]):
skip_ue=True) -> tuple[list[Game], dict[str, list[Game]]]:
"""
Gets a list of Games without assets for installation, for instance Games delivered via
third-party stores that do not have assets for installation
@ -581,20 +606,20 @@ class LegendaryCore:
def get_installed_platforms(self):
return {i.platform for i in self._get_installed_list(False)}
def get_installed_list(self, include_dlc=False) -> List[InstalledGame]:
def get_installed_list(self, include_dlc=False) -> list[InstalledGame]:
if self.egl_sync_enabled:
self.log.debug('Running EGL sync...')
self.egl_sync()
return self._get_installed_list(include_dlc)
def _get_installed_list(self, include_dlc=False) -> List[InstalledGame]:
def _get_installed_list(self, include_dlc=False) -> list[InstalledGame]:
if include_dlc:
return self.lgd.get_installed_list()
else:
return [g for g in self.lgd.get_installed_list() if not g.is_dlc]
def get_installed_dlc_list(self) -> List[InstalledGame]:
def get_installed_dlc_list(self) -> list[InstalledGame]:
return [g for g in self.lgd.get_installed_list() if g.is_dlc]
def get_installed_game(self, app_name, skip_sync=False) -> InstalledGame:
@ -831,7 +856,7 @@ class LegendaryCore:
return f'link2ea://launchgame/{app_name}?{urlencode(parameters)}'
def get_save_games(self, app_name: str = ''):
savegames = self.egs.get_user_cloud_saves(app_name, manifests=not not app_name)
savegames = self.egs.get_user_cloud_saves(app_name, manifests=bool(app_name))
_saves = []
for fname, f in savegames['files'].items():
if '.manifest' not in fname:
@ -966,7 +991,7 @@ class LegendaryCore:
return absolute_path
def check_savegame_state(self, path: str, save: SaveGameFile) -> (SaveGameStatus, (datetime, datetime)):
def check_savegame_state(self, path: str, save: SaveGameFile) -> tuple[SaveGameStatus, tuple[datetime | None, datetime | None]]:
latest = 0
for _dir, _, _files in os.walk(path):
for _file in _files:
@ -1107,8 +1132,8 @@ class LegendaryCore:
f'"legendary clean-saves {app_name}" and try again.')
return
else:
self.log.error(f'No chunks were available, skipping. You can run "legendary clean-saves" '
f'to remove this broken save from your account.')
self.log.error('No chunks were available, skipping. You can run "legendary clean-saves" '
'to remove this broken save from your account.')
continue
for fm in m.file_manifest_list.elements:
@ -1183,14 +1208,14 @@ class LegendaryCore:
deletion_list.append(fname)
continue
elif 0 < missing_chunks < total_chunks:
self.log.error(f'Some chunk(s) missing, optionally run "legendary download-saves" to obtain a backup '
f'of the corrupted save, then re-run this command with "--delete-incomplete" to remove '
f'it from the cloud save service.')
self.log.error('Some chunk(s) missing, optionally run "legendary download-saves" to obtain a backup '
'of the corrupted save, then re-run this command with "--delete-incomplete" to remove '
'it from the cloud save service.')
used_chunks |= chunk_fnames
# check for orphaned chunks (not used in any manifests)
for fname, f in files.items():
for fname in files:
if fname in used_chunks or '.manifest' in fname:
continue
# skip chunks where orphan status could not be reliably determined
@ -1353,7 +1378,7 @@ class LegendaryCore:
game.base_urls = _base_urls
if not old_bytes:
self.log.error(f'Could not load old manifest, patching will not work!')
self.log.error('Could not load old manifest, patching will not work!')
else:
old_manifest = self.load_manifest(old_bytes)
@ -1408,7 +1433,7 @@ class LegendaryCore:
f'"{new_manifest.meta.build_id}"...')
new_manifest.apply_delta_manifest(delta_manifest)
else:
self.log.debug(f'No Delta manifest received from CDN.')
self.log.debug('No Delta manifest received from CDN.')
# reuse existing installation's directory
if igame := self.get_installed_game(base_game.app_name if base_game else game.app_name):
@ -1734,7 +1759,7 @@ class LegendaryCore:
installed_game.install_path, filelist,
case_insensitive=installed_game.platform.startswith('Win')
):
self.log.warning(f'Deleting some deselected files failed, please check/remove manually.')
self.log.warning('Deleting some deselected files failed, please check/remove manually.')
def prereq_installed(self, app_name):
igame = self.lgd.get_installed_game(app_name)
@ -1775,7 +1800,7 @@ class LegendaryCore:
needs_verify = True
if not needs_verify:
self.log.debug(f'No in-progress installation found, assuming complete...')
self.log.debug('No in-progress installation found, assuming complete...')
manifest_secrets = dict()
if not manifest_data:
@ -1922,8 +1947,8 @@ class LegendaryCore:
except ValueError as e:
self.log.warning(f'Deleting EGL manifest failed: {e!r}')
except FileNotFoundError:
self.log.warning(f'EGL manifest was already deleted, in case you uninstalled the Epic Games Launcher'
f' please disable and unlink EGL Sync.')
self.log.warning('EGL manifest was already deleted, in case you uninstalled the Epic Games Launcher'
' please disable and unlink EGL Sync.')
if delete_files:
delete_folder(os.path.join(igame.install_path, '.egstore'))

View file

@ -1,22 +1,31 @@
# coding: utf-8
# please don't look at this code too hard, it's a mess.
import logging
import os
import time
from collections import Counter, defaultdict, deque
from logging.handlers import QueueHandler
from multiprocessing import cpu_count, Process, Queue as MPQueue
from multiprocessing import Process, cpu_count
from multiprocessing import Queue as MPQueue
from multiprocessing.shared_memory import SharedMemory
from queue import Empty
from sys import exit
from threading import Condition, Thread
from legendary.downloader.mp.workers import DLWorker, FileWorker
from legendary.models.downloading import *
from legendary.models.manifest import ManifestComparison, Manifest
from legendary.models.downloading import (
AnalysisResult,
ChunkTask,
DownloaderTask,
FileTask,
SharedMemorySegment,
TaskFlags,
TerminateWorkerTask,
UIUpdate,
WriterTask,
)
from legendary.models.manifest import Manifest, ManifestComparison
class DLManager(Process):
@ -107,7 +116,7 @@ class DLManager(Process):
is_1mib = analysis_res.biggest_chunk == 1024 * 1024
self.log.debug(f'Biggest chunk size: {analysis_res.biggest_chunk} bytes (== 1 MiB? {is_1mib})')
self.log.debug(f'Creating manifest comparison...')
self.log.debug('Creating manifest comparison...')
mc = ManifestComparison.create(manifest, old_manifest)
analysis_res.manifest_comparison = mc
@ -380,11 +389,11 @@ class DLManager(Process):
if reused:
self.log.debug(f' + Reusing {reused} chunks from: {current_file.filename}')
# open temporary file that will contain download + old file contents
self.tasks.append(FileTask(current_file.filename + u'.tmp', flags=TaskFlags.OPEN_FILE))
self.tasks.append(FileTask(current_file.filename + '.tmp', flags=TaskFlags.OPEN_FILE))
self.tasks.extend(chunk_tasks)
self.tasks.append(FileTask(current_file.filename + u'.tmp', flags=TaskFlags.CLOSE_FILE))
self.tasks.append(FileTask(current_file.filename + '.tmp', flags=TaskFlags.CLOSE_FILE))
# delete old file and rename temporary
self.tasks.append(FileTask(current_file.filename, old_file=current_file.filename + u'.tmp',
self.tasks.append(FileTask(current_file.filename, old_file=current_file.filename + '.tmp',
flags=TaskFlags.RENAME_FILE | TaskFlags.DELETE_FILE))
else:
self.tasks.append(FileTask(current_file.filename, flags=TaskFlags.OPEN_FILE))
@ -659,7 +668,7 @@ class DLManager(Process):
self.dl_result_q = MPQueue(-1)
self.writer_result_q = MPQueue(-1)
self.log.info(f'Starting download workers...')
self.log.info('Starting download workers...')
bind_ip = None
for i in range(self.max_workers):
@ -773,7 +782,7 @@ class DLManager(Process):
time.sleep(self.update_interval)
for i in range(self.max_workers):
for _ in range(self.max_workers):
self.dl_worker_queue.put_nowait(TerminateWorkerTask())
self.log.info('Waiting for installation to finish...')
@ -781,7 +790,7 @@ class DLManager(Process):
writer_p.join(timeout=10.0)
if writer_p.exitcode is None:
self.log.warning(f'Terminating writer process, no exit code!')
self.log.warning('Terminating writer process, no exit code!')
writer_p.terminate()
# forcibly kill DL workers that are not actually dead yet

View file

@ -1,23 +1,24 @@
# coding: utf-8
import logging
import os
import time
import logging
from logging.handlers import QueueHandler
from multiprocessing import Process
from multiprocessing.shared_memory import SharedMemory
from queue import Empty
import requests
from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK
from requests.adapters import DEFAULT_POOLBLOCK, HTTPAdapter
from legendary.lfs.wine_helpers import case_insensitive_file_search
from legendary.models.chunk import Chunk
from legendary.models.downloading import (
DownloaderTask, DownloaderTaskResult,
WriterTask, WriterTaskResult,
TerminateWorkerTask, TaskFlags
DownloaderTask,
DownloaderTaskResult,
TaskFlags,
TerminateWorkerTask,
WriterTask,
WriterTaskResult,
)
@ -36,8 +37,10 @@ class BindingHTTPAdapter(HTTPAdapter):
class DLWorker(Process):
def __init__(self, name, queue, out_queue, shm, max_retries=7,
logging_queue=None, dl_timeout=10, bind_addr=None, secrets=dict()):
logging_queue=None, dl_timeout=10, bind_addr=None, secrets=None):
super().__init__(name=name)
if secrets is None:
secrets = dict()
self.q = queue
self.o_q = out_queue
self.secrets = secrets
@ -65,7 +68,7 @@ class DLWorker(Process):
logger = logging.getLogger(self.name)
logger.setLevel(self.log_level)
logger.debug(f'Download worker reporting for duty!')
logger.debug('Download worker reporting for duty!')
empty = False
while True:

View file

@ -1,6 +1,6 @@
import logging
import plistlib
import os
import plistlib
import subprocess
_logger = logging.getLogger('CXHelpers')

View file

@ -1,11 +1,8 @@
# coding: utf-8
import configparser
import json
import os
from typing import List
from legendary.models.egl import EGLManifest
@ -62,7 +59,7 @@ class EPCLFS:
data = json.load(open(os.path.join(self.programdata_path, f), encoding='utf-8'))
self.manifests[data['AppName']] = data
def get_manifests(self) -> List[EGLManifest]:
def get_manifests(self) -> list[EGLManifest]:
if not self.manifests:
self.read_manifests()

View file

@ -1,10 +1,19 @@
import os
import logging
import os
from legendary.models.game import Game
if os.name == 'nt':
from legendary.lfs.windows_helpers import *
from legendary.lfs.windows_helpers import (
HKEY_CURRENT_USER,
HKEY_LOCAL_MACHINE,
TYPE_DWORD,
TYPE_STRING,
list_registry_values,
query_registry_value,
remove_registry_value,
set_registry_value,
)
logger = logging.getLogger('EOSUtils')
# Dummy Game objects to use with Core methods that expect them
@ -98,7 +107,6 @@ def add_registry_entries(overlay_path, prefix=None):
overlay_path = f'Z:{overlay_path}'
overlay_line = f'"{EOS_OVERLAY_VALUE}"="{overlay_path}"\n'
overlay_idx = None
section_idx = None
for idx, line in enumerate(reg_lines):

View file

@ -1,23 +1,20 @@
# coding: utf-8
import json
import os
import logging
from contextlib import contextmanager
import os
from collections import defaultdict
from contextlib import contextmanager
from pathlib import Path
from time import time
from filelock import FileLock
from .utils import clean_filename, LockedJSONData
from legendary.models.game import *
from legendary.utils.aliasing import generate_aliases
from legendary.models.config import LGDConf
from legendary.models.game import Game, GameAsset, InstalledGame
from legendary.utils.aliasing import generate_aliases
from legendary.utils.env import is_windows_mac_or_pyi
from .utils import LockedJSONData, clean_filename
FILELOCK_DEBUG = False
@ -229,7 +226,7 @@ class LGDLFS:
try:
return open(self._get_manifest_filename(app_name, version, platform), 'rb').read()
except FileNotFoundError: # all other errors should propagate
self.log.debug(f'Loading manifest failed, retrying without platform in filename...')
self.log.debug('Loading manifest failed, retrying without platform in filename...')
try:
return open(self._get_manifest_filename(app_name, version), 'rb').read()
except FileNotFoundError: # all other errors should propagate
@ -464,7 +461,7 @@ class LGDLFS:
collisions = set()
alias_map = defaultdict(set)
for app_name in self._game_metadata.keys():
for app_name in self._game_metadata:
game = self.get_game_meta(app_name)
if game.is_dlc:
continue
@ -478,7 +475,7 @@ class LGDLFS:
collisions.add(alias)
# remove colliding aliases from map and add aliases to lookup table
for app_name, aliases in alias_map.items():
for app_name in alias_map:
alias_map[app_name] -= collisions
for alias in alias_map[app_name]:
self.aliases[alias] = app_name

View file

@ -1,15 +1,13 @@
# coding: utf-8
import os
import shutil
import hashlib
import json
import logging
import os
import shutil
from collections.abc import Iterator
from pathlib import Path
from sys import stdout
from time import perf_counter
from typing import List, Iterator
from filelock import FileLock
@ -33,7 +31,7 @@ def delete_folder(path: str, recursive=True) -> bool:
return True
def delete_filelist(path: str, filenames: List[str],
def delete_filelist(path: str, filenames: list[str],
delete_root_directory: bool = False,
silent: bool = False, case_insensitive: bool = True) -> bool:
dirs = set()
@ -87,7 +85,7 @@ def delete_filelist(path: str, filenames: List[str],
return no_error
def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1',
def validate_files(base_path: str, filelist: list[tuple], hash_type='sha1',
large_file_threshold=1024 * 1024 * 512, case_insensitive: bool = True) -> Iterator[tuple]:
"""
Validates the files in filelist in path against the provided hashes

View file

@ -1,6 +1,6 @@
import ctypes
import logging
import winreg
import ctypes
_logger = logging.getLogger('WindowsHelpers')

View file

@ -1,11 +1,10 @@
# coding: utf-8
import struct
import zlib
from hashlib import sha1
from io import BytesIO
from uuid import uuid4
from Cryptodome.Cipher import AES
from legendary.utils.rolling_hash import get_hash
@ -74,7 +73,7 @@ class Chunk:
@property
def guid_str(self):
if not self._guid_str:
self._guid_str = '-'.join('{:08x}'.format(g) for g in self.guid)
self._guid_str = '-'.join(f'{g:08x}' for g in self.guid)
return self._guid_str
@property
@ -123,7 +122,7 @@ class Chunk:
if _chunk.header_version >= 4:
_chunk.secret_guid = struct.unpack('<IIII', bio.read(16))
_chunk.secret_key = secrets.get(''.join('{:08X}'.format(g) for g in _chunk.secret_guid))
_chunk.secret_key = secrets.get(''.join(f'{g:08X}' for g in _chunk.secret_guid))
_chunk.encryption_tag = bio.read(16)
if bio.tell() - head_start != _chunk.header_size:

View file

@ -1,7 +1,6 @@
# coding: utf-8
from enum import Flag, auto
from dataclasses import dataclass
from enum import Flag, auto
from typing import Optional
from .manifest import ManifestComparison

View file

@ -1,9 +1,8 @@
from copy import deepcopy
from legendary.models.game import InstalledGame, Game
from legendary.models.game import Game, InstalledGame
from legendary.utils.cli import strtobool
_template = {
'AppCategories': ['public', 'games', 'applications'],
'AppName': '',

View file

@ -1,4 +1,3 @@
# coding: utf-8
class InvalidCredentialsError(Exception):
pass

View file

@ -1,9 +1,8 @@
# coding: utf-8
from datetime import datetime
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional, List, Dict
from typing import Optional
@dataclass
@ -17,7 +16,7 @@ class GameAsset:
catalog_item_id: str = ''
label_name: str = ''
namespace: str = ''
metadata: Dict = field(default_factory=dict)
metadata: dict = field(default_factory=dict)
sidecar_rev: int = 0
@classmethod
@ -52,7 +51,7 @@ class Sidecar:
"""
App sidecar data
"""
config: Dict
config: dict
rev: int
@classmethod
@ -71,9 +70,9 @@ class Game:
app_name: str
app_title: str
asset_infos: Dict[str, GameAsset] = field(default_factory=dict)
base_urls: List[str] = field(default_factory=list)
metadata: Dict = field(default_factory=dict)
asset_infos: dict[str, GameAsset] = field(default_factory=dict)
base_urls: list[str] = field(default_factory=list)
metadata: dict = field(default_factory=dict)
sidecar: Optional[Sidecar] = None
def app_version(self, platform='Windows'):
@ -177,19 +176,19 @@ class InstalledGame:
title: str
version: str
base_urls: List[str] = field(default_factory=list)
base_urls: list[str] = field(default_factory=list)
can_run_offline: bool = False
egl_guid: str = ''
executable: str = ''
install_size: int = 0
install_tags: List[str] = field(default_factory=list)
install_tags: list[str] = field(default_factory=list)
is_dlc: bool = False
launch_parameters: str = ''
manifest_path: str = ''
needs_verification: bool = False
platform: str = 'Windows'
prereq_info: Optional[Dict] = None
uninstaller: Optional[Dict] = None
prereq_info: Optional[dict] = None
uninstaller: Optional[dict] = None
requires_ot: bool = False
save_path: Optional[str] = None
is_preloaded: bool = False

View file

@ -1,12 +1,17 @@
# coding: utf-8
import json
import struct
from copy import deepcopy
from legendary.models.manifest import (
Manifest, ManifestMeta, CDL, ChunkPart, ChunkInfo, FML, FileManifest, CustomFields
CDL,
FML,
ChunkInfo,
ChunkPart,
CustomFields,
FileManifest,
Manifest,
ManifestMeta,
)

View file

@ -1,17 +1,16 @@
# coding: utf-8
from __future__ import annotations
import base64
import contextlib
import hashlib
import logging
import struct
import base64
import zlib
from Cryptodome.Cipher import AES
from base64 import b64encode
from io import BytesIO
from typing import Optional
from Cryptodome.Cipher import AES
logger = logging.getLogger('Manifest')
@ -82,11 +81,11 @@ class Manifest:
self.data = b''
# remainder
self.meta: Optional[ManifestMeta] = None
self.chunk_data_list: Optional[CDL] = None
self.file_manifest_list: Optional[FML] = None
self.custom_fields: Optional[CustomFields] = None
self.encrypted_data: Optional[EncryptedData] = None
self.meta: ManifestMeta | None = None
self.chunk_data_list: CDL | None = None
self.file_manifest_list: FML | None = None
self.custom_fields: CustomFields | None = None
self.encrypted_data: EncryptedData | None = None
@property
def compressed(self):
@ -99,7 +98,7 @@ class Manifest:
def decrypt(self, secrets):
if not self.encrypted:
return True
secret_str = ''.join('{:08X}'.format(guid) for guid in self.secret_guid)
secret_str = ''.join(f'{guid:08X}' for guid in self.secret_guid)
secret = secrets.get(secret_str)
if secret is None:
return False
@ -261,10 +260,8 @@ class Manifest:
self.file_manifest_list._path_map = None
# ensure guid map exists (0 will most likely yield no result, so ignore ValueError)
try:
with contextlib.suppress(ValueError):
self.chunk_data_list.get_chunk_by_guid(0)
except ValueError:
pass
# add new chunks from delta manifest to main manifest and again clear maps and update count
existing_chunk_guids = self.chunk_data_list._guid_int_map.keys()
@ -559,14 +556,12 @@ class ChunkInfo:
self._guid_num = None
def __repr__(self):
return '<ChunkInfo (guid={}, hash={}, sha_hash={}, group_num={}, window_size={}, file_size={})>'.format(
self.guid_str, self.hash, self.sha_hash.hex(), self.group_num, self.window_size, self.file_size
)
return f'<ChunkInfo (guid={self.guid_str}, hash={self.hash}, sha_hash={self.sha_hash.hex()}, group_num={self.group_num}, window_size={self.window_size}, file_size={self.file_size})>'
@property
def guid_str(self):
if not self._guid_str:
self._guid_str = '-'.join('{:08x}'.format(g) for g in self.guid)
self._guid_str = '-'.join(f'{g:08x}' for g in self.guid)
return self._guid_str
@ -599,12 +594,10 @@ class ChunkInfo:
secret_b64 = base64.urlsafe_b64encode(struct.pack('<IIII', *self.secret_guid)).decode().strip('=')
hash_b64 = base64.urlsafe_b64encode(struct.pack('<Q', self.hash)).decode().strip('=')
guid_b64 = base64.urlsafe_b64encode(struct.pack('<IIII', *self.guid)).decode().strip('=')
return '{}/{}/{:02d}/{}_{}.chunk'.format(
get_chunk_dir(self._manifest_version), secret_b64,
self.group_num, hash_b64, guid_b64)
return f'{get_chunk_dir(self._manifest_version)}/{secret_b64}/{self.group_num:02d}/{hash_b64}_{guid_b64}.chunk'
return '{}/{:02d}/{:016X}_{}.chunk'.format(
get_chunk_dir(self._manifest_version), self.group_num,
self.hash, ''.join('{:08X}'.format(g) for g in self.guid))
self.hash, ''.join(f'{g:08X}' for g in self.guid))
class FML:
@ -812,7 +805,7 @@ class ChunkPart:
@property
def guid_str(self):
if not self._guid_str:
self._guid_str = '-'.join('{:08x}'.format(g) for g in self.guid)
self._guid_str = '-'.join(f'{g:08x}' for g in self.guid)
return self._guid_str
@property
@ -822,9 +815,8 @@ class ChunkPart:
return self._guid_num
def __repr__(self):
guid_readable = '-'.join('{:08x}'.format(g) for g in self.guid)
return '<ChunkPart (guid={}, offset={}, size={}, file_offset={})>'.format(
guid_readable, self.offset, self.size, self.file_offset)
guid_readable = '-'.join(f'{g:08x}' for g in self.guid)
return f'<ChunkPart (guid={guid_readable}, offset={self.offset}, size={self.size}, file_offset={self.file_offset})>'
class CustomFields:

View file

@ -40,7 +40,7 @@ roman = {
def _filter(input):
return ''.join(l for l in input if l in allowed_characters)
return ''.join(char for char in input if char in allowed_characters)
def generate_aliases(game_name, game_folder=None, split_words=True, app_name=None):

View file

@ -4,17 +4,11 @@ def get_boolean_choice(prompt, default=True):
choice = input(f'{prompt} [{yn}]: ')
if not choice:
return default
elif choice[0].lower() == 'y':
return True
else:
return False
return choice[0].lower() == 'y'
def get_int_choice(prompt, default=None, min_choice=None, max_choice=None, return_on_invalid=False):
if default is not None:
prompt = f'{prompt} [{default}]: '
else:
prompt = f'{prompt}: '
prompt = f'{prompt} [{default}]: ' if default is not None else f'{prompt}: '
while True:
try:
@ -55,7 +49,7 @@ def sdl_prompt(sdl_data, title):
continue
print(' *', tag, '-', info['name'])
examples = ', '.join([g for g in sdl_data.keys() if g != '__required'][:2])
examples = ', '.join([g for g in sdl_data if g != '__required'][:2])
print(f'Please enter tags of pack(s) to install (space/comma-separated, e.g. "{examples}")')
print('Leave blank to use defaults (only required data will be downloaded).')
choices = input('Additional packs [Enter to confirm]: ')
@ -87,5 +81,5 @@ def strtobool(val):
elif val in ('n', 'no', 'f', 'false', 'off', '0', ''):
return 0
else:
raise ValueError("invalid truth value %r" % (val,))
raise ValueError(f"invalid truth value {val!r}")

View file

@ -78,7 +78,8 @@ def add_round_key(s, k):
# learned from http://cs.ucsb.edu/~koc/cs178/projects/JT/aes.c
xtime = lambda a: (((a << 1) ^ 0x1B) & 0xFF) if (a & 0x80) else (a << 1)
def xtime(a):
return (((a << 1) ^ 0x1B) & 0xFF) if (a & 0x80) else (a << 1)
def mix_single_column(a):
@ -243,6 +244,6 @@ def decrypt_epic_data(key, encrypted):
for encoding in (locale.getpreferredencoding(), 'cp1252', 'cp932', 'ascii', 'utf-8'):
try:
return decrypted.decode(encoding)
except: # ignore exception, just try the next encoding
except Exception: # ignore exception, just try the next encoding
continue
raise ValueError('Failed to decode decrypted data')

View file

@ -1,4 +1,3 @@
# coding: utf-8
from sys import platform

View file

@ -1,6 +1,5 @@
import logging
import os
from datetime import datetime
from fnmatch import fnmatch
from hashlib import sha1
@ -8,8 +7,16 @@ from io import BytesIO
from tempfile import TemporaryFile
from legendary.models.chunk import Chunk
from legendary.models.manifest import \
Manifest, ManifestMeta, CDL, FML, CustomFields, FileManifest, ChunkPart, ChunkInfo
from legendary.models.manifest import (
CDL,
FML,
ChunkInfo,
ChunkPart,
CustomFields,
FileManifest,
Manifest,
ManifestMeta,
)
def _filename_matches(filename, patterns):

View file

@ -1,5 +1,4 @@
# This file contains definitions for selective downloading for supported games
# coding: utf-8
_cyberpunk_sdl = {
'de': {'tags': ['voice_de_de'], 'name': 'Deutsch'},
@ -32,7 +31,7 @@ games = {
def get_sdl_appname(app_name):
for k in games.keys():
for k in games:
if k.endswith('_Mac'):
continue

View file

@ -1,5 +1,5 @@
import logging
import json
import logging
import os
import webbrowser
@ -115,7 +115,7 @@ class MockLauncher:
try:
j = json.loads(sid_json)
sid = j['sid']
logger.debug(f'Got SID (stage 2)! Executing sid login callback...')
logger.debug('Got SID (stage 2)! Executing sid login callback...')
exchange_code = self.callback_sid(sid)
if exchange_code:
self.callback_result = self.callback_code(exchange_code)

View file

@ -39,9 +39,24 @@ legendary = "legendary.cli:main"
[project.urls]
source = "https://github.com/derrod/legendary"
[dependency-groups]
dev = [
"ruff>=0.15.10",
]
[tool.uv]
required-version = ">=0.11.0"
[tool.uv.build-backend]
module-name = "legendary"
module-root = ""
[tool.ruff]
extend-exclude = ["zipapp_main.py"]
[tool.ruff.lint]
select = [ "E", "F", "UP", "B", "SIM", "I" ]
ignore = [ "E501", "UP015", "SIM102", "SIM115" ]
[tool.ruff.format]
quote-style = "single"

33
uv.lock
View file

@ -214,6 +214,11 @@ webview-gtk = [
{ name = "pywebview" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "filelock" },
@ -226,6 +231,9 @@ requires-dist = [
]
provides-extras = ["webview", "webview-gtk", "pyinstaller-build"]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.10" }]
[[package]]
name = "macholib"
version = "1.16.4"
@ -554,6 +562,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "ruff"
version = "0.15.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
]
[[package]]
name = "setuptools"
version = "82.0.1"