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 # !/usr/bin/env python
# coding: utf-8
import logging
import urllib.parse import urllib.parse
import requests import requests
import requests.adapters import requests.adapters
import logging
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from legendary.models.exceptions import InvalidCredentialsError 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: class EPCAPI:

View file

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

View file

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

View file

@ -1,46 +1,73 @@
# coding: utf-8
import contextlib
import json import json
import logging
import os
import shlex import shlex
import shutil import shutil
from base64 import b64decode from base64 import b64decode
from collections import defaultdict from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import timezone from datetime import datetime, timezone
from hashlib import sha1 from hashlib import sha1
from locale import getdefaultlocale from locale import getdefaultlocale
from multiprocessing import Queue from multiprocessing import Queue
from platform import system from platform import system
from requests import session
from requests.exceptions import HTTPError, ConnectionError
from sys import platform as sys_platform from sys import platform as sys_platform
from urllib.parse import parse_qsl, urlencode, urlparse
from uuid import uuid4 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 import __version__
from legendary.api.egs import EPCAPI from legendary.api.egs import EPCAPI
from legendary.api.lgd import LGDAPI from legendary.api.lgd import LGDAPI
from legendary.downloader.mp.manager import DLManager 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.egl import EPCLFS
from legendary.lfs.eos import EOSOverlayApp, query_registry_entries
from legendary.lfs.lgndry import LGDLFS 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.downloading import AnalysisResult, ConditionCheckResult
from legendary.models.egl import EGLManifest from legendary.models.egl import EGLManifest
from legendary.models.exceptions import * from legendary.models.exceptions import InvalidCredentialsError
from legendary.models.game import * from legendary.models.game import (
Game,
GameAsset,
InstalledGame,
LaunchParameters,
SaveGameFile,
SaveGameStatus,
Sidecar,
)
from legendary.models.json_manifest import JSONManifest from legendary.models.json_manifest import JSONManifest
from legendary.models.manifest import Manifest, ManifestMeta 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.egl_crypt import decrypt_epic_data
from legendary.utils.env import is_windows_mac_or_pyi 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 (
from legendary.utils.game_workarounds import is_opt_enabled, update_workarounds, get_exe_override get_exe_override,
is_opt_enabled,
update_workarounds,
)
from legendary.utils.savegame_helper import SaveGameHelper from legendary.utils.savegame_helper import SaveGameHelper
from legendary.utils.selective_dl import games as sdl_games 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 # 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) # 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) update_workarounds(game_overrides)
if sdl_config := game_overrides.get('sdl_config'): if sdl_config := game_overrides.get('sdl_config'):
# add placeholder for games to fetch from API that aren't hardcoded # 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: if app_name not in sdl_games:
sdl_games[app_name] = None sdl_games[app_name] = None
if lgd_config := version_info.get('legendary_config'): if lgd_config := version_info.get('legendary_config'):
@ -344,7 +371,7 @@ class LegendaryCore:
if _aliases_enabled and (force or not self.lgd.aliases): if _aliases_enabled and (force or not self.lgd.aliases):
self.lgd.generate_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 # 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 self.lgd.assets or update_assets or platform not in self.lgd.assets:
# if not logged in, return empty list # if not logged in, return empty list
@ -372,8 +399,8 @@ class LegendaryCore:
try: try:
return next(i for i in self.lgd.assets[platform] if i.app_name == app_name) return next(i for i in self.lgd.assets[platform] if i.app_name == app_name)
except StopIteration: except StopIteration as e:
raise ValueError raise ValueError from e
def asset_valid(self, app_name) -> bool: def asset_valid(self, app_name) -> bool:
# EGL sync is only supported for Windows titles so this is fine # 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) self.get_game_list(True, platform=platform)
return self.lgd.get_game_meta(app_name) 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] 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', 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 = [] _ret = []
_dlc = defaultdict(list) _dlc = defaultdict(list)
meta_updated = False meta_updated = False
@ -433,7 +460,7 @@ class LegendaryCore:
game = self.lgd.get_game_meta(app_name) game = self.lgd.get_game_meta(app_name)
asset_updated = sidecar_updated = False asset_updated = sidecar_updated = False
if game: 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. # 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 sidecar_updated = (app_assets['Windows'].sidecar_rev > 0 and
(not game.sidecar or game.sidecar.rev != app_assets['Windows'].sidecar_rev)) (not game.sidecar or game.sidecar.rev != app_assets['Windows'].sidecar_rev))
@ -467,10 +494,8 @@ class LegendaryCore:
sidecar=sidecar) sidecar=sidecar)
self.lgd.set_game_meta(game.app_name, game) self.lgd.set_game_meta(game.app_name, game)
games[app_name] = game games[app_name] = game
try: with contextlib.suppress(KeyError):
still_needs_update.remove(app_name) 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. # 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} still_needs_update = {e[0] for e in fetch_list}
@ -525,7 +550,7 @@ class LegendaryCore:
self.lgd.delete_game_meta(app_name) self.lgd.delete_game_meta(app_name)
def get_non_asset_library_items(self, force_refresh=False, 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 Gets a list of Games without assets for installation, for instance Games delivered via
third-party stores that do not have assets for installation third-party stores that do not have assets for installation
@ -581,20 +606,20 @@ class LegendaryCore:
def get_installed_platforms(self): def get_installed_platforms(self):
return {i.platform for i in self._get_installed_list(False)} 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: if self.egl_sync_enabled:
self.log.debug('Running EGL sync...') self.log.debug('Running EGL sync...')
self.egl_sync() self.egl_sync()
return self._get_installed_list(include_dlc) 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: if include_dlc:
return self.lgd.get_installed_list() return self.lgd.get_installed_list()
else: else:
return [g for g in self.lgd.get_installed_list() if not g.is_dlc] 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] 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: 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)}' return f'link2ea://launchgame/{app_name}?{urlencode(parameters)}'
def get_save_games(self, app_name: str = ''): 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 = [] _saves = []
for fname, f in savegames['files'].items(): for fname, f in savegames['files'].items():
if '.manifest' not in fname: if '.manifest' not in fname:
@ -966,7 +991,7 @@ class LegendaryCore:
return absolute_path 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 latest = 0
for _dir, _, _files in os.walk(path): for _dir, _, _files in os.walk(path):
for _file in _files: for _file in _files:
@ -1107,8 +1132,8 @@ class LegendaryCore:
f'"legendary clean-saves {app_name}" and try again.') f'"legendary clean-saves {app_name}" and try again.')
return return
else: else:
self.log.error(f'No chunks were available, skipping. You can run "legendary clean-saves" ' self.log.error('No chunks were available, skipping. You can run "legendary clean-saves" '
f'to remove this broken save from your account.') 'to remove this broken save from your account.')
continue continue
for fm in m.file_manifest_list.elements: for fm in m.file_manifest_list.elements:
@ -1183,14 +1208,14 @@ class LegendaryCore:
deletion_list.append(fname) deletion_list.append(fname)
continue continue
elif 0 < missing_chunks < total_chunks: elif 0 < missing_chunks < total_chunks:
self.log.error(f'Some chunk(s) missing, optionally run "legendary download-saves" to obtain a backup ' self.log.error('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 ' 'of the corrupted save, then re-run this command with "--delete-incomplete" to remove '
f'it from the cloud save service.') 'it from the cloud save service.')
used_chunks |= chunk_fnames used_chunks |= chunk_fnames
# check for orphaned chunks (not used in any manifests) # 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: if fname in used_chunks or '.manifest' in fname:
continue continue
# skip chunks where orphan status could not be reliably determined # skip chunks where orphan status could not be reliably determined
@ -1353,7 +1378,7 @@ class LegendaryCore:
game.base_urls = _base_urls game.base_urls = _base_urls
if not old_bytes: 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: else:
old_manifest = self.load_manifest(old_bytes) old_manifest = self.load_manifest(old_bytes)
@ -1408,7 +1433,7 @@ class LegendaryCore:
f'"{new_manifest.meta.build_id}"...') f'"{new_manifest.meta.build_id}"...')
new_manifest.apply_delta_manifest(delta_manifest) new_manifest.apply_delta_manifest(delta_manifest)
else: 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 # reuse existing installation's directory
if igame := self.get_installed_game(base_game.app_name if base_game else game.app_name): 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, installed_game.install_path, filelist,
case_insensitive=installed_game.platform.startswith('Win') 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): def prereq_installed(self, app_name):
igame = self.lgd.get_installed_game(app_name) igame = self.lgd.get_installed_game(app_name)
@ -1775,7 +1800,7 @@ class LegendaryCore:
needs_verify = True needs_verify = True
if not needs_verify: 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() manifest_secrets = dict()
if not manifest_data: if not manifest_data:
@ -1922,8 +1947,8 @@ class LegendaryCore:
except ValueError as e: except ValueError as e:
self.log.warning(f'Deleting EGL manifest failed: {e!r}') self.log.warning(f'Deleting EGL manifest failed: {e!r}')
except FileNotFoundError: except FileNotFoundError:
self.log.warning(f'EGL manifest was already deleted, in case you uninstalled the Epic Games Launcher' self.log.warning('EGL manifest was already deleted, in case you uninstalled the Epic Games Launcher'
f' please disable and unlink EGL Sync.') ' please disable and unlink EGL Sync.')
if delete_files: if delete_files:
delete_folder(os.path.join(igame.install_path, '.egstore')) 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. # please don't look at this code too hard, it's a mess.
import logging import logging
import os import os
import time import time
from collections import Counter, defaultdict, deque from collections import Counter, defaultdict, deque
from logging.handlers import QueueHandler 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 multiprocessing.shared_memory import SharedMemory
from queue import Empty from queue import Empty
from sys import exit from sys import exit
from threading import Condition, Thread from threading import Condition, Thread
from legendary.downloader.mp.workers import DLWorker, FileWorker from legendary.downloader.mp.workers import DLWorker, FileWorker
from legendary.models.downloading import * from legendary.models.downloading import (
from legendary.models.manifest import ManifestComparison, Manifest AnalysisResult,
ChunkTask,
DownloaderTask,
FileTask,
SharedMemorySegment,
TaskFlags,
TerminateWorkerTask,
UIUpdate,
WriterTask,
)
from legendary.models.manifest import Manifest, ManifestComparison
class DLManager(Process): class DLManager(Process):
@ -107,7 +116,7 @@ class DLManager(Process):
is_1mib = analysis_res.biggest_chunk == 1024 * 1024 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'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) mc = ManifestComparison.create(manifest, old_manifest)
analysis_res.manifest_comparison = mc analysis_res.manifest_comparison = mc
@ -380,11 +389,11 @@ class DLManager(Process):
if reused: if reused:
self.log.debug(f' + Reusing {reused} chunks from: {current_file.filename}') self.log.debug(f' + Reusing {reused} chunks from: {current_file.filename}')
# open temporary file that will contain download + old file contents # 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.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 # 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)) flags=TaskFlags.RENAME_FILE | TaskFlags.DELETE_FILE))
else: else:
self.tasks.append(FileTask(current_file.filename, flags=TaskFlags.OPEN_FILE)) 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.dl_result_q = MPQueue(-1)
self.writer_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 bind_ip = None
for i in range(self.max_workers): for i in range(self.max_workers):
@ -773,7 +782,7 @@ class DLManager(Process):
time.sleep(self.update_interval) 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.dl_worker_queue.put_nowait(TerminateWorkerTask())
self.log.info('Waiting for installation to finish...') self.log.info('Waiting for installation to finish...')
@ -781,7 +790,7 @@ class DLManager(Process):
writer_p.join(timeout=10.0) writer_p.join(timeout=10.0)
if writer_p.exitcode is None: 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() writer_p.terminate()
# forcibly kill DL workers that are not actually dead yet # forcibly kill DL workers that are not actually dead yet

