Skip to content

Commit

Permalink
[core/lfs] Use filelock for user data
Browse files Browse the repository at this point in the history
Closes #566

Co-authored-by: Mathis Dröge <mathis.droege@ewe.net>
  • Loading branch information
derrod and CommandMC committed Jun 17, 2023
1 parent bdd53fb commit e26b9e6
Show file tree
Hide file tree
Showing 5 changed files with 92 additions and 25 deletions.
31 changes: 19 additions & 12 deletions legendary/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ def auth_code(self, code) -> bool:
Handles authentication via authorization code (either retrieved manually or automatically)
"""
try:
self.lgd.userdata = self.egs.start_session(authorization_code=code)
with self.lgd.userdata_lock as lock:
lock.data = self.egs.start_session(authorization_code=code)
return True
except Exception as e:
self.log.error(f'Logging in failed with {e!r}, please try again.')
Expand All @@ -142,7 +143,8 @@ def auth_ex_token(self, code) -> bool:
Handles authentication via exchange token (either retrieved manually or automatically)
"""
try:
self.lgd.userdata = self.egs.start_session(exchange_token=code)
with self.lgd.userdata_lock as lock:
lock.data = self.egs.start_session(exchange_token=code)
return True
except Exception as e:
self.log.error(f'Logging in failed with {e!r}, please try again.')
Expand Down Expand Up @@ -171,22 +173,23 @@ def auth_import(self) -> bool:
raise ValueError('No login session in config')
refresh_token = re_data['Token']
try:
self.lgd.userdata = self.egs.start_session(refresh_token=refresh_token)
with self.lgd.userdata_lock as lock:
lock.data = self.egs.start_session(refresh_token=refresh_token)
return True
except Exception as e:
self.log.error(f'Logging in failed with {e!r}, please try again.')
return False

def login(self, force_refresh=False) -> bool:
def _login(self, lock, force_refresh=False) -> bool:
"""
Attempts logging in with existing credentials.
raises ValueError if no existing credentials or InvalidCredentialsError if the API return an error
"""
if not self.lgd.userdata:
if not lock.data:
raise ValueError('No saved credentials')
elif self.logged_in and self.lgd.userdata['expires_at']:
dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1])
elif self.logged_in and lock.data['expires_at']:
dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
dt_now = datetime.utcnow()
td = dt_now - dt_exp

Expand All @@ -212,16 +215,16 @@ def login(self, force_refresh=False) -> bool:
except Exception as e:
self.log.warning(f'Checking for EOS Overlay updates failed: {e!r}')

if self.lgd.userdata['expires_at'] and not force_refresh:
dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1])
if lock.data['expires_at'] and not force_refresh:
dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
dt_now = datetime.utcnow()
td = dt_now - dt_exp

# if session still has at least 10 minutes left we can re-use it.
if dt_exp > dt_now and abs(td.total_seconds()) > 600:
self.log.info('Trying to re-use existing login session...')
try:
self.egs.resume_session(self.lgd.userdata)
self.egs.resume_session(lock.data)
self.logged_in = True
return True
except InvalidCredentialsError as e:
Expand All @@ -233,7 +236,7 @@ def login(self, force_refresh=False) -> bool:

try:
self.log.info('Logging in...')
userdata = self.egs.start_session(self.lgd.userdata['refresh_token'])
userdata = self.egs.start_session(lock.data['refresh_token'])
except InvalidCredentialsError:
self.log.error('Stored credentials are no longer valid! Please login again.')
self.lgd.invalidate_userdata()
Expand All @@ -242,10 +245,14 @@ def login(self, force_refresh=False) -> bool:
self.log.error(f'HTTP request for login failed: {e!r}, please try again later.')
return False

self.lgd.userdata = userdata
lock.data = userdata
self.logged_in = True
return True

def login(self, force_refresh=False) -> bool:
with self.lgd.userdata_lock as lock:
return self._login(lock, force_refresh=force_refresh)

def update_check_enabled(self):
return not self.lgd.config.getboolean('Legendary', 'disable_update_check', fallback=False)

Expand Down
37 changes: 25 additions & 12 deletions legendary/lfs/lgndry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@
import os
import logging

from contextlib import contextmanager
from collections import defaultdict
from pathlib import Path
from time import time

