diff --git a/EDMarketConnector.py b/EDMarketConnector.py index fb16422d..3b06bfdf 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -7,6 +7,7 @@ import html import json import locale import pathlib +import queue import re import sys # import threading @@ -14,6 +15,7 @@ import webbrowser from builtins import object, str from os import chdir, environ from os.path import dirname, join +from queue import Queue from sys import platform from time import localtime, strftime, time from typing import TYPE_CHECKING, Optional, Tuple @@ -384,6 +386,8 @@ class AppWindow(object): def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out self.holdofftime = config.get_int('querytime', default=0) + companion.holdoff + self.capi_response_queue: Queue = Queue() + companion.session.set_capi_response_queue(self.capi_response_queue) self.w = master self.w.title(applongname) @@ -933,126 +937,138 @@ class AppWindow(object): def capi_handle_response(self, event=None): """Handle the resulting data from a CAPI query.""" - data = ... - # Validation - if 'commander' not in data: - # This can happen with EGS Auth if no commander created yet - # LANG: No data was returned for the commander from the Frontier CAPI - err = self.status['text'] = _('CAPI: No commander data returned') + try: + data = self.capi_response_queue.get(block=False) - elif not data.get('commander', {}).get('name'): - # LANG: We didn't have the commander name when we should have - err = self.status['text'] = _("Who are you?!") # Shouldn't happen + except queue.Empty: + logger.error('There was no response in the queue!') + # TODO: Set status text + return - elif (not data.get('lastSystem', {}).get('name') - or (data['commander'].get('docked') - and not data.get('lastStarport', {}).get('name'))): - # LANG: We don't know where the commander is, when we should - err = self.status['text'] = _("Where are you?!") # Shouldn't happen + else: + if isinstance(data, companion.CAPIFailedRequest): + logger.trace_if('capi.worker', f'Failed Request: {data.message}') + raise data.exception - elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'): - # LANG: We don't know what ship the commander is in, when we should - err = self.status['text'] = _("What are you flying?!") # Shouldn't happen + # Validation + if 'commander' not in data: + # This can happen with EGS Auth if no commander created yet + # LANG: No data was returned for the commander from the Frontier CAPI + err = self.status['text'] = _('CAPI: No commander data returned') - elif monitor.cmdr and data['commander']['name'] != monitor.cmdr: - # Companion API Commander doesn't match Journal - raise companion.CmdrError() + elif not data.get('commander', {}).get('name'): + # LANG: We didn't have the commander name when we should have + err = self.status['text'] = _("Who are you?!") # Shouldn't happen - elif auto_update and not monitor.state['OnFoot'] and not data['commander'].get('docked'): - # auto update is only when just docked - logger.warning(f"{auto_update!r} and not {monitor.state['OnFoot']!r} and " - f"not {data['commander'].get('docked')!r}") - raise companion.ServerLagging() + elif (not data.get('lastSystem', {}).get('name') + or (data['commander'].get('docked') + and not data.get('lastStarport', {}).get('name'))): + # LANG: We don't know where the commander is, when we should + err = self.status['text'] = _("Where are you?!") # Shouldn't happen - elif data['lastSystem']['name'] != monitor.system: - # CAPI system must match last journal one - logger.warning(f"{data['lastSystem']['name']!r} != {monitor.system!r}") - raise companion.ServerLagging() + elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'): + # LANG: We don't know what ship the commander is in, when we should + err = self.status['text'] = _("What are you flying?!") # Shouldn't happen - elif data['lastStarport']['name'] != monitor.station: - if monitor.state['OnFoot'] and monitor.station: - logger.warning(f"({data['lastStarport']['name']!r} != {monitor.station!r}) AND " - f"{monitor.state['OnFoot']!r} and {monitor.station!r}") - raise companion.ServerLagging() + elif monitor.cmdr and data['commander']['name'] != monitor.cmdr: + # Companion API Commander doesn't match Journal + raise companion.CmdrError() - else: - last_station = None - if data['commander']['docked']: - last_station = data['lastStarport']['name'] + elif auto_update and not monitor.state['OnFoot'] and not data['commander'].get('docked'): + # auto update is only when just docked + logger.warning(f"{auto_update!r} and not {monitor.state['OnFoot']!r} and " + f"not {data['commander'].get('docked')!r}") + raise companion.ServerLagging() - if monitor.station is None: - # Likely (re-)Embarked on ship docked at an EDO settlement. - # Both Disembark and Embark have `"Onstation": false` in Journal. - # So there's nothing to tell us which settlement we're (still, - # or now, if we came here in Apex and then recalled ship) docked at. - logger.debug("monitor.station is None - so EDO settlement?") - raise companion.NoMonitorStation() + elif data['lastSystem']['name'] != monitor.system: + # CAPI system must match last journal one + logger.warning(f"{data['lastSystem']['name']!r} != {monitor.system!r}") + raise companion.ServerLagging() - if last_station != monitor.station: - # CAPI lastStarport must match - logger.warning(f"({data['lastStarport']['name']!r} != {monitor.station!r}) AND " - f"{last_station!r} != {monitor.station!r}") - raise companion.ServerLagging() - - self.holdofftime = querytime + companion.holdoff - - elif not monitor.state['OnFoot'] and data['ship']['id'] != monitor.state['ShipID']: - # CAPI ship must match - logger.warning(f"not {monitor.state['OnFoot']!r} and " - f"{data['ship']['id']!r} != {monitor.state['ShipID']!r}") - raise companion.ServerLagging() - - elif not monitor.state['OnFoot'] and data['ship']['name'].lower() != monitor.state['ShipType']: - # CAPI ship type must match - logger.warning(f"not {monitor.state['OnFoot']!r} and " - f"{data['ship']['name'].lower()!r} != {monitor.state['ShipType']!r}") + elif data['lastStarport']['name'] != monitor.station: + if monitor.state['OnFoot'] and monitor.station: + logger.warning(f"({data['lastStarport']['name']!r} != {monitor.station!r}) AND " + f"{monitor.state['OnFoot']!r} and {monitor.station!r}") raise companion.ServerLagging() else: - if __debug__: # Recording - companion.session.dump_capi_data(data) + last_station = None + if data['commander']['docked']: + last_station = data['lastStarport']['name'] - if not monitor.state['ShipType']: # Started game in SRV or fighter - self.ship['text'] = ship_name_map.get(data['ship']['name'].lower(), data['ship']['name']) - monitor.state['ShipID'] = data['ship']['id'] - monitor.state['ShipType'] = data['ship']['name'].lower() + if monitor.station is None: + # Likely (re-)Embarked on ship docked at an EDO settlement. + # Both Disembark and Embark have `"Onstation": false` in Journal. + # So there's nothing to tell us which settlement we're (still, + # or now, if we came here in Apex and then recalled ship) docked at. + logger.debug("monitor.station is None - so EDO settlement?") + raise companion.NoMonitorStation() - if not monitor.state['Modules']: - self.ship.configure(state=tk.DISABLED) + if last_station != monitor.station: + # CAPI lastStarport must match + logger.warning(f"({data['lastStarport']['name']!r} != {monitor.station!r}) AND " + f"{last_station!r} != {monitor.station!r}") + raise companion.ServerLagging() - # We might have disabled this in the conditional above. - if monitor.state['Modules']: - self.ship.configure(state=True) + self.holdofftime = querytime + companion.holdoff - if monitor.state.get('SuitCurrent') is not None: - if (loadout := data.get('loadout')) is not None: - if (suit := loadout.get('suit')) is not None: - if (suitname := suit.get('edmcName')) is not None: - # We've been paranoid about loadout->suit->suitname, now just assume loadouts is there - loadout_name = index_possibly_sparse_list( - data['loadouts'], loadout['loadoutSlotId'] - )['name'] + elif not monitor.state['OnFoot'] and data['ship']['id'] != monitor.state['ShipID']: + # CAPI ship must match + logger.warning(f"not {monitor.state['OnFoot']!r} and " + f"{data['ship']['id']!r} != {monitor.state['ShipID']!r}") + raise companion.ServerLagging() - self.suit['text'] = f'{suitname} ({loadout_name})' + elif not monitor.state['OnFoot'] and data['ship']['name'].lower() != monitor.state['ShipType']: + # CAPI ship type must match + logger.warning(f"not {monitor.state['OnFoot']!r} and " + f"{data['ship']['name'].lower()!r} != {monitor.state['ShipType']!r}") + raise companion.ServerLagging() - self.suit_show_if_set() + else: + if __debug__: # Recording + companion.session.dump_capi_data(data) - if data['commander'].get('credits') is not None: - monitor.state['Credits'] = data['commander']['credits'] - monitor.state['Loan'] = data['commander'].get('debt', 0) + if not monitor.state['ShipType']: # Started game in SRV or fighter + self.ship['text'] = ship_name_map.get(data['ship']['name'].lower(), data['ship']['name']) + monitor.state['ShipID'] = data['ship']['id'] + monitor.state['ShipType'] = data['ship']['name'].lower() - # stuff we can do when not docked - err = plug.notify_newdata(data, monitor.is_beta) - self.status['text'] = err and err or '' - if err: - play_bad = True + if not monitor.state['Modules']: + self.ship.configure(state=tk.DISABLED) - # Export market data - if not self.export_market_data(data): - err = 'Error: Exporting Market data' - play_bad = True + # We might have disabled this in the conditional above. + if monitor.state['Modules']: + self.ship.configure(state=True) - self.holdofftime = querytime + companion.holdoff + if monitor.state.get('SuitCurrent') is not None: + if (loadout := data.get('loadout')) is not None: + if (suit := loadout.get('suit')) is not None: + if (suitname := suit.get('edmcName')) is not None: + # We've been paranoid about loadout->suit->suitname, now just assume loadouts is there + loadout_name = index_possibly_sparse_list( + data['loadouts'], loadout['loadoutSlotId'] + )['name'] + + self.suit['text'] = f'{suitname} ({loadout_name})' + + self.suit_show_if_set() + + if data['commander'].get('credits') is not None: + monitor.state['Credits'] = data['commander']['credits'] + monitor.state['Loan'] = data['commander'].get('debt', 0) + + # stuff we can do when not docked + err = plug.notify_newdata(data, monitor.is_beta) + self.status['text'] = err and err or '' + if err: + play_bad = True + + # Export market data + if not self.export_market_data(data): + err = 'Error: Exporting Market data' + play_bad = True + + self.holdofftime = querytime + companion.holdoff # # Companion API problem # except companion.ServerLagging as e: diff --git a/companion.py b/companion.py index 66d3440f..d1177a31 100644 --- a/companion.py +++ b/companion.py @@ -15,6 +15,7 @@ import numbers import os import random import threading +import tkinter as tk import time import urllib.parse import webbrowser @@ -465,6 +466,13 @@ class Auth(object): return base64.urlsafe_b64encode(text).decode().replace('=', '') +class CAPIFailedRequest(): + """CAPI failed query error class.""" + + def __init__(self, message, exception=None): + self.message = message + self.exception = exception + class Session(object): """Methods for handling Frontier Auth and CAPI queries.""" @@ -477,8 +485,10 @@ class Session(object): self.session: Optional[requests.Session] = None self.auth: Optional[Auth] = None self.retrying = False # Avoid infinite loop when successful auth / unsuccessful query + self.tk_master: Optional[tk.Tk] = None logger.info('Starting CAPI queries thread...') + self.capi_response_queue: Queue self.capi_query_queue: Queue = Queue() self.capi_query_thread = threading.Thread( target=self.capi_query_worker, @@ -488,6 +498,12 @@ class Session(object): self.capi_query_thread.start() logger.info('Done') + def set_capi_response_queue(self, capi_response_queue: Queue) -> None: + self.capi_response_queue = capi_response_queue + + def set_tk_master(self, master: tk.Tk) -> None: + self.tk_master = master + ###################################################################### # Frontier Authorization ###################################################################### @@ -591,18 +607,61 @@ class Session(object): ###################################################################### # CAPI queries ###################################################################### - def capi_query_worker(self, ): + def capi_query_worker(self): """Worker thread that performs actual CAPI queries.""" logger.info('CAPI worker thread starting') while True: - query: Optional[str] = self.capi_query_queue.get() - if not query: + endpoint: Optional[str] = self.capi_query_queue.get() + if not endpoint: logger.info('Empty queue message, exiting...') break - logger.trace_if('capi.worker', f'Processing query: {query}') - time.sleep(1) + logger.trace_if('capi.worker', f'Processing query: {endpoint}') + # XXX + self.capi_response_queue.put( + CAPIFailedRequest(f'Unable to connect to endpoint {endpoint}') + ) + self.tk_master.event_generate('<>') + continue + # XXX + try: + r = self.session.get(self.server + endpoint, timeout=timeout) # type: ignore + r.raise_for_status() # Typically 403 "Forbidden" on token expiry + data = CAPIData(r.json(), endpoint) # May also fail here if token expired since response is empty + + except requests.ConnectionError as e: + logger.warning(f'Unable to resolve name for CAPI: {e} (for request: {endpoint})') + self.capi_response_queue.put( + CAPIFailedRequest(f'Unable to connect to endpoint {endpoint}', exception=e) + ) + continue + # raise ServerConnectionError(f'Unable to connect to endpoint {endpoint}') from e + + except Exception as e: + logger.debug('Attempting GET', exc_info=e) + # LANG: Frontier CAPI data retrieval failed + # raise ServerError(f'{_("Frontier CAPI query failure")}: {endpoint}') from e + self.capi_response_queue.put( + CAPIFailedRequest(f'Frontier CAPI query failure: {endpoint}', exception=e) + ) + continue + + if r.url.startswith(SERVER_AUTH): + logger.info('Redirected back to Auth Server') + # Redirected back to Auth server - force full re-authentication + self.dump(r) + self.invalidate() + self.retrying = False + self.login() + raise CredentialsError() + + elif 500 <= r.status_code < 600: + # Server error. Typically 500 "Internal Server Error" if server is down + logger.debug('500 status back from CAPI') + self.dump(r) + # LANG: Frontier CAPI data retrieval failed with 5XX code + raise ServerError(f'{_("Frontier CAPI server error")}: {r.status_code}') logger.info('CAPI worker thread DONE') @@ -625,38 +684,9 @@ class Session(object): if conf_module.capi_pretend_down: raise ServerConnectionError(f'Pretending CAPI down: {endpoint}') + self.capi_query_queue.put(endpoint) try: - self.capi_query_queue.put(endpoint) - r = self.session.get(self.server + endpoint, timeout=timeout) # type: ignore - - except requests.ConnectionError as e: - logger.warning(f'Unable to resolve name for CAPI: {e} (for request: {endpoint})') - raise ServerConnectionError(f'Unable to connect to endpoint {endpoint}') from e - - except Exception as e: - logger.debug('Attempting GET', exc_info=e) - # LANG: Frontier CAPI data retrieval failed - raise ServerError(f'{_("Frontier CAPI query failure")}: {endpoint}') from e - - if r.url.startswith(SERVER_AUTH): - logger.info('Redirected back to Auth Server') - # Redirected back to Auth server - force full re-authentication - self.dump(r) - self.invalidate() - self.retrying = False - self.login() - raise CredentialsError() - - elif 500 <= r.status_code < 600: - # Server error. Typically 500 "Internal Server Error" if server is down - logger.debug('500 status back from CAPI') - self.dump(r) - # LANG: Frontier CAPI data retrieval failed with 5XX code - raise ServerError(f'{_("Frontier CAPI server error")}: {r.status_code}') - - try: - r.raise_for_status() # Typically 403 "Forbidden" on token expiry - data = CAPIData(r.json(), endpoint) # May also fail here if token expired since response is empty + ... except (requests.HTTPError, ValueError) as e: logger.exception('Frontier CAPI Auth: GET ')