View file

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

View file

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

View file

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

View file

@ -1,10 +1,19 @@
import os
import logging import logging
import os
from legendary.models.game import Game from legendary.models.game import Game
if os.name == 'nt': 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') logger = logging.getLogger('EOSUtils')
# Dummy Game objects to use with Core methods that expect them # 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_path = f'Z:{overlay_path}'
overlay_line = f'"{EOS_OVERLAY_VALUE}"="{overlay_path}"\n' overlay_line = f'"{EOS_OVERLAY_VALUE}"="{overlay_path}"\n'
overlay_idx = None
section_idx = None section_idx = None
for idx, line in enumerate(reg_lines): for idx, line in enumerate(reg_lines):

View file

@ -1,23 +1,20 @@
# coding: utf-8
import json import json
import os
import logging import logging
import os
from contextlib import contextmanager
from collections import defaultdict from collections import defaultdict
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from time import time from time import time
from filelock import FileLock 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.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 legendary.utils.env import is_windows_mac_or_pyi
from .utils import LockedJSONData, clean_filename
FILELOCK_DEBUG = False FILELOCK_DEBUG = False
@ -229,7 +226,7 @@ class LGDLFS:
try: try:
return open(self._get_manifest_filename(app_name, version, platform), 'rb').read() return open(self._get_manifest_filename(app_name, version, platform), 'rb').read()
except FileNotFoundError: # all other errors should propagate 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: try:
return open(self._get_manifest_filename(app_name, version), 'rb').read() return open(self._get_manifest_filename(app_name, version), 'rb').read()
except FileNotFoundError: # all other errors should propagate except FileNotFoundError: # all other errors should propagate
@ -464,7 +461,7 @@ class LGDLFS:
collisions = set() collisions = set()
alias_map = defaultdict(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) game = self.get_game_meta(app_name)
if game.is_dlc: if game.is_dlc:
continue continue
@ -478,7 +475,7 @@ class LGDLFS:
collisions.add(alias) collisions.add(alias)
# remove colliding aliases from map and add aliases to lookup table # 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 alias_map[app_name] -= collisions
for alias in alias_map[app_name]: for alias in alias_map[app_name]:
self.aliases[alias] = app_name self.aliases[alias] = app_name

View file

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

View file

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

View file

@ -1,11 +1,10 @@
# coding: utf-8
import struct import struct
import zlib import zlib
from hashlib import sha1 from hashlib import sha1
from io import BytesIO from io import BytesIO
from uuid import uuid4 from uuid import uuid4
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from legendary.utils.rolling_hash import get_hash from legendary.utils.rolling_hash import get_hash
@ -74,7 +73,7 @@ class Chunk:
@property @property
def guid_str(self): def guid_str(self):
if not self._guid_str: 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 return self._guid_str
@property @property
@ -123,7 +122,7 @@ class Chunk:
if _chunk.header_version >= 4: if _chunk.header_version >= 4:
_chunk.secret_guid = struct.unpack('<IIII', bio.read(16)) _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) _chunk.encryption_tag = bio.read(16)
if bio.tell() - head_start != _chunk.header_size: 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 dataclasses import dataclass
from enum import Flag, auto
from typing import Optional from typing import Optional
from .manifest import ManifestComparison from .manifest import ManifestComparison

View file

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

View file

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

View file

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

View file

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

View file

@ -40,7 +40,7 @@ roman = {
def _filter(input): 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): 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}]: ') choice = input(f'{prompt} [{yn}]: ')
if not choice: if not choice:
return default return default
elif choice[0].lower() == 'y': return choice[0].lower() == 'y'
return True
else:
return False
def get_int_choice(prompt, default=None, min_choice=None, max_choice=None, return_on_invalid=False): 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}]: ' if default is not None else f'{prompt}: '
prompt = f'{prompt} [{default}]: '
else:
prompt = f'{prompt}: '
while True: while True:
try: try:
@ -55,7 +49,7 @@ def sdl_prompt(sdl_data, title):
continue continue
print(' *', tag, '-', info['name']) 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(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).') print('Leave blank to use defaults (only required data will be downloaded).')
choices = input('Additional packs [Enter to confirm]: ') choices = input('Additional packs [Enter to confirm]: ')
@ -87,5 +81,5 @@ def strtobool(val):
elif val in ('n', 'no', 'f', 'false', 'off', '0', ''): elif val in ('n', 'no', 'f', 'false', 'off', '0', ''):
return 0 return 0
else: 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 # 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): 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'): for encoding in (locale.getpreferredencoding(), 'cp1252', 'cp932', 'ascii', 'utf-8'):
try: try:
return decrypted.decode(encoding) return decrypted.decode(encoding)
except: # ignore exception, just try the next encoding except Exception: # ignore exception, just try the next encoding
continue continue
raise ValueError('Failed to decode decrypted data') raise ValueError('Failed to decode decrypted data')

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import logging
import json import json
import logging
import os import os
import webbrowser import webbrowser
@ -115,7 +115,7 @@ class MockLauncher:
try: try:
j = json.loads(sid_json) j = json.loads(sid_json)
sid = j['sid'] 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) exchange_code = self.callback_sid(sid)
if exchange_code: if exchange_code:
self.callback_result = self.callback_code(exchange_code) self.callback_result = self.callback_code(exchange_code)

View file

@ -39,9 +39,24 @@ legendary = "legendary.cli:main"
[project.urls] [project.urls]
source = "https://github.com/derrod/legendary" source = "https://github.com/derrod/legendary"
[dependency-groups]
dev = [
"ruff>=0.15.10",
]
[tool.uv] [tool.uv]
required-version = ">=0.11.0" required-version = ">=0.11.0"
[tool.uv.build-backend] [tool.uv.build-backend]
module-name = "legendary" module-name = "legendary"
module-root = "" 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" }, { name = "pywebview" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "filelock" }, { name = "filelock" },
@ -226,6 +231,9 @@ requires-dist = [
] ]
provides-extras = ["webview", "webview-gtk", "pyinstaller-build"] provides-extras = ["webview", "webview-gtk", "pyinstaller-build"]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.10" }]
[[package]] [[package]]
name = "macholib" name = "macholib"
version = "1.16.4" 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" }, { 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]] [[package]]
name = "setuptools" name = "setuptools"
version = "82.0.1" version = "82.0.1"