diff --git a/.mypy.ini b/.mypy.ini index 38afbb67..10ef53b7 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -5,5 +5,5 @@ scripts_are_modules = True ; Without this bare `mypy ` may get warnings for e.g. ; ` = ` ; i.e. no typing info. -check-untyped-defs = True +check_untyped_defs = True ; platform = darwin diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50d91e96..d841cda1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,7 @@ repos: # mypy - static type checking # mypy --follow-imports skip - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.931' + rev: 'v0.991' hooks: - id: mypy # verbose: true diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 21531679..f8c676df 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -168,6 +168,11 @@ if __name__ == '__main__': # noqa: C901 help='Have EDDN plugin show what it is tracking', action='store_true', ) + + parser.add_argument( + '--killswitches-file', + help='Specify a custom killswitches file', + ) ########################################################################### args = parser.parse_args() @@ -907,6 +912,12 @@ class AppWindow(object): def login(self): """Initiate CAPI/Frontier login and set other necessary state.""" + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) + if should_return: + logger.warning('capi.auth has been disabled via killswitch. Returning.') + self.status['text'] = 'CAPI auth disabled by killswitch' + return + if not self.status['text']: # LANG: Status - Attempting to get a Frontier Auth Access Token self.status['text'] = _('Logging in...') @@ -992,6 +1003,13 @@ class AppWindow(object): :param event: Tk generated event details. """ logger.trace_if('capi.worker', 'Begin') + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) + if should_return: + logger.warning('capi.auth has been disabled via killswitch. Returning.') + self.status['text'] = 'CAPI auth disabled by killswitch' + hotkeymgr.play_bad() + return + auto_update = not event play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.get_int('hotkey_mute') @@ -1210,10 +1228,15 @@ class AppWindow(object): if err: play_bad = True - # Export market data - if not self.export_market_data(capi_response.capi_data): - err = 'Error: Exporting Market data' - play_bad = True + should_return, new_data = killswitch.check_killswitch('capi.request./market', {}) + if should_return: + logger.warning("capi.request./market has been disabled by killswitch. Returning.") + + else: + # Export market data + if not self.export_market_data(capi_response.capi_data): + err = 'Error: Exporting Market data' + play_bad = True self.capi_query_holdoff_time = capi_response.query_time + companion.capi_query_cooldown @@ -1460,7 +1483,9 @@ class AppWindow(object): auto_update = True if auto_update: - self.w.after(int(SERVER_RETRY * 1000), self.capi_request_data) + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) + if not should_return: + self.w.after(int(SERVER_RETRY * 1000), self.capi_request_data) if entry['event'] == 'ShutDown': # Enable WinSparkle automatic update checks @@ -1859,10 +1884,13 @@ Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}''' ) -def setup_killswitches(): +def setup_killswitches(filename: Optional[str]): """Download and setup the main killswitch list.""" logger.debug('fetching killswitches...') - killswitch.setup_main_list() + if filename is not None: + filename = "file:" + filename + + killswitch.setup_main_list(filename) def show_killswitch_poppup(root=None): @@ -1891,9 +1919,9 @@ def show_killswitch_poppup(root=None): for version in kills: tk.Label(frame, text=f'Version: {version.version}').grid(row=idx, sticky=tk.W) idx += 1 - for id, reason in version.kills.items(): + for id, kill in version.kills.items(): tk.Label(frame, text=id).grid(column=0, row=idx, sticky=tk.W, padx=(10, 0)) - tk.Label(frame, text=reason).grid(column=1, row=idx, sticky=tk.E, padx=(0, 10)) + tk.Label(frame, text=kill.reason).grid(column=1, row=idx, sticky=tk.E, padx=(0, 10)) idx += 1 idx += 1 @@ -2026,7 +2054,8 @@ sys.path: {sys.path}''' Translations.install(config.get_str('language')) # Can generate errors so wait til log set up - setup_killswitches() + setup_killswitches(args.killswitches_file) + root = tk.Tk(className=appname.lower()) if sys.platform != 'win32' and ((f := config.get_str('font')) is not None or f != ''): size = config.get_int('font_size', default=-1) diff --git a/PLUGINS.md b/PLUGINS.md index dc23752d..dd71ca28 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -910,10 +910,9 @@ constants. ### Commander Data from Frontier CAPI If a plugin has a `cmdr_data()` function it gets called when the application -has just fetched fresh Cmdr and station data from Frontier's servers, **but not -for the Legacy galaxy**. See `cmdr_data_legacy()` below for Legacy data -handling. - +has just fetched fresh Cmdr and station data from Frontier's CAPI servers, +**but not for the Legacy galaxy**. See `cmdr_data_legacy()` below for Legacy +data handling. ```python from companion import CAPIData, SERVER_LIVE, SERVER_LEGACY, SERVER_BETA @@ -948,6 +947,16 @@ have extra properties, such as `source_host`, as shown above. Plugin authors are free to use *that* property, **but MUST NOT rely on any other extra properties present in `CAPIData`, they are for internal use only.** +The contents of `data` will always have at least the data returned by a CAPI +`/profile` query. If the player is docked at a station, and the relevant +services are available then the `lastStarport` key's value will have been +augmented with `/market` and/or `/shipyard` data. **But do not assume this +will always be the case**. + +If there is a killswitch in effect for some of the CAPI endpoints, then the +data passed to this function might not be as complete as you expect. Code +defensively. + #### CAPI data for Legacy When CAPI data has been retrieved from the separate CAPI host for the Legacy diff --git a/companion.py b/companion.py index 3aa085ae..1c68e38b 100644 --- a/companion.py +++ b/companion.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, OrderedDic import requests import config as conf_module +import killswitch import protocol from config import config, user_agent from edmc_data import companion_category_map as category_map @@ -325,6 +326,11 @@ class Auth(object): """ logger.debug(f'Trying for "{self.cmdr}"') + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) + if should_return: + logger.warning('capi.auth has been disabled via killswitch. Returning.') + return None + self.verifier = None cmdrs = config.get_list('cmdrs', default=[]) logger.debug(f'Cmdrs: {cmdrs}') @@ -393,9 +399,11 @@ class Auth(object): return None def authorize(self, payload: str) -> str: # noqa: CCR001 - """Handle oAuth authorization callback. + """ + Handle oAuth authorization callback. - :return: access token if successful, otherwise raises CredentialsError. + :return: access token if successful + :raises CredentialsError """ logger.debug('Checking oAuth authorization callback') if '?' not in payload: @@ -593,7 +601,7 @@ class EDMCCAPIFailedRequest(EDMCCAPIReturn): ): super().__init__(query_time=query_time, play_sound=play_sound, auto_update=auto_update) self.message: str = message # User-friendly reason for failure. - self.exception: int = exception # Exception that recipient should raise. + self.exception: Exception = exception # Exception that recipient should raise. class Session(object): @@ -611,7 +619,7 @@ class Session(object): def __init__(self) -> None: self.state = Session.STATE_INIT self.credentials: Optional[Dict[str, Any]] = None - self.requests_session: Optional[requests.Session] = None + self.requests_session: Optional[requests.Session] = requests.Session() self.auth: Optional[Auth] = None self.retrying = False # Avoid infinite loop when successful auth / unsuccessful query self.tk_master: Optional[tk.Tk] = None @@ -644,9 +652,10 @@ class Session(object): def start_frontier_auth(self, access_token: str) -> None: """Start an oAuth2 session.""" logger.debug('Starting session') - self.requests_session = requests.Session() - self.requests_session.headers['Authorization'] = f'Bearer {access_token}' - self.requests_session.headers['User-Agent'] = user_agent + if self.requests_session is not None: + self.requests_session.headers['Authorization'] = f'Bearer {access_token}' + self.requests_session.headers['User-Agent'] = user_agent + self.state = Session.STATE_OK def login(self, cmdr: Optional[str] = None, is_beta: Optional[bool] = None) -> bool: @@ -655,6 +664,11 @@ class Session(object): :return: True if login succeeded, False if re-authorization initiated. """ + should_return, new_data = killswitch.check_killswitch('capi.auth', {}) + if should_return: + logger.warning('capi.auth has been disabled via killswitch. Returning.') + return False + if not Auth.CLIENT_ID: logger.error('self.CLIENT_ID is None') raise CredentialsError('cannot login without a valid Client ID') @@ -759,7 +773,12 @@ class Session(object): :param timeout: requests query timeout to use. :return: The resulting CAPI data, of type CAPIData. """ - capi_data: CAPIData + capi_data: CAPIData = CAPIData() + should_return, new_data = killswitch.check_killswitch('capi.request.' + capi_endpoint, {}) + if should_return: + logger.warning(f"capi.request.{capi_endpoint} has been disabled by killswitch. Returning.") + return capi_data + try: logger.trace_if('capi.worker', 'Sending HTTP request...') if conf_module.capi_pretend_down: @@ -841,6 +860,10 @@ class Session(object): """ station_data = capi_single_query(capi_host, self.FRONTIER_CAPI_PATH_PROFILE, timeout=timeout) + if not station_data.get('commander'): + # If even this doesn't exist, probably killswitched. + return station_data + if not station_data['commander'].get('docked') and not monitor.state['OnFoot']: return station_data @@ -881,6 +904,10 @@ class Session(object): if services.get('commodities'): market_data = capi_single_query(capi_host, self.FRONTIER_CAPI_PATH_MARKET, timeout=timeout) + if not market_data.get('id'): + # Probably killswitched + return station_data + if last_starport_id != int(market_data['id']): logger.warning(f"{last_starport_id!r} != {int(market_data['id'])!r}") raise ServerLagging() @@ -891,6 +918,10 @@ class Session(object): if services.get('outfitting') or services.get('shipyard'): shipyard_data = capi_single_query(capi_host, self.FRONTIER_CAPI_PATH_SHIPYARD, timeout=timeout) + if not shipyard_data.get('id'): + # Probably killswitched + return station_data + if last_starport_id != int(shipyard_data['id']): logger.warning(f"{last_starport_id!r} != {int(shipyard_data['id'])!r}") raise ServerLagging() @@ -946,7 +977,8 @@ class Session(object): # event too, so assume it will be polling the response queue. if query.tk_response_event is not None: logger.trace_if('capi.worker', 'Sending <>') - self.tk_master.event_generate('<>') + if self.tk_master is not None: + self.tk_master.event_generate('<>') logger.info('CAPI worker thread DONE') diff --git a/docs/Killswitches.md b/docs/Killswitches.md index d90607a1..bae03c2d 100644 --- a/docs/Killswitches.md +++ b/docs/Killswitches.md @@ -119,10 +119,17 @@ JSON primitives and their python equivalents json compound types (`object -- {}` and `array -- []`) may be set. ### Testing +You can supply a custom killswitches file for testing against +EDMarketConnector: +```bash +python EDMarketConnector.py --killswitches-file +``` +This will be relative to the CWD of the process. -Killswitch files can be tested using the script in `scripts/killswitch_test.py`. -Providing a file as an argument or `-` for stdin will output the behaviour of -the provided file, including indicating typos, if applicable. +Alternatively, killswitch files can be independently tested using the script in +`scripts/killswitch_test.py`. Providing a file as an argument or `-` for stdin +will output the behaviour of the provided file, including indicating typos, if +applicable. ### Versions diff --git a/killswitch.py b/killswitch.py index a7701739..42d02844 100644 --- a/killswitch.py +++ b/killswitch.py @@ -1,6 +1,7 @@ """Fetch kill switches from EDMC Repo.""" from __future__ import annotations +import json import threading from copy import deepcopy from typing import ( @@ -339,6 +340,16 @@ def fetch_kill_switches(target=DEFAULT_KILLSWITCH_URL) -> Optional[KillSwitchJSO :return: a list of dicts containing kill switch data, or None """ logger.info("Attempting to fetch kill switches") + if target.startswith('file:'): + target = target.replace('file:', '') + try: + with open(target, 'r') as t: + return json.load(t) + + except FileNotFoundError: + logger.warning(f"No such file '{target}'") + return None + try: data = requests.get(target, timeout=10).json() @@ -458,13 +469,16 @@ def get_kill_switches_thread( active: KillSwitchSet = KillSwitchSet([]) -def setup_main_list(): +def setup_main_list(filename: Optional[str]): """ Set up the global set of kill switches for querying. Plugins should NOT call this EVER. """ - if (data := get_kill_switches(DEFAULT_KILLSWITCH_URL, OLD_KILLSWITCH_URL)) is None: + if filename is None: + filename = DEFAULT_KILLSWITCH_URL + + if (data := get_kill_switches(filename, OLD_KILLSWITCH_URL)) is None: logger.warning("Unable to fetch kill switches. Setting global set to an empty set") return diff --git a/plugins/eddn.py b/plugins/eddn.py index 2f2ccc78..afe54047 100644 --- a/plugins/eddn.py +++ b/plugins/eddn.py @@ -636,6 +636,16 @@ class EDDN: :param data: a dict containing the starport data :param is_beta: whether or not we're currently in beta mode """ + should_return, new_data = killswitch.check_killswitch('capi.request./market', {}) + if should_return: + logger.warning("capi.request./market has been disabled by killswitch. Returning.") + return + + should_return, new_data = killswitch.check_killswitch('eddn.capi_export.commodities', {}) + if should_return: + logger.warning("eddn.capi_export.commodities has been disabled by killswitch. Returning.") + return + modules, ships = self.safe_modules_and_ships(data) horizons: bool = capi_is_horizons( data['lastStarport'].get('economies', {}), @@ -757,6 +767,16 @@ class EDDN: :param data: dict containing the outfitting data :param is_beta: whether or not we're currently in beta mode """ + should_return, new_data = killswitch.check_killswitch('capi.request./shipyard', {}) + if should_return: + logger.warning("capi.request./shipyard has been disabled by killswitch. Returning.") + return + + should_return, new_data = killswitch.check_killswitch('eddn.capi_export.outfitting', {}) + if should_return: + logger.warning("eddn.capi_export.outfitting has been disabled by killswitch. Returning.") + return + modules, ships = self.safe_modules_and_ships(data) # Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"), @@ -813,6 +833,16 @@ class EDDN: :param data: dict containing the shipyard data :param is_beta: whether or not we are in beta mode """ + should_return, new_data = killswitch.check_killswitch('capi.request./shipyard', {}) + if should_return: + logger.warning("capi.request./shipyard has been disabled by killswitch. Returning.") + return + + should_return, new_data = killswitch.check_killswitch('eddn.capi_export.shipyard', {}) + if should_return: + logger.warning("eddn.capi_export.shipyard has been disabled by killswitch. Returning.") + return + modules, ships = self.safe_modules_and_ships(data) horizons: bool = capi_is_horizons( @@ -1857,7 +1887,7 @@ class EDDN: match = self.CANONICALISE_RE.match(item) return match and match.group(1) or item - def capi_gameversion_from_host_endpoint(self, capi_host: str, capi_endpoint: str) -> str: + def capi_gameversion_from_host_endpoint(self, capi_host: Optional[str], capi_endpoint: str) -> str: """ Return the correct CAPI gameversion string for the given host/endpoint.