from .utils import clean_filename
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.utils.env import is_windows_mac_or_pyi


FILELOCK_DEBUG = False


class LGDLFS:
def __init__(self, config_file=None):
self.log = logging.getLogger('LGDLFS')
Expand Down Expand Up @@ -84,6 +88,11 @@ def __init__(self, config_file=None):
self.log.warning(f'Removing "{os.path.join(self.path, "manifests", "old")}" folder failed: '
f'{e!r}, please remove manually')

if not FILELOCK_DEBUG:
# Prevent filelock logger from spamming Legendary debug output
filelock_logger = logging.getLogger('filelock')
filelock_logger.setLevel(logging.INFO)

# try loading config
try:
self.config.read(self.config_path)
Expand Down Expand Up @@ -130,31 +139,35 @@ def __init__(self, config_file=None):
except Exception as e:
self.log.debug(f'Loading aliases failed with {e!r}')

@property
@contextmanager
def userdata_lock(self) -> LockedJSONData:
"""Wrapper around the lock to automatically update user data when it is released"""
with LockedJSONData(os.path.join(self.path, 'user.json')) as lock:
try:
yield lock
finally:
self._user_data = lock.data

@property
def userdata(self):
if self._user_data is not None:
return self._user_data

try:
self._user_data = json.load(open(os.path.join(self.path, 'user.json')))
return self._user_data
with self.userdata_lock as locked:
return locked.data
except Exception as e:
self.log.debug(f'Failed to load user data: {e!r}')
return None

@userdata.setter
def userdata(self, userdata):
if userdata is None:
raise ValueError('Userdata is none!')

self._user_data = userdata
json.dump(userdata, open(os.path.join(self.path, 'user.json'), 'w'),
indent=2, sort_keys=True)
raise NotImplementedError('The setter has been removed, use the locked userdata instead.')

def invalidate_userdata(self):
self._user_data = None
if os.path.exists(os.path.join(self.path, 'user.json')):
os.remove(os.path.join(self.path, 'user.json'))
with self.userdata_lock as lock:
lock.clear()

This comment has been minimized.

Copy link
@loathingKernel

loathingKernel Jul 27, 2023

Contributor

When coming from LegendaryCore.login(), this is trying to lock userdata_lock again, blocking execution.

This comment has been minimized.

Copy link
@derrod

derrod Jul 27, 2023

Author Owner

I missed that one, should be fixed with b759d9d


@property
def entitlements(self):
Expand Down
45 changes: 45 additions & 0 deletions legendary/lfs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
import os
import shutil
import hashlib
import json
import logging

from pathlib import Path
from sys import stdout
from time import perf_counter
from typing import List, Iterator

from filelock import FileLock

from legendary.models.game import VerifyResult

logger = logging.getLogger('LFS Utils')
Expand Down Expand Up @@ -153,3 +156,45 @@ def clean_filename(filename):

def get_dir_size(path):
return sum(f.stat().st_size for f in Path(path).glob('**/*') if f.is_file())


class LockedJSONData(FileLock):
def __init__(self, file_path: str):
super().__init__(file_path + '.lock')

self._file_path = file_path
self._data = None
self._initial_data = None

def __enter__(self):
super().__enter__()

if os.path.exists(self._file_path):
with open(self._file_path, 'r', encoding='utf-8') as f:
self._data = json.load(f)
self._initial_data = self._data
return self

def __exit__(self, exc_type, exc_val, exc_tb):
super().__exit__(exc_type, exc_val, exc_tb)

if self._data != self._initial_data:
if self._data is not None:
with open(self._file_path, 'w', encoding='utf-8') as f:
json.dump(self._data, f, indent=2, sort_keys=True)
else:
if os.path.exists(self._file_path):
os.remove(self._file_path)

@property
def data(self):
return self._data

@data.setter
def data(self, new_data):
if new_data is None:
raise ValueError('Invalid new data, use clear() explicitly to reset file data')
self._data = new_data

def clear(self):
self._data = None
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
requests<3.0
filelock
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
install_requires=[
'requests<3.0',
'setuptools',
'wheel'
'wheel',
'filelock'
],
extras_require=dict(
webview=['pywebview>=3.4'],
Expand Down

0 comments on commit e26b9e6

Please sign in to comment.