mirror of
https://github.com/derrod/legendary.git
synced 2026-05-07 06:23:22 +00:00
Merge 301b89749b into aeb61d4eea
This commit is contained in:
commit
29cbb80cc9
13
.github/workflows/lint-ruff.yml
vendored
Normal file
13
.github/workflows/lint-ruff.yml
vendored
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
||||
|
||||
|
|
|
|||
121
legendary/cli.py
121
legendary/cli.py
|
|
@ -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']:
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
import plistlib
|
||||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
|
||||
_logger = logging.getLogger('CXHelpers')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import ctypes
|
||||
import logging
|
||||
import winreg
|
||||
import ctypes
|
||||
|
||||
_logger = logging.getLogger('WindowsHelpers')
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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': '',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# coding: utf-8
|
||||
|
||||
class InvalidCredentialsError(Exception):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# coding: utf-8
|
||||
|
||||
from sys import platform
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
33
uv.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue