diff --git a/.github/workflows/pr-annotate-with-flake8.yml b/.github/workflows/pr-annotate-with-flake8.yml index 2f089a9d..f293a9fc 100644 --- a/.github/workflows/pr-annotate-with-flake8.yml +++ b/.github/workflows/pr-annotate-with-flake8.yml @@ -1,7 +1,9 @@ # This workflow will: # -# install Python dependencies -# Run flake8 to add annotations to the PR +# 1. Store some github context in env vars. +# 2. Only checkout if base and head ref owners are the same. +# 3. Check if any *.py files are in the diff. +# 4. Only then install python and perform the annotation with flake8. # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: PR-annotate-flake8 @@ -11,19 +13,68 @@ on: branches: [ develop ] jobs: - build: - + flake8_annotate: runs-on: ubuntu-18.04 steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 - with: - python-version: 3.7 - - name: Annotate with Flake8 - uses: "tayfun/flake8-your-pr@master" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ################################################################## + # Check if the base and head repos are the same, if not we won't + # be able to add annotations. + ################################################################## + - name: Check head and base repo same + env: + BASE_REPO_OWNER: ${{github.event.pull_request.base.repo.owner.login}} + HEAD_REPO_OWNER: ${{github.event.pull_request.head.repo.owner.login}} + BASE_REF: ${{github.base_ref}} + run: | + # Just to be running something + env + ################################################################## + + ################################################################## + # Perform the checkout + ################################################################## + - name: Checkout + if: ${{ github.event.pull_request.base.repo.owner.login == github.event.pull_request.head.repo.owner.login }} + uses: actions/checkout@v2 + with: + fetch-depth: 0 + ################################################################## + + ################################################################## + # flake8-your-pr 'fails' if no *.py files changed + # So we check if any were affected and store that in env + ################################################################## + - name: Check for PY files + if: ${{ github.event.pull_request.base.repo.owner.login == github.event.pull_request.head.repo.owner.login }} + run: | + # Checkout might not have happened. + if [ ! -d ".git" ]; then exit 0 ; fi + # Get a list of files ending with ".py", stuff in environment. + # We don't rely on exit status because Workflows run with + # "set -e" so any failure in a pipe is total failure. + PYFILES=$(git diff --name-only "refs/remotes/origin/${BASE_REF}" -- | egrep '.py$' || true) + # Use magic output to store in env for rest of workflow + echo "::set-env name=PYFILES::${PYFILES}" + ################################################################## + + ################################################################## + # Get Python set up + ################################################################## + - name: Set up Python 3.7 + if: ${{ env.PYFILES != '' }} + uses: actions/setup-python@v2 + with: + python-version: 3.7 + ################################################################## + + ################################################################## + # Perform the annotation + ################################################################## + - name: Annotate with Flake8 + # Only if at least one *.py file was affected + if: ${{ env.PYFILES != '' }} + uses: "tayfun/flake8-your-pr@master" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ################################################################## diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 00000000..f39bb335 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,87 @@ +# This workflow will: +# +# * install Python dependencies +# * lint with a single version of Python +# +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: PR-Checks + +on: + pull_request: + branches: [ develop ] + +jobs: + flake8: + runs-on: ubuntu-18.04 + + steps: + + #################################################################### + # Checkout the necessary commits + #################################################################### + # We need the repo from the 'head' of the PR, not what it's + # based on. + - name: Checkout head commits + # https://github.com/actions/checkout + uses: actions/checkout@v2 + with: + repository: ${{github.event.pull_request.head.repo.full_name}} + fetch-depth: 0 + + # But we do need the base references + - name: Fetch base commits + env: + BASE_REPO_URL: ${{github.event.pull_request.base.repo.svn_url}} + BASE_REPO_OWNER: ${{github.event.pull_request.base.repo.owner.login}} + + run: | + # Add the 'base' repo as a new remote + git remote add ${BASE_REPO_OWNER} ${BASE_REPO_URL} + # And then fetch its references + git fetch ${BASE_REPO_OWNER} + #################################################################### + + #################################################################### + # Get Python set up + #################################################################### + - name: Set up Python 3.7 + uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; elif [ -f requirements.txt ]; then pip install -r requirements.txt ; fi + #################################################################### + + #################################################################### + # Show full github context to aid debugging. + #################################################################### + - name: Show github context + run: | + env + cat $GITHUB_EVENT_PATH + #################################################################### + + #################################################################### + # Lint with flake8 + #################################################################### + - name: Lint with flake8 + env: + BASE_REPO_URL: ${{github.event.pull_request.base.repo.svn_url}} + BASE_REPO_OWNER: ${{github.event.pull_request.base.repo.owner.login}} + BASE_REF: ${{github.base_ref}} + + run: | + # Explicitly check for some errors + # E9 - Runtime (syntax and the like) + # F63 - 'tests' checking + # F7 - syntax errors + # F82 - undefined checking + git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --diff + # Can optionally add `--exit-zero` to the flake8 arguments so that + # this doesn't fail the build. + git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --statistics --diff + #################################################################### diff --git a/.gitignore b/.gitignore index e6e08332..a1e314a8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ appcast_win_*.xml appcast_mac_*.xml EDMarketConnector.VisualElementsManifest.xml *.zip + +.idea +.vscode +venv diff --git a/.pyproject.toml b/.pyproject.toml deleted file mode 100644 index 7ff18c21..00000000 --- a/.pyproject.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.autopep8] -max_line_length = 120 diff --git a/Contributing.md b/Contributing.md index ff683dff..dfbd1b1e 100644 --- a/Contributing.md +++ b/Contributing.md @@ -184,14 +184,14 @@ Coding Conventions Yes: ```python -if somethingTrue: - Things_we_then_do() +if something_true: + one_thing_we_do() ``` No: ```python -if somethingTrue: One_thing_we_do() +if something_true: one_thing_we_do() ``` Yes, some existing code still flouts this rule. @@ -224,6 +224,43 @@ No: * Going forwards please do place [type hints](https://docs.python.org/3/library/typing.html) on the declarations of your functions, both their arguments and return types. +* Use `logging` not `print()`, and definitely not `sys.stdout.write()`! + `EDMarketConnector.py` sets up a `logging.Logger` for this under the + `appname`, so: + + import logging + from config import appname + logger = logging.getLogger(appname) + + logger.info(f'Some message with a {variable}') + + try: + something + except Exception as e: # Try to be more specific + logger.error(f'Error in ... with ...', exc_info=e) + + **DO NOT** use the following, as you might cause a circular import: + + from EDMarketConnector import logger + + We have implemented a `logging.Filter` that adds support for the following + in `logging.Formatter()` strings: + + 1. `%(qualname)s` which gets the full `.ClassA(.ClassB...).func` + of the calling function. + 1. `%(class)s` which gets just the enclosing class name(s) of the calling + function. + + If you want to see how we did this, check `EDMCLogging.py`. + + So don't worry about adding anything about the class or function you're + logging from, it's taken care of. + + *Do use a pertinent message, even when using `exc_info=...` to log an + exception*. e.g. Logging will know you were in your `get_foo()` function + but you should still tell it what actually (failed to have) happened + in there. + * In general, please follow [PEP8](https://www.python.org/dev/peps/pep-0008/). --- diff --git a/EDMC.py b/EDMC.py index 1fa37f35..2d2eb476 100755 --- a/EDMC.py +++ b/EDMC.py @@ -5,9 +5,9 @@ import argparse import json -import requests import sys import os +from typing import Any, Optional # workaround for https://github.com/EDCD/EDMarketConnector/issues/568 os.environ["EDMC_NO_UI"] = "1" @@ -36,199 +36,267 @@ sys.path.append(config.internal_plugin_dir) import eddn -SERVER_RETRY = 5 # retry pause for Companion servers [s] +SERVER_RETRY = 5 # retry pause for Companion servers [s] EXIT_SUCCESS, EXIT_SERVER, EXIT_CREDENTIALS, EXIT_VERIFICATION, EXIT_LAGGING, EXIT_SYS_ERR = range(6) +JOURNAL_RE = re.compile(r'^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$') + + # quick and dirty version comparison assuming "strict" numeric only version numbers def versioncmp(versionstring): return list(map(int, versionstring.split('.'))) -try: - # arg parsing - parser = argparse.ArgumentParser(prog=appcmdname, description='Prints the current system and station (if docked) to stdout and optionally writes player status, ship locations, ship loadout and/or station data to file. Requires prior setup through the accompanying GUI app.') - parser.add_argument('-v', '--version', help='print program version and exit', action='store_const', const=True) - parser.add_argument('-a', metavar='FILE', help='write ship loadout to FILE in Companion API json format') - parser.add_argument('-e', metavar='FILE', help='write ship loadout to FILE in E:D Shipyard plain text format') - parser.add_argument('-l', metavar='FILE', help='write ship locations to FILE in CSV format') - parser.add_argument('-m', metavar='FILE', help='write station commodity market data to FILE in CSV format') - parser.add_argument('-o', metavar='FILE', help='write station outfitting data to FILE in CSV format') - parser.add_argument('-s', metavar='FILE', help='write station shipyard data to FILE in CSV format') - parser.add_argument('-t', metavar='FILE', help='write player status to FILE in CSV format') - parser.add_argument('-d', metavar='FILE', help='write raw JSON data to FILE') - parser.add_argument('-n', action='store_true', help='send data to EDDN') - parser.add_argument('-p', metavar='CMDR', help='Returns data from the specified player account') - parser.add_argument('-j', help=argparse.SUPPRESS) # Import JSON dump - args = parser.parse_args() +def deep_get(target: dict, *args: str, default=None) -> Any: + if not hasattr(target, 'get'): + raise ValueError(f"Cannot call get on {target} ({type(target)})") - if args.version: - updater = Updater(provider='internal') - newversion: EDMCVersion = updater.check_appcast() - if newversion: - print('{CURRENT} ("{UPDATE}" is available)'.format( - CURRENT=appversion, - UPDATE=newversion.title)) - else: - print(appversion) - sys.exit(EXIT_SUCCESS) + current = target + for arg in args: + res = current.get(arg) + if res is None: + return default - if args.j: - # Import and collate from JSON dump - data = json.load(open(args.j)) - config.set('querytime', int(getmtime(args.j))) - else: - # Get state from latest Journal file - try: - logdir = config.get('journaldir') or config.default_journal_dir - logfiles = sorted([x for x in os.listdir(logdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)], - key=lambda x: x.split('.')[1:]) - logfile = join(logdir, logfiles[-1]) - with open(logfile, 'r') as loghandle: - for line in loghandle: - try: - monitor.parse_entry(line) - except: - if __debug__: - print('Invalid journal entry "%s"' % repr(line)) - except Exception as e: - print("Can't read Journal file: {}\n".format(str(e)), file=sys.stderr) - sys.exit(EXIT_SYS_ERR) + current = res - if not monitor.cmdr: - sys.stderr.write('Not available while E:D is at the main menu\n') - sys.exit(EXIT_SYS_ERR) + return current - # Get data from Companion API - if args.p: - cmdrs = config.get('cmdrs') or [] - if args.p in cmdrs: - idx = cmdrs.index(args.p) + +def main(): + try: + # arg parsing + parser = argparse.ArgumentParser( + prog=appcmdname, + description='Prints the current system and station (if docked) to stdout and optionally writes player ' + 'status, ship locations, ship loadout and/or station data to file. ' + 'Requires prior setup through the accompanying GUI app.' + ) + + parser.add_argument('-v', '--version', help='print program version and exit', action='store_const', const=True) + parser.add_argument('-a', metavar='FILE', help='write ship loadout to FILE in Companion API json format') + parser.add_argument('-e', metavar='FILE', help='write ship loadout to FILE in E:D Shipyard plain text format') + parser.add_argument('-l', metavar='FILE', help='write ship locations to FILE in CSV format') + parser.add_argument('-m', metavar='FILE', help='write station commodity market data to FILE in CSV format') + parser.add_argument('-o', metavar='FILE', help='write station outfitting data to FILE in CSV format') + parser.add_argument('-s', metavar='FILE', help='write station shipyard data to FILE in CSV format') + parser.add_argument('-t', metavar='FILE', help='write player status to FILE in CSV format') + parser.add_argument('-d', metavar='FILE', help='write raw JSON data to FILE') + parser.add_argument('-n', action='store_true', help='send data to EDDN') + parser.add_argument('-p', metavar='CMDR', help='Returns data from the specified player account') + parser.add_argument('-j', help=argparse.SUPPRESS) # Import JSON dump + args = parser.parse_args() + + if args.version: + updater = Updater(provider='internal') + newversion: Optional[EDMCVersion] = updater.check_appcast() + if newversion: + print(f'{appversion} ({newversion.title!r} is available)') else: - for idx, cmdr in enumerate(cmdrs): - if cmdr.lower() == args.p.lower(): - break - else: - raise companion.CredentialsError() - companion.session.login(cmdrs[idx], monitor.is_beta) + print(appversion) + sys.exit(EXIT_SUCCESS) + + if args.j: + # Import and collate from JSON dump + data = json.load(open(args.j)) + config.set('querytime', int(getmtime(args.j))) + else: - cmdrs = config.get('cmdrs') or [] - if monitor.cmdr not in cmdrs: - raise companion.CredentialsError() - companion.session.login(monitor.cmdr, monitor.is_beta) - querytime = int(time()) - data = companion.session.station() - config.set('querytime', querytime) + # Get state from latest Journal file + try: + logdir = config.get('journaldir') or config.default_journal_dir + logfiles = sorted((x for x in os.listdir(logdir) if JOURNAL_RE.search(x)), key=lambda x: x.split('.')[1:]) - # Validation - if not data.get('commander') or not data['commander'].get('name','').strip(): - sys.stderr.write('Who are you?!\n') - sys.exit(EXIT_SERVER) - elif (not data.get('lastSystem', {}).get('name') or - (data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked - sys.stderr.write('Where are you?!\n') # Shouldn't happen - sys.exit(EXIT_SERVER) - elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name','').strip(): - sys.stderr.write('What are you flying?!\n') # Shouldn't happen - sys.exit(EXIT_SERVER) - elif args.j: - pass # Skip further validation - elif data['commander']['name'] != monitor.cmdr: - sys.stderr.write('Wrong Cmdr\n') # Companion API return doesn't match Journal - sys.exit(EXIT_CREDENTIALS) - elif ((data['lastSystem']['name'] != monitor.system) or - ((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or - (data['ship']['id'] != monitor.state['ShipID']) or - (data['ship']['name'].lower() != monitor.state['ShipType'])): - sys.stderr.write('Frontier server is lagging\n') - sys.exit(EXIT_LAGGING) + logfile = join(logdir, logfiles[-1]) - # stuff we can do when not docked - if args.d: - with open(args.d, 'wb') as h: - h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) - if args.a: - loadout.export(data, args.a) - if args.e: - edshipyard.export(data, args.e) - if args.l: - stats.export_ships(data, args.l) - if args.t: - stats.export_status(data, args.t) + with open(logfile, 'r') as loghandle: + for line in loghandle: + try: + monitor.parse_entry(line) + except Exception: + if __debug__: + print(f'Invalid journal entry {line!r}') - if data['commander'].get('docked'): - print('%s,%s' % (data.get('lastSystem', {}).get('name', 'Unknown'), data.get('lastStarport', {}).get('name', 'Unknown'))) - else: - print(data.get('lastSystem', {}).get('name', 'Unknown')) + except Exception as e: + print(f"Can't read Journal file: {str(e)}", file=sys.stderr) + sys.exit(EXIT_SYS_ERR) - if (args.m or args.o or args.s or args.n or args.j): - if not data['commander'].get('docked'): - sys.stderr.write("You're not docked at a station!\n") - sys.exit(EXIT_SUCCESS) - elif not data.get('lastStarport', {}).get('name'): - sys.stderr.write("Unknown station!\n") + if not monitor.cmdr: + print('Not available while E:D is at the main menu', file=sys.stderr) + sys.exit(EXIT_SYS_ERR) + + # Get data from Companion API + if args.p: + cmdrs = config.get('cmdrs') or [] + if args.p in cmdrs: + idx = cmdrs.index(args.p) + + else: + for idx, cmdr in enumerate(cmdrs): + if cmdr.lower() == args.p.lower(): + break + + else: + raise companion.CredentialsError() + + companion.session.login(cmdrs[idx], monitor.is_beta) + + else: + cmdrs = config.get('cmdrs') or [] + if monitor.cmdr not in cmdrs: + raise companion.CredentialsError() + + companion.session.login(monitor.cmdr, monitor.is_beta) + + querytime = int(time()) + data = companion.session.station() + config.set('querytime', querytime) + + # Validation + if not deep_get(data, 'commander', 'name', default='').strip(): + print('Who are you?!', file=sys.stderr) + sys.exit(EXIT_SERVER) + + elif not deep_get(data, 'lastSystem', 'name') or \ + data['commander'].get('docked') and not \ + deep_get(data, 'lastStarport', 'name'): # Only care if docked + + print('Where are you?!', file=sys.stderr) # Shouldn't happen + sys.exit(EXIT_SERVER) + + elif not deep_get(data, 'ship', 'modules') or not deep_get(data, 'ship', 'name', default=''): + print('What are you flying?!', file=sys.stderr) # Shouldn't happen + sys.exit(EXIT_SERVER) + + elif args.j: + pass # Skip further validation + + elif data['commander']['name'] != monitor.cmdr: + print('Wrong Cmdr', file=sys.stderr) # Companion API return doesn't match Journal + sys.exit(EXIT_CREDENTIALS) + + elif data['lastSystem']['name'] != monitor.system or \ + ((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or \ + data['ship']['id'] != monitor.state['ShipID'] or \ + data['ship']['name'].lower() != monitor.state['ShipType']: + + print('Frontier server is lagging', file=sys.stderr) sys.exit(EXIT_LAGGING) - elif not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): # Ignore possibly missing shipyard info - sys.stderr.write("Station doesn't have anything!\n") + + # stuff we can do when not docked + if args.d: + out = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')) + with open(args.d, 'wb') as f: + f.write(out.encode("utf-8")) + + if args.a: + loadout.export(data, args.a) + + if args.e: + edshipyard.export(data, args.e) + + if args.l: + stats.export_ships(data, args.l) + + if args.t: + stats.export_status(data, args.t) + + if data['commander'].get('docked'): + print('{},{}'.format( + deep_get(data, 'lastSystem', 'name', default='Unknown'), + deep_get(data, 'lastStarport', 'name', default='Unknown') + )) + + else: + print(deep_get(data, 'lastSystem', 'name', default='Unknown')) + + if (args.m or args.o or args.s or args.n or args.j): + if not data['commander'].get('docked'): + print("You're not docked at a station!", file=sys.stderr) + sys.exit(EXIT_SUCCESS) + + elif not deep_get(data, 'lastStarport', 'name'): + print("Unknown station!", file=sys.stderr) + sys.exit(EXIT_LAGGING) + + # Ignore possibly missing shipyard info + elif not data['lastStarport'].get('commodities') or data['lastStarport'].get('modules'): + print("Station doesn't have anything!", file=sys.stderr) + sys.exit(EXIT_SUCCESS) + + else: sys.exit(EXIT_SUCCESS) - else: + + # Finally - the data looks sane and we're docked at a station + + if args.j: + # Collate from JSON dump + collate.addcommodities(data) + collate.addmodules(data) + collate.addships(data) + + if args.m: + if data['lastStarport'].get('commodities'): + # Fixup anomalies in the commodity data + fixed = companion.fixup(data) + commodity.export(fixed, COMMODITY_DEFAULT, args.m) + + else: + print("Station doesn't have a market", file=sys.stderr) + + if args.o: + if data['lastStarport'].get('modules'): + outfitting.export(data, args.o) + + else: + print("Station doesn't supply outfitting", file=sys.stderr) + + if (args.s or args.n) and not args.j and not \ + data['lastStarport'].get('ships') and data['lastStarport']['services'].get('shipyard'): + + # Retry for shipyard + sleep(SERVER_RETRY) + new_data = companion.session.station() + # might have undocked while we were waiting for retry in which case station data is unreliable + if new_data['commander'].get('docked') and \ + deep_get(new_data, 'lastSystem', 'name') == monitor.system and \ + deep_get(new_data, 'lastStarport', 'name') == monitor.station: + + data = new_data + + if args.s: + if deep_get(data, 'lastStarport', 'ships', 'shipyard_list'): + shipyard.export(data, args.s) + + elif not args.j and monitor.stationservices and 'Shipyard' in monitor.stationservices: + print("Failed to get shipyard data", file=sys.stderr) + + else: + print("Station doesn't have a shipyard", file=sys.stderr) + + if args.n: + try: + eddn_sender = eddn.EDDN(None) + eddn_sender.export_commodities(data, monitor.is_beta) + eddn_sender.export_outfitting(data, monitor.is_beta) + eddn_sender.export_shipyard(data, monitor.is_beta) + + except Exception as e: + print(f"Failed to send data to EDDN: {str(e)}", file=sys.stderr) + sys.exit(EXIT_SUCCESS) - # Finally - the data looks sane and we're docked at a station + except companion.ServerError: + print('Server is down', file=sys.stderr) + sys.exit(EXIT_SERVER) - if args.j: - # Collate from JSON dump - collate.addcommodities(data) - collate.addmodules(data) - collate.addships(data) + except companion.SKUError: + print('Server SKU problem', file=sys.stderr) + sys.exit(EXIT_SERVER) - if args.m: - if data['lastStarport'].get('commodities'): - # Fixup anomalies in the commodity data - fixed = companion.fixup(data) - commodity.export(fixed, COMMODITY_DEFAULT, args.m) - else: - sys.stderr.write("Station doesn't have a market\n") + except companion.CredentialsError: + print('Invalid Credentials', file=sys.stderr) + sys.exit(EXIT_CREDENTIALS) - if args.o: - if data['lastStarport'].get('modules'): - outfitting.export(data, args.o) - else: - sys.stderr.write("Station doesn't supply outfitting\n") - if (args.s or args.n) and not args.j and not data['lastStarport'].get('ships') and data['lastStarport']['services'].get('shipyard'): - # Retry for shipyard - sleep(SERVER_RETRY) - data2 = companion.session.station() - if (data2['commander'].get('docked') and # might have undocked while we were waiting for retry in which case station data is unreliable - data2.get('lastSystem', {}).get('name') == monitor.system and - data2.get('lastStarport', {}).get('name') == monitor.station): - data = data2 - - if args.s: - if data['lastStarport'].get('ships', {}).get('shipyard_list'): - shipyard.export(data, args.s) - elif not args.j and monitor.stationservices and 'Shipyard' in monitor.stationservices: - sys.stderr.write("Failed to get shipyard data\n") - else: - sys.stderr.write("Station doesn't have a shipyard\n") - - if args.n: - try: - eddn_sender = eddn.EDDN(None) - eddn_sender.export_commodities(data, monitor.is_beta) - eddn_sender.export_outfitting(data, monitor.is_beta) - eddn_sender.export_shipyard(data, monitor.is_beta) - except Exception as e: - sys.stderr.write("Failed to send data to EDDN: %s\n" % unicode(e).encode('ascii', 'replace')) - - sys.exit(EXIT_SUCCESS) - -except companion.ServerError as e: - sys.stderr.write('Server is down\n') - sys.exit(EXIT_SERVER) -except companion.SKUError as e: - sys.stderr.write('Server SKU problem\n') - sys.exit(EXIT_SERVER) -except companion.CredentialsError as e: - sys.stderr.write('Invalid Credentials\n') - sys.exit(EXIT_CREDENTIALS) +if __name__ == '__main__': + main() diff --git a/EDMCLogging.py b/EDMCLogging.py new file mode 100644 index 00000000..81487d8a --- /dev/null +++ b/EDMCLogging.py @@ -0,0 +1,293 @@ +""" +Set up required logging for the application. + +This module provides for a common logging-powered log facility. +Mostly it implements a logging.Filter() in order to get two extra +members on the logging.LogRecord instance for use in logging.Formatter() +strings. +""" + +# So that any warning about accessing a protected member is only in one place. +from sys import _getframe as getframe +import inspect +import logging +import pathlib +from typing import Tuple + +from config import config + +# TODO: Tests: +# +# 1. Call from bare function in file. +# 2. Call from `if __name__ == "__main__":` section +# +# 3. Call from 1st level function in 1st level Class in file +# 4. Call from 2nd level function in 1st level Class in file +# 5. Call from 3rd level function in 1st level Class in file +# +# 6. Call from 1st level function in 2nd level Class in file +# 7. Call from 2nd level function in 2nd level Class in file +# 8. Call from 3rd level function in 2nd level Class in file +# +# 9. Call from 1st level function in 3rd level Class in file +# 10. Call from 2nd level function in 3rd level Class in file +# 11. Call from 3rd level function in 3rd level Class in file +# +# 12. Call from 2nd level file, all as above. +# +# 13. Call from *module* +# +# 14. Call from *package* + +_default_loglevel = logging.DEBUG + + +class Logger: + """ + Wrapper class for all logging configuration and code. + + Class instantiation requires the 'logger name' and optional loglevel. + It is intended that this 'logger name' be re-used in all files/modules + that need to log. + + Users of this class should then call getLogger() to get the + logging.Logger instance. + """ + + def __init__(self, logger_name: str, loglevel: int = _default_loglevel): + """ + Set up a `logging.Logger` with our preferred configuration. + + This includes using an EDMCContextFilter to add 'class' and 'qualname' + expansions for logging.Formatter(). + """ + self.logger = logging.getLogger(logger_name) + # Configure the logging.Logger + self.logger.setLevel(loglevel) + + # Set up filter for adding class name + self.logger_filter = EDMCContextFilter() + self.logger.addFilter(self.logger_filter) + + self.logger_channel = logging.StreamHandler() + self.logger_channel.setLevel(loglevel) + + self.logger_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(module)s.%(qualname)s:%(lineno)d: %(message)s') # noqa: E501 + self.logger_formatter.default_time_format = '%Y-%m-%d %H:%M:%S' + self.logger_formatter.default_msec_format = '%s.%03d' + + self.logger_channel.setFormatter(self.logger_formatter) + self.logger.addHandler(self.logger_channel) + + def get_logger(self) -> logging.Logger: + """ + Obtain the self.logger of the class instance. + + Not to be confused with logging.getLogger(). + """ + return self.logger + + +def get_plugin_logger(name: str, loglevel: int = _default_loglevel) -> logging.Logger: + """ + Return a logger suitable for a plugin. + + 'Found' plugins need their own logger to call out where the logging is + coming from, but we don't need to set up *everything* for them. + + The name will be '{config.appname}.{plugin.name}', e.g. + 'EDMarketConnector.miggytest'. This means that any logging sent through + there *also* goes to the channels defined in the 'EDMarketConnector' + logger, so we can let that take care of the formatting. + + If we add our own channel then the output gets duplicated (assuming same + logLevel set). + + However we do need to attach our filter to this still. That's not at + the channel level. + :param name: Name of this Logger. + :param loglevel: Optional logLevel for this Logger. + :return: logging.Logger instance, all set up. + """ + plugin_logger = logging.getLogger(name) + plugin_logger.setLevel(loglevel) + + plugin_logger.addFilter(EDMCContextFilter()) + + return plugin_logger + + +class EDMCContextFilter(logging.Filter): + """ + Implements filtering to add extra format specifiers, and tweak others. + + logging.Filter sub-class to place extra attributes of the calling site + into the record. + """ + + def filter(self, record: logging.LogRecord) -> bool: + """ + Attempt to set/change fields in the LogRecord. + + 1. class = class name(s) of the call site, if applicable + 2. qualname = __qualname__ of the call site. This simplifies + logging.Formatter() as you can use just this no matter if there is + a class involved or not, so you get a nice clean: + .[.classB....]. + + If we fail to be able to properly set either then: + + 1. Use print() to alert, to be SURE a message is seen. + 2. But also return strings noting the error, so there'll be + something in the log output if it happens. + + :param record: The LogRecord we're "filtering" + :return: bool - Always true in order for this record to be logged. + """ + (class_name, qualname, module_name) = self.caller_attributes(module_name=getattr(record, 'module')) + + # Only set if we got a useful value + if module_name: + setattr(record, 'module', module_name) + + # Only set if not already provided by logging itself + if getattr(record, 'class', None) is None: + setattr(record, 'class', class_name) + + # Only set if not already provided by logging itself + if getattr(record, 'qualname', None) is None: + setattr(record, 'qualname', qualname) + + return True + + @classmethod # noqa: CCR001 - this is as refactored as is sensible + def caller_attributes(cls, module_name: str = '') -> Tuple[str, str, str]: + """ + Determine extra or changed fields for the caller. + + 1. qualname finds the relevant object and its __qualname__ + 2. caller_class_names is just the full class names of the calling + class if relevant. + 3. module is munged if we detect the caller is an EDMC plugin, + whether internal or found. + """ + frame = cls.find_caller_frame() + + caller_qualname = caller_class_names = '' + if frame: + # + frame_info = inspect.getframeinfo(frame) + args, _, _, value_dict = inspect.getargvalues(frame) + if len(args) and args[0] in ('self', 'cls'): + frame_class = value_dict[args[0]] + + if frame_class: + # Find __qualname__ of the caller + fn = getattr(frame_class, frame_info.function) + if fn and fn.__qualname__: + caller_qualname = fn.__qualname__ + + # Find containing class name(s) of caller, if any + if frame_class.__class__ and frame_class.__class__.__qualname__: + caller_class_names = frame_class.__class__.__qualname__ + + # It's a call from the top level module file + elif frame_info.function == '': + caller_class_names = '' + caller_qualname = value_dict['__name__'] + + elif frame_info.function != '': + caller_class_names = '' + caller_qualname = frame_info.function + + module_name = cls.munge_module_name(frame_info, module_name) + + # https://docs.python.org/3.7/library/inspect.html#the-interpreter-stack + del frame + + if caller_qualname == '': + print('ALERT! Something went wrong with finding caller qualname for logging!') + caller_qualname = '' + + if caller_class_names == '': + print('ALERT! Something went wrong with finding caller class name(s) for logging!') + caller_class_names = '' + + return caller_class_names, caller_qualname, module_name + + @classmethod + def find_caller_frame(cls): + """ + Find the stack frame of the logging caller. + + :returns: 'frame' object such as from sys._getframe() + """ + # Go up through stack frames until we find the first with a + # type(f_locals.self) of logging.Logger. This should be the start + # of the frames internal to logging. + frame: 'frame' = getframe(0) + while frame: + if isinstance(frame.f_locals.get('self'), logging.Logger): + frame = frame.f_back # Want to start on the next frame below + break + frame = frame.f_back + # Now continue up through frames until we find the next one where + # that is *not* true, as it should be the call site of the logger + # call + while frame: + if not isinstance(frame.f_locals.get('self'), logging.Logger): + break # We've found the frame we want + frame = frame.f_back + return frame + + @classmethod + def munge_module_name(cls, frame_info: inspect.Traceback, module_name: str) -> str: + """ + Adjust module_name based on the file path for the given frame. + + We want to distinguish between other code and both our internal plugins + and the 'found' ones. + + For internal plugins we want "plugins.". + For 'found' plugins we want "....". + + :param frame_info: The frame_info of the caller. + :param module_name: The module_name string to munge. + :return: The munged module_name. + """ + file_name = pathlib.Path(frame_info.filename).expanduser() + plugin_dir = pathlib.Path(config.plugin_dir).expanduser() + internal_plugin_dir = pathlib.Path(config.internal_plugin_dir).expanduser() + # Find the first parent called 'plugins' + plugin_top = file_name + while plugin_top and plugin_top.name != '': + if plugin_top.parent.name == 'plugins': + break + + plugin_top = plugin_top.parent + + # Check we didn't walk up to the root/anchor + if plugin_top.name != '': + # Check we're still inside config.plugin_dir + if plugin_top.parent == plugin_dir: + # In case of deeper callers we need a range of the file_name + pt_len = len(plugin_top.parts) + name_path = '.'.join(file_name.parts[(pt_len - 1):-1]) + module_name = f'.{name_path}.{module_name}' + + # Check we're still inside the installation folder. + elif file_name.parent == internal_plugin_dir: + # Is this a deeper caller ? + pt_len = len(plugin_top.parts) + name_path = '.'.join(file_name.parts[(pt_len - 1):-1]) + + # Pre-pend 'plugins..' to module + if name_path == '': + # No sub-folder involved so module_name is sufficient + module_name = f'plugins.{module_name}' + + else: + # Sub-folder(s) involved, so include them + module_name = f'plugins.{name_path}.{module_name}' + + return module_name diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 86901081..cfd92b1c 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -5,19 +5,15 @@ from builtins import str from builtins import object import sys from sys import platform -from collections import OrderedDict -from functools import partial import json from os import chdir, environ -from os.path import dirname, expanduser, isdir, join +from os.path import dirname, isdir, join import re import html -import requests -from time import gmtime, time, localtime, strftime, strptime -import _strptime # Workaround for http://bugs.python.org/issue7980 -from calendar import timegm +from time import time, localtime, strftime import webbrowser +import EDMCLogging from config import appname, applongname, appversion, appversion_nobuild, copyright, config if getattr(sys, 'frozen', False): @@ -36,7 +32,6 @@ import tkinter.messagebox from ttkHyperlinkLabel import HyperlinkLabel if __debug__: - from traceback import print_exc if platform != 'win32': import pdb import signal @@ -57,7 +52,7 @@ from dashboard import dashboard from theme import theme -SERVER_RETRY = 5 # retry pause for Companion servers [s] +SERVER_RETRY = 5 # retry pause for Companion servers [s] SHIPYARD_HTML_TEMPLATE = """ @@ -79,8 +74,8 @@ class AppWindow(object): # Tkinter Event types EVENT_KEYPRESS = 2 - EVENT_BUTTON = 4 - EVENT_VIRTUAL = 35 + EVENT_BUTTON = 4 + EVENT_VIRTUAL = 35 def __init__(self, master): @@ -99,10 +94,10 @@ class AppWindow(object): if platform == 'win32': self.w.wm_iconbitmap(default='EDMarketConnector.ico') else: - self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file = join(config.respath, 'EDMarketConnector.png'))) - self.theme_icon = tk.PhotoImage(data = 'R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') - self.theme_minimize = tk.BitmapImage(data = '#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') - self.theme_close = tk.BitmapImage(data = '#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') + self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file=join(config.respath, 'EDMarketConnector.png'))) # noqa: E501 + self.theme_icon = tk.PhotoImage(data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501 + self.theme_minimize = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 + self.theme_close = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 frame = tk.Frame(self.w, name=appname.lower()) frame.grid(sticky=tk.NSEW) @@ -118,10 +113,10 @@ class AppWindow(object): self.system_label.grid(row=3, column=0, sticky=tk.W) self.station_label.grid(row=4, column=0, sticky=tk.W) - self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name = 'cmdr') - self.ship = HyperlinkLabel(frame, compound=tk.RIGHT, url = self.shipyard_url, name = 'ship') - self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url = self.system_url, popup_copy = True, name = 'system') - self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url = self.station_url, name = 'station') + self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr') + self.ship = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.shipyard_url, name='ship') + self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.system_url, popup_copy=True, name='system') + self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station') self.cmdr.grid(row=1, column=1, sticky=tk.EW) self.ship.grid(row=2, column=1, sticky=tk.EW) @@ -131,39 +126,41 @@ class AppWindow(object): for plugin in plug.PLUGINS: appitem = plugin.get_app(frame) if appitem: - tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator - if isinstance(appitem, tuple) and len(appitem)==2: + tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator + if isinstance(appitem, tuple) and len(appitem) == 2: row = frame.grid_size()[1] appitem[0].grid(row=row, column=0, sticky=tk.W) appitem[1].grid(row=row, column=1, sticky=tk.EW) else: appitem.grid(columnspan=2, sticky=tk.EW) - self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED) # Update button in main window - self.theme_button = tk.Label(frame, width = platform == 'darwin' and 32 or 28, state=tk.DISABLED) + # Update button in main window + self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED) + self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED) self.status = tk.Label(frame, name='status', anchor=tk.W) row = frame.grid_size()[1] self.button.grid(row=row, columnspan=2, sticky=tk.NSEW) self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW) - theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row':row, 'columnspan':2, 'sticky':tk.NSEW}) + theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': row, 'columnspan': 2, 'sticky': tk.NSEW}) # noqa: E501 self.status.grid(columnspan=2, sticky=tk.EW) self.button.bind('', self.getandsend) theme.button_bind(self.theme_button, self.getandsend) for child in frame.winfo_children(): - child.grid_configure(padx=5, pady=(platform!='win32' or isinstance(child, tk.Frame)) and 2 or 0) + child.grid_configure(padx=5, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) self.menubar = tk.Menu() - if platform=='darwin': + if platform == 'darwin': # Can't handle (de)iconify if topmost is set, so suppress iconify button - # http://wiki.tcl.tk/13428 and p15 of https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf + # http://wiki.tcl.tk/13428 and p15 of + # https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf root.call('tk::unsupported::MacWindowStyle', 'style', root, 'document', 'closeBox resizable') # https://www.tcl.tk/man/tcl/TkCmd/menu.htm self.system_menu = tk.Menu(self.menubar, name='apple') - self.system_menu.add_command(command=lambda:self.w.call('tk::mac::standardAboutPanel')) - self.system_menu.add_command(command=lambda:self.updater.checkForUpdates()) + self.system_menu.add_command(command=lambda: self.w.call('tk::mac::standardAboutPanel')) + self.system_menu.add_command(command=lambda: self.updater.checkForUpdates()) self.menubar.add_cascade(menu=self.system_menu) self.file_menu = tk.Menu(self.menubar, name='file') self.file_menu.add_command(command=self.save_raw) @@ -173,7 +170,7 @@ class AppWindow(object): self.menubar.add_cascade(menu=self.edit_menu) self.w.bind('', self.copy) self.view_menu = tk.Menu(self.menubar, name='view') - self.view_menu.add_command(command=lambda:stats.StatsDialog(self)) + self.view_menu.add_command(command=lambda: stats.StatsDialog(self)) self.menubar.add_cascade(menu=self.view_menu) window_menu = tk.Menu(self.menubar, name='window') self.menubar.add_cascade(menu=window_menu) @@ -185,17 +182,17 @@ class AppWindow(object): self.w['menu'] = self.menubar # https://www.tcl.tk/man/tcl/TkCmd/tk_mac.htm self.w.call('set', 'tk::mac::useCompatibilityMetrics', '0') - self.w.createcommand('tkAboutDialog', lambda:self.w.call('tk::mac::standardAboutPanel')) + self.w.createcommand('tkAboutDialog', lambda: self.w.call('tk::mac::standardAboutPanel')) self.w.createcommand("::tk::mac::Quit", self.onexit) - self.w.createcommand("::tk::mac::ShowPreferences", lambda:prefs.PreferencesDialog(self.w, self.postprefs)) - self.w.createcommand("::tk::mac::ReopenApplication", self.w.deiconify) # click on app in dock = restore - self.w.protocol("WM_DELETE_WINDOW", self.w.withdraw) # close button shouldn't quit app - self.w.resizable(tk.FALSE, tk.FALSE) # Can't be only resizable on one axis + self.w.createcommand("::tk::mac::ShowPreferences", lambda: prefs.PreferencesDialog(self.w, self.postprefs)) + self.w.createcommand("::tk::mac::ReopenApplication", self.w.deiconify) # click on app in dock = restore + self.w.protocol("WM_DELETE_WINDOW", self.w.withdraw) # close button shouldn't quit app + self.w.resizable(tk.FALSE, tk.FALSE) # Can't be only resizable on one axis else: self.file_menu = self.view_menu = tk.Menu(self.menubar, tearoff=tk.FALSE) - self.file_menu.add_command(command=lambda:stats.StatsDialog(self)) + self.file_menu.add_command(command=lambda: stats.StatsDialog(self)) self.file_menu.add_command(command=self.save_raw) - self.file_menu.add_command(command=lambda:prefs.PreferencesDialog(self.w, self.postprefs)) + self.file_menu.add_command(command=lambda: prefs.PreferencesDialog(self.w, self.postprefs)) self.file_menu.add_separator() self.file_menu.add_command(command=self.onexit) self.menubar.add_cascade(menu=self.file_menu) @@ -206,20 +203,22 @@ class AppWindow(object): self.help_menu.add_command(command=self.help_general) self.help_menu.add_command(command=self.help_privacy) self.help_menu.add_command(command=self.help_releases) - self.help_menu.add_command(command=lambda:self.updater.checkForUpdates()) - self.help_menu.add_command(command=lambda:not self.help_about.showing and self.help_about(self.w)) + self.help_menu.add_command(command=lambda: self.updater.checkForUpdates()) + self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w)) self.menubar.add_cascade(menu=self.help_menu) if platform == 'win32': # Must be added after at least one "real" menu entry - self.always_ontop = tk.BooleanVar(value = config.getint('always_ontop')) + self.always_ontop = tk.BooleanVar(value=config.getint('always_ontop')) self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE) self.system_menu.add_separator() - self.system_menu.add_checkbutton(label=_('Always on top'), variable = self.always_ontop, command=self.ontop_changed) # Appearance setting + self.system_menu.add_checkbutton(label=_('Always on top'), + variable=self.always_ontop, + command=self.ontop_changed) # Appearance setting self.menubar.add_cascade(menu=self.system_menu) self.w.bind('', self.copy) self.w.protocol("WM_DELETE_WINDOW", self.onexit) - theme.register(self.menubar) # menus and children aren't automatically registered + theme.register(self.menubar) # menus and children aren't automatically registered theme.register(self.file_menu) theme.register(self.edit_menu) theme.register(self.help_menu) @@ -227,7 +226,9 @@ class AppWindow(object): # Alternate title bar and menu for dark theme self.theme_menubar = tk.Frame(frame) self.theme_menubar.columnconfigure(2, weight=1) - theme_titlebar = tk.Label(self.theme_menubar, text=applongname, image=self.theme_icon, cursor='fleur', anchor=tk.W, compound=tk.LEFT) + theme_titlebar = tk.Label(self.theme_menubar, text=applongname, + image=self.theme_icon, cursor='fleur', + anchor=tk.W, compound=tk.LEFT) theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW) self.drag_offset = None theme_titlebar.bind('', self.drag_start) @@ -241,37 +242,49 @@ class AppWindow(object): theme.button_bind(theme_close, self.onexit, image=self.theme_close) self.theme_file_menu = tk.Label(self.theme_menubar, anchor=tk.W) self.theme_file_menu.grid(row=1, column=0, padx=5, sticky=tk.W) - theme.button_bind(self.theme_file_menu, lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height())) + theme.button_bind(self.theme_file_menu, + lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(), + e.widget.winfo_rooty() + + e.widget.winfo_height())) self.theme_edit_menu = tk.Label(self.theme_menubar, anchor=tk.W) self.theme_edit_menu.grid(row=1, column=1, sticky=tk.W) - theme.button_bind(self.theme_edit_menu, lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height())) + theme.button_bind(self.theme_edit_menu, + lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(), + e.widget.winfo_rooty() + + e.widget.winfo_height())) self.theme_help_menu = tk.Label(self.theme_menubar, anchor=tk.W) self.theme_help_menu.grid(row=1, column=2, sticky=tk.W) - theme.button_bind(self.theme_help_menu, lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height())) + theme.button_bind(self.theme_help_menu, + lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(), + e.widget.winfo_rooty() + + e.widget.winfo_height())) tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=5, sticky=tk.EW) - theme.register(self.theme_minimize) # images aren't automatically registered + theme.register(self.theme_minimize) # images aren't automatically registered theme.register(self.theme_close) self.blank_menubar = tk.Frame(frame) tk.Label(self.blank_menubar).grid() tk.Label(self.blank_menubar).grid() tk.Frame(self.blank_menubar, height=2).grid() - theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar), {'row':0, 'columnspan':2, 'sticky':tk.NSEW}) + theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar), + {'row': 0, 'columnspan': 2, 'sticky': tk.NSEW}) self.w.resizable(tk.TRUE, tk.FALSE) # update geometry if config.get('geometry'): - match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry')) + match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry')) # noqa: W605 if match: if platform == 'darwin': - if int(match.group(2)) >= 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 + # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 + if int(match.group(2)) >= 0: self.w.geometry(config.get('geometry')) elif platform == 'win32': # Check that the titlebar will be at least partly on screen import ctypes from ctypes.wintypes import POINT # https://msdn.microsoft.com/en-us/library/dd145064 - MONITOR_DEFAULTTONULL = 0 - if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16), MONITOR_DEFAULTTONULL): + MONITOR_DEFAULTTONULL = 0 # noqa: N806 + if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16), + MONITOR_DEFAULTTONULL): self.w.geometry(config.get('geometry')) else: self.w.geometry(config.get('geometry')) @@ -280,22 +293,22 @@ class AppWindow(object): theme.register(frame) theme.apply(self.w) - self.w.bind('', self.onmap) # Special handling for overrideredict - self.w.bind('', self.onenter) # Special handling for transparency - self.w.bind('', self.onenter) # " - self.w.bind('', self.onleave) # " - self.w.bind('', self.onleave) # " + self.w.bind('', self.onmap) # Special handling for overrideredict + self.w.bind('', self.onenter) # Special handling for transparency + self.w.bind('', self.onenter) # Special handling for transparency + self.w.bind('', self.onleave) # Special handling for transparency + self.w.bind('', self.onleave) # Special handling for transparency self.w.bind('', self.getandsend) self.w.bind('', self.getandsend) - self.w.bind_all('<>', self.getandsend) # Hotkey monitoring - self.w.bind_all('<>', self.journal_event) # Journal monitoring - self.w.bind_all('<>', self.dashboard_event) # Dashboard monitoring - self.w.bind_all('<>', self.plugin_error) # Statusbar - self.w.bind_all('<>', self.auth) # cAPI auth - self.w.bind_all('<>', self.onexit) # Updater + self.w.bind_all('<>', self.getandsend) # Hotkey monitoring + self.w.bind_all('<>', self.journal_event) # Journal monitoring + self.w.bind_all('<>', self.dashboard_event) # Dashboard monitoring + self.w.bind_all('<>', self.plugin_error) # Statusbar + self.w.bind_all('<>', self.auth) # cAPI auth + self.w.bind_all('<>', self.onexit) # Updater # Start a protocol handler to handle cAPI registration. Requires main loop to be running. - self.w.after_idle(lambda:protocolhandler.start(self.w)) + self.w.after_idle(lambda: protocolhandler.start(self.w)) # Load updater after UI creation (for WinSparkle) import update @@ -307,7 +320,7 @@ class AppWindow(object): self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps try: - config.get_password('') # Prod SecureStorage on Linux to initialise + config.get_password('') # Prod SecureStorage on Linux to initialise except RuntimeError: pass @@ -319,97 +332,97 @@ class AppWindow(object): config.delete('password') config.delete('logdir') - self.postprefs(False) # Companion login happens in callback from monitor - + self.postprefs(False) # Companion login happens in callback from monitor # callback after the Preferences dialog is applied def postprefs(self, dologin=True): self.prefsdialog = None - self.set_labels() # in case language has changed + self.set_labels() # in case language has changed # Reset links in case plugins changed them - self.ship.configure(url = self.shipyard_url) - self.system.configure(url = self.system_url) - self.station.configure(url = self.station_url) + self.ship.configure(url=self.shipyard_url) + self.system.configure(url=self.system_url) + self.station.configure(url=self.station_url) # (Re-)install hotkey monitoring hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods')) # (Re-)install log monitoring if not monitor.start(self.w): - self.status['text'] = 'Error: Check %s' % _('E:D journal file location') # Location of the new Journal file in E:D 2.2 + self.status['text'] = f'Error: Check {_("E:D journal file location")}' if dologin and monitor.cmdr: - self.login() # Login if not already logged in with this Cmdr + self.login() # Login if not already logged in with this Cmdr # set main window labels, e.g. after language change def set_labels(self): - self.cmdr_label['text'] = _('Cmdr') + ':' # Main window - self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or # Multicrew role label in main window - _('Ship')) + ':' # Main window - self.system_label['text'] = _('System') + ':' # Main window - self.station_label['text'] = _('Station') + ':' # Main window - self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window + self.cmdr_label['text'] = _('Cmdr') + ':' # Main window + # Multicrew role label in main window + self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window + self.system_label['text'] = _('System') + ':' # Main window + self.station_label['text'] = _('Station') + ':' # Main window + self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window if platform == 'darwin': - self.menubar.entryconfigure(1, label=_('File')) # Menu title - self.menubar.entryconfigure(2, label=_('Edit')) # Menu title - self.menubar.entryconfigure(3, label=_('View')) # Menu title on OSX - self.menubar.entryconfigure(4, label=_('Window')) # Menu title on OSX - self.menubar.entryconfigure(5, label=_('Help')) # Menu title - self.system_menu.entryconfigure(0, label=_("About {APP}").format(APP=applongname)) # App menu entry on OSX - self.system_menu.entryconfigure(1, label=_("Check for Updates...")) # Menu item - self.file_menu.entryconfigure(0, label=_('Save Raw Data...')) # Menu item - self.view_menu.entryconfigure(0, label=_('Status')) # Menu item - self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item - self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item + self.menubar.entryconfigure(1, label=_('File')) # Menu title + self.menubar.entryconfigure(2, label=_('Edit')) # Menu title + self.menubar.entryconfigure(3, label=_('View')) # Menu title on OSX + self.menubar.entryconfigure(4, label=_('Window')) # Menu title on OSX + self.menubar.entryconfigure(5, label=_('Help')) # Menu title + self.system_menu.entryconfigure(0, label=_("About {APP}").format(APP=applongname)) # App menu entry on OSX + self.system_menu.entryconfigure(1, label=_("Check for Updates...")) # Menu item + self.file_menu.entryconfigure(0, label=_('Save Raw Data...')) # Menu item + self.view_menu.entryconfigure(0, label=_('Status')) # Menu item + self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item + self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item else: - self.menubar.entryconfigure(1, label=_('File')) # Menu title - self.menubar.entryconfigure(2, label=_('Edit')) # Menu title - self.menubar.entryconfigure(3, label=_('Help')) # Menu title - self.theme_file_menu['text'] = _('File') # Menu title - self.theme_edit_menu['text'] = _('Edit') # Menu title - self.theme_help_menu['text'] = _('Help') # Menu title + self.menubar.entryconfigure(1, label=_('File')) # Menu title + self.menubar.entryconfigure(2, label=_('Edit')) # Menu title + self.menubar.entryconfigure(3, label=_('Help')) # Menu title + self.theme_file_menu['text'] = _('File') # Menu title + self.theme_edit_menu['text'] = _('Edit') # Menu title + self.theme_help_menu['text'] = _('Help') # Menu title - ## File menu - self.file_menu.entryconfigure(0, label=_('Status')) # Menu item - self.file_menu.entryconfigure(1, label=_('Save Raw Data...')) # Menu item - self.file_menu.entryconfigure(2, label=_('Settings')) # Item in the File menu on Windows - self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows + # File menu + self.file_menu.entryconfigure(0, label=_('Status')) # Menu item + self.file_menu.entryconfigure(1, label=_('Save Raw Data...')) # Menu item + self.file_menu.entryconfigure(2, label=_('Settings')) # Item in the File menu on Windows + self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows - ## Help menu - self.help_menu.entryconfigure(0, label=_('Documentation')) # Help menu item - self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item - self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item - self.help_menu.entryconfigure(3, label=_('Check for Updates...')) # Menu item - self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry + # Help menu + self.help_menu.entryconfigure(0, label=_('Documentation')) # Help menu item + self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item + self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item + self.help_menu.entryconfigure(3, label=_('Check for Updates...')) # Menu item + self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry - ## Edit menu - self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste + # Edit menu + self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste def login(self): if not self.status['text']: self.status['text'] = _('Logging in...') self.button['state'] = self.theme_button['state'] = tk.DISABLED if platform == 'darwin': - self.view_menu.entryconfigure(0, state=tk.DISABLED) # Status - self.file_menu.entryconfigure(0, state=tk.DISABLED) # Save Raw Data + self.view_menu.entryconfigure(0, state=tk.DISABLED) # Status + self.file_menu.entryconfigure(0, state=tk.DISABLED) # Save Raw Data else: - self.file_menu.entryconfigure(0, state=tk.DISABLED) # Status - self.file_menu.entryconfigure(1, state=tk.DISABLED) # Save Raw Data + self.file_menu.entryconfigure(0, state=tk.DISABLED) # Status + self.file_menu.entryconfigure(1, state=tk.DISABLED) # Save Raw Data self.w.update_idletasks() try: if companion.session.login(monitor.cmdr, monitor.is_beta): - self.status['text'] = _('Authentication successful') # Successfully authenticated with the Frontier website + # Successfully authenticated with the Frontier website + self.status['text'] = _('Authentication successful') if platform == 'darwin': - self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status - self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data + self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status + self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data else: - self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status - self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data + self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status + self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e: self.status['text'] = str(e) except Exception as e: - if __debug__: print_exc() + logger.debug('Frontier CAPI Auth', exc_info=e) self.status['text'] = str(e) self.cooldown() @@ -420,7 +433,7 @@ class AppWindow(object): play_bad = False if not monitor.cmdr or not monitor.mode or monitor.state['Captain'] or not monitor.system: - return # In CQC or on crew - do nothing + return # In CQC or on crew - do nothing if companion.session.state == companion.Session.STATE_AUTH: # Attempt another Auth @@ -428,10 +441,10 @@ class AppWindow(object): return if not retrying: - if time() < self.holdofftime: # Was invoked by key while in cooldown + if time() < self.holdofftime: # Was invoked by key while in cooldown self.status['text'] = '' if play_sound and (self.holdofftime-time()) < companion.holdoff*0.75: - hotkeymgr.play_bad() # Don't play sound in first few seconds to prevent repeats + hotkeymgr.play_bad() # Don't play sound in first few seconds to prevent repeats return elif play_sound: hotkeymgr.play_good() @@ -446,31 +459,41 @@ class AppWindow(object): # Validation if not data.get('commander', {}).get('name'): - self.status['text'] = _("Who are you?!") # Shouldn't happen - elif (not data.get('lastSystem', {}).get('name') or - (data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked - self.status['text'] = _("Where are you?!") # Shouldn't happen + self.status['text'] = _("Who are you?!") # Shouldn't happen + elif (not data.get('lastSystem', {}).get('name') + or (data['commander'].get('docked') + and not data.get('lastStarport', {}).get('name'))): # Only care if docked + self.status['text'] = _("Where are you?!") # Shouldn't happen elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'): - self.status['text'] = _("What are you flying?!") # Shouldn't happen + self.status['text'] = _("What are you flying?!") # Shouldn't happen elif monitor.cmdr and data['commander']['name'] != monitor.cmdr: - raise companion.CmdrError() # Companion API return doesn't match Journal - elif ((auto_update and not data['commander'].get('docked')) or - (data['lastSystem']['name'] != monitor.system) or - ((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or - (data['ship']['id'] != monitor.state['ShipID']) or - (data['ship']['name'].lower() != monitor.state['ShipType'])): + # Companion API return doesn't match Journal + raise companion.CmdrError() + elif ((auto_update and not data['commander'].get('docked')) + or (data['lastSystem']['name'] != monitor.system) + or ((data['commander']['docked'] + and data['lastStarport']['name'] or None) != monitor.station) + or (data['ship']['id'] != monitor.state['ShipID']) + or (data['ship']['name'].lower() != monitor.state['ShipType'])): raise companion.ServerLagging() else: - if __debug__: # Recording + if __debug__: # Recording if isdir('dump'): - with open('dump/%s%s.%s.json' % (data['lastSystem']['name'], data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '', strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h: - h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) + with open('dump/{system}{station}.{timestamp}.json'.format( + system=data['lastSystem']['name'], + station=data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '', + timestamp=strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h: + h.write(json.dumps(data, + ensure_ascii=False, + indent=2, + sort_keys=True, + separators=(',', ': ')).encode('utf-8')) - if not monitor.state['ShipType']: # Started game in SRV or fighter + if not monitor.state['ShipType']: # Started game in SRV or fighter self.ship['text'] = companion.ship_map.get(data['ship']['name'].lower(), data['ship']['name']) - monitor.state['ShipID'] = data['ship']['id'] + monitor.state['ShipID'] = data['ship']['id'] monitor.state['ShipType'] = data['ship']['name'].lower() if data['commander'].get('credits') is not None: @@ -487,16 +510,19 @@ class AppWindow(object): if config.getint('output') & (config.OUT_STATION_ANY): if not data['commander'].get('docked'): if not self.status['text']: - # Signal as error because the user might actually be docked but the server hosting the Companion API hasn't caught up + # Signal as error because the user might actually be docked + # but the server hosting the Companion API hasn't caught up self.status['text'] = _("You're not docked at a station!") play_bad = True - elif (config.getint('output') & config.OUT_MKT_EDDN) and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): # Ignore possibly missing shipyard info + # Ignore possibly missing shipyard info + elif (config.getint('output') & config.OUT_MKT_EDDN)\ + and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): if not self.status['text']: self.status['text'] = _("Station doesn't have anything!") elif not data['lastStarport'].get('commodities'): if not self.status['text']: self.status['text'] = _("Station doesn't have a market!") - elif config.getint('output') & (config.OUT_MKT_CSV|config.OUT_MKT_TD): + elif config.getint('output') & (config.OUT_MKT_CSV | config.OUT_MKT_TD): # Fixup anomalies in the commodity data fixed = companion.fixup(data) if config.getint('output') & config.OUT_MKT_CSV: @@ -513,22 +539,22 @@ class AppWindow(object): play_bad = True else: # Retry once if Companion server is unresponsive - self.w.after(int(SERVER_RETRY * 1000), lambda:self.getandsend(event, True)) - return # early exit to avoid starting cooldown count + self.w.after(int(SERVER_RETRY * 1000), lambda: self.getandsend(event, True)) + return # early exit to avoid starting cooldown count - except companion.CmdrError as e: # Companion API return doesn't match Journal + except companion.CmdrError as e: # Companion API return doesn't match Journal self.status['text'] = str(e) play_bad = True companion.session.invalidate() self.login() - except Exception as e: # Including CredentialsError, ServerError - if __debug__: print_exc() + except Exception as e: # Including CredentialsError, ServerError + logger.debug('"other" exception', exc_info=e) self.status['text'] = str(e) play_bad = True - if not self.status['text']: # no errors - self.status['text'] = strftime(_('Last updated at %H:%M:%S').format(HH='%H', MM='%M', SS='%S'), localtime(querytime)) + if not self.status['text']: # no errors + self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime)) if play_sound and play_bad: hotkeymgr.play_bad() @@ -538,17 +564,24 @@ class AppWindow(object): # Try again to get shipyard data and send to EDDN. Don't report errors if can't get or send the data. try: data = companion.session.station() - if __debug__: - print('Retry for shipyard - ' + (data['commander'].get('docked') and (data.get('lastStarport', {}).get('ships') and 'Success' or 'Failure') or 'Undocked!')) + if data['commander'].get('docked'): + if data.get('lastStarport', {}).get('ships'): + report = 'Success' + else: + report = 'Failure' + else: + report = 'Undocked!' + logger.debug(f'Retry for shipyard - {report}') if not data['commander'].get('docked'): - pass # might have undocked while we were waiting for retry in which case station data is unreliable + # might have un-docked while we were waiting for retry in which case station data is unreliable + pass elif (data.get('lastSystem', {}).get('name') == monitor.system and data.get('lastStarport', {}).get('name') == monitor.station and data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')): self.eddn.export_shipyard(data, monitor.is_beta) - elif tries > 1: # bogus data - retry - self.w.after(int(SERVER_RETRY * 1000), lambda:self.retry_for_shipyard(tries-1)) - except: + elif tries > 1: # bogus data - retry + self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries-1)) + except Exception: pass # Handle event(s) from the journal @@ -559,9 +592,9 @@ class AppWindow(object): return { None: '', 'Idle': '', - 'FighterCon': _('Fighter'), # Multicrew role - 'FireCon': _('Gunner'), # Multicrew role - 'FlightCon': _('Helm'), # Multicrew role + 'FighterCon': _('Fighter'), # Multicrew role + 'FireCon': _('Gunner'), # Multicrew role + 'FlightCon': _('Helm'), # Multicrew role }.get(role, role) while True: @@ -572,26 +605,43 @@ class AppWindow(object): # Update main window self.cooldown() if monitor.cmdr and monitor.state['Captain']: - self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.state['Captain']) - self.ship_label['text'] = _('Role') + ':' # Multicrew role label in main window - self.ship.configure(state = tk.NORMAL, text = crewroletext(monitor.state['Role']), url = None) + self.cmdr['text'] = f'{monitor.cmdr} / {monitor.state["Captain"]}' + self.ship_label['text'] = _('Role') + ':' # Multicrew role label in main window + self.ship.configure(state=tk.NORMAL, text=crewroletext(monitor.state['Role']), url=None) elif monitor.cmdr: if monitor.group: - self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.group) + self.cmdr['text'] = f'{monitor.cmdr} / {monitor.group}' else: self.cmdr['text'] = monitor.cmdr - self.ship_label['text'] = _('Ship') + ':' # Main window - self.ship.configure(text = monitor.state['ShipName'] or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType']) or '', - url = self.shipyard_url) + self.ship_label['text'] = _('Ship') + ':' # Main window + self.ship.configure( + text=monitor.state['ShipName'] + or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType']) + or '', + url=self.shipyard_url) else: self.cmdr['text'] = '' - self.ship_label['text'] = _('Ship') + ':' # Main window + self.ship_label['text'] = _('Ship') + ':' # Main window self.ship['text'] = '' - self.edit_menu.entryconfigure(0, state=monitor.system and tk.NORMAL or tk.DISABLED) # Copy + self.edit_menu.entryconfigure(0, state=monitor.system and tk.NORMAL or tk.DISABLED) # Copy - if entry['event'] in ['Undocked', 'StartJump', 'SetUserShipName', 'ShipyardBuy', 'ShipyardSell', 'ShipyardSwap', 'ModuleBuy', 'ModuleSell', 'MaterialCollected', 'MaterialDiscarded', 'ScientificResearch', 'EngineerCraft', 'Synthesis', 'JoinACrew']: - self.status['text'] = '' # Periodically clear any old error + if entry['event'] in ( + 'Undocked', + 'StartJump', + 'SetUserShipName', + 'ShipyardBuy', + 'ShipyardSell', + 'ShipyardSwap', + 'ModuleBuy', + 'ModuleSell', + 'MaterialCollected', + 'MaterialDiscarded', + 'ScientificResearch', + 'EngineerCraft', + 'Synthesis', + 'JoinACrew'): + self.status['text'] = '' # Periodically clear any old error self.w.update_idletasks() # Companion login @@ -601,53 +651,64 @@ class AppWindow(object): self.login() if not entry['event'] or not monitor.mode: - return # Startup or in CQC + return # Startup or in CQC if entry['event'] in ['StartUp', 'LoadGame'] and monitor.started: # Disable WinSparkle automatic update checks, IFF configured to do so when in-game if config.getint('disable_autoappupdatecheckingame') and 1: self.updater.setAutomaticUpdatesCheck(False) - print('Monitor: Disable WinSparkle automatic update checks') + logger.info('Monitor: Disable WinSparkle automatic update checks') # Can start dashboard monitoring if not dashboard.start(self.w, monitor.started): - print("Can't start Status monitoring") + logger.info("Can't start Status monitoring") # Export loadout - if entry['event'] == 'Loadout' and not monitor.state['Captain'] and config.getint('output') & config.OUT_SHIP: + if entry['event'] == 'Loadout' and not monitor.state['Captain']\ + and config.getint('output') & config.OUT_SHIP: monitor.export_ship() # Plugins - err = plug.notify_journal_entry(monitor.cmdr, monitor.is_beta, monitor.system, monitor.station, entry, monitor.state) + err = plug.notify_journal_entry(monitor.cmdr, + monitor.is_beta, + monitor.system, + monitor.station, + entry, + monitor.state) if err: self.status['text'] = err if not config.getint('hotkey_mute'): hotkeymgr.play_bad() # Auto-Update after docking, but not if auth callback is pending - if entry['event'] in ['StartUp', 'Location', 'Docked'] and monitor.station and not config.getint('output') & config.OUT_MKT_MANUAL and config.getint('output') & config.OUT_STATION_ANY and companion.session.state != companion.Session.STATE_AUTH: + if entry['event'] in ('StartUp', 'Location', 'Docked')\ + and monitor.station\ + and not config.getint('output') & config.OUT_MKT_MANUAL\ + and config.getint('output') & config.OUT_STATION_ANY\ + and companion.session.state != companion.Session.STATE_AUTH: self.w.after(int(SERVER_RETRY * 1000), self.getandsend) if entry['event'] == 'ShutDown': # Enable WinSparkle automatic update checks # NB: Do this blindly, in case option got changed whilst in-game self.updater.setAutomaticUpdatesCheck(True) - print('Monitor: Enable WinSparkle automatic update checks') + logger.info('Monitor: Enable WinSparkle automatic update checks') # cAPI auth def auth(self, event=None): try: companion.session.auth_callback() - self.status['text'] = _('Authentication successful') # Successfully authenticated with the Frontier website + # Successfully authenticated with the Frontier website + self.status['text'] = _('Authentication successful') if platform == 'darwin': - self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status - self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data + self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status + self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data else: - self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status - self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data + self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status + self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data except companion.ServerError as e: self.status['text'] = str(e) except Exception as e: - if __debug__: print_exc() + logger.debug('Frontier CAPI Auth:', exc_info=e) self.status['text'] = str(e) self.cooldown() @@ -685,7 +746,7 @@ class AppWindow(object): provider_name=html.escape(str(provider)), ship_name=html.escape(str(shipname)) ), file=f) - + return f'file://localhost/{file_name}' def system_url(self, system): @@ -696,10 +757,12 @@ class AppWindow(object): def cooldown(self): if time() < self.holdofftime: - self.button['text'] = self.theme_button['text'] = _('cooldown {SS}s').format(SS = int(self.holdofftime - time())) # Update button in main window + # Update button in main window + self.button['text'] = self.theme_button['text'] \ + = _('cooldown {SS}s').format(SS=int(self.holdofftime - time())) self.w.after(1000, self.cooldown) else: - self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window + self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window self.button['state'] = self.theme_button['state'] = (monitor.cmdr and monitor.mode and not monitor.state['Captain'] and @@ -713,7 +776,7 @@ class AppWindow(object): def copy(self, event=None): if monitor.system: self.w.clipboard_clear() - self.w.clipboard_append(monitor.station and '%s,%s' % (monitor.system, monitor.station) or monitor.system) + self.w.clipboard_append(monitor.station and f'{monitor.system},{monitor.station}' or monitor.system) def help_general(self, event=None): webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki') @@ -724,7 +787,7 @@ class AppWindow(object): def help_releases(self, event=None): webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases') - class help_about(tk.Toplevel): + class HelpAbout(tk.Toplevel): showing = False def __init__(self, parent): @@ -741,22 +804,19 @@ class AppWindow(object): self.transient(parent) # position over parent - if platform!='darwin' or parent.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 - self.geometry("+%d+%d" % (parent.winfo_rootx(), parent.winfo_rooty())) + # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 + if platform != 'darwin' or parent.winfo_rooty() > 0: + self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}') # remove decoration - if platform=='win32': + if platform == 'win32': self.attributes('-toolwindow', tk.TRUE) - + self.resizable(tk.FALSE, tk.FALSE) frame = ttk.Frame(self) frame.grid(sticky=tk.NSEW) - PADX = 10 - BUTTONX = 12 # indent Checkbuttons and Radiobuttons - PADY = 2 # close spacing - row = 1 ############################################################ # applongname @@ -772,8 +832,8 @@ class AppWindow(object): self.appversion_label = tk.Label(frame, text=appversion) self.appversion_label.grid(row=row, column=0, sticky=tk.E) self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=_('Release Notes'), - url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/{VERSION}'.format( - VERSION=appversion_nobuild), + url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/' + f'{appversion_nobuild}', underline=True) self.appversion.grid(row=row, column=2, sticky=tk.W) row += 1 @@ -798,11 +858,11 @@ class AppWindow(object): row += 1 button = ttk.Button(frame, text=_('OK'), command=self.apply) button.grid(row=row, column=2, sticky=tk.E) - button.bind("", lambda event:self.apply()) + button.bind("", lambda event: self.apply()) self.protocol("WM_DELETE_WINDOW", self._destroy) ############################################################ - print('Current version is {}'.format(appversion)) + logger.info(f'Current version is {appversion}') def apply(self): self._destroy() @@ -819,24 +879,37 @@ class AppWindow(object): try: data = companion.session.station() self.status['text'] = '' - f = tkinter.filedialog.asksaveasfilename(parent = self.w, - defaultextension = platform=='darwin' and '.json' or '', - filetypes = [('JSON', '.json'), ('All Files', '*')], - initialdir = config.get('outdir'), - initialfile = '%s%s.%s.json' % (data.get('lastSystem', {}).get('name', 'Unknown'), data['commander'].get('docked') and '.'+data.get('lastStarport', {}).get('name', 'Unknown') or '', strftime('%Y-%m-%dT%H.%M.%S', localtime()))) + default_extension: str = '' + if platform == 'darwin': + default_extension = '.json' + last_system: str = data.get("lastSystem", {}).get("name", "Unknown") + last_starport: str = '' + if data['commander'].get('docked'): + last_starport = '.'+data.get('lastStarport', {}).get('name', 'Unknown') + timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime()) + f = tkinter.filedialog.asksaveasfilename(parent=self.w, + defaultextension=default_extension, + filetypes=[('JSON', '.json'), ('All Files', '*')], + initialdir=config.get('outdir'), + initialfile=f'{last_system}{last_starport}.{timestamp}') if f: with open(f, 'wb') as h: - h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) + h.write(json.dumps(data, + ensure_ascii=False, + indent=2, + sort_keys=True, + separators=(',', ': ')).encode('utf-8')) except companion.ServerError as e: self.status['text'] = str(e) except Exception as e: - if __debug__: print_exc() + logger.debug('"other" exception', exc_info=e) self.status['text'] = str(e) def onexit(self, event=None): - if platform!='darwin' or self.w.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 + # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 + if platform != 'darwin' or self.w.winfo_rooty() > 0: config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+'))) - self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen + self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen protocolhandler.close() hotkeymgr.unregister() dashboard.close() @@ -852,17 +925,19 @@ class AppWindow(object): def drag_continue(self, event): if self.drag_offset: - self.w.geometry('+%d+%d' % (event.x_root - self.drag_offset[0], event.y_root - self.drag_offset[1])) + offset_x = event.x_root - self.drag_offset[0] + offset_y = event.y_root - self.drag_offset[1] + self.w.geometry(f'+{offset_x:d}+{offset_y:d}') def drag_end(self, event): self.drag_offset = None def oniconify(self, event=None): - self.w.overrideredirect(0) # Can't iconize while overrideredirect + self.w.overrideredirect(0) # Can't iconize while overrideredirect self.w.iconify() - self.w.update_idletasks() # Size and windows styles get recalculated here - self.w.wait_visibility() # Need main window to be re-created before returning - theme.active = None # So theme will be re-applied on map + self.w.update_idletasks() # Size and windows styles get recalculated here + self.w.wait_visibility() # Need main window to be re-created before returning + theme.active = None # So theme will be re-applied on map def onmap(self, event=None): if event.widget == self.w: @@ -875,73 +950,102 @@ class AppWindow(object): self.theme_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) def onleave(self, event=None): - if config.getint('theme') > 1 and event.widget==self.w: + if config.getint('theme') > 1 and event.widget == self.w: self.w.attributes("-transparentcolor", 'grey4') self.theme_menubar.grid_remove() self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) -# Run the app -if __name__ == "__main__": +def enforce_single_instance() -> None: # Ensure only one copy of the app is running under this user account. OSX does this automatically. Linux TODO. if platform == 'win32': import ctypes - from ctypes.wintypes import * - EnumWindows = ctypes.windll.user32.EnumWindows - GetClassName = ctypes.windll.user32.GetClassNameW - GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] - GetWindowText = ctypes.windll.user32.GetWindowTextW - GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] - GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW - GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd + from ctypes.wintypes import HWND, LPWSTR, LPCWSTR, INT, BOOL, LPARAM + EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806 + GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806 + GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806 + GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806 + GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806 + GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806 + GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806 - SW_RESTORE = 9 - SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow - ShowWindow = ctypes.windll.user32.ShowWindow - ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync + SW_RESTORE = 9 # noqa: N806 + SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806 + ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806 + ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806 - COINIT_MULTITHREADED = 0 - COINIT_APARTMENTTHREADED = 0x2 - COINIT_DISABLE_OLE1DDE = 0x4 - CoInitializeEx = ctypes.windll.ole32.CoInitializeEx + COINIT_MULTITHREADED = 0 # noqa: N806,F841 + COINIT_APARTMENTTHREADED = 0x2 # noqa: N806 + COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806 + CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806 - ShellExecute = ctypes.windll.shell32.ShellExecuteW - ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT] + ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806 + ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT] - def WindowTitle(h): + def window_title(h): if h: - l = GetWindowTextLength(h) + 1 - buf = ctypes.create_unicode_buffer(l) - if GetWindowText(h, buf, l): + text_length = GetWindowTextLength(h) + 1 + buf = ctypes.create_unicode_buffer(text_length) + if GetWindowText(h, buf, text_length): return buf.value return None @ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) - def enumwindowsproc(hWnd, lParam): + def enumwindowsproc(window_handle, l_param): # class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576 cls = ctypes.create_unicode_buffer(257) - if GetClassName(hWnd, cls, 257) and cls.value == 'TkTopLevel' and WindowTitle(hWnd) == applongname and GetProcessHandleFromHwnd(hWnd): + if GetClassName(window_handle, cls, 257)\ + and cls.value == 'TkTopLevel'\ + and window_title(window_handle) == applongname\ + and GetProcessHandleFromHwnd(window_handle): # If GetProcessHandleFromHwnd succeeds then the app is already running as this user if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler.redirect): # Browser invoked us directly with auth response. Forward the response to the other app instance. CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) - ShowWindow(hWnd, SW_RESTORE) # Wait for it to be responsive to avoid ShellExecute recursing + # Wait for it to be responsive to avoid ShellExecute recursing + ShowWindow(window_handle, SW_RESTORE) ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE) else: - ShowWindowAsync(hWnd, SW_RESTORE) - SetForegroundWindow(hWnd) + ShowWindowAsync(window_handle, SW_RESTORE) + SetForegroundWindow(window_handle) sys.exit(0) return True EnumWindows(enumwindowsproc, 0) + +def test_logging(): + logger.debug('Test from EDMarketConnector.py top-level test_logging()') + + +# Run the app +if __name__ == "__main__": + # Keep this as the very first code run to be as sure as possible of no + # output until after this redirect is done, if needed. if getattr(sys, 'frozen', False): # By default py2exe tries to write log to dirname(sys.executable) which fails when installed import tempfile - sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), '%s.log' % appname), 'wt', 1) # unbuffered not allowed for text in python3, so use line buffering - print('%s %s %s' % (applongname, appversion, strftime('%Y-%m-%dT%H:%M:%S', localtime()))) + # unbuffered not allowed for text in python3, so use `1 for line buffering + sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='wt', buffering=1) - Translations.install(config.get('language') or None) # Can generate errors so wait til log set up + enforce_single_instance() + + logger = EDMCLogging.Logger(appname).get_logger() + + # TODO: unittests in place of these + # logger.debug('Test from __main__') + # test_logging() + class A(object): + class B(object): + def __init__(self): + logger.debug('A call from A.B.__init__') + + # abinit = A.B() + + # Plain, not via `logger` + print(f'{applongname} {appversion}') + + Translations.install(config.get('language') or None) # Can generate errors so wait til log set up root = tk.Tk(className=appname.lower()) app = AppWindow(root) diff --git a/L10n/es.strings b/L10n/es.strings index 8dc4eb49..31b4234e 100644 --- a/L10n/es.strings +++ b/L10n/es.strings @@ -236,6 +236,7 @@ "Language" = "Idioma"; /* [EDMarketConnector.py] - Leave '%H:%M:%S' as-is */ +>>>>>>> develop "Last updated at %H:%M:%S" = "Última actualización: %H:%M:%S"; /* Federation rank. [stats.py] */ diff --git a/PLUGINS.md b/PLUGINS.md index 8a43bf6f..8e1a8d77 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -47,6 +47,9 @@ breaking with future code changes.** `from monitor import gamerunning` - in case a plugin needs to know if we think the game is running. + +`import timeout_session` - provides a method called `new_session` that creates a requests.session with a default timeout +on all requests. Recommended to reduce noise in HTTP requests ```python diff --git a/companion.py b/companion.py index 57911342..3bb23fa1 100644 --- a/companion.py +++ b/companion.py @@ -4,9 +4,10 @@ from builtins import object import base64 import csv import requests +from typing import TYPE_CHECKING # TODO: see https://github.com/EDCD/EDMarketConnector/issues/569 -from http.cookiejar import LWPCookieJar # No longer needed but retained in case plugins use it +from http.cookiejar import LWPCookieJar # noqa: F401 - No longer needed but retained in case plugins use it from email.utils import parsedate import hashlib import numbers @@ -14,14 +15,15 @@ import os from os.path import join import random import time -from traceback import print_exc import urllib.parse import webbrowser from config import appname, appversion, config from protocol import protocolhandler +import logging +logger = logging.getLogger(appname) + -from typing import TYPE_CHECKING if TYPE_CHECKING: _ = lambda x: x # noqa # to make flake8 stop complaining that the hacked in _ method doesnt exist @@ -196,29 +198,28 @@ class Auth(object): return data.get('access_token') else: - print('Auth\tCan\'t refresh token for {}'.format(self.cmdr)) + logger.error(f"Frontier CAPI Auth: Can't refresh token for \"{self.cmdr}\"") self.dump(r) - except Exception: - print('Auth\tCan\'t refresh token for {}'.format(self.cmdr)) - print_exc() + except Exception as e: + logger.exception(f"Frontier CAPI Auth: Can't refresh token for \"{self.cmdr}\"") else: - print('Auth\tNo token for {}'.format(self.cmdr)) + logger.error(f"Frontier CAPI Auth: No token for \"{self.cmdr}\"") # New request - print('Auth\tNew authorization request') + logger.info('Frontier CAPI Auth: New authorization request') v = random.SystemRandom().getrandbits(8 * 32) - self.verifier = self.base64URLEncode(v.to_bytes(32, byteorder='big')).encode('utf-8') + self.verifier = self.base64_url_encode(v.to_bytes(32, byteorder='big')).encode('utf-8') s = random.SystemRandom().getrandbits(8 * 32) - self.state = self.base64URLEncode(s.to_bytes(32, byteorder='big')) + self.state = self.base64_url_encode(s.to_bytes(32, byteorder='big')) # Won't work under IE: https://blogs.msdn.microsoft.com/ieinternals/2011/07/13/understanding-protocols/ webbrowser.open( '{server_auth}{url_auth}?response_type=code&audience=frontier&scope=capi&client_id={client_id}&code_challenge={challenge}&code_challenge_method=S256&state={state}&redirect_uri={redirect}'.format( # noqa: E501 # I cant make this any shorter server_auth=SERVER_AUTH, url_auth=URL_AUTH, client_id=CLIENT_ID, - challenge=self.base64URLEncode(hashlib.sha256(self.verifier).digest()), + challenge=self.base64_url_encode(hashlib.sha256(self.verifier).digest()), state=self.state, redirect=protocolhandler.redirect ) @@ -228,16 +229,16 @@ class Auth(object): # Handle OAuth authorization code callback. # Returns access token if successful, otherwise raises CredentialsError if '?' not in payload: - print('Auth\tMalformed response {!r}'.format(payload)) + logger.error(f'Frontier CAPI Auth: Malformed response\n{payload}\n') raise CredentialsError('malformed payload') # Not well formed data = urllib.parse.parse_qs(payload[(payload.index('?') + 1):]) if not self.state or not data.get('state') or data['state'][0] != self.state: - print('Auth\tUnexpected response {!r}'.format(payload)) + logger.error(f'Frontier CAPI Auth: Unexpected response\n{payload}\n') raise CredentialsError('Unexpected response from authorization {!r}'.format(payload)) # Unexpected reply if not data.get('code'): - print('Auth\tNegative response {!r}'.format(payload)) + logger.error(f'Frontier CAPI Auth: Negative response\n{payload}\n') error = next( (data[k] for k in ('error_description', 'error', 'message') if k in data), ('',) ) @@ -256,7 +257,7 @@ class Auth(object): r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=auth_timeout) data = r.json() if r.status_code == requests.codes.ok: - print('Auth\tNew token for {}'.format(self.cmdr)) + logger.info(f'Frontier CAPI Auth: New token for \"{self.cmdr}\"') cmdrs = config.get('cmdrs') idx = cmdrs.index(self.cmdr) tokens = config.get('fdev_apikeys') or [] @@ -268,21 +269,20 @@ class Auth(object): return data.get('access_token') except Exception as e: - print('Auth\tCan\'t get token for {}'.format(self.cmdr)) - print_exc() + logger.exception(f"Frontier CAPI Auth: Can't get token for \"{self.cmdr}\"") if r: self.dump(r) raise CredentialsError('unable to get token') from e - print('Auth\tCan\'t get token for {}'.format(self.cmdr)) + logger.error(f"Frontier CAPI Auth: Can't get token for \"{self.cmdr}\"") self.dump(r) error = next((data[k] for k in ('error_description', 'error', 'message') if k in data), ('',)) raise CredentialsError('Error: {!r}'.format(error)[0]) @staticmethod def invalidate(cmdr): - print('Auth\tInvalidated token for {}'.format(cmdr)) + logger.info(f'Frontier CAPI Auth: Invalidated token for "{cmdr}"') cmdrs = config.get('cmdrs') idx = cmdrs.index(cmdr) tokens = config.get('fdev_apikeys') or [] @@ -292,9 +292,9 @@ class Auth(object): config.save() # Save settings now for use by command-line app def dump(self, r): - print('Auth\t' + r.url, r.status_code, r.reason if r.reason else 'None', r.text) + logger.debug(f'Frontier CAPI Auth: {r.url} {r.status_code} {r.reason if r.reason else "None"} {r.text}') - def base64URLEncode(self, text): + def base64_url_encode(self, text): return base64.urlsafe_b64encode(text).decode().replace('=', '') @@ -379,10 +379,8 @@ class Session(object): r = self.session.get(self.server + endpoint, timeout=timeout) except Exception as e: - if __debug__: - print_exc() - - raise ServerError('unable to get endpoint {}'.format(endpoint)) from e + logger.debug('Attempting GET', exc_info=e) + raise ServerError(f'unable to get endpoint {endpoint}') from e if r.url.startswith(SERVER_AUTH): # Redirected back to Auth server - force full re-authentication @@ -402,7 +400,7 @@ class Session(object): data = r.json() # May also fail here if token expired since response is empty except (requests.HTTPError, ValueError) as e: - print_exc() + logger.exception('Frontier CAPI Auth: GET ') self.dump(r) self.close() @@ -410,6 +408,7 @@ class Session(object): self.invalidate() self.retrying = False self.login() + logger.error('Frontier CAPI Auth: query failed after refresh') raise CredentialsError('query failed after refresh') from e elif self.login(): # Maybe our token expired. Re-authorize in any case @@ -418,6 +417,7 @@ class Session(object): else: self.retrying = False + logger.error('Frontier CAPI Auth: HTTP error or invalid JSON') raise CredentialsError('HTTP error or invalid JSON') from e self.retrying = False @@ -461,9 +461,8 @@ class Session(object): try: self.session.close() - except Exception: - if __debug__: - print_exc() + except Exception as e: + logger.debug('Frontier CAPI Auth: closing', exc_info=e) self.session = None @@ -473,7 +472,7 @@ class Session(object): Auth.invalidate(self.credentials['cmdr']) def dump(self, r): - print('cAPI\t' + r.url, r.status_code, r.reason and r.reason or 'None', r.text) + logger.error(f'Frontier CAPI Auth: {r.url} {r.status_code} {r.reason and r.reason or "None"} {r.text}') # Returns a shallow copy of the received data suitable for export to older tools # English commodity names and anomalies fixed up @@ -497,15 +496,7 @@ def fixup(data): # But also see https://github.com/Marginal/EDMarketConnector/issues/32 for thing in ('buyPrice', 'sellPrice', 'demand', 'demandBracket', 'stock', 'stockBracket'): if not isinstance(commodity.get(thing), numbers.Number): - if __debug__: - print( - 'Invalid {!r}:{!r} ({}) for {!r}'.format( - thing, - commodity.get(thing), - type(commodity.get(thing)), - commodity.get('name', '') - ) - ) + logger.debug(f'Invalid {thing}:{commodity.get(thing)} ({type(commodity.get(thing))}) for {commodity.get("name", "")}') # noqa: E501 break else: @@ -520,20 +511,16 @@ def fixup(data): pass elif not commodity.get('categoryname'): - if __debug__: - print('Missing "categoryname" for {!r}'.format(commodity.get('name', ''))) + logger.debug(f'Missing "categoryname" for {commodity.get("name", "")}') elif not commodity.get('name'): - if __debug__: - print('Missing "name" for a commodity in {!r}'.format(commodity.get('categoryname', ''))) + logger.debug(f'Missing "name" for a commodity in {commodity.get("categoryname", "")}') elif not commodity['demandBracket'] in range(4): - if __debug__: - print('Invalid "demandBracket":{!r} for {!r}'.format(commodity['demandBracket'], commodity['name'])) + logger.debug(f'Invalid "demandBracket":{commodity["demandBracket"]} for {commodity["name"]}') elif not commodity['stockBracket'] in range(4): - if __debug__: - print('Invalid "stockBracket":{!r} for {!r}'.format(commodity['stockBracket'], commodity['name'])) + logger.debug(f'Invalid "stockBracket":{commodity["stockBracket"]} for {commodity["name"]}') else: # Rewrite text fields diff --git a/monitor.py b/monitor.py index 90988dfd..e1fbf037 100644 --- a/monitor.py +++ b/monitor.py @@ -3,11 +3,15 @@ import json import re import threading from operator import itemgetter -from os import listdir, SEEK_SET, SEEK_CUR, SEEK_END +from os import listdir, SEEK_SET, SEEK_END from os.path import basename, expanduser, isdir, join from sys import platform from time import gmtime, localtime, sleep, strftime, strptime, time from calendar import timegm +from typing import Any, Optional, OrderedDict as OrderedDictT, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + import tkinter if __debug__: from traceback import print_exc @@ -16,51 +20,53 @@ from config import config from companion import ship_file_name -if platform=='darwin': +if platform == 'darwin': from AppKit import NSWorkspace - from Foundation import NSSearchPathForDirectoriesInDomains, NSApplicationSupportDirectory, NSUserDomainMask from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from fcntl import fcntl F_GLOBAL_NOCACHE = 55 -elif platform=='win32': +elif platform == 'win32': from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler import ctypes - from ctypes.wintypes import * + from ctypes.wintypes import BOOL, HWND, LPARAM, LPWSTR - EnumWindows = ctypes.windll.user32.EnumWindows - EnumWindowsProc = ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) + EnumWindows = ctypes.windll.user32.EnumWindows + EnumWindowsProc = ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) - CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle = ctypes.windll.kernel32.CloseHandle - GetWindowText = ctypes.windll.user32.GetWindowTextW + GetWindowText = ctypes.windll.user32.GetWindowTextW GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] - GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW + GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd else: # Linux's inotify doesn't work over CIFS or NFS, so poll - FileSystemEventHandler = object # dummy + FileSystemEventHandler = object # dummy # Journal handler -class EDLogs(FileSystemEventHandler): +class EDLogs(FileSystemEventHandler): # type: ignore # See below + # Magic with FileSystemEventHandler can confuse type checkers when they do not have access to every import _POLL = 1 # Polling is cheap, so do it often _RE_CANONICALISE = re.compile(r'\$(.+)_name;') _RE_CATEGORY = re.compile(r'\$MICRORESOURCE_CATEGORY_(.+);') + _RE_LOGFILE = re.compile(r'^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$') def __init__(self): - FileSystemEventHandler.__init__(self) # futureproofing - not need for current version of watchdog + # TODO(A_D): A bunch of these should be switched to default values (eg '' for strings) and no longer be Optional + FileSystemEventHandler.__init__(self) # futureproofing - not need for current version of watchdog self.root = None - self.currentdir = None # The actual logdir that we're monitoring - self.logfile = None + self.currentdir: Optional[str] = None # The actual logdir that we're monitoring + self.logfile: Optional[str] = None self.observer = None self.observed = None # a watchdog ObservedWatch, or None if polling - self.thread = None + self.thread: Optional[threading.Thread] = None self.event_queue = [] # For communicating journal entries back to main thread # On startup we might be: @@ -71,52 +77,52 @@ class EDLogs(FileSystemEventHandler): # If 3 we need to inject a special 'StartUp' event since consumers won't see the LoadGame event. self.live = False - self.game_was_running = False # For generation the "ShutDown" event + self.game_was_running = False # For generation of the "ShutDown" event # Context for journal handling - self.version = None + self.version: Optional[str] = None self.is_beta = False - self.mode = None - self.group = None - self.cmdr = None - self.planet = None - self.system = None - self.station = None - self.station_marketid = None - self.stationtype = None - self.coordinates = None - self.systemaddress = None - self.started = None # Timestamp of the LoadGame event + self.mode: Optional[str] = None + self.group: Optional[str] = None + self.cmdr: Optional[str] = None + self.planet: Optional[str] = None + self.system: Optional[str] = None + self.station: Optional[str] = None + self.station_marketid: Optional[int] = None + self.stationtype: Optional[str] = None + self.coordinates: Optional[Tuple[int, int, int]] = None + self.systemaddress: Optional[int] = None + self.started: Optional[int] = None # Timestamp of the LoadGame event # Cmdr state shared with EDSM and plugins # If you change anything here update PLUGINS.md documentation! self.state = { - 'Captain' : None, # On a crew - 'Cargo' : defaultdict(int), - 'Credits' : None, - 'FID' : None, # Frontier Cmdr ID - 'Horizons' : None, # Does this user have Horizons? - 'Loan' : None, - 'Raw' : defaultdict(int), - 'Manufactured' : defaultdict(int), - 'Encoded' : defaultdict(int), - 'Engineers' : {}, - 'Rank' : {}, - 'Reputation' : {}, - 'Statistics' : {}, - 'Role' : None, # Crew role - None, Idle, FireCon, FighterCon - 'Friends' : set(), # Online friends - 'ShipID' : None, - 'ShipIdent' : None, - 'ShipName' : None, - 'ShipType' : None, - 'HullValue' : None, - 'ModulesValue' : None, - 'Rebuy' : None, - 'Modules' : None, + 'Captain': None, # On a crew + 'Cargo': defaultdict(int), + 'Credits': None, + 'FID': None, # Frontier Cmdr ID + 'Horizons': None, # Does this user have Horizons? + 'Loan': None, + 'Raw': defaultdict(int), + 'Manufactured': defaultdict(int), + 'Encoded': defaultdict(int), + 'Engineers': {}, + 'Rank': {}, + 'Reputation': {}, + 'Statistics': {}, + 'Role': None, # Crew role - None, Idle, FireCon, FighterCon + 'Friends': set(), # Online friends + 'ShipID': None, + 'ShipIdent': None, + 'ShipName': None, + 'ShipType': None, + 'HullValue': None, + 'ModulesValue': None, + 'Rebuy': None, + 'Modules': None, } - def start(self, root): + def start(self, root: 'tkinter.Tk'): self.root = root journal_dir = config.get('journaldir') or config.default_journal_dir @@ -133,15 +139,20 @@ class EDLogs(FileSystemEventHandler): if self.currentdir and self.currentdir != logdir: self.stop() + self.currentdir = logdir # Latest pre-existing logfile - e.g. if E:D is already running. Assumes logs sort alphabetically. # Do this before setting up the observer in case the journal directory has gone away - try: - logfiles = sorted([x for x in listdir(self.currentdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)], - key=lambda x: x.split('.')[1:]) - self.logfile = logfiles and join(self.currentdir, logfiles[-1]) or None - except: + try: # TODO: This should be replaced with something specific ONLY wrapping listdir + logfiles = sorted( + (x for x in listdir(self.currentdir) if self._RE_LOGFILE.search(x)), # type: ignore # config is weird + key=lambda x: x.split('.')[1:] + ) + + self.logfile = join(self.currentdir, logfiles[-1]) if logfiles else None # type: ignore # config is weird + + except Exception: self.logfile = None return False @@ -154,6 +165,7 @@ class EDLogs(FileSystemEventHandler): self.observer = Observer() self.observer.daemon = True self.observer.start() + elif polling and self.observer: self.observer.stop() self.observer = None @@ -162,11 +174,11 @@ class EDLogs(FileSystemEventHandler): self.observed = self.observer.schedule(self, self.currentdir) if __debug__: - print('%s Journal "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir)) - print('Start logfile "%s"' % self.logfile) + print('{} Journal {!r}'.format('Polling' if polling else 'Monitoring', self.currentdir)) + print('Start logfile {!r}'.format(self.logfile)) if not self.running(): - self.thread = threading.Thread(target = self.worker, name = 'Journal worker') + self.thread = threading.Thread(target=self.worker, name='Journal worker') self.thread.daemon = True self.thread.start() @@ -175,19 +187,32 @@ class EDLogs(FileSystemEventHandler): def stop(self): if __debug__: print('Stopping monitoring Journal') + self.currentdir = None - self.version = self.mode = self.group = self.cmdr = self.planet = self.system = self.station = self.station_marketid = self.stationtype = self.stationservices = self.coordinates = self.systemaddress = None + self.version = None + self.mode = None + self.group = None + self.cmdr = None + self.planet = None + self.system = None + self.station = None + self.station_marketid = None + self.stationtype = None + self.stationservices = None + self.coordinates = None + self.systemaddress = None self.is_beta = False if self.observed: self.observed = None self.observer.unschedule_all() - self.thread = None # Orphan the worker thread - will terminate at next poll + + self.thread = None # Orphan the worker thread - will terminate at next poll def close(self): - thread = self.thread self.stop() if self.observer: self.observer.stop() + if self.observer: self.observer.join() self.observer = None @@ -197,7 +222,8 @@ class EDLogs(FileSystemEventHandler): def on_created(self, event): # watchdog callback, e.g. client (re)started. - if not event.is_directory and re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', basename(event.src_path)): + if not event.is_directory and self._RE_LOGFILE.search(basename(event.src_path)): + self.logfile = event.src_path def worker(self): @@ -208,16 +234,20 @@ class EDLogs(FileSystemEventHandler): # Seek to the end of the latest log file logfile = self.logfile if logfile: - loghandle = open(logfile, 'rb', 0) # unbuffered + loghandle = open(logfile, 'rb', 0) # unbuffered if platform == 'darwin': - fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB + fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB + for line in loghandle: try: - self.parse_entry(line) # Some events are of interest even in the past - except: + self.parse_entry(line) # Some events are of interest even in the past + + except Exception: if __debug__: - print('Invalid journal entry "%s"' % repr(line)) - logpos = loghandle.tell() + print('Invalid journal entry {!r}'.format(line)) + + log_pos = loghandle.tell() + else: loghandle = None @@ -226,7 +256,7 @@ class EDLogs(FileSystemEventHandler): if self.live: if self.game_was_running: # Game is running locally - entry = OrderedDict([ + entry: OrderedDictT[str, Any] = OrderedDict([ ('timestamp', strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())), ('event', 'StartUp'), ('StarSystem', self.system), @@ -234,80 +264,105 @@ class EDLogs(FileSystemEventHandler): ('SystemAddress', self.systemaddress), ('Population', self.systempopulation), ]) + if self.planet: entry['Body'] = self.planet + entry['Docked'] = bool(self.station) + if self.station: entry['StationName'] = self.station entry['StationType'] = self.stationtype entry['MarketID'] = self.station_marketid + self.event_queue.append(json.dumps(entry, separators=(', ', ':'))) + else: - self.event_queue.append(None) # Generate null event to update the display (with possibly out-of-date info) + # Generate null event to update the display (with possibly out-of-date info) + self.event_queue.append(None) self.live = False - # Watchdog thread - emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute + # Watchdog thread -- there is a way to get this by using self.observer.emitters and checking for an attribute: + # watch, but that may have unforseen differences in behaviour. + emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute while True: # Check whether new log file started, e.g. client (re)started. if emitter and emitter.is_alive(): - newlogfile = self.logfile # updated by on_created watchdog callback + newlogfile = self.logfile # updated by on_created watchdog callback else: # Poll try: - logfiles = sorted([x for x in listdir(self.currentdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)], - key=lambda x: x.split('.')[1:]) - newlogfile = logfiles and join(self.currentdir, logfiles[-1]) or None - except: - if __debug__: print_exc() + logfiles = sorted( + (x for x in listdir(self.currentdir) if self._RE_LOGFILE.search(x)), + key=lambda x: x.split('.')[1:] + ) + + newlogfile = join(self.currentdir, logfiles[-1]) if logfiles else None # type: ignore + + except Exception: + if __debug__: + print_exc() + newlogfile = None if logfile != newlogfile: logfile = newlogfile if loghandle: loghandle.close() + if logfile: - loghandle = open(logfile, 'rb', 0) # unbuffered + loghandle = open(logfile, 'rb', 0) # unbuffered if platform == 'darwin': - fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB - logpos = 0 + fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB + + log_pos = 0 + if __debug__: - print('New logfile "%s"' % logfile) + print('New logfile {!r}'.format(logfile)) if logfile: - loghandle.seek(0, SEEK_END) # required to make macOS notice log change over SMB - loghandle.seek(logpos, SEEK_SET) # reset EOF flag + loghandle.seek(0, SEEK_END) # required to make macOS notice log change over SMB + loghandle.seek(log_pos, SEEK_SET) # reset EOF flag # TODO: log_pos reported as possibly unbound for line in loghandle: self.event_queue.append(line) + if self.event_queue: self.root.event_generate('<>', when="tail") - logpos = loghandle.tell() + + log_pos = loghandle.tell() sleep(self._POLL) # Check whether we're still supposed to be running if threading.current_thread() != self.thread: - return # Terminate + return # Terminate if self.game_was_running: if not self.game_running(): - self.event_queue.append('{ "timestamp":"%s", "event":"ShutDown" }' % strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())) + self.event_queue.append( + '{{ "timestamp":"{}", "event":"ShutDown" }}'.format(strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())) + ) + self.root.event_generate('<>', when="tail") self.game_was_running = False + else: self.game_was_running = self.game_running() - - def parse_entry(self, line): + def parse_entry(self, line: str): + # TODO(A_D): a bunch of these can be simplified to use if itertools.product and filters if line is None: - return { 'event': None } # Fake startup event + return {'event': None} # Fake startup event try: - entry = json.loads(line, object_pairs_hook=OrderedDict) # Preserve property order because why not? - entry['timestamp'] # we expect this to exist - if entry['event'] == 'Fileheader': + # Preserve property order because why not? + entry: OrderedDictT[str, Any] = json.loads(line, object_pairs_hook=OrderedDict) + entry['timestamp'] # we expect this to exist # TODO: replace with assert? or an if key in check + + event_type = entry['event'] + if event_type == 'Fileheader': self.live = False self.version = entry['gameversion'] self.is_beta = 'beta' in entry['gameversion'].lower() @@ -324,35 +379,38 @@ class EDLogs(FileSystemEventHandler): self.systemaddress = None self.started = None self.state = { - 'Captain' : None, - 'Cargo' : defaultdict(int), - 'Credits' : None, - 'FID' : None, - 'Horizons' : None, - 'Loan' : None, - 'Raw' : defaultdict(int), - 'Manufactured' : defaultdict(int), - 'Encoded' : defaultdict(int), - 'Engineers' : {}, - 'Rank' : {}, - 'Reputation' : {}, - 'Statistics' : {}, - 'Role' : None, - 'Friends' : set(), - 'ShipID' : None, - 'ShipIdent' : None, - 'ShipName' : None, - 'ShipType' : None, - 'HullValue' : None, - 'ModulesValue' : None, - 'Rebuy' : None, - 'Modules' : None, + 'Captain': None, + 'Cargo': defaultdict(int), + 'Credits': None, + 'FID': None, + 'Horizons': None, + 'Loan': None, + 'Raw': defaultdict(int), + 'Manufactured': defaultdict(int), + 'Encoded': defaultdict(int), + 'Engineers': {}, + 'Rank': {}, + 'Reputation': {}, + 'Statistics': {}, + 'Role': None, + 'Friends': set(), + 'ShipID': None, + 'ShipIdent': None, + 'ShipName': None, + 'ShipType': None, + 'HullValue': None, + 'ModulesValue': None, + 'Rebuy': None, + 'Modules': None, } - elif entry['event'] == 'Commander': - self.live = True # First event in 3.0 - elif entry['event'] == 'LoadGame': + + elif event_type == 'Commander': + self.live = True # First event in 3.0 + + elif event_type == 'LoadGame': self.cmdr = entry['Commander'] - self.mode = entry.get('GameMode') # 'Open', 'Solo', 'Group', or None for CQC (and Training - but no LoadGame event) + # 'Open', 'Solo', 'Group', or None for CQC (and Training - but no LoadGame event) + self.mode = entry.get('GameMode') self.group = entry.get('Group') self.planet = None self.system = None @@ -363,48 +421,55 @@ class EDLogs(FileSystemEventHandler): self.coordinates = None self.systemaddress = None self.started = timegm(strptime(entry['timestamp'], '%Y-%m-%dT%H:%M:%SZ')) - self.state.update({ # Don't set Ship, ShipID etc since this will reflect Fighter or SRV if starting in those - 'Captain' : None, - 'Credits' : entry['Credits'], - 'FID' : entry.get('FID'), # From 3.3 - 'Horizons' : entry['Horizons'], # From 3.0 - 'Loan' : entry['Loan'], - 'Engineers' : {}, - 'Rank' : {}, - 'Reputation' : {}, - 'Statistics' : {}, - 'Role' : None, + # Don't set Ship, ShipID etc since this will reflect Fighter or SRV if starting in those + self.state.update({ + 'Captain': None, + 'Credits': entry['Credits'], + 'FID': entry.get('FID'), # From 3.3 + 'Horizons': entry['Horizons'], # From 3.0 + 'Loan': entry['Loan'], + 'Engineers': {}, + 'Rank': {}, + 'Reputation': {}, + 'Statistics': {}, + 'Role': None, }) - elif entry['event'] == 'NewCommander': + + elif event_type == 'NewCommander': self.cmdr = entry['Name'] self.group = None - elif entry['event'] == 'SetUserShipName': - self.state['ShipID'] = entry['ShipID'] - if 'UserShipId' in entry: # Only present when changing the ship's ident + + elif event_type == 'SetUserShipName': + self.state['ShipID'] = entry['ShipID'] + if 'UserShipId' in entry: # Only present when changing the ship's ident self.state['ShipIdent'] = entry['UserShipId'] - self.state['ShipName'] = entry.get('UserShipName') - self.state['ShipType'] = self.canonicalise(entry['Ship']) - elif entry['event'] == 'ShipyardBuy': + + self.state['ShipName'] = entry.get('UserShipName') + self.state['ShipType'] = self.canonicalise(entry['Ship']) + + elif event_type == 'ShipyardBuy': self.state['ShipID'] = None self.state['ShipIdent'] = None - self.state['ShipName'] = None + self.state['ShipName'] = None self.state['ShipType'] = self.canonicalise(entry['ShipType']) self.state['HullValue'] = None self.state['ModulesValue'] = None self.state['Rebuy'] = None self.state['Modules'] = None - elif entry['event'] == 'ShipyardSwap': + + elif event_type == 'ShipyardSwap': self.state['ShipID'] = entry['ShipID'] self.state['ShipIdent'] = None - self.state['ShipName'] = None + self.state['ShipName'] = None self.state['ShipType'] = self.canonicalise(entry['ShipType']) self.state['HullValue'] = None self.state['ModulesValue'] = None self.state['Rebuy'] = None self.state['Modules'] = None - elif (entry['event'] == 'Loadout' and - not 'fighter' in self.canonicalise(entry['Ship']) and - not 'buggy' in self.canonicalise(entry['Ship'])): + + elif (event_type == 'Loadout' and + 'fighter' not in self.canonicalise(entry['Ship']) and + 'buggy' not in self.canonicalise(entry['Ship'])): self.state['ShipID'] = entry['ShipID'] self.state['ShipIdent'] = entry['ShipIdent'] @@ -415,9 +480,9 @@ class EDLogs(FileSystemEventHandler): if entry['ShipName'] and entry['ShipName'] not in ('', ' '): self.state['ShipName'] = entry['ShipName'] - self.state['ShipType'] = self.canonicalise(entry['Ship']) - self.state['HullValue'] = entry.get('HullValue') # not present on exiting Outfitting - self.state['ModulesValue'] = entry.get('ModulesValue') # " + self.state['ShipType'] = self.canonicalise(entry['Ship']) + self.state['HullValue'] = entry.get('HullValue') # not present on exiting Outfitting + self.state['ModulesValue'] = entry.get('ModulesValue') # " self.state['Rebuy'] = entry.get('Rebuy') # Remove spurious differences between initial Loadout event and subsequent self.state['Modules'] = {} @@ -426,197 +491,258 @@ class EDLogs(FileSystemEventHandler): module['Item'] = self.canonicalise(module['Item']) if ('Hardpoint' in module['Slot'] and not module['Slot'].startswith('TinyHardpoint') and - module.get('AmmoInClip') == module.get('AmmoInHopper') == 1): # lasers + module.get('AmmoInClip') == module.get('AmmoInHopper') == 1): # lasers module.pop('AmmoInClip') module.pop('AmmoInHopper') + self.state['Modules'][module['Slot']] = module - elif entry['event'] == 'ModuleBuy': + + elif event_type == 'ModuleBuy': self.state['Modules'][entry['Slot']] = { - 'Slot' : entry['Slot'], - 'Item' : self.canonicalise(entry['BuyItem']), - 'On' : True, - 'Priority' : 1, - 'Health' : 1.0, - 'Value' : entry['BuyPrice'], + 'Slot': entry['Slot'], + 'Item': self.canonicalise(entry['BuyItem']), + 'On': True, + 'Priority': 1, + 'Health': 1.0, + 'Value': entry['BuyPrice'], } - elif entry['event'] == 'ModuleSell': + + elif event_type == 'ModuleSell': self.state['Modules'].pop(entry['Slot'], None) - elif entry['event'] == 'ModuleSwap': - toitem = self.state['Modules'].get(entry['ToSlot']) - self.state['Modules'][entry['ToSlot']] = self.state['Modules'][entry['FromSlot']] - if toitem: - self.state['Modules'][entry['FromSlot']] = toitem + + elif event_type == 'ModuleSwap': + to_item = self.state['Modules'].get(entry['ToSlot']) + to_slot = entry['ToSlot'] + from_slot = entry['FromSlot'] + modules = self.state['Modules'] + modules[to_slot] = modules[from_slot] + if to_item: + modules[from_slot] = to_item + else: - self.state['Modules'].pop(entry['FromSlot'], None) - elif entry['event'] in ['Undocked']: + modules.pop(from_slot, None) + + elif event_type == 'Undocked': self.station = None self.station_marketid = None self.stationtype = None self.stationservices = None - elif entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']: - if entry['event'] in ('Location', 'CarrierJump'): + + elif event_type in ('Location', 'FSDJump', 'Docked', 'CarrierJump'): + if event_type in ('Location', 'CarrierJump'): self.planet = entry.get('Body') if entry.get('BodyType') == 'Planet' else None - elif entry['event'] == 'FSDJump': + + elif event_type == 'FSDJump': self.planet = None + if 'StarPos' in entry: self.coordinates = tuple(entry['StarPos']) + elif self.system != entry['StarSystem']: - self.coordinates = None # Docked event doesn't include coordinates + self.coordinates = None # Docked event doesn't include coordinates + self.systemaddress = entry.get('SystemAddress') - if entry['event'] in ['Location', 'FSDJump', 'CarrierJump']: + if event_type in ('Location', 'FSDJump', 'CarrierJump'): self.systempopulation = entry.get('Population') - (self.system, self.station) = (entry['StarSystem'] == 'ProvingGround' and 'CQC' or entry['StarSystem'], - entry.get('StationName')) # May be None - self.station_marketid = entry.get('MarketID') # May be None - self.stationtype = entry.get('StationType') # May be None - self.stationservices = entry.get('StationServices') # None under E:D < 2.4 - elif entry['event'] == 'ApproachBody': + self.system = 'CQC' if entry['StarSystem'] == 'ProvingGround' else entry['StarSystem'] + self.station = entry.get('StationName') # May be None + self.station_marketid = entry.get('MarketID') # May be None + self.stationtype = entry.get('StationType') # May be None + self.stationservices = entry.get('StationServices') # None under E:D < 2.4 + + elif event_type == 'ApproachBody': self.planet = entry['Body'] - elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']: + + elif event_type in ('LeaveBody', 'SupercruiseEntry'): self.planet = None - elif entry['event'] in ['Rank', 'Promotion']: + elif event_type in ('Rank', 'Promotion'): payload = dict(entry) payload.pop('event') payload.pop('timestamp') - for k,v in payload.items(): - self.state['Rank'][k] = (v,0) - elif entry['event'] == 'Progress': - for k,v in entry.items(): - if k in self.state['Rank']: - self.state['Rank'][k] = (self.state['Rank'][k][0], min(v, 100)) # perhaps not taken promotion mission yet - elif entry['event'] in ['Reputation', 'Statistics']: + + self.state['Rank'].update({k: (v, 0) for k, v in payload.items()}) + + elif event_type == 'Progress': + rank = self.state['Rank'] + for k, v in entry.items(): + if k in rank: + # perhaps not taken promotion mission yet + rank[k] = (rank[k][0], min(v, 100)) + + elif event_type in ('Reputation', 'Statistics'): payload = OrderedDict(entry) payload.pop('event') payload.pop('timestamp') - self.state[entry['event']] = payload + self.state[event_type] = payload - elif entry['event'] == 'EngineerProgress': - if 'Engineers' in entry: # Startup summary - self.state['Engineers'] = { e['Engineer']: (e['Rank'], e.get('RankProgress', 0)) if 'Rank' in e else e['Progress'] for e in entry['Engineers'] } - else: # Promotion - self.state['Engineers'][entry['Engineer']] = (entry['Rank'], entry.get('RankProgress', 0)) if 'Rank' in entry else entry['Progress'] + elif event_type == 'EngineerProgress': + engineers = self.state['Engineers'] + if 'Engineers' in entry: # Startup summary + self.state['Engineers'] = { + e['Engineer']: ((e['Rank'], e.get('RankProgress', 0)) if 'Rank' in e else e['Progress']) + for e in entry['Engineers'] + } - elif entry['event'] == 'Cargo' and entry.get('Vessel') == 'Ship': + else: # Promotion + engineer = entry['Engineer'] + if 'Rank' in entry: + engineers[engineer] = (entry['Rank'], entry.get('RankProgress', 0)) + + else: + engineers[engineer] = entry['Progress'] + + elif event_type == 'Cargo' and entry.get('Vessel') == 'Ship': self.state['Cargo'] = defaultdict(int) - if 'Inventory' not in entry: # From 3.3 full Cargo event (after the first one) is written to a separate file - with open(join(self.currentdir, 'Cargo.json'), 'rb') as h: - entry = json.load(h, object_pairs_hook=OrderedDict) # Preserve property order because why not? - self.state['Cargo'].update({ self.canonicalise(x['Name']): x['Count'] for x in entry['Inventory'] }) - elif entry['event'] in ['CollectCargo', 'MarketBuy', 'BuyDrones', 'MiningRefined']: + # From 3.3 full Cargo event (after the first one) is written to a separate file + if 'Inventory' not in entry: + with open(join(self.currentdir, 'Cargo.json'), 'rb') as h: # type: ignore + entry = json.load(h, object_pairs_hook=OrderedDict) # Preserve property order because why not? + + self.state['Cargo'].update({self.canonicalise(x['Name']): x['Count'] for x in entry['Inventory']}) + + elif event_type in ('CollectCargo', 'MarketBuy', 'BuyDrones', 'MiningRefined'): commodity = self.canonicalise(entry['Type']) self.state['Cargo'][commodity] += entry.get('Count', 1) - elif entry['event'] in ['EjectCargo', 'MarketSell', 'SellDrones']: + + elif event_type in ('EjectCargo', 'MarketSell', 'SellDrones'): commodity = self.canonicalise(entry['Type']) - self.state['Cargo'][commodity] -= entry.get('Count', 1) - if self.state['Cargo'][commodity] <= 0: - self.state['Cargo'].pop(commodity) - elif entry['event'] == 'SearchAndRescue': + cargo = self.state['Cargo'] + cargo[commodity] -= entry.get('Count', 1) + if cargo[commodity] <= 0: + cargo.pop(commodity) + + elif event_type == 'SearchAndRescue': for item in entry.get('Items', []): commodity = self.canonicalise(item['Name']) - self.state['Cargo'][commodity] -= item.get('Count', 1) - if self.state['Cargo'][commodity] <= 0: - self.state['Cargo'].pop(commodity) + cargo = self.state['Cargo'] + cargo[commodity] -= item.get('Count', 1) + if cargo[commodity] <= 0: + cargo.pop(commodity) - elif entry['event'] == 'Materials': - for category in ['Raw', 'Manufactured', 'Encoded']: + elif event_type == 'Materials': + for category in ('Raw', 'Manufactured', 'Encoded'): self.state[category] = defaultdict(int) - self.state[category].update({ self.canonicalise(x['Name']): x['Count'] for x in entry.get(category, []) }) - elif entry['event'] == 'MaterialCollected': + self.state[category].update({ + self.canonicalise(x['Name']): x['Count'] for x in entry.get(category, []) + }) + + elif event_type == 'MaterialCollected': material = self.canonicalise(entry['Name']) self.state[entry['Category']][material] += entry['Count'] - elif entry['event'] in ['MaterialDiscarded', 'ScientificResearch']: + + elif event_type in ('MaterialDiscarded', 'ScientificResearch'): material = self.canonicalise(entry['Name']) - self.state[entry['Category']][material] -= entry['Count'] - if self.state[entry['Category']][material] <= 0: - self.state[entry['Category']].pop(material) - elif entry['event'] == 'Synthesis': - for category in ['Raw', 'Manufactured', 'Encoded']: + state_category = self.state[entry['Category']] + state_category[material] -= entry['Count'] + if state_category[material] <= 0: + state_category.pop(material) + + elif event_type == 'Synthesis': + for category in ('Raw', 'Manufactured', 'Encoded'): for x in entry['Materials']: material = self.canonicalise(x['Name']) if material in self.state[category]: self.state[category][material] -= x['Count'] if self.state[category][material] <= 0: self.state[category].pop(material) - elif entry['event'] == 'MaterialTrade': - category = self.category(entry['Paid']['Category']) - self.state[category][entry['Paid']['Material']] -= entry['Paid']['Quantity'] - if self.state[category][entry['Paid']['Material']] <= 0: - self.state[category].pop(entry['Paid']['Material']) - category = self.category(entry['Received']['Category']) - self.state[category][entry['Received']['Material']] += entry['Received']['Quantity'] - elif entry['event'] == 'EngineerCraft' or (entry['event'] == 'EngineerLegacyConvert' and not entry.get('IsPreview')): - for category in ['Raw', 'Manufactured', 'Encoded']: + elif event_type == 'MaterialTrade': + category = self.category(entry['Paid']['Category']) + state_category = self.state[category] + paid = entry['Paid'] + received = entry['Received'] + + state_category[paid['Material']] -= paid['Quantity'] + if state_category[paid['Material']] <= 0: + state_category.pop(paid['Material']) + + category = self.category(received['Category']) + state_category[received['Material']] += received['Quantity'] + + elif event_type == 'EngineerCraft' or ( + event_type == 'EngineerLegacyConvert' and not entry.get('IsPreview') + ): + + for category in ('Raw', 'Manufactured', 'Encoded'): for x in entry.get('Ingredients', []): material = self.canonicalise(x['Name']) if material in self.state[category]: self.state[category][material] -= x['Count'] if self.state[category][material] <= 0: self.state[category].pop(material) + module = self.state['Modules'][entry['Slot']] assert(module['Item'] == self.canonicalise(entry['Module'])) module['Engineering'] = { - 'Engineer' : entry['Engineer'], - 'EngineerID' : entry['EngineerID'], - 'BlueprintName' : entry['BlueprintName'], - 'BlueprintID' : entry['BlueprintID'], - 'Level' : entry['Level'], - 'Quality' : entry['Quality'], - 'Modifiers' : entry['Modifiers'], - } + 'Engineer': entry['Engineer'], + 'EngineerID': entry['EngineerID'], + 'BlueprintName': entry['BlueprintName'], + 'BlueprintID': entry['BlueprintID'], + 'Level': entry['Level'], + 'Quality': entry['Quality'], + 'Modifiers': entry['Modifiers'], + } + if 'ExperimentalEffect' in entry: module['Engineering']['ExperimentalEffect'] = entry['ExperimentalEffect'] module['Engineering']['ExperimentalEffect_Localised'] = entry['ExperimentalEffect_Localised'] + else: module['Engineering'].pop('ExperimentalEffect', None) module['Engineering'].pop('ExperimentalEffect_Localised', None) - elif entry['event'] == 'MissionCompleted': + elif event_type == 'MissionCompleted': for reward in entry.get('CommodityReward', []): commodity = self.canonicalise(reward['Name']) self.state['Cargo'][commodity] += reward.get('Count', 1) + for reward in entry.get('MaterialsReward', []): - if 'Category' in reward: # Category not present in E:D 3.0 + if 'Category' in reward: # Category not present in E:D 3.0 category = self.category(reward['Category']) material = self.canonicalise(reward['Name']) self.state[category][material] += reward.get('Count', 1) - elif entry['event'] == 'EngineerContribution': + + elif event_type == 'EngineerContribution': commodity = self.canonicalise(entry.get('Commodity')) if commodity: self.state['Cargo'][commodity] -= entry['Quantity'] if self.state['Cargo'][commodity] <= 0: self.state['Cargo'].pop(commodity) + material = self.canonicalise(entry.get('Material')) if material: - for category in ['Raw', 'Manufactured', 'Encoded']: + for category in ('Raw', 'Manufactured', 'Encoded'): if material in self.state[category]: self.state[category][material] -= entry['Quantity'] if self.state[category][material] <= 0: self.state[category].pop(material) - elif entry['event'] == 'TechnologyBroker': - for thing in entry.get('Ingredients', []): # 3.01 - for category in ['Cargo', 'Raw', 'Manufactured', 'Encoded']: + + elif event_type == 'TechnologyBroker': + for thing in entry.get('Ingredients', []): # 3.01 + for category in ('Cargo', 'Raw', 'Manufactured', 'Encoded'): item = self.canonicalise(thing['Name']) if item in self.state[category]: self.state[category][item] -= thing['Count'] if self.state[category][item] <= 0: self.state[category].pop(item) - for thing in entry.get('Commodities', []): # 3.02 + + for thing in entry.get('Commodities', []): # 3.02 commodity = self.canonicalise(thing['Name']) self.state['Cargo'][commodity] -= thing['Count'] if self.state['Cargo'][commodity] <= 0: self.state['Cargo'].pop(commodity) - for thing in entry.get('Materials', []): # 3.02 + + for thing in entry.get('Materials', []): # 3.02 material = self.canonicalise(thing['Name']) category = thing['Category'] self.state[category][material] -= thing['Count'] if self.state[category][material] <= 0: self.state[category].pop(material) - elif entry['event'] == 'JoinACrew': + elif event_type == 'JoinACrew': self.state['Captain'] = entry['Captain'] self.state['Role'] = 'Idle' self.planet = None @@ -627,9 +753,11 @@ class EDLogs(FileSystemEventHandler): self.stationservices = None self.coordinates = None self.systemaddress = None - elif entry['event'] == 'ChangeCrewRole': + + elif event_type == 'ChangeCrewRole': self.state['Role'] = entry['Role'] - elif entry['event'] == 'QuitACrew': + + elif event_type == 'QuitACrew': self.state['Captain'] = None self.state['Role'] = None self.planet = None @@ -641,38 +769,52 @@ class EDLogs(FileSystemEventHandler): self.coordinates = None self.systemaddress = None - elif entry['event'] == 'Friends': - if entry['Status'] in ['Online', 'Added']: + elif event_type == 'Friends': + if entry['Status'] in ('Online', 'Added'): self.state['Friends'].add(entry['Name']) + else: self.state['Friends'].discard(entry['Name']) return entry - except: + except Exception: if __debug__: - print('Invalid journal entry "%s"' % repr(line)) + print('Invalid journal entry {!r}'.format(line)) print_exc() - return { 'event': None } - # Commodities, Modules and Ships can appear in different forms e.g. "$HNShockMount_Name;", "HNShockMount", and "hnshockmount", - # "$int_cargorack_size6_class1_name;" and "Int_CargoRack_Size6_Class1", "python" and "Python", etc. + return {'event': None} + + # Commodities, Modules and Ships can appear in different forms e.g. "$HNShockMount_Name;", "HNShockMount", + # and "hnshockmount", "$int_cargorack_size6_class1_name;" and "Int_CargoRack_Size6_Class1", + # "python" and "Python", etc. # This returns a simple lowercased name e.g. 'hnshockmount', 'int_cargorack_size6_class1', 'python', etc - def canonicalise(self, item): - if not item: return '' + def canonicalise(self, item: Optional[str]): + if not item: + return '' + item = item.lower() match = self._RE_CANONICALISE.match(item) - return match and match.group(1) or item - def category(self, item): + if match: + return match.group(1) + + return item + + def category(self, item: str): match = self._RE_CATEGORY.match(item) - return (match and match.group(1) or item).capitalize() + + if match: + return match.group(1).capitalize() + + return item.capitalize() def get_entry(self): if not self.event_queue: return None + else: entry = self.parse_entry(self.event_queue.pop(0)) - if not self.live and entry['event'] not in [None, 'Fileheader']: + if not self.live and entry['event'] not in (None, 'Fileheader'): # Game not running locally, but Journal has been updated self.live = True if self.station: @@ -687,6 +829,7 @@ class EDLogs(FileSystemEventHandler): ('StarPos', self.coordinates), ('SystemAddress', self.systemaddress), ]) + else: entry = OrderedDict([ ('timestamp', strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())), @@ -696,25 +839,28 @@ class EDLogs(FileSystemEventHandler): ('StarPos', self.coordinates), ('SystemAddress', self.systemaddress), ]) + self.event_queue.append(json.dumps(entry, separators=(', ', ':'))) + elif self.live and entry['event'] == 'Music' and entry.get('MusicTrack') == 'MainMenu': - self.event_queue.append('{ "timestamp":"%s", "event":"ShutDown" }' % strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())) + self.event_queue.append( + '{{ "timestamp":"{}", "event":"ShutDown" }}'.format(strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())) + ) + return entry def game_running(self): - if platform == 'darwin': for app in NSWorkspace.sharedWorkspace().runningApplications(): if app.bundleIdentifier() == 'uk.co.frontier.EliteDangerous': return True elif platform == 'win32': - def WindowTitle(h): if h: - l = GetWindowTextLength(h) + 1 - buf = ctypes.create_unicode_buffer(l) - if GetWindowText(h, buf, l): + length = GetWindowTextLength(h) + 1 + buf = ctypes.create_unicode_buffer(length) + if GetWindowText(h, buf, length): return buf.value return None @@ -722,62 +868,83 @@ class EDLogs(FileSystemEventHandler): name = WindowTitle(hWnd) if name and name.startswith('Elite - Dangerous'): handle = GetProcessHandleFromHwnd(hWnd) - if handle: # If GetProcessHandleFromHwnd succeeds then the app is already running as this user + if handle: # If GetProcessHandleFromHwnd succeeds then the app is already running as this user CloseHandle(handle) - return False # stop enumeration + return False # stop enumeration + return True return not EnumWindows(EnumWindowsProc(callback), 0) return False - # Return a subset of the received data describing the current ship as a Loadout event def ship(self, timestamped=True): if not self.state['Modules']: return None - standard_order = ['ShipCockpit', 'CargoHatch', 'Armour', 'PowerPlant', 'MainEngines', 'FrameShiftDrive', 'LifeSupport', 'PowerDistributor', 'Radar', 'FuelTank'] + standard_order = ( + 'ShipCockpit', 'CargoHatch', 'Armour', 'PowerPlant', 'MainEngines', 'FrameShiftDrive', 'LifeSupport', + 'PowerDistributor', 'Radar', 'FuelTank' + ) d = OrderedDict() if timestamped: d['timestamp'] = strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()) + d['event'] = 'Loadout' d['Ship'] = self.state['ShipType'] d['ShipID'] = self.state['ShipID'] + if self.state['ShipName']: d['ShipName'] = self.state['ShipName'] + if self.state['ShipIdent']: d['ShipIdent'] = self.state['ShipIdent'] + # sort modules by slot - hardpoints, standard, internal d['Modules'] = [] - for slot in sorted(self.state['Modules'], key=lambda x: ('Hardpoint' not in x, x not in standard_order and len(standard_order) or standard_order.index(x), 'Slot' not in x, x)): + + for slot in sorted( + self.state['Modules'], + key=lambda x: ( + 'Hardpoint' not in x, + len(standard_order) if x not in standard_order else standard_order.index(x), + 'Slot' not in x, + x + ) + ): + module = dict(self.state['Modules'][slot]) module.pop('Health', None) module.pop('Value', None) d['Modules'].append(module) - return d + return d # Export ship loadout as a Loadout event def export_ship(self, filename=None): - string = json.dumps(self.ship(False), ensure_ascii=False, indent=2, separators=(',', ': ')) # pretty print - + # TODO(A_D): Some type checking has been disabled in here due to config.get getting weird outputs + string = json.dumps(self.ship(False), ensure_ascii=False, indent=2, separators=(',', ': ')) # pretty print if filename: with open(filename, 'wt') as h: h.write(string) + return ship = ship_file_name(self.state['ShipName'], self.state['ShipType']) - regexp = re.compile(re.escape(ship) + '\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt') - oldfiles = sorted([x for x in listdir(config.get('outdir')) if regexp.match(x)]) + regexp = re.compile(re.escape(ship) + r'\.\d{4}\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt') + oldfiles = sorted((x for x in listdir(config.get('outdir')) if regexp.match(x))) # type: ignore if oldfiles: - with open(join(config.get('outdir'), oldfiles[-1]), 'rU') as h: + with open(join(config.get('outdir'), oldfiles[-1]), 'rU') as h: # type: ignore if h.read() == string: - return # same as last time - don't write + return # same as last time - don't write # Write - filename = join(config.get('outdir'), '%s.%s.txt' % (ship, strftime('%Y-%m-%dT%H.%M.%S', localtime(time())))) + filename = join( # type: ignore + config.get('outdir'), '{}.{}.txt'.format(ship, strftime('%Y-%m-%dT%H.%M.%S', localtime(time()))) + ) + with open(filename, 'wt') as h: h.write(string) diff --git a/plug.py b/plug.py index 56eaa884..0a0a3f8a 100644 --- a/plug.py +++ b/plug.py @@ -7,49 +7,51 @@ import os import importlib import sys import operator -import threading # We don't use it, but plugins might -from traceback import print_exc - +import threading # noqa: F401 - We don't use it, but plugins might +from typing import Optional +import logging import tkinter as tk -import myNotebook as nb -from config import config -from time import time as time +import myNotebook as nb # noqa: N813 +from config import config, appname +import EDMCLogging + +logger = logging.getLogger(appname) # Dashboard Flags constants -FlagsDocked = 1<<0 # on a landing pad -FlagsLanded = 1<<1 # on planet surface -FlagsLandingGearDown = 1<<2 -FlagsShieldsUp = 1<<3 -FlagsSupercruise = 1<<4 -FlagsFlightAssistOff = 1<<5 -FlagsHardpointsDeployed = 1<<6 -FlagsInWing = 1<<7 -FlagsLightsOn = 1<<8 -FlagsCargoScoopDeployed = 1<<9 -FlagsSilentRunning = 1<<10 -FlagsScoopingFuel = 1<<11 -FlagsSrvHandbrake = 1<<12 -FlagsSrvTurret = 1<<13 # using turret view -FlagsSrvUnderShip = 1<<14 # turret retracted -FlagsSrvDriveAssist = 1<<15 -FlagsFsdMassLocked = 1<<16 -FlagsFsdCharging = 1<<17 -FlagsFsdCooldown = 1<<18 -FlagsLowFuel = 1<<19 # <25% -FlagsOverHeating = 1<<20 # > 100% -FlagsHasLatLong = 1<<21 -FlagsIsInDanger = 1<<22 -FlagsBeingInterdicted = 1<<23 -FlagsInMainShip = 1<<24 -FlagsInFighter = 1<<25 -FlagsInSRV = 1<<26 -FlagsAnalysisMode = 1<<27 # Hud in Analysis mode -FlagsNightVision = 1<<28 -FlagsAverageAltitude = 1<<29 # Altitude from Average radius -FlagsFsdJump = 1<<30 -FlagsSrvHighBeam = 1<<31 +FlagsDocked = 1 << 0 # on a landing pad +FlagsLanded = 1 << 1 # on planet surface +FlagsLandingGearDown = 1 << 2 +FlagsShieldsUp = 1 << 3 +FlagsSupercruise = 1 << 4 +FlagsFlightAssistOff = 1 << 5 +FlagsHardpointsDeployed = 1 << 6 +FlagsInWing = 1 << 7 +FlagsLightsOn = 1 << 8 +FlagsCargoScoopDeployed = 1 << 9 +FlagsSilentRunning = 1 << 10 +FlagsScoopingFuel = 1 << 11 +FlagsSrvHandbrake = 1 << 12 +FlagsSrvTurret = 1 << 13 # using turret view +FlagsSrvUnderShip = 1 << 14 # turret retracted +FlagsSrvDriveAssist = 1 << 15 +FlagsFsdMassLocked = 1 << 16 +FlagsFsdCharging = 1 << 17 +FlagsFsdCooldown = 1 << 18 +FlagsLowFuel = 1 << 19 # <25% +FlagsOverHeating = 1 << 20 # > 100% +FlagsHasLatLong = 1 << 21 +FlagsIsInDanger = 1 << 22 +FlagsBeingInterdicted = 1 << 23 +FlagsInMainShip = 1 << 24 +FlagsInFighter = 1 << 25 +FlagsInSRV = 1 << 26 +FlagsAnalysisMode = 1 << 27 # Hud in Analysis mode +FlagsNightVision = 1 << 28 +FlagsAverageAltitude = 1 << 29 # Altitude from Average radius +FlagsFsdJump = 1 << 30 +FlagsSrvHighBeam = 1 << 31 # Dashboard GuiFocus constants GuiFocusNoFocus = 0 @@ -79,7 +81,7 @@ last_error = { class Plugin(object): - def __init__(self, name, loadfile): + def __init__(self, name: str, loadfile: str, plugin_logger: Optional[logging.Logger]): """ Load a single plugin :param name: module name @@ -87,28 +89,31 @@ class Plugin(object): :raises Exception: Typically ImportError or OSError """ - self.name = name # Display name. - self.folder = name # basename of plugin folder. None for internal plugins. - self.module = None # None for disabled plugins. + self.name = name # Display name. + self.folder = name # basename of plugin folder. None for internal plugins. + self.module = None # None for disabled plugins. + self.logger = plugin_logger if loadfile: - sys.stdout.write('loading plugin {} from "{}"\n'.format(name.replace('.', '_'), loadfile)) + logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"') try: - module = importlib.machinery.SourceFileLoader('plugin_{}'.format(name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')), loadfile).load_module() + module = importlib.machinery.SourceFileLoader('plugin_{}'.format( + name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')), + loadfile).load_module() if getattr(module, 'plugin_start3', None): newname = module.plugin_start3(os.path.dirname(loadfile)) self.name = newname and str(newname) or name self.module = module elif getattr(module, 'plugin_start', None): - sys.stdout.write('plugin %s needs migrating\n' % name) + logger.warning(f'plugin {name} needs migrating\n') PLUGINS_not_py3.append(self) else: - sys.stdout.write('plugin %s has no plugin_start3() function\n' % name) - except: - print_exc() + logger.error(f'plugin {name} has no plugin_start3() function') + except Exception as e: + logger.exception(f': Failed for Plugin "{name}"') raise else: - sys.stdout.write('plugin %s disabled\n' % name) + logger.info(f'plugin {name} disabled') def _get_func(self, funcname): """ @@ -136,8 +141,8 @@ class Plugin(object): elif not isinstance(appitem, tk.Widget): raise AssertionError return appitem - except: - print_exc() + except Exception as e: + logger.exception(f'Failed for Plugin "{self.name}"') return None def get_prefs(self, parent, cmdr, is_beta): @@ -156,8 +161,8 @@ class Plugin(object): if not isinstance(frame, nb.Frame): raise AssertionError return frame - except: - print_exc() + except Exception as e: + logger.exception(f'Failed for Plugin "{self.name}"') return None @@ -171,12 +176,12 @@ def load_plugins(master): for name in sorted(os.listdir(config.internal_plugin_dir)): if name.endswith('.py') and not name[0] in ['.', '_']: try: - plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name)) - plugin.folder = None # Suppress listing in Plugins prefs tab + plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name), logger) + plugin.folder = None # Suppress listing in Plugins prefs tab internal.append(plugin) - except: - pass - PLUGINS.extend(sorted(internal, key = lambda p: operator.attrgetter('name')(p).lower())) + except Exception as e: + logger.exception(f'Failure loading internal Plugin "{name}"') + PLUGINS.extend(sorted(internal, key=lambda p: operator.attrgetter('name')(p).lower())) # Add plugin folder to load path so packages can be loaded from plugin folder sys.path.append(config.plugin_dir) @@ -189,15 +194,22 @@ def load_plugins(master): pass elif name.endswith('.disabled'): name, discard = name.rsplit('.', 1) - found.append(Plugin(name, None)) + found.append(Plugin(name, None, logger)) else: try: # Add plugin's folder to load path in case plugin has internal package dependencies sys.path.append(os.path.join(config.plugin_dir, name)) - found.append(Plugin(name, os.path.join(config.plugin_dir, name, 'load.py'))) - except: + + # Create a logger for this 'found' plugin. Must be before the + # load.py is loaded. + plugin_logger = EDMCLogging.get_plugin_logger(f'{appname}.{name}') + + found.append(Plugin(name, os.path.join(config.plugin_dir, name, 'load.py'), plugin_logger)) + except Exception as e: + logger.exception(f'Failure loading found Plugin "{name}"') pass - PLUGINS.extend(sorted(found, key = lambda p: operator.attrgetter('name')(p).lower())) + PLUGINS.extend(sorted(found, key=lambda p: operator.attrgetter('name')(p).lower())) + def provides(fn_name): """ @@ -240,8 +252,8 @@ def notify_stop(): try: newerror = plugin_stop() error = error or newerror - except: - print_exc() + except Exception as e: + logger.exception(f'Plugin "{plugin.name}" failed') return error @@ -257,8 +269,8 @@ def notify_prefs_cmdr_changed(cmdr, is_beta): if prefs_cmdr_changed: try: prefs_cmdr_changed(cmdr, is_beta) - except: - print_exc() + except Exception as e: + logger.exception(f'Plugin "{plugin.name}" failed') def notify_prefs_changed(cmdr, is_beta): @@ -275,8 +287,8 @@ def notify_prefs_changed(cmdr, is_beta): if prefs_changed: try: prefs_changed(cmdr, is_beta) - except: - print_exc() + except Exception as e: + logger.exception(f'Plugin "{plugin.name}" failed') def notify_journal_entry(cmdr, is_beta, system, station, entry, state): @@ -298,8 +310,8 @@ def notify_journal_entry(cmdr, is_beta, system, station, entry, state): # Pass a copy of the journal entry in case the callee modifies it newerror = journal_entry(cmdr, is_beta, system, station, dict(entry), dict(state)) error = error or newerror - except: - print_exc() + except Exception as e: + logger.exception(f'Plugin "{plugin.name}" failed') return error @@ -319,8 +331,8 @@ def notify_dashboard_entry(cmdr, is_beta, entry): # Pass a copy of the status entry in case the callee modifies it newerror = status(cmdr, is_beta, dict(entry)) error = error or newerror - except: - print_exc() + except Exception as e: + logger.exception(f'Plugin "{plugin.name}" failed') return error @@ -338,8 +350,8 @@ def notify_newdata(data, is_beta): try: newerror = cmdr_data(data, is_beta) error = error or newerror - except: - print_exc() + except Exception as e: + logger.exception(f'Plugin "{plugin.name}" failed') return error diff --git a/plugins/coriolis.py b/plugins/coriolis.py index 2a75662e..21077886 100644 --- a/plugins/coriolis.py +++ b/plugins/coriolis.py @@ -7,17 +7,21 @@ import io # Migrate settings from <= 3.01 from config import config + if not config.get('shipyard_provider') and config.getint('shipyard'): config.set('shipyard_provider', 'Coriolis') + config.delete('shipyard') -def plugin_start3(plugin_dir): +def plugin_start3(_): return 'Coriolis' -# Return a URL for the current ship + def shipyard_url(loadout, is_beta): - string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8') # most compact representation + """Return a URL for the current ship""" + # most compact representation + string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8') if not string: return False @@ -25,4 +29,7 @@ def shipyard_url(loadout, is_beta): with gzip.GzipFile(fileobj=out, mode='w') as f: f.write(string) - return (is_beta and 'https://beta.coriolis.io/import?data=' or 'https://coriolis.io/import?data=') + base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D') + encoded = base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D') + url = 'https://beta.coriolis.io/import?data=' if is_beta else 'https://coriolis.io/import?data=' + + return f'{url}{encoded}' diff --git a/plugins/eddb.py b/plugins/eddb.py index 7f48f0a2..da7b8e75 100644 --- a/plugins/eddb.py +++ b/plugins/eddb.py @@ -24,23 +24,27 @@ import sys +from typing import Any, Optional, TYPE_CHECKING import requests from config import config +if TYPE_CHECKING: + from tkinter import Tk -STATION_UNDOCKED: str = u'×' # "Station" name to display when not docked = U+00D7 -this = sys.modules[__name__] # For holding module globals +STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 + +this: Any = sys.modules[__name__] # For holding module globals # Main window clicks -this.system_link = None -this.system = None -this.system_address = None -this.system_population = None -this.station_link = None -this.station = None -this.station_marketid = None +this.system_link: Optional[str] = None +this.system: Optional[str] = None +this.system_address: Optional[str] = None +this.system_population: Optional[int] = None +this.station_link: 'Optional[Tk]' = None +this.station: Optional[str] = None +this.station_marketid: Optional[int] = None def system_url(system_name: str) -> str: @@ -61,21 +65,21 @@ def station_url(system_name: str, station_name: str) -> str: def plugin_start3(plugin_dir): return 'eddb' -def plugin_app(parent): - this.system_link = parent.children['system'] # system label in main window + +def plugin_app(parent: 'Tk'): + this.system_link = parent.children['system'] # system label in main window this.system = None this.system_address = None this.station = None this.station_marketid = None # Frontier MarketID this.station_link = parent.children['station'] # station label in main window - this.station_link.configure(popup_copy = lambda x: x != STATION_UNDOCKED) + this.station_link.configure(popup_copy=lambda x: x != STATION_UNDOCKED) def prefs_changed(cmdr, is_beta): # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. pass - def journal_entry(cmdr, is_beta, system, station, entry, state): # Always update our system address even if we're not currently the provider for system or station, but dont update # on events that contain "future" data, such as FSDTarget @@ -105,7 +109,15 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): # But only actually change the URL if we are current station provider. if config.get('station_provider') == 'eddb': - this.station_link['text'] = this.station or (this.system_population and this.system_population > 0 and STATION_UNDOCKED or '') + text = this.station + if not text: + if this.system_population is not None and this.system_population > 0: + text = STATION_UNDOCKED + + else: + text = '' + + this.station_link['text'] = text # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.station_link.update_idletasks() @@ -113,11 +125,15 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): def cmdr_data(data, is_beta): # Always store initially, even if we're not the *current* system provider. - if not this.station_marketid: - this.station_marketid = data['commander']['docked'] and data['lastStarport']['id'] + if not this.station_marketid and data['commander']['docked']: + this.station_marketid = data['lastStarport']['id'] + # Only trust CAPI if these aren't yet set - this.system = this.system or data['lastSystem']['name'] - this.station = this.station or data['commander']['docked'] and data['lastStarport']['name'] + if not this.system: + this.system = data['lastSystem']['name'] + + if not this.station and data['commander']['docked']: + this.station = data['lastStarport']['name'] # Override standard URL functions if config.get('system_provider') == 'eddb': @@ -125,15 +141,17 @@ def cmdr_data(data, is_beta): # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.system_link.update_idletasks() + if config.get('station_provider') == 'eddb': if data['commander']['docked']: this.station_link['text'] = this.station + elif data['lastStarport']['name'] and data['lastStarport']['name'] != "": this.station_link['text'] = STATION_UNDOCKED + else: this.station_link['text'] = '' # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.station_link.update_idletasks() - diff --git a/plugins/eddn.py b/plugins/eddn.py index 15d7941c..b16fc98b 100644 --- a/plugins/eddn.py +++ b/plugins/eddn.py @@ -1,32 +1,40 @@ # Export to EDDN -from collections import OrderedDict +import itertools import json -from os import SEEK_SET, SEEK_CUR, SEEK_END +import logging +import pathlib +import re +import sys +import tkinter as tk +from collections import OrderedDict +from os import SEEK_SET from os.path import exists, join from platform import system -import re +from typing import TYPE_CHECKING, Any, AnyStr, Dict, Iterator, List, Mapping, MutableMapping, Optional +from typing import OrderedDict as OrderedDictT +from typing import Sequence, TextIO, Tuple, Union + import requests -import sys -import uuid - -import tkinter as tk -from ttkHyperlinkLabel import HyperlinkLabel -import myNotebook as nb +import myNotebook as nb # noqa: N813 +from companion import category_map +from config import applongname, appname, appversion, config +from myNotebook import Frame from prefs import prefsVersion +from ttkHyperlinkLabel import HyperlinkLabel if sys.platform != 'win32': from fcntl import lockf, LOCK_EX, LOCK_NB -if __debug__: - from traceback import print_exc -from config import applongname, appversion, config -from companion import category_map +if TYPE_CHECKING: + def _(x: str) -> str: + return x +logger = logging.getLogger(appname) -this = sys.modules[__name__] # For holding module globals +this: Any = sys.modules[__name__] # For holding module globals # Track location to add to Journal events this.systemaddress = None @@ -35,130 +43,180 @@ this.planet = None # Avoid duplicates this.marketId = None -this.commodities = this.outfitting = this.shipyard = None +this.commodities = None +this.outfitting: Optional[Tuple[bool, MutableMapping[str, Any]]] = None +this.shipyard = None + +HORIZ_SKU = 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' -class EDDN(object): +# TODO: a good few of these methods are static or could be classmethods. they should be created as such. - ### SERVER = 'http://localhost:8081' # testing +class EDDN: + # SERVER = 'http://localhost:8081' # testing SERVER = 'https://eddn.edcd.io:4430' - UPLOAD = '%s/upload/' % SERVER - REPLAYPERIOD = 400 # Roughly two messages per second, accounting for send delays [ms] - REPLAYFLUSH = 20 # Update log on disk roughly every 10 seconds - TIMEOUT= 10 # requests timeout - MODULE_RE = re.compile('^Hpt_|^Int_|Armour_', re.IGNORECASE) + UPLOAD = f'{SERVER}/upload/' + REPLAYPERIOD = 400 # Roughly two messages per second, accounting for send delays [ms] + REPLAYFLUSH = 20 # Update log on disk roughly every 10 seconds + TIMEOUT = 10 # requests timeout + MODULE_RE = re.compile(r'^Hpt_|^Int_|Armour_', re.IGNORECASE) CANONICALISE_RE = re.compile(r'\$(.+)_name;') - def __init__(self, parent): - self.parent = parent + def __init__(self, parent: tk.Tk): + self.parent: tk.Tk = parent self.session = requests.Session() - self.replayfile = None # For delayed messages - self.replaylog = [] + self.replayfile: Optional[TextIO] = None # For delayed messages + self.replaylog: List[str] = [] - def load(self): + def load_journal_replay(self) -> bool: + """ + Load cached journal entries from disk + + :return: a bool indicating success + """ # Try to obtain exclusive access to the journal cache filename = join(config.app_dir, 'replay.jsonl') try: try: # Try to open existing file self.replayfile = open(filename, 'r+', buffering=1) - except: + + except Exception: if exists(filename): - raise # Couldn't open existing file + raise # Couldn't open existing file + else: - self.replayfile = open(filename, 'w+', buffering=1) # Create file - if sys.platform != 'win32': # open for writing is automatically exclusive on Windows - lockf(self.replayfile, LOCK_EX|LOCK_NB) - except: - if __debug__: print_exc() + self.replayfile = open(filename, 'w+', buffering=1) # Create file + + if sys.platform != 'win32': # open for writing is automatically exclusive on Windows + lockf(self.replayfile, LOCK_EX | LOCK_NB) + + except Exception as e: + logger.debug('Failed opening "replay.jsonl"', exc_info=e) if self.replayfile: self.replayfile.close() + self.replayfile = None return False + self.replaylog = [line.strip() for line in self.replayfile] return True def flush(self): + """ + flush flushes the replay file, clearing any data currently there that is not in the replaylog list + """ self.replayfile.seek(0, SEEK_SET) self.replayfile.truncate() for line in self.replaylog: - self.replayfile.write('%s\n' % line) + self.replayfile.write(f'{line}\n') + self.replayfile.flush() def close(self): + """ + close closes the replay file + """ if self.replayfile: self.replayfile.close() + self.replayfile = None - def send(self, cmdr, msg): - uploaderID = cmdr + def send(self, cmdr: str, msg: Mapping[str, Any]) -> None: + """ + Send sends an update to EDDN - msg = OrderedDict([ + :param cmdr: the CMDR to use as the uploader ID + :param msg: the payload to send + """ + uploader_id = cmdr + + to_send: OrderedDictT[str, str] = OrderedDict([ ('$schemaRef', msg['$schemaRef']), - ('header', OrderedDict([ - ('softwareName', '%s [%s]' % (applongname, sys.platform=='darwin' and "Mac OS" or system())), + ('header', OrderedDict([ + ('softwareName', f'{applongname} [{system() if sys.platform != "darwin" else "Mac OS"}]'), ('softwareVersion', appversion), - ('uploaderID', uploaderID), + ('uploaderID', uploader_id), ])), - ('message', msg['message']), + ('message', msg['message']), ]) - r = self.session.post(self.UPLOAD, data=json.dumps(msg), timeout=self.TIMEOUT) - if __debug__ and r.status_code != requests.codes.ok: - print('Status\t%s' % r.status_code) - print('URL\t%s' % r.url) - print('Headers\t%s' % r.headers) - print('Content:\n%s' % r.text) + r = self.session.post(self.UPLOAD, data=json.dumps(to_send), timeout=self.TIMEOUT) + if r.status_code != requests.codes.ok: + logger.debug(f':\nStatus\t{r.status_code}URL\t{r.url}Headers\t{r.headers}Content:\n{r.text}') + r.raise_for_status() - def sendreplay(self): + def sendreplay(self) -> None: + """ + sendreplay updates EDDN with cached journal lines + """ if not self.replayfile: - return # Probably closing app + return # Probably closing app - status = self.parent.children['status'] + status: Dict[str, Any] = self.parent.children['status'] if not self.replaylog: status['text'] = '' return + localized: str = _('Sending data to EDDN...') if len(self.replaylog) == 1: - status['text'] = _('Sending data to EDDN...') + status['text'] = localized + else: - status['text'] = '%s [%d]' % (_('Sending data to EDDN...').replace('...',''), len(self.replaylog)) + status['text'] = f'{localized.replace("...", "")} [{len(self.replaylog)}]' + self.parent.update_idletasks() + try: cmdr, msg = json.loads(self.replaylog[0], object_pairs_hook=OrderedDict) - except: + + except json.JSONDecodeError as e: # Couldn't decode - shouldn't happen! - if __debug__: - print(self.replaylog[0]) - print_exc() - self.replaylog.pop(0) # Discard and continue + logger.debug(f'\n{self.replaylog[0]}\n', exc_info=e) + # Discard and continue + self.replaylog.pop(0) + else: # Rewrite old schema name if msg['$schemaRef'].startswith('http://schemas.elite-markets.net/eddn/'): - msg['$schemaRef'] = 'https://eddn.edcd.io/schemas/' + msg['$schemaRef'][38:] + msg['$schemaRef'] = str(msg['$schemaRef']).replace( + 'http://schemas.elite-markets.net/eddn/', + 'https://eddn.edcd.io/schemas/' + ) + try: self.send(cmdr, msg) self.replaylog.pop(0) if not len(self.replaylog) % self.REPLAYFLUSH: self.flush() + except requests.exceptions.RequestException as e: - if __debug__: print_exc() + logger.debug('Failed sending', exc_info=e) status['text'] = _("Error: Can't connect to EDDN") - return # stop sending + return # stop sending + except Exception as e: - if __debug__: print_exc() + logger.debug('Failed sending', exc_info=e) status['text'] = str(e) - return # stop sending + return # stop sending self.parent.after(self.REPLAYPERIOD, self.sendreplay) - def export_commodities(self, data, is_beta): - commodities = [] + def export_commodities(self, data: Mapping[str, Any], is_beta: bool) -> None: + """ + export_commodities updates EDDN with the commodities on the current (lastStarport) station. + Once the send is complete, this.commodities is updated with the new data. + + :param data: a dict containing the starport data + :param is_beta: whether or not we're currently in beta mode + """ + commodities: List[OrderedDictT[str, Any]] = [] for commodity in data['lastStarport'].get('commodities') or []: - if (category_map.get(commodity['categoryname'], True) and # Check marketable - not commodity.get('legality')): # check not prohibited + # Check 'marketable' and 'not prohibited' + if (category_map.get(commodity['categoryname'], True) + and not commodity.get('legality')): commodities.append(OrderedDict([ ('name', commodity['name'].lower()), ('meanPrice', int(commodity['meanPrice'])), @@ -169,41 +227,68 @@ class EDDN(object): ('demand', int(commodity['demand'])), ('demandBracket', commodity['demandBracket']), ])) + if commodity['statusFlags']: commodities[-1]['statusFlags'] = commodity['statusFlags'] - commodities.sort(key = lambda c: c['name']) - if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it - message = OrderedDict([ + commodities.sort(key=lambda c: c['name']) + + if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it + message: OrderedDictT[str, Any] = OrderedDict([ ('timestamp', data['timestamp']), ('systemName', data['lastSystem']['name']), ('stationName', data['lastStarport']['name']), ('marketId', data['lastStarport']['id']), ('commodities', commodities), ]) + if 'economies' in data['lastStarport']: - message['economies'] = sorted(list([x for x in (data['lastStarport']['economies'] or {}).values()]), key=lambda x: x['name']) + message['economies'] = sorted( + (x for x in (data['lastStarport']['economies'] or {}).values()), key=lambda x: x['name'] + ) + if 'prohibited' in data['lastStarport']: - message['prohibited'] = sorted(list([x for x in (data['lastStarport']['prohibited'] or {}).values()])) + message['prohibited'] = sorted(x for x in (data['lastStarport']['prohibited'] or {}).values()) + self.send(data['commander']['name'], { - '$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''), - 'message' : message, + '$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}', + 'message': message, }) + this.commodities = commodities - def export_outfitting(self, data, is_beta): - economies = data['lastStarport'].get('economies') or {} - modules = data['lastStarport'].get('modules') or {} - ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] } - # Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"), prison or rescue Megaships, or under Pirate Attack etc - horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or - any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or - any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values()))) - outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['name'].lower()) for module in modules.values() if self.MODULE_RE.search(module['name']) and module.get('sku') in [None, 'ELITE_HORIZONS_V_PLANETARY_LANDINGS'] and module['name'] != 'Int_PlanetApproachSuite']) - if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it + def export_outfitting(self, data: Mapping[str, Any], is_beta: bool) -> None: + """ + export_outfitting updates EDDN with the current (lastStarport) station's outfitting options, if any. + Once the send is complete, this.outfitting is updated with the given data. + + :param data: dict containing the outfitting data + :param is_beta: whether or not we're currently in beta mode + """ + modules: Dict[str, Any] = data['lastStarport'].get('modules') or {} + + # Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"), + # prison or rescue Megaships, or under Pirate Attack etc + horizons: bool = is_horizons( + data['lastStarport'].get('economies', {}), + modules, + data['lastStarport'].get('ships', {'shipyard_list': {}, 'unavailable_list': []}) + ) + + to_search: Iterator[Mapping[str, Any]] = filter( + lambda m: self.MODULE_RE.search(m['name']) and m.get('sku') in (None, HORIZ_SKU) and + m['name'] != 'Int_PlanetApproachSuite', + modules.values() + ) + + outfitting: List[str] = sorted( + self.MODULE_RE.sub(lambda match: match.group(0).capitalize(), mod['name'].lower()) for mod in to_search + ) + # Don't send empty modules list - schema won't allow it + if outfitting and this.outfitting != (horizons, outfitting): self.send(data['commander']['name'], { - '$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''), - 'message' : OrderedDict([ + '$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}', + 'message': OrderedDict([ ('timestamp', data['timestamp']), ('systemName', data['lastSystem']['name']), ('stationName', data['lastStarport']['name']), @@ -212,20 +297,35 @@ class EDDN(object): ('modules', outfitting), ]), }) + this.outfitting = (horizons, outfitting) - def export_shipyard(self, data, is_beta): - economies = data['lastStarport'].get('economies') or {} - modules = data['lastStarport'].get('modules') or {} - ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] } - horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or - any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or - any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values()))) - shipyard = sorted([ship['name'].lower() for ship in list((ships['shipyard_list'] or {}).values()) + ships['unavailable_list']]) - if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard. + def export_shipyard(self, data: Dict[str, Any], is_beta: bool) -> None: + """ + export_shipyard updates EDDN with the current (lastStarport) station's outfitting options, if any. + once the send is complete, this.shipyard is updated to the new data. + + :param data: dict containing the shipyard data + :param is_beta: whether or not we are in beta mode + """ + ships: Dict[str, Any] = data['lastStarport'].get('ships', {'shipyard_list': {}, 'unavailable_list': []}) + horizons: bool = is_horizons( + data['lastStarport'].get('economies', {}), + data['lastStarport'].get('modules', {}), + ships + ) + + shipyard: List[Mapping[str, Any]] = sorted( + itertools.chain( + (ship['name'].lower() for ship in (ships['shipyard_list'] or {}).values()), + ships['unavailable_list'] + ) + ) + # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard. + if shipyard and this.shipyard != (horizons, shipyard): self.send(data['commander']['name'], { - '$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''), - 'message' : OrderedDict([ + '$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}', + 'message': OrderedDict([ ('timestamp', data['timestamp']), ('systemName', data['lastSystem']['name']), ('stationName', data['lastStarport']['name']), @@ -234,11 +334,20 @@ class EDDN(object): ('ships', shipyard), ]), }) + this.shipyard = (horizons, shipyard) - def export_journal_commodities(self, cmdr, is_beta, entry): - items = entry.get('Items') or [] - commodities = sorted([OrderedDict([ + def export_journal_commodities(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None: + """ + export_journal_commodities updates EDDN with the commodities list on the current station (lastStarport) from + data in the journal. As a side effect, it also updates this.commodities with the data + + :param cmdr: The commander to send data under + :param is_beta: whether or not we're in beta mode + :param entry: the journal entry containing the commodities data + """ + items: List[Mapping[str, Any]] = entry.get('Items') or [] + commodities: Sequence[OrderedDictT[AnyStr, Any]] = sorted((OrderedDict([ ('name', self.canonicalise(commodity['Name'])), ('meanPrice', commodity['MeanPrice']), ('buyPrice', commodity['BuyPrice']), @@ -247,12 +356,12 @@ class EDDN(object): ('sellPrice', commodity['SellPrice']), ('demand', commodity['Demand']), ('demandBracket', commodity['DemandBracket']), - ]) for commodity in items], key = lambda c: c['name']) + ]) for commodity in items), key=lambda c: c['name']) - if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it + if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it self.send(cmdr, { - '$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''), - 'message' : OrderedDict([ + '$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}', + 'message': OrderedDict([ ('timestamp', entry['timestamp']), ('systemName', entry['StarSystem']), ('stationName', entry['StationName']), @@ -260,16 +369,31 @@ class EDDN(object): ('commodities', commodities), ]), }) - this.commodities = commodities - def export_journal_outfitting(self, cmdr, is_beta, entry): - modules = entry.get('Items') or [] - horizons = entry.get('Horizons', False) - outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['Name']) for module in modules if module['Name'] != 'int_planetapproachsuite']) - if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it + this.commodities: OrderedDictT[str, Any] = commodities + + def export_journal_outfitting(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None: + """ + export_journal_outfitting updates EDDN with station outfitting based on a journal entry. As a side effect, + it also updates this.outfitting with the data + + :param cmdr: The commander to send data under + :param is_beta: Whether or not we're in beta mode + :param entry: The relevant journal entry + """ + modules: List[Mapping[str, Any]] = entry.get('Items', []) + horizons: bool = entry.get('Horizons', False) + # outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['Name']) + # for module in modules if module['Name'] != 'int_planetapproachsuite']) + outfitting: List[str] = sorted( + self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), mod['Name']) for mod in + filter(lambda m: m['Name'] != 'int_planetapproachsuite', modules) + ) + # Don't send empty modules list - schema won't allow it + if outfitting and this.outfitting != (horizons, outfitting): self.send(cmdr, { - '$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''), - 'message' : OrderedDict([ + '$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}', + 'message': OrderedDict([ ('timestamp', entry['timestamp']), ('systemName', entry['StarSystem']), ('stationName', entry['StationName']), @@ -278,16 +402,26 @@ class EDDN(object): ('modules', outfitting), ]), }) + this.outfitting = (horizons, outfitting) - def export_journal_shipyard(self, cmdr, is_beta, entry): - ships = entry.get('PriceList') or [] - horizons = entry.get('Horizons', False) - shipyard = sorted([ship['ShipType'] for ship in ships]) - if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard. + def export_journal_shipyard(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None: + """ + export_journal_shipyard updates EDDN with station shipyard data based on a journal entry. As a side effect, + this.shipyard is updated with the data. + + :param cmdr: the commander to send this update under + :param is_beta: Whether or not we're in beta mode + :param entry: the relevant journal entry + """ + ships: List[Mapping[str, Any]] = entry.get('PriceList') or [] + horizons: bool = entry.get('Horizons', False) + shipyard = sorted(ship['ShipType'] for ship in ships) + # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard. + if shipyard and this.shipyard != (horizons, shipyard): self.send(cmdr, { - '$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''), - 'message' : OrderedDict([ + '$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}', + 'message': OrderedDict([ ('timestamp', entry['timestamp']), ('systemName', entry['StarSystem']), ('stationName', entry['StationName']), @@ -296,130 +430,206 @@ class EDDN(object): ('ships', shipyard), ]), }) + this.shipyard = (horizons, shipyard) - def export_journal_entry(self, cmdr, is_beta, entry): + def export_journal_entry(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None: + """ + export_journal_entry updates EDDN with a line from the journal. Additionally if additional lines are cached, + it may send those as well. + + :param cmdr: the commander under which this upload is made + :param is_beta: whether or not we are in beta mode + :param entry: the journal entry to send + """ msg = { - '$schemaRef' : 'https://eddn.edcd.io/schemas/journal/1' + (is_beta and '/test' or ''), - 'message' : entry + '$schemaRef': f'https://eddn.edcd.io/schemas/journal/1{"/test" if is_beta else ""}', + 'message': entry } - if self.replayfile or self.load(): + + if self.replayfile or self.load_journal_replay(): # Store the entry self.replaylog.append(json.dumps([cmdr, msg])) - self.replayfile.write('%s\n' % self.replaylog[-1]) + self.replayfile.write(f'{self.replaylog[-1]}\n') + + if ( + entry['event'] == 'Docked' or (entry['event'] == 'Location' and entry['Docked']) or not + (config.getint('output') & config.OUT_SYS_DELAY) + ): + self.parent.after(self.REPLAYPERIOD, self.sendreplay) # Try to send this and previous entries - if (entry['event'] == 'Docked' or - (entry['event'] == 'Location' and entry['Docked']) or - not (config.getint('output') & config.OUT_SYS_DELAY)): - self.parent.after(self.REPLAYPERIOD, self.sendreplay) # Try to send this and previous entries else: # Can't access replay file! Send immediately. - status = self.parent.children['status'] + status: MutableMapping[str, str] = self.parent.children['status'] status['text'] = _('Sending data to EDDN...') self.parent.update_idletasks() self.send(cmdr, msg) status['text'] = '' - def canonicalise(self, item): + def canonicalise(self, item: str) -> str: match = self.CANONICALISE_RE.match(item) return match and match.group(1) or item # Plugin callbacks -def plugin_start3(plugin_dir): +def plugin_start3(plugin_dir: str) -> str: return 'EDDN' -def plugin_app(parent): + +def plugin_app(parent: tk.Tk) -> None: this.parent = parent this.eddn = EDDN(parent) # Try to obtain exclusive lock on journal cache, even if we don't need it yet - if not this.eddn.load(): - this.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing + if not this.eddn.load_journal_replay(): + # Shouldn't happen - don't bother localizing + this.status['text'] = 'Error: Is another copy of this app already running?' -def plugin_prefs(parent, cmdr, is_beta): - PADX = 10 - BUTTONX = 12 # indent Checkbuttons and Radiobuttons - PADY = 2 # close spacing +def plugin_prefs(parent, cmdr: str, is_beta: bool) -> Frame: + PADX = 10 # noqa: N806 + BUTTONX = 12 # noqa: N806 # indent Checkbuttons and Radiobuttons if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.getint('output'))): - output = (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN) # default settings + output: int = (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN) # default settings + else: - output = config.getint('output') + output: int = config.getint('output') eddnframe = nb.Frame(parent) - HyperlinkLabel(eddnframe, text='Elite Dangerous Data Network', background=nb.Label().cget('background'), url='https://github.com/EDSM-NET/EDDN/wiki', underline=True).grid(padx=PADX, sticky=tk.W) # Don't translate - this.eddn_station= tk.IntVar(value = (output & config.OUT_MKT_EDDN) and 1) - this.eddn_station_button = nb.Checkbutton(eddnframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=this.eddn_station, command=prefsvarchanged) # Output setting - this.eddn_station_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) - this.eddn_system = tk.IntVar(value = (output & config.OUT_SYS_EDDN) and 1) - this.eddn_system_button = nb.Checkbutton(eddnframe, text=_('Send system and scan data to the Elite Dangerous Data Network'), variable=this.eddn_system, command=prefsvarchanged) # Output setting new in E:D 2.2 - this.eddn_system_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) - this.eddn_delay= tk.IntVar(value = (output & config.OUT_SYS_DELAY) and 1) - this.eddn_delay_button = nb.Checkbutton(eddnframe, text=_('Delay sending until docked'), variable=this.eddn_delay) # Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2 + HyperlinkLabel( + eddnframe, + text='Elite Dangerous Data Network', + background=nb.Label().cget('background'), + url='https://github.com/EDSM-NET/EDDN/wiki', + underline=True + ).grid(padx=PADX, sticky=tk.W) # Don't translate + + this.eddn_station = tk.IntVar(value=(output & config.OUT_MKT_EDDN) and 1) + this.eddn_station_button = nb.Checkbutton( + eddnframe, + text=_('Send station data to the Elite Dangerous Data Network'), + variable=this.eddn_station, + command=prefsvarchanged + ) # Output setting + + this.eddn_station_button.grid(padx=BUTTONX, pady=(5, 0), sticky=tk.W) + this.eddn_system = tk.IntVar(value=(output & config.OUT_SYS_EDDN) and 1) + # Output setting new in E:D 2.2 + this.eddn_system_button = nb.Checkbutton( + eddnframe, + text=_('Send system and scan data to the Elite Dangerous Data Network'), + variable=this.eddn_system, + command=prefsvarchanged + ) + + this.eddn_system_button.grid(padx=BUTTONX, pady=(5, 0), sticky=tk.W) + this.eddn_delay = tk.IntVar(value=(output & config.OUT_SYS_DELAY) and 1) + # Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2 + this.eddn_delay_button = nb.Checkbutton( + eddnframe, + text=_('Delay sending until docked'), + variable=this.eddn_delay + ) this.eddn_delay_button.grid(padx=BUTTONX, sticky=tk.W) return eddnframe -def prefsvarchanged(event=None): + +def prefsvarchanged(event=None) -> None: this.eddn_station_button['state'] = tk.NORMAL - this.eddn_system_button['state']= tk.NORMAL + this.eddn_system_button['state'] = tk.NORMAL this.eddn_delay_button['state'] = this.eddn.replayfile and this.eddn_system.get() and tk.NORMAL or tk.DISABLED -def prefs_changed(cmdr, is_beta): - config.set('output', - (config.getint('output') & (config.OUT_MKT_TD | config.OUT_MKT_CSV | config.OUT_SHIP |config.OUT_MKT_MANUAL)) + - (this.eddn_station.get() and config.OUT_MKT_EDDN) + - (this.eddn_system.get() and config.OUT_SYS_EDDN) + - (this.eddn_delay.get() and config.OUT_SYS_DELAY)) -def plugin_stop(): +def prefs_changed(cmdr: str, is_beta: bool) -> None: + config.set( + 'output', + (config.getint('output') & (config.OUT_MKT_TD | config.OUT_MKT_CSV | config.OUT_SHIP | config.OUT_MKT_MANUAL)) + + (this.eddn_station.get() and config.OUT_MKT_EDDN) + + (this.eddn_system.get() and config.OUT_SYS_EDDN) + + (this.eddn_delay.get() and config.OUT_SYS_DELAY) + ) + + +def plugin_stop() -> None: this.eddn.close() -def journal_entry(cmdr, is_beta, system, station, entry, state): +def journal_entry( + cmdr: str, is_beta: bool, system: str, station: str, entry: MutableMapping[str, Any], state: Mapping[str, Any] +) -> Optional[str]: # Recursively filter '*_Localised' keys from dict - def filter_localised(d): - filtered = OrderedDict() + def filter_localised(d: Mapping[str, Any]) -> OrderedDictT[str, Any]: + filtered: OrderedDictT[str, Any] = OrderedDict() for k, v in d.items(): if k.endswith('_Localised'): pass - elif hasattr(v, 'items'): # dict -> recurse + + elif hasattr(v, 'items'): # dict -> recurse filtered[k] = filter_localised(v) - elif isinstance(v, list): # list of dicts -> recurse + + elif isinstance(v, list): # list of dicts -> recurse filtered[k] = [filter_localised(x) if hasattr(x, 'items') else x for x in v] + else: filtered[k] = v + return filtered # Track location - if entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']: + if entry['event'] in ('Location', 'FSDJump', 'Docked', 'CarrierJump'): if entry['event'] in ('Location', 'CarrierJump'): - this.planet = entry.get('Body') if entry.get('BodyType') == 'Planet' else None + this.planet: Optional[str] = entry.get('Body') if entry.get('BodyType') == 'Planet' else None + elif entry['event'] == 'FSDJump': - this.planet = None + this.planet: Optional[str] = None + if 'StarPos' in entry: - this.coordinates = tuple(entry['StarPos']) + this.coordinates: Optional[Tuple[int, int, int]] = tuple(entry['StarPos']) + elif this.systemaddress != entry.get('SystemAddress'): - this.coordinates = None # Docked event doesn't include coordinates - this.systemaddress = entry.get('SystemAddress') + this.coordinates: Optional[Tuple[int, int, int]] = None # Docked event doesn't include coordinates + + this.systemaddress: Optional[str] = entry.get('SystemAddress') + elif entry['event'] == 'ApproachBody': this.planet = entry['Body'] - elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']: + + elif entry['event'] in ('LeaveBody', 'SupercruiseEntry'): this.planet = None # Send interesting events to EDDN, but not when on a crew if (config.getint('output') & config.OUT_SYS_EDDN and not state['Captain'] and (entry['event'] in ('Location', 'FSDJump', 'Docked', 'Scan', 'SAASignalsFound', 'CarrierJump')) and - ('StarPos' in entry or this.coordinates)): + ('StarPos' in entry or this.coordinates)): + # strip out properties disallowed by the schema - for thing in ['ActiveFine', 'CockpitBreach', 'BoostUsed', 'FuelLevel', 'FuelUsed', 'JumpDist', 'Latitude', 'Longitude', 'Wanted']: + for thing in ( + 'ActiveFine', + 'CockpitBreach', + 'BoostUsed', + 'FuelLevel', + 'FuelUsed', + 'JumpDist', + 'Latitude', + 'Longitude', + 'Wanted' + ): entry.pop(thing, None) + if 'Factions' in entry: - # Filter faction state. `entry` is a shallow copy so replace 'Factions' value rather than modify in-place. - entry['Factions'] = [ {k: v for k, v in f.items() if k not in ['HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction']} for f in entry['Factions']] + # Filter faction state to comply with schema restrictions regarding personal data. `entry` is a shallow copy + # so replace 'Factions' value rather than modify in-place. + entry['Factions'] = [ + { + k: v for k, v in f.items() if k not in ( + 'HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction' + ) + } + for f in entry['Factions'] + ] # add planet to Docked event for planetary stations if known if entry['event'] == 'Docked' and this.planet: @@ -429,50 +639,66 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): # add mandatory StarSystem, StarPos and SystemAddress properties to Scan events if 'StarSystem' not in entry: if not system: - return("system is None, can't add StarSystem") + logger.warn("system is None, can't add StarSystem") + return "system is None, can't add StarSystem" + entry['StarSystem'] = system + if 'StarPos' not in entry: if not this.coordinates: - return("this.coordinates is None, can't add StarPos") + logger.warn("this.coordinates is None, can't add StarPos") + return "this.coordinates is None, can't add StarPos" + entry['StarPos'] = list(this.coordinates) + if 'SystemAddress' not in entry: if not this.systemaddress: - return("this.systemaddress is None, can't add SystemAddress") + logger.warn("this.systemaddress is None, can't add SystemAddress") + return "this.systemaddress is None, can't add SystemAddress" + entry['SystemAddress'] = this.systemaddress try: this.eddn.export_journal_entry(cmdr, is_beta, filter_localised(entry)) + except requests.exceptions.RequestException as e: - if __debug__: print_exc() + logger.debug('Failed in export_journal_entry', exc_info=e) return _("Error: Can't connect to EDDN") + except Exception as e: - if __debug__: print_exc() + logger.debug('Failed in export_journal_entry', exc_info=e) return str(e) elif (config.getint('output') & config.OUT_MKT_EDDN and not state['Captain'] and - entry['event'] in ['Market', 'Outfitting', 'Shipyard']): + entry['event'] in ('Market', 'Outfitting', 'Shipyard')): + try: if this.marketId != entry['MarketID']: this.commodities = this.outfitting = this.shipyard = None this.marketId = entry['MarketID'] - with open(join(config.get('journaldir') or config.default_journal_dir, '%s.json' % entry['event']), 'rb') as h: - entry = json.load(h) + path = pathlib.Path(str(config.get('journaldir') or config.default_journal_dir)) / f'{entry["event"]}.json' + with path.open('rb') as f: + entry = json.load(f) if entry['event'] == 'Market': this.eddn.export_journal_commodities(cmdr, is_beta, entry) + elif entry['event'] == 'Outfitting': this.eddn.export_journal_outfitting(cmdr, is_beta, entry) + elif entry['event'] == 'Shipyard': this.eddn.export_journal_shipyard(cmdr, is_beta, entry) except requests.exceptions.RequestException as e: - if __debug__: print_exc() + logger.debug(f'Failed exporting {entry["event"]}', exc_info=e) return _("Error: Can't connect to EDDN") + except Exception as e: - if __debug__: print_exc() + logger.debug(f'Failed exporting {entry["event"]}', exc_info=e) return str(e) -def cmdr_data(data, is_beta): + +def cmdr_data(data: Mapping[str, Any], is_beta: bool) -> str: if data['commander'].get('docked') and config.getint('output') & config.OUT_MKT_EDDN: try: if this.marketId != data['lastStarport']['id']: @@ -484,6 +710,7 @@ def cmdr_data(data, is_beta): if not old_status: status['text'] = _('Sending data to EDDN...') status.update_idletasks() + this.eddn.export_commodities(data, is_beta) this.eddn.export_outfitting(data, is_beta) this.eddn.export_shipyard(data, is_beta) @@ -492,9 +719,20 @@ def cmdr_data(data, is_beta): status.update_idletasks() except requests.RequestException as e: - if __debug__: print_exc() + logger.debug('Failed exporting data', exc_info=e) return _("Error: Can't connect to EDDN") except Exception as e: - if __debug__: print_exc() + logger.debug('Failed exporting data', exc_info=e) return str(e) + + +MAP_STR_ANY = Mapping[str, Any] + + +def is_horizons(economies: MAP_STR_ANY, modules: MAP_STR_ANY, ships: MAP_STR_ANY) -> bool: + return ( + any(economy['name'] == 'Colony' for economy in economies.values()) or + any(module.get('sku') == HORIZ_SKU for module in modules.values()) or + any(ship.get('sku') == HORIZ_SKU for ship in (ships['shipyard_list'] or {}).values()) + ) diff --git a/plugins/edsm.py b/plugins/edsm.py index a90526fe..806536cb 100644 --- a/plugins/edsm.py +++ b/plugins/edsm.py @@ -16,16 +16,16 @@ import requests import sys from queue import Queue from threading import Thread +import logging import tkinter as tk from ttkHyperlinkLabel import HyperlinkLabel -import myNotebook as nb +import myNotebook as nb # noqa: N813 from config import appname, applongname, appversion, config import plug -if __debug__: - from traceback import print_exc +logger = logging.getLogger(appname) EDSM_POLL = 0.1 _TIMEOUT = 20 @@ -371,23 +371,28 @@ def worker(): r.raise_for_status() reply = r.json() (msgnum, msg) = reply['msgnum'], reply['msg'] - # 1xx = OK, 2xx = fatal error, 3&4xx not generated at top-level, 5xx = error but events saved for later processing + # 1xx = OK + # 2xx = fatal error + # 3&4xx not generated at top-level + # 5xx = error but events saved for later processing if msgnum // 100 == 2: - print('EDSM\t%s %s\t%s' % (msgnum, msg, json.dumps(pending, separators = (',', ': ')))) + logger.warning(f'EDSM\t{msgnum} {msg}\t{json.dumps(pending, separators = (",", ": "))}') plug.show_error(_('Error: EDSM {MSG}').format(MSG=msg)) else: for e, r in zip(pending, reply['events']): if not closing and e['event'] in ['StartUp', 'Location', 'FSDJump', 'CarrierJump']: # Update main window's system status this.lastlookup = r - this.system_link.event_generate('<>', when="tail") # calls update_status in main thread + # calls update_status in main thread + this.system_link.event_generate('<>', when="tail") elif r['msgnum'] // 100 != 1: - print('EDSM\t%s %s\t%s' % (r['msgnum'], r['msg'], json.dumps(e, separators = (',', ': ')))) + logger.warning(f'EDSM\t{r["msgnum"]} {r["msg"]}\t' + f'{json.dumps(e, separators = (",", ": "))}') pending = [] break - except: - if __debug__: print_exc() + except Exception as e: + logger.debug('Sending API events', exc_info=e) retrying += 1 else: plug.show_error(_("Error: Can't connect to EDSM")) diff --git a/plugins/inara.py b/plugins/inara.py index 61fb7abe..fd9402e8 100644 --- a/plugins/inara.py +++ b/plugins/inara.py @@ -2,57 +2,69 @@ # Inara sync # -from collections import OrderedDict +import dataclasses import json -import requests +import logging import sys import time +import tkinter as tk +# For new impl +from collections import OrderedDict, defaultdict, deque from operator import itemgetter from queue import Queue -from threading import Thread +from threading import Lock, Thread +from typing import TYPE_CHECKING, Any, AnyStr, Callable, Deque, Dict, List, Mapping, NamedTuple, Optional +from typing import OrderedDict as OrderedDictT +from typing import Sequence, Union -import tkinter as tk -from ttkHyperlinkLabel import HyperlinkLabel -import myNotebook as nb +import requests -from config import appname, applongname, appversion, config +import myNotebook as nb # noqa: N813 import plug +import timeout_session +from config import applongname, appname, appversion, config +from ttkHyperlinkLabel import HyperlinkLabel -if __debug__: - from traceback import print_exc +logger = logging.getLogger(appname) + +if TYPE_CHECKING: + def _(x): + return x _TIMEOUT = 20 -FAKE = ['CQC', 'Training', 'Destination'] # Fake systems that shouldn't be sent to Inara +FAKE = ('CQC', 'Training', 'Destination') # Fake systems that shouldn't be sent to Inara CREDIT_RATIO = 1.05 # Update credits if they change by 5% over the course of a session -this = sys.modules[__name__] # For holding module globals -this.session = requests.Session() -this.queue = Queue() # Items to be sent to Inara by worker thread -this.lastlocation = None # eventData from the last Commander's Flight Log event -this.lastship = None # eventData from the last addCommanderShip or setCommanderShip event +this: Any = sys.modules[__name__] # For holding module globals +this.session = timeout_session.new_session() +this.queue = Queue() # Items to be sent to Inara by worker thread +this.lastlocation = None # eventData from the last Commander's Flight Log event +this.lastship = None # eventData from the last addCommanderShip or setCommanderShip event # Cached Cmdr state -this.events = [] # Unsent events -this.cmdr = None -this.FID = None # Frontier ID -this.multicrew = False # don't send captain's ship info to Inara while on a crew -this.newuser = False # just entered API Key - send state immediately -this.newsession = True # starting a new session - wait for Cargo event -this.undocked = False # just undocked -this.suppress_docked = False # Skip initial Docked event if started docked -this.cargo = None -this.materials = None -this.lastcredits = 0 # Send credit update soon after Startup / new game -this.storedmodules = None -this.loadout = None -this.fleet = None -this.shipswap = False # just swapped ship +this.events: List[OrderedDictT[str, Any]] = [] # Unsent events +this.event_lock = Lock +this.cmdr: Optional[str] = None +this.FID: Optional[str] = None # Frontier ID +this.multicrew: bool = False # don't send captain's ship info to Inara while on a crew +this.newuser: bool = False # just entered API Key - send state immediately +this.newsession: bool = True # starting a new session - wait for Cargo event +this.undocked: bool = False # just undocked +this.suppress_docked = False # Skip initial Docked event if started docked +this.cargo: Optional[OrderedDictT[str, Any]] = None +this.materials: Optional[OrderedDictT[str, Any]] = None +this.lastcredits: int = 0 # Send credit update soon after Startup / new game +this.storedmodules: Optional[OrderedDictT[str, Any]] = None +this.loadout: Optional[OrderedDictT[str, Any]] = None +this.fleet: Optional[List[OrderedDictT[str, Any]]] = None +this.shipswap: bool = False # just swapped ship # last time we updated, if unset in config this is 0, which means an instant update LAST_UPDATE_CONF_KEY = 'inara_last_update' -# this.last_update_time = config.getint(LAST_UPDATE_CONF_KEY) -FLOOD_LIMIT_SECONDS = 30 # minimum time between sending events +EVENT_COLLECT_TIME = 31 # Minimum time to take collecting events before requesting a send +WORKER_WAIT_TIME = 35 # Minimum time for worker to wait between sends + this.timer_run = True @@ -66,7 +78,52 @@ this.station = None this.station_marketid = None STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 -def system_url(system_name): + +class Credentials(NamedTuple): + """ + Credentials holds the set of credentials required to identify an inara API payload to inara + """ + cmdr: str + fid: str + api_key: str + + +EVENT_DATA = Union[Mapping[AnyStr, Any], Sequence[Mapping[AnyStr, Any]]] + + +class Event(NamedTuple): + """ + Event represents an event for the Inara API + """ + name: str + timestamp: str + data: EVENT_DATA + + +@dataclasses.dataclass +class NewThis: + events: Dict[Credentials, Deque[Event]] = dataclasses.field(default_factory=lambda: defaultdict(deque)) + event_lock: Lock = dataclasses.field(default_factory=Lock) # protects events, for use when rewriting events + + def filter_events(self, key: Credentials, predicate: Callable[[Event], bool]): + """ + filter_events is the equivalent of running filter() on any event list in the events dict. + it will automatically handle locking, and replacing the event list with the filtered version. + + :param key: the key to filter + :param predicate: the predicate to use while filtering + """ + with self.event_lock: + tmp = self.events[key].copy() + self.events[key].clear() + self.events[key].extend(filter(predicate, tmp)) + + +new_this = NewThis() +TARGET_URL = 'https://inara.cz/inapi/v1/' + + +def system_url(system_name: str): if this.system_address: return requests.utils.requote_uri(f'https://inara.cz/galaxy-starsystem/?search={this.system_address}') @@ -88,53 +145,63 @@ def station_url(system_name, station_name): return '' - def plugin_start3(plugin_dir): - this.thread = Thread(target = worker, name = 'Inara worker') + this.thread = Thread(target=new_worker, name='Inara worker') this.thread.daemon = True this.thread.start() - this.timer_thread = Thread(target=call_timer, name='Inara timer') - this.timer_thread.daemon = True - this.timer_thread.start() return 'Inara' -def plugin_app(parent): - this.system_link = parent.children['system'] # system label in main window - this.station_link = parent.children['station'] # station label in main window + +def plugin_app(parent: tk.Tk): + this.system_link = parent.children['system'] # system label in main window + this.station_link = parent.children['station'] # station label in main window this.system_link.bind_all('<>', update_location) this.system_link.bind_all('<>', update_ship) + def plugin_stop(): - # Send any unsent events - call() - time.sleep(0.1) # Sleep for 100ms to allow call to go out, and to force a context switch to our other threads # Signal thread to close and wait for it this.queue.put(None) - this.thread.join() - this.thread = None + # this.thread.join() + # this.thread = None this.timer_run = False -def plugin_prefs(parent, cmdr, is_beta): +def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool): PADX = 10 - BUTTONX = 12 # indent Checkbuttons and Radiobuttons + BUTTONX = 12 # indent Checkbuttons and Radiobuttons PADY = 2 # close spacing frame = nb.Frame(parent) frame.columnconfigure(1, weight=1) - HyperlinkLabel(frame, text='Inara', background=nb.Label().cget('background'), url='https://inara.cz/', underline=True).grid(columnspan=2, padx=PADX, sticky=tk.W) # Don't translate - this.log = tk.IntVar(value = config.getint('inara_out') and 1) - this.log_button = nb.Checkbutton(frame, text=_('Send flight log and Cmdr status to Inara'), variable=this.log, command=prefsvarchanged) - this.log_button.grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W) + HyperlinkLabel( + frame, text='Inara', background=nb.Label().cget('background'), url='https://inara.cz/', underline=True + ).grid(columnspan=2, padx=PADX, sticky=tk.W) # Don't translate + + this.log = tk.IntVar(value=config.getint('inara_out') and 1) + this.log_button = nb.Checkbutton( + frame, text=_('Send flight log and Cmdr status to Inara'), variable=this.log, command=prefsvarchanged + ) + + this.log_button.grid(columnspan=2, padx=BUTTONX, pady=(5, 0), sticky=tk.W) + + nb.Label(frame).grid(sticky=tk.W) # big spacer + + # Section heading in settings + this.label = HyperlinkLabel( + frame, + text=_('Inara credentials'), + background=nb.Label().cget('background'), + url='https://inara.cz/settings-api', + underline=True + ) - nb.Label(frame).grid(sticky=tk.W) # big spacer - this.label = HyperlinkLabel(frame, text=_('Inara credentials'), background=nb.Label().cget('background'), url='https://inara.cz/settings-api', underline=True) # Section heading in settings this.label.grid(columnspan=2, padx=PADX, sticky=tk.W) - this.apikey_label = nb.Label(frame, text=_('API Key')) # EDSM setting + this.apikey_label = nb.Label(frame, text=_('API Key')) # Inara setting this.apikey_label.grid(row=12, padx=PADX, sticky=tk.W) this.apikey = nb.Entry(frame) this.apikey.grid(row=12, column=1, padx=PADX, pady=PADY, sticky=tk.EW) @@ -143,20 +210,36 @@ def plugin_prefs(parent, cmdr, is_beta): return frame -def prefs_cmdr_changed(cmdr, is_beta): - this.log_button['state'] = cmdr and not is_beta and tk.NORMAL or tk.DISABLED + +def prefs_cmdr_changed(cmdr: str, is_beta: bool): + this.log_button['state'] = tk.NORMAL if cmdr and not is_beta else tk.DISABLED this.apikey['state'] = tk.NORMAL this.apikey.delete(0, tk.END) if cmdr: cred = credentials(cmdr) if cred: this.apikey.insert(0, cred) - this.label['state'] = this.apikey_label['state'] = this.apikey['state'] = cmdr and not is_beta and this.log.get() and tk.NORMAL or tk.DISABLED + + state = tk.DISABLED + if cmdr and not is_beta and this.log.get(): + state = tk.NORMAL + + this.label['state'] = state + this.apikey_label['state'] = state + this.apikey['state'] = state + def prefsvarchanged(): - this.label['state'] = this.apikey_label['state'] = this.apikey['state'] = this.log.get() and this.log_button['state'] or tk.DISABLED + state = tk.DISABLED + if this.log.get(): + state = this.log_button['state'] -def prefs_changed(cmdr, is_beta): + this.label['state'] = state + this.apikey_label['state'] = state + this.apikey['state'] = state + + +def prefs_changed(cmdr: str, is_beta: bool): changed = config.getint('inara_out') != this.log.get() config.set('inara_out', this.log.get()) @@ -170,48 +253,60 @@ def prefs_changed(cmdr, is_beta): apikeys.extend([''] * (1 + idx - len(apikeys))) changed |= (apikeys[idx] != this.apikey.get().strip()) apikeys[idx] = this.apikey.get().strip() + else: config.set('inara_cmdrs', cmdrs + [cmdr]) changed = True apikeys.append(this.apikey.get().strip()) + config.set('inara_apikeys', apikeys) if this.log.get() and changed: - this.newuser = True # Send basic info at next Journal event - add_event('getCommanderProfile', time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), { 'searchName': cmdr }) - call() + this.newuser = True # Send basic info at next Journal event + new_add_event('getCommanderProfile', time.strftime( + '%Y-%m-%dT%H:%M:%SZ', time.gmtime()), {'searchName': cmdr}) + # call() -def credentials(cmdr): - # Credentials for cmdr + +def credentials(cmdr: str) -> Optional[str]: + """ + credentials fetches the credentials for the given commander + + :param cmdr: Commander name to search for credentials + :return: Credentials for the given commander or None + """ if not cmdr: return None cmdrs = config.get('inara_cmdrs') or [] if cmdr in cmdrs and config.get('inara_apikeys'): return config.get('inara_apikeys')[cmdrs.index(cmdr)] + else: return None -def journal_entry(cmdr, is_beta, system, station, entry, state): - +def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Dict[str, Any], state: Dict[str, Any]): # Send any unsent events when switching accounts - if cmdr and cmdr != this.cmdr: - call(force=True) + # if cmdr and cmdr != this.cmdr: + # call(force=True) + event_name = entry['event'] this.cmdr = cmdr this.FID = state['FID'] this.multicrew = bool(state['Role']) - if entry['event'] == 'LoadGame' or this.newuser: + if event_name == 'LoadGame' or this.newuser: # clear cached state - if entry['event'] == 'LoadGame': + if event_name == 'LoadGame': # User setup Inara API while at the loading screen - proceed as for new session this.newuser = False this.newsession = True + else: this.newuser = True this.newsession = False + this.undocked = False this.suppress_docked = False this.cargo = None @@ -225,10 +320,12 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): this.system_address = None this.station = None this.station_marketid = None - elif entry['event'] in ['Resurrect', 'ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy']: + + elif event_name in ('Resurrect', 'ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy'): # Events that mean a significant change in credits so we should send credits after next "Update" this.lastcredits = 0 - elif entry['event'] in ['ShipyardNew', 'ShipyardSwap'] or (entry['event'] == 'Location' and entry['Docked']): + + elif event_name in ('ShipyardNew', 'ShipyardSwap') or (event_name == 'Location' and entry['Docked']): this.suppress_docked = True # Always update our system address even if we're not currently the provider for system or station, but dont update @@ -243,283 +340,351 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): if pop is not None: this.system_population = pop - this.station = entry.get('StationName') or this.station - this.station_marketid = entry.get('MarketID') or this.station_marketid + this.station = entry.get('StationName', this.station) + this.station_marketid = entry.get('MarketID', this.station_marketid) or this.station_marketid # We might pick up StationName in DockingRequested, make sure we clear it if leaving - if entry['event'] in ('Undocked', 'FSDJump', 'SupercruiseEntry'): + if event_name in ('Undocked', 'FSDJump', 'SupercruiseEntry'): this.station = None this.station_marketid = None - # Send location and status on new game or StartUp. Assumes Cargo is the last event on a new game (other than Docked). - # Always send an update on Docked, FSDJump, Undocked+SuperCruise, Promotion, EngineerProgress and PowerPlay affiliation. - # Also send material and cargo (if changed) whenever we send an update. - if config.getint('inara_out') and not is_beta and not this.multicrew and credentials(cmdr): + current_creds = Credentials(this.cmdr, this.FID, str(credentials(this.cmdr))) try: # Dump starting state to Inara - if (this.newuser or - entry['event'] == 'StartUp' or - (this.newsession and entry['event'] == 'Cargo')): + event_name == 'StartUp' or + (this.newsession and event_name == 'Cargo')): + this.newuser = False this.newsession = False # Send rank info to Inara on startup - add_event('setCommanderRankPilot', entry['timestamp'], - [ - OrderedDict([ - ('rankName', k.lower()), - ('rankValue', v[0]), - ('rankProgress', v[1] / 100.0), - ]) for k,v in state['Rank'].items() if v is not None - ]) - add_event('setCommanderReputationMajorFaction', entry['timestamp'], - [ - OrderedDict([ - ('majorfactionName', k.lower()), - ('majorfactionReputation', v / 100.0), - ]) for k,v in state['Reputation'].items() if v is not None - ]) - if state['Engineers']: # Not populated < 3.3 - add_event('setCommanderRankEngineer', entry['timestamp'], - [ - OrderedDict([ - ('engineerName', k), - type(v) is tuple and ('rankValue', v[0]) or ('rankStage', v), - ]) for k,v in state['Engineers'].items() - ]) + new_add_event( + 'setCommanderRankPilot', + entry['timestamp'], + [ + {'rankName': k.lower(), 'rankValue': v[0], 'rankProgress': v[1] / 100.0} + for k, v in state['Rank'].items() if v is not None + ] + ) + + new_add_event( + 'setCommanderReputationMajorFaction', + entry['timestamp'], + [ + {'majorfactionName': k.lower(), 'majorfactionReputation': v / 100.0} + for k, v in state['Reputation'].items() if v is not None + ] + ) + + if state['Engineers']: # Not populated < 3.3 + to_send = [] + for k, v in state['Engineers'].items(): + e = {'engineerName': k} + if isinstance(v, tuple): + e['rankValue'] = v[0] + + else: + e['rankStage'] = v + + to_send.append(e) + + new_add_event( + 'setCommanderRankEngineer', + entry['timestamp'], + to_send, + ) # Update location - add_event('setCommanderTravelLocation', entry['timestamp'], - OrderedDict([ - ('starsystemName', system), - ('stationName', station), # Can be None - ])) + new_add_event( + 'setCommanderTravelLocation', + entry['timestamp'], + OrderedDict([ + ('starsystemName', system), + ('stationName', station), # Can be None + ]) + ) # Update ship - if state['ShipID']: # Unknown if started in Fighter or SRV - data = OrderedDict([ - ('shipType', state['ShipType']), - ('shipGameID', state['ShipID']), - ('shipName', state['ShipName']), # Can be None - ('shipIdent', state['ShipIdent']), # Can be None - ('isCurrentShip', True), - ]) + if state['ShipID']: # Unknown if started in Fighter or SRV + cur_ship = { + 'shipType': state['ShipType'], + 'shipGameID': state['ShipID'], + 'shipName': state['ShipName'], + 'shipIdent': state['ShipIdent'], + 'isCurrentShip': True, + + } + if state['HullValue']: - data['shipHullValue'] = state['HullValue'] + cur_ship['shipHullValue'] = state['HullValue'] + if state['ModulesValue']: - data['shipModulesValue'] = state['ModulesValue'] - data['shipRebuyCost'] = state['Rebuy'] - add_event('setCommanderShip', entry['timestamp'], data) + cur_ship['shipModulesValue'] = state['ModulesValue'] + + cur_ship['shipRebuyCost'] = state['Rebuy'] + new_add_event('setCommanderShip', entry['timestamp'], cur_ship) this.loadout = make_loadout(state) - add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout) - - call() # Call here just to be sure that if we can send, we do, otherwise it'll get it in the next tick - + new_add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout) # Promotions - elif entry['event'] == 'Promotion': - for k,v in state['Rank'].items(): + elif event_name == 'Promotion': + for k, v in state['Rank'].items(): if k in entry: - add_event('setCommanderRankPilot', entry['timestamp'], - OrderedDict([ - ('rankName', k.lower()), - ('rankValue', v[0]), - ('rankProgress', 0), - ])) - elif entry['event'] == 'EngineerProgress' and 'Engineer' in entry: - add_event('setCommanderRankEngineer', entry['timestamp'], - OrderedDict([ - ('engineerName', entry['Engineer']), - 'Rank' in entry and ('rankValue', entry['Rank']) or ('rankStage', entry['Progress']), - ])) + new_add_event( + 'setCommanderRankPilot', + entry['timestamp'], + {'rankName': k.lower(), 'rankValue': v[0], 'rankProgress': 0} + ) + + elif event_name == 'EngineerProgress' and 'Engineer' in entry: + to_send = {'engineerName': entry['Engineer']} + if 'Rank' in entry: + to_send['rankValue'] = entry['Rank'] + + else: + to_send['rankStage'] = entry['Progress'] + + new_add_event( + 'setCommanderRankEngineer', + entry['timestamp'], + to_send + ) # PowerPlay status change - if entry['event'] == 'PowerplayJoin': - add_event('setCommanderRankPower', entry['timestamp'], - OrderedDict([ - ('powerName', entry['Power']), - ('rankValue', 1), - ])) - elif entry['event'] == 'PowerplayLeave': - add_event('setCommanderRankPower', entry['timestamp'], - OrderedDict([ - ('powerName', entry['Power']), - ('rankValue', 0), - ])) - elif entry['event'] == 'PowerplayDefect': - add_event('setCommanderRankPower', entry['timestamp'], - OrderedDict([ - ('powerName', entry['ToPower']), - ('rankValue', 1), - ])) + if event_name == 'PowerplayJoin': + new_add_event( + 'setCommanderRankPower', + entry['timestamp'], + {'powerName': entry['Power'], 'rankValue': 1} + ) + + elif event_name == 'PowerplayLeave': + new_add_event( + 'setCommanderRankPower', + entry['timestamp'], + {'powerName': entry['Power'], 'rankValue': 0} + ) + + elif event_name == 'PowerplayDefect': + new_add_event( + 'setCommanderRankPower', + entry['timestamp'], + {'powerName': entry['ToPower'], 'rankValue': 1} + ) # Ship change - if entry['event'] == 'Loadout' and this.shipswap: - data = OrderedDict([ - ('shipType', state['ShipType']), - ('shipGameID', state['ShipID']), - ('shipName', state['ShipName']), # Can be None - ('shipIdent', state['ShipIdent']), # Can be None - ('isCurrentShip', True), - ]) + if event_name == 'Loadout' and this.shipswap: + cur_ship = { + 'shipType': state['ShipType'], + 'shipGameID': state['ShipID'], + 'shipName': state['ShipName'], # Can be None + 'shipIdent': state['ShipIdent'], # Can be None + 'isCurrentShip': True, + } + if state['HullValue']: - data['shipHullValue'] = state['HullValue'] + cur_ship['shipHullValue'] = state['HullValue'] + if state['ModulesValue']: - data['shipModulesValue'] = state['ModulesValue'] - data['shipRebuyCost'] = state['Rebuy'] - add_event('setCommanderShip', entry['timestamp'], data) + cur_ship['shipModulesValue'] = state['ModulesValue'] + + cur_ship['shipRebuyCost'] = state['Rebuy'] + new_add_event('setCommanderShip', entry['timestamp'], cur_ship) this.loadout = make_loadout(state) - add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout) + new_add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout) this.shipswap = False # Location change - elif entry['event'] == 'Docked': + elif event_name == 'Docked': if this.undocked: # Undocked and now docking again. Don't send. this.undocked = False + elif this.suppress_docked: # Don't send initial Docked event on new game this.suppress_docked = False + else: - add_event('addCommanderTravelDock', entry['timestamp'], - OrderedDict([ - ('starsystemName', system), - ('stationName', station), - ('shipType', state['ShipType']), - ('shipGameID', state['ShipID']), - ])) - elif entry['event'] == 'Undocked': + new_add_event( + 'addCommanderTravelDock', + entry['timestamp'], + { + 'starsystemName': system, + 'stationName': station, + 'shipType': state['ShipType'], + 'shipGameID': state['ShipID'], + } + ) + + elif event_name == 'Undocked': this.undocked = True this.station = None - elif entry['event'] == 'SupercruiseEntry': + + elif event_name == 'SupercruiseEntry': if this.undocked: # Staying in system after undocking - send any pending events from in-station action - add_event('setCommanderTravelLocation', entry['timestamp'], - OrderedDict([ - ('starsystemName', system), - ('shipType', state['ShipType']), - ('shipGameID', state['ShipID']), - ])) + new_add_event( + 'setCommanderTravelLocation', + entry['timestamp'], + { + 'starsystemName': system, + 'shipType': state['ShipType'], + 'shipGameID': state['ShipID'], + } + ) + this.undocked = False - elif entry['event'] == 'FSDJump': + + elif event_name == 'FSDJump': this.undocked = False - add_event('addCommanderTravelFSDJump', entry['timestamp'], - OrderedDict([ - ('starsystemName', entry['StarSystem']), - ('jumpDistance', entry['JumpDist']), - ('shipType', state['ShipType']), - ('shipGameID', state['ShipID']), - ])) + new_add_event( + 'addCommanderTravelFSDJump', + entry['timestamp'], + { + 'starsystemName': entry['StarSystem'], + 'jumpDistance': entry['JumpDist'], + 'shipType': state['ShipType'], + 'shipGameID': state['ShipID'], + } + ) if entry.get('Factions'): - add_event('setCommanderReputationMinorFaction', entry['timestamp'], - [ - OrderedDict([ - ('minorfactionName', f['Name']), - ('minorfactionReputation', f['MyReputation']/100.0), - ]) for f in entry['Factions'] - ]) - elif entry['event'] == 'CarrierJump': - add_event('addCommanderTravelCarrierJump', entry['timestamp'], - OrderedDict([ - ('starsystemName', entry['StarSystem']), - ('stationName', entry['StationName']), - ('marketID', entry['MarketID']), - ('shipType', state['ShipType']), - ('shipGameID', state['ShipID']), - ])) + new_add_event( + 'setCommanderReputationMinorFaction', + entry['timestamp'], + [ + {'minorfactionName': f['Name'], 'minorfactionReputation': f['MyReputation'] / 100.0} + for f in entry['Factions'] + ] + ) + + elif event_name == 'CarrierJump': + new_add_event( + 'addCommanderTravelCarrierJump', + entry['timestamp'], + { + 'starsystemName': entry['StarSystem'], + 'stationName': entry['StationName'], + 'marketID': entry['MarketID'], + 'shipType': state['ShipType'], + 'shipGameID': state['ShipID'], + } + ) + if entry.get('Factions'): - add_event('setCommanderReputationMinorFaction', entry['timestamp'], - [ - OrderedDict([ - ('minorfactionName', f['Name']), - ('minorfactionReputation', f['MyReputation']/100.0), - ]) for f in entry['Factions'] - ]) + new_add_event( + 'setCommanderReputationMinorFaction', + entry['timestamp'], + [ + {'minorfactionName': f['Name'], 'minorfactionReputation': f['MyReputation'] / 100.0} + for f in entry['Factions'] + ] + ) + # Ignore the following 'Docked' event this.suppress_docked = True - - cargo = [OrderedDict([('itemName', k), ('itemCount', state['Cargo'][k])]) for k in sorted(state['Cargo'])] + cargo = [{'itemName': k, 'itemCount': state['Cargo'][k]} for k in sorted(state['Cargo'])] # Send cargo and materials if changed if this.cargo != cargo: - add_event('setCommanderInventoryCargo', entry['timestamp'], cargo) + new_add_event('setCommanderInventoryCargo', entry['timestamp'], cargo) this.cargo = cargo + materials = [] - for category in ['Raw', 'Manufactured', 'Encoded']: - materials.extend([ OrderedDict([('itemName', k), ('itemCount', state[category][k])]) for k in sorted(state[category]) ]) + for category in ('Raw', 'Manufactured', 'Encoded'): + materials.extend( + [OrderedDict([('itemName', k), ('itemCount', state[category][k])]) for k in sorted(state[category])] + ) + if this.materials != materials: - add_event('setCommanderInventoryMaterials', entry['timestamp'], materials) + new_add_event('setCommanderInventoryMaterials', entry['timestamp'], materials) this.materials = materials except Exception as e: - if __debug__: print_exc() + logger.debug('Adding events', exc_info=e) return str(e) # Send credits and stats to Inara on startup only - otherwise may be out of date - if entry['event'] == 'LoadGame': - add_event('setCommanderCredits', entry['timestamp'], - OrderedDict([ - ('commanderCredits', state['Credits']), - ('commanderLoan', state['Loan']), - ])) + if event_name == 'LoadGame': + new_add_event( + 'setCommanderCredits', + entry['timestamp'], + {'commanderCredits': state['Credits'], 'commanderLoan': state['Loan']} + ) + this.lastcredits = state['Credits'] - elif entry['event'] == 'Statistics': - add_event('setCommanderGameStatistics', entry['timestamp'], state['Statistics']) # may be out of date + + elif event_name == 'Statistics': + new_add_event('setCommanderGameStatistics', entry['timestamp'], state['Statistics']) # may be out of date # Selling / swapping ships - if entry['event'] == 'ShipyardNew': - add_event('addCommanderShip', entry['timestamp'], - OrderedDict([ - ('shipType', entry['ShipType']), - ('shipGameID', entry['NewShipID']), - ])) - this.shipswap = True # Want subsequent Loadout event to be sent immediately + if event_name == 'ShipyardNew': + new_add_event( + 'addCommanderShip', + entry['timestamp'], + {'shipType': entry['ShipType'], 'shipGameID': entry['NewShipID']} + ) + + this.shipswap = True # Want subsequent Loadout event to be sent immediately + + elif event_name in ('ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy', 'ShipyardSwap'): + if event_name == 'ShipyardSwap': + this.shipswap = True # Don't know new ship name and ident 'til the following Loadout event - elif entry['event'] in ['ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy', 'ShipyardSwap']: - if entry['event'] == 'ShipyardSwap': - this.shipswap = True # Don't know new ship name and ident 'til the following Loadout event if 'StoreShipID' in entry: - add_event('setCommanderShip', entry['timestamp'], - OrderedDict([ - ('shipType', entry['StoreOldShip']), - ('shipGameID', entry['StoreShipID']), - ('starsystemName', system), - ('stationName', station), - ])) + new_add_event( + 'setCommanderShip', + entry['timestamp'], + { + 'shipType': entry['StoreOldShip'], + 'shipGameID': entry['StoreShipID'], + 'starsystemName': system, + 'stationName': station, + } + ) + elif 'SellShipID' in entry: - add_event('delCommanderShip', entry['timestamp'], - OrderedDict([ - ('shipType', entry.get('SellOldShip', entry['ShipType'])), - ('shipGameID', entry['SellShipID']), - ])) + new_add_event( + 'delCommanderShip', + entry['timestamp'], + { + 'shipType': entry.get('SellOldShip', entry['ShipType']), + 'shipGameID': entry['SellShipID'], + } + ) - elif entry['event'] == 'SetUserShipName': - add_event('setCommanderShip', entry['timestamp'], - OrderedDict([ - ('shipType', state['ShipType']), - ('shipGameID', state['ShipID']), - ('shipName', state['ShipName']), # Can be None - ('shipIdent', state['ShipIdent']), # Can be None - ('isCurrentShip', True), - ])) + elif event_name == 'SetUserShipName': + new_add_event( + 'setCommanderShip', + entry['timestamp'], + { + 'shipType': state['ShipType'], + 'shipGameID': state['ShipID'], + 'shipName': state['ShipName'], # Can be None + 'shipIdent': state['ShipIdent'], # Can be None + 'isCurrentShip': True, + } + ) - elif entry['event'] == 'ShipyardTransfer': - add_event('setCommanderShipTransfer', entry['timestamp'], - OrderedDict([ - ('shipType', entry['ShipType']), - ('shipGameID', entry['ShipID']), - ('starsystemName', system), - ('stationName', station), - ('transferTime', entry['TransferTime']), - ])) + elif event_name == 'ShipyardTransfer': + new_add_event( + 'setCommanderShipTransfer', + entry['timestamp'], + { + 'shipType': entry['ShipType'], + 'shipGameID': entry['ShipID'], + 'starsystemName': system, + 'stationName': station, + 'transferTime': entry['TransferTime'], + } + ) # Fleet - if entry['event'] == 'StoredShips': + if event_name == 'StoredShips': fleet = sorted( [{ 'shipType': x['ShipType'], @@ -535,32 +700,40 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): 'shipGameID': x['ShipID'], 'shipName': x.get('Name'), 'isHot': x['Hot'], - 'starsystemName': x.get('StarSystem'), # Not present for ships in transit - 'marketID': x.get('ShipMarketID'), # " + 'starsystemName': x.get('StarSystem'), # Not present for ships in transit + 'marketID': x.get('ShipMarketID'), # " } for x in entry['ShipsRemote']], - key = itemgetter('shipGameID') + key=itemgetter('shipGameID') ) + if this.fleet != fleet: this.fleet = fleet - this.events = [x for x in this.events if x['eventName'] != 'setCommanderShip'] # Remove any unsent + new_this.filter_events(current_creds, lambda e: e.name != 'setCommanderShip') + + # this.events = [x for x in this.events if x['eventName'] != 'setCommanderShip'] # Remove any unsent for ship in this.fleet: - add_event('setCommanderShip', entry['timestamp'], ship) + new_add_event('setCommanderShip', entry['timestamp'], ship) # Loadout - if entry['event'] == 'Loadout' and not this.newsession: + if event_name == 'Loadout' and not this.newsession: loadout = make_loadout(state) if this.loadout != loadout: this.loadout = loadout - this.events = [x for x in this.events if x['eventName'] != 'setCommanderShipLoadout' or x['shipGameID'] != this.loadout['shipGameID']] # Remove any unsent for this ship - add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout) + + new_this.filter_events( + current_creds, + lambda e: e.name != 'setCommanderShipLoadout' or e.data['shipGameID'] != this.loadout['shipGameID'] + ) + + new_add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout) # Stored modules - if entry['event'] == 'StoredModules': - items = dict([(x['StorageSlot'], x) for x in entry['Items']]) # Impose an order + if event_name == 'StoredModules': + items = {mod['StorageSlot']: mod for mod in entry['Items']} # Impose an order modules = [] for slot in sorted(items): item = items[slot] - module = OrderedDict([ + module: OrderedDictT[str, Any] = OrderedDict([ ('itemName', item['Name']), ('itemValue', item['BuyPrice']), ('isHot', item['Hot']), @@ -569,6 +742,7 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): # Location can be absent if in transit if 'StarSystem' in item: module['starsystemName'] = item['StarSystem'] + if 'MarketID' in item: module['marketID'] = item['MarketID'] @@ -576,6 +750,7 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): module['engineering'] = OrderedDict([('blueprintName', item['EngineerModifications'])]) if 'Level' in item: module['engineering']['blueprintLevel'] = item['Level'] + if 'Quality' in item: module['engineering']['blueprintQuality'] = item['Quality'] @@ -584,11 +759,14 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): if this.storedmodules != modules: # Only send on change this.storedmodules = modules - this.events = [x for x in this.events if x['eventName'] != 'setCommanderStorageModules'] # Remove any unsent - add_event('setCommanderStorageModules', entry['timestamp'], this.storedmodules) + # Remove any unsent + new_this.filter_events(current_creds, lambda e: e.name != 'setCommanderStorageModules') + + # this.events = list(filter(lambda e: e['eventName'] != 'setCommanderStorageModules', this.events)) + new_add_event('setCommanderStorageModules', entry['timestamp'], this.storedmodules) # Missions - if entry['event'] == 'MissionAccepted': + if event_name == 'MissionAccepted': data = OrderedDict([ ('missionName', entry['Name']), ('missionGameID', entry['MissionID']), @@ -598,9 +776,10 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): ('stationNameOrigin', station), ('minorfactionNameOrigin', entry['Faction']), ]) + # optional mission-specific properties for (iprop, prop) in [ - ('missionExpiry', 'Expiry'), # Listed as optional in the docs, but always seems to be present + ('missionExpiry', 'Expiry'), # Listed as optional in the docs, but always seems to be present ('starsystemNameTarget', 'DestinationSystem'), ('stationNameTarget', 'DestinationStation'), ('minorfactionNameTarget', 'TargetFaction'), @@ -614,97 +793,141 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): ('passengerIsVIP', 'PassengerVIPs'), ('passengerIsWanted', 'PassengerWanted'), ]: + if prop in entry: data[iprop] = entry[prop] - add_event('addCommanderMission', entry['timestamp'], data) - elif entry['event'] == 'MissionAbandoned': - add_event('setCommanderMissionAbandoned', entry['timestamp'], { 'missionGameID': entry['MissionID'] }) + new_add_event('addCommanderMission', entry['timestamp'], data) - elif entry['event'] == 'MissionCompleted': + elif event_name == 'MissionAbandoned': + new_add_event('setCommanderMissionAbandoned', entry['timestamp'], {'missionGameID': entry['MissionID']}) + + elif event_name == 'MissionCompleted': for x in entry.get('PermitsAwarded', []): - add_event('addCommanderPermit', entry['timestamp'], { 'starsystemName': x }) + new_add_event('addCommanderPermit', entry['timestamp'], {'starsystemName': x}) - data = OrderedDict([ ('missionGameID', entry['MissionID']) ]) + data = OrderedDict([('missionGameID', entry['MissionID'])]) if 'Donation' in entry: data['donationCredits'] = entry['Donation'] + if 'Reward' in entry: data['rewardCredits'] = entry['Reward'] + if 'PermitsAwarded' in entry: - data['rewardPermits'] = [{ 'starsystemName': x } for x in entry['PermitsAwarded']] + data['rewardPermits'] = [{'starsystemName': x} for x in entry['PermitsAwarded']] + if 'CommodityReward' in entry: - data['rewardCommodities'] = [{ 'itemName': x['Name'], 'itemCount': x['Count'] } for x in entry['CommodityReward']] + data['rewardCommodities'] = [{'itemName': x['Name'], 'itemCount': x['Count']} + for x in entry['CommodityReward']] + if 'MaterialsReward' in entry: - data['rewardMaterials'] = [{ 'itemName': x['Name'], 'itemCount': x['Count'] } for x in entry['MaterialsReward']] + data['rewardMaterials'] = [{'itemName': x['Name'], 'itemCount': x['Count']} + for x in entry['MaterialsReward']] + factioneffects = [] for faction in entry.get('FactionEffects', []): - effect = OrderedDict([ ('minorfactionName', faction['Faction']) ]) + effect: OrderedDictT[str, Any] = OrderedDict([('minorfactionName', faction['Faction'])]) for influence in faction.get('Influence', []): if 'Influence' in influence: - effect['influenceGain'] = len(effect.get('influenceGain', '')) > len(influence['Influence']) and effect['influenceGain'] or influence['Influence'] # pick highest + highest_gain = influence['Influence'] + if len(effect.get('influenceGain', '')) > len(highest_gain): + highest_gain = effect['influenceGain'] + + effect['influenceGain'] = highest_gain + if 'Reputation' in faction: effect['reputationGain'] = faction['Reputation'] + factioneffects.append(effect) + if factioneffects: data['minorfactionEffects'] = factioneffects - add_event('setCommanderMissionCompleted', entry['timestamp'], data) - elif entry['event'] == 'MissionFailed': - add_event('setCommanderMissionFailed', entry['timestamp'], { 'missionGameID': entry['MissionID'] }) + new_add_event('setCommanderMissionCompleted', entry['timestamp'], data) + + elif event_name == 'MissionFailed': + new_add_event('setCommanderMissionFailed', entry['timestamp'], {'missionGameID': entry['MissionID']}) # Combat - if entry['event'] == 'Died': - data = OrderedDict([ ('starsystemName', system) ]) + if event_name == 'Died': + data = OrderedDict([('starsystemName', system)]) if 'Killers' in entry: data['wingOpponentNames'] = [x['Name'] for x in entry['Killers']] + elif 'KillerName' in entry: data['opponentName'] = entry['KillerName'] - add_event('addCommanderCombatDeath', entry['timestamp'], data) - elif entry['event'] == 'Interdicted': + new_add_event('addCommanderCombatDeath', entry['timestamp'], data) + + elif event_name == 'Interdicted': data = OrderedDict([('starsystemName', system), ('isPlayer', entry['IsPlayer']), ('isSubmit', entry['Submitted']), - ]) + ]) + if 'Interdictor' in entry: data['opponentName'] = entry['Interdictor'] + elif 'Faction' in entry: data['opponentName'] = entry['Faction'] + elif 'Power' in entry: data['opponentName'] = entry['Power'] - add_event('addCommanderCombatInterdicted', entry['timestamp'], data) - elif entry['event'] == 'Interdiction': - data = OrderedDict([('starsystemName', system), - ('isPlayer', entry['IsPlayer']), - ('isSuccess', entry['Success']), + new_add_event('addCommanderCombatInterdicted', entry['timestamp'], data) + + elif event_name == 'Interdiction': + data: OrderedDictT[str, Any] = OrderedDict([ + ('starsystemName', system), + ('isPlayer', entry['IsPlayer']), + ('isSuccess', entry['Success']), ]) + if 'Interdicted' in entry: data['opponentName'] = entry['Interdicted'] + elif 'Faction' in entry: data['opponentName'] = entry['Faction'] + elif 'Power' in entry: data['opponentName'] = entry['Power'] - add_event('addCommanderCombatInterdiction', entry['timestamp'], data) - elif entry['event'] == 'EscapeInterdiction': - add_event('addCommanderCombatInterdictionEscape', entry['timestamp'], - OrderedDict([('starsystemName', system), - ('opponentName', entry['Interdictor']), - ('isPlayer', entry['IsPlayer']), - ])) + new_add_event('addCommanderCombatInterdiction', entry['timestamp'], data) - elif entry['event'] == 'PVPKill': - add_event('addCommanderCombatKill', entry['timestamp'], - OrderedDict([('starsystemName', system), - ('opponentName', entry['Victim']), - ])) + elif event_name == 'EscapeInterdiction': + new_add_event( + 'addCommanderCombatInterdictionEscape', + entry['timestamp'], + { + 'starsystemName': system, + 'opponentName': entry['Interdictor'], + 'isPlayer': entry['IsPlayer'], + } + ) + + elif event_name == 'PVPKill': + new_add_event( + 'addCommanderCombatKill', + entry['timestamp'], + { + 'starsystemName': system, + 'opponentName': entry['Victim'], + } + ) # Community Goals - if entry['event'] == 'CommunityGoal': - this.events = [x for x in this.events if x['eventName'] not in ['setCommunityGoal', 'setCommanderCommunityGoalProgress']] # Remove any unsent - for goal in entry['CurrentGoals']: + if event_name == 'CommunityGoal': + # Remove any unsent + new_this.filter_events( + current_creds, lambda e: e.name not in ('setCommunityGoal', 'setCommanderCommunityGoalProgress') + ) + # this.events = list(filter( + # lambda e: e['eventName'] not in ('setCommunityGoal', 'setCommanderCommunityGoalProgress'), + # this.events + # )) + + for goal in entry['CurrentGoals']: data = OrderedDict([ ('communitygoalGameID', goal['CGID']), ('communitygoalName', goal['Title']), @@ -715,38 +938,54 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): ('contributorsNum', goal['NumContributors']), ('contributionsTotal', goal['CurrentTotal']), ]) + if 'TierReached' in goal: data['tierReached'] = int(goal['TierReached'].split()[-1]) + if 'TopRankSize' in goal: data['topRankSize'] = goal['TopRankSize'] + if 'TopTier' in goal: data['tierMax'] = int(goal['TopTier']['Name'].split()[-1]) data['completionBonus'] = goal['TopTier']['Bonus'] - add_event('setCommunityGoal', entry['timestamp'], data) + + new_add_event('setCommunityGoal', entry['timestamp'], data) data = OrderedDict([ ('communitygoalGameID', goal['CGID']), ('contribution', goal['PlayerContribution']), ('percentileBand', goal['PlayerPercentileBand']), ]) + if 'Bonus' in goal: data['percentileBandReward'] = goal['Bonus'] + if 'PlayerInTopRank' in goal: data['isTopRank'] = goal['PlayerInTopRank'] - add_event('setCommanderCommunityGoalProgress', entry['timestamp'], data) + + new_add_event('setCommanderCommunityGoalProgress', entry['timestamp'], data) # Friends - if entry['event'] == 'Friends': + if event_name == 'Friends': if entry['Status'] in ['Added', 'Online']: - add_event('addCommanderFriend', entry['timestamp'], - OrderedDict([('commanderName', entry['Name']), - ('gamePlatform', 'pc'), - ])) + new_add_event( + 'addCommanderFriend', + entry['timestamp'], + { + 'commanderName': entry['Name'], + 'gamePlatform': 'pc', + } + ) + elif entry['Status'] in ['Declined', 'Lost']: - add_event('delCommanderFriend', entry['timestamp'], - OrderedDict([('commanderName', entry['Name']), - ('gamePlatform', 'pc'), - ])) + new_add_event( + 'delCommanderFriend', + entry['timestamp'], + { + 'commanderName': entry['Name'], + 'gamePlatform': 'pc', + } + ) this.newuser = False @@ -758,20 +997,31 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): this.system_link.update_idletasks() if config.get('station_provider') == 'Inara': - this.station_link['text'] = this.station or (this.system_population and this.system_population > 0 and STATION_UNDOCKED or '') + to_set = this.station + if not to_set: + if this.system_population is not None and this.system_population > 0: + to_set = STATION_UNDOCKED + else: + to_set = '' + + this.station_link['text'] = to_set # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.station_link.update_idletasks() + def cmdr_data(data, is_beta): this.cmdr = data['commander']['name'] # Always store initially, even if we're not the *current* system provider. if not this.station_marketid: this.station_marketid = data['commander']['docked'] and data['lastStarport']['id'] + # Only trust CAPI if these aren't yet set - this.system = this.system or data['lastSystem']['name'] - this.station = this.station or data['commander']['docked'] and data['lastStarport']['name'] + this.system = this.system if this.system else data['lastSystem']['name'] + + if not this.station and data['commander']['docked']: + this.station = data['lastStarport']['name'] # Override standard URL functions if config.get('system_provider') == 'Inara': @@ -779,11 +1029,14 @@ def cmdr_data(data, is_beta): # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.system_link.update_idletasks() + if config.get('station_provider') == 'Inara': if data['commander']['docked']: this.station_link['text'] = this.station + elif data['lastStarport']['name'] and data['lastStarport']['name'] != "": this.station_link['text'] = STATION_UNDOCKED + else: this.station_link['text'] = '' @@ -793,53 +1046,72 @@ def cmdr_data(data, is_beta): if config.getint('inara_out') and not is_beta and not this.multicrew and credentials(this.cmdr): if not (CREDIT_RATIO > this.lastcredits / data['commander']['credits'] > 1/CREDIT_RATIO): - this.events = [x for x in this.events if x['eventName'] != 'setCommanderCredits'] # Remove any unsent - add_event('setCommanderCredits', data['timestamp'], - OrderedDict([ - ('commanderCredits', data['commander']['credits']), - ('commanderLoan', data['commander'].get('debt', 0)), - ])) + new_this.filter_events( + Credentials(this.cmdr, this.FID, str(credentials(this.cmdr))), + lambda e: e.name != 'setCommanderCredits') + + # this.events = [x for x in this.events if x['eventName'] != 'setCommanderCredits'] # Remove any unsent + new_add_event( + 'setCommanderCredits', + data['timestamp'], + { + 'commanderCredits': data['commander']['credits'], + 'commanderLoan': data['commander'].get('debt', 0), + } + ) + this.lastcredits = float(data['commander']['credits']) -def make_loadout(state): +def make_loadout(state: Dict[str, Any]) -> OrderedDictT[str, Any]: modules = [] for m in state['Modules'].values(): - module = OrderedDict([ + module: OrderedDictT[str, Any] = OrderedDict([ ('slotName', m['Slot']), ('itemName', m['Item']), ('itemHealth', m['Health']), ('isOn', m['On']), ('itemPriority', m['Priority']), ]) + if 'AmmoInClip' in m: module['itemAmmoClip'] = m['AmmoInClip'] + if 'AmmoInHopper' in m: module['itemAmmoHopper'] = m['AmmoInHopper'] + if 'Value' in m: module['itemValue'] = m['Value'] + if 'Hot' in m: module['isHot'] = m['Hot'] + if 'Engineering' in m: - engineering = OrderedDict([ + engineering: OrderedDictT[str, Any] = OrderedDict([ ('blueprintName', m['Engineering']['BlueprintName']), ('blueprintLevel', m['Engineering']['Level']), ('blueprintQuality', m['Engineering']['Quality']), ]) + if 'ExperimentalEffect' in m['Engineering']: engineering['experimentalEffect'] = m['Engineering']['ExperimentalEffect'] + engineering['modifiers'] = [] for mod in m['Engineering']['Modifiers']: - modifier = OrderedDict([ + modifier: OrderedDictT[str, Any] = OrderedDict([ ('name', mod['Label']), ]) + if 'OriginalValue' in mod: modifier['value'] = mod['Value'] modifier['originalValue'] = mod['OriginalValue'] modifier['lessIsGood'] = mod['LessIsGood'] + else: modifier['value'] = mod['ValueStr'] + engineering['modifiers'].append(modifier) + module['engineering'] = engineering modules.append(module) @@ -850,110 +1122,171 @@ def make_loadout(state): ('shipLoadout', modules), ]) -def add_event(name, timestamp, data): - this.events.append(OrderedDict([ - ('eventName', name), - ('eventTimestamp', timestamp), - ('eventData', data), - ])) -def call_timer(wait=FLOOD_LIMIT_SECONDS): - while this.timer_run: - time.sleep(wait) - if this.timer_run: # check again in here just in case we're closing and the stars align - call() +def new_add_event( + name: str, + timestamp: str, + data: EVENT_DATA, + cmdr: Optional[str] = None, + fid: Optional[str] = None +): + """ + add a journal event to the queue, to be sent to inara at the next opportunity. If provided, use the given cmdr + name over the current one -# Queue a call to Inara, handled in Worker thread -def call(callback=None, force=False): - if not this.events: + :param name: name of the event + :param timestamp: timestamp of the event + :param data: payload for the event + :param cmdr: the commander to send as, defaults to the current commander + """ + if cmdr is None: + cmdr = this.cmdr + + if fid is None: + fid = this.FID + + api_key = credentials(this.cmdr) + if api_key is None: + logger.warn(f"cannot find an API key for cmdr {this.cmdr!r}") return - if (time.time() - config.getint(LAST_UPDATE_CONF_KEY)) <= FLOOD_LIMIT_SECONDS and not force: - return + key = Credentials(str(cmdr), str(fid), api_key) # this fails type checking due to `this` weirdness, hence str() - config.set(LAST_UPDATE_CONF_KEY, int(time.time())) - print(f"INARA: {time.asctime()} sending {len(this.events)} entries to inara (call)") - data = OrderedDict([ - ('header', OrderedDict([ - ('appName', applongname), - ('appVersion', appversion), - ('APIkey', credentials(this.cmdr)), - ('commanderName', this.cmdr), - ('commanderFrontierID', this.FID), - ])), - ('events', list(this.events)), # shallow copy - ]) - this.events = [] - this.queue.put(('https://inara.cz/inapi/v1/', data, None)) + with new_this.event_lock: + new_this.events[key].append(Event(name, timestamp, data)) -# Worker thread -def worker(): + +def new_worker(): while True: - item = this.queue.get() - if not item: - return # Closing - else: - (url, data, callback) = item + events = get_events() + for creds, event_list in events.items(): + if not event_list: + continue - print(f"INARA: {time.asctime()} sending {len(data['events'])} entries to inara (worker)") + data = { + 'header': { + 'appName': applongname, + 'appVersion': appversion, + 'APIkey': creds.api_key, + 'commanderName': creds.cmdr, + 'commanderFrontierID': creds.fid + }, + 'events': [ + {'eventName': e.name, 'eventTimestamp': e.timestamp, 'eventData': e.data} for e in event_list + ] + } + logger.info(f'sending {len(data["events"])} events for {creds.cmdr}') + try_send_data(TARGET_URL, data) - retrying = 0 - while retrying < 3: - try: - r = this.session.post(url, data=json.dumps(data, separators = (',', ':')), timeout=_TIMEOUT) - r.raise_for_status() - reply = r.json() - status = reply['header']['eventStatus'] - if callback: - callback(reply) - elif status // 100 != 2: # 2xx == OK (maybe with warnings) - # Log fatal errors - print('Inara\t%s %s' % (reply['header']['eventStatus'], reply['header'].get('eventStatusText', ''))) - print(json.dumps(data, indent=2, separators = (',', ': '))) - plug.show_error(_('Error: Inara {MSG}').format(MSG=reply['header'].get('eventStatusText', status))) - else: - # Log individual errors and warnings - for data_event, reply_event in zip(data['events'], reply['events']): - if reply_event['eventStatus'] != 200: - print('Inara\t%s %s\t%s' % (reply_event['eventStatus'], reply_event.get('eventStatusText', ''), json.dumps(data_event))) - if reply_event['eventStatus'] // 100 != 2: - plug.show_error(_('Error: Inara {MSG}').format( - MSG=f'{data_event["eventName"]},' - f'{reply_event.get("eventStatusText", reply_event["eventStatus"])}')) - if data_event['eventName'] in ('addCommanderTravelCarrierJump', - 'addCommanderTravelDock', - 'addCommanderTravelFSDJump', - 'setCommanderTravelLocation'): - this.lastlocation = reply_event.get('eventData', {}) - # calls update_location in main thread - this.system_link.event_generate('<>', when="tail") - elif data_event['eventName'] in ['addCommanderShip', 'setCommanderShip']: - this.lastship = reply_event.get('eventData', {}) - # calls update_ship in main thread - this.system_link.event_generate('<>', when="tail") + time.sleep(WORKER_WAIT_TIME) + +def get_events(clear=True) -> Dict[Credentials, List[Event]]: + """ + get_events fetches all events from the current queue and returns a frozen version of them + + :param clear: whether or not to clear the queues as we go, defaults to True + :return: the frozen event list + """ + out: Dict[Credentials, List[Event]] = {} + with new_this.event_lock: + for key, events in new_this.events.items(): + out[key] = list(events) + if clear: + events.clear() + + return out + + +def try_send_data(url: str, data: Mapping[str, Any]): + """ + attempt repeatedly to send the payload forward + + :param url: target URL for the payload + :param data: the payload + """ + for i in range(3): + logger.debug(f"sending data to API, attempt #{i}") + try: + if send_data(url, data): break - except Exception: - logger.debug('Sending events', exc_info=e) - retrying += 1 - else: - if callback: - callback(None) - else: - plug.show_error(_("Error: Can't connect to Inara")) + + except Exception as e: + logger.debug('unable to send events', exc_info=e) + return + + +def send_data(url: str, data: Mapping[str, Any]) -> bool: + """ + write a set of events to the inara API + + :param url: the target URL to post to + :param data: the data to POST + :return: success state + """ + r = this.session.post(url, data=json.dumps(data, separators=(',', ':')), timeout=_TIMEOUT) + r.raise_for_status() + reply = r.json() + status = reply['header']['eventStatus'] + + if status // 100 != 2: # 2xx == OK (maybe with warnings) + # Log fatal errors + logger.warning(f'Inara\t{status} {reply["header"].get("eventStatusText", "")}') + logger.debug(f'JSON data:\n{json.dumps(data, indent=2, separators = (",", ": "))}') + plug.show_error(_('Error: Inara {MSG}').format(MSG=reply['header'].get('eventStatusText', status))) + + else: + # Log individual errors and warnings + for data_event, reply_event in zip(data['events'], reply['events']): + if reply_event['eventStatus'] != 200: + logger.warning(f'Inara\t{status} {reply_event.get("eventStatusText", "")}') + logger.debug(f'JSON data:\n{json.dumps(data_event)}') + if reply_event['eventStatus'] // 100 != 2: + plug.show_error(_('Error: Inara {MSG}').format( + MSG=f'{data_event["eventName"]},' + f'{reply_event.get("eventStatusText", reply_event["eventStatus"])}' + )) + + if data_event['eventName'] in ( + 'addCommanderTravelCarrierJump', + 'addCommanderTravelDock', + 'addCommanderTravelFSDJump', + 'setCommanderTravelLocation' + ): + this.lastlocation = reply_event.get('eventData', {}) + # calls update_location in main thread + this.system_link.event_generate('<>', when="tail") + + elif data_event['eventName'] in ['addCommanderShip', 'setCommanderShip']: + this.lastship = reply_event.get('eventData', {}) + # calls update_ship in main thread + this.system_link.event_generate('<>', when="tail") + + return True # regardless of errors above, we DID manage to send it, therefore inform our caller as such -# Call inara_notify_location() in this and other interested plugins with Inara's response when changing system or station def update_location(event=None): + """ + Call inara_notify_location in this and other interested plugins with Inara's response when changing system + or station + + :param event: Unused and ignored, defaults to None + """ if this.lastlocation: for plugin in plug.provides('inara_notify_location'): plug.invoke(plugin, None, 'inara_notify_location', this.lastlocation) + def inara_notify_location(eventData): pass -# Call inara_notify_ship() in interested plugins with Inara's response when changing ship + def update_ship(event=None): + """ + Call inara_notify_ship() in interested plugins with Inara's response when changing ship + + :param event: Unused and ignored, defaults to None + """ if this.lastship: for plugin in plug.provides('inara_notify_ship'): plug.invoke(plugin, None, 'inara_notify_ship', this.lastship) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5b2d299b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.autopep8] +max_line_length = 120 + +[tool.isort] +multi_line_output = 5 +line_length = 119 diff --git a/requirements-dev.txt b/requirements-dev.txt index 433b140d..cdd511d1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,6 +6,7 @@ flake8-comprehensions==3.2.3 flake8-pep3101==1.3.0 flake8-polyfill==1.0.2 flake8-json +flake8-isort==3.0.1 pep8-naming==0.11.1 # Code formatting tools diff --git a/timeout_session.py b/timeout_session.py new file mode 100644 index 00000000..3da5e3ab --- /dev/null +++ b/timeout_session.py @@ -0,0 +1,40 @@ + +import requests +from requests.adapters import HTTPAdapter + +REQUEST_TIMEOUT = 10 # reasonable timeout that all HTTP requests should use + + +class TimeoutAdapter(HTTPAdapter): + """ + TimeoutAdapter is an HTTP Adapter that enforces an overridable default timeout on HTTP requests. + """ + def __init__(self, timeout, *args, **kwargs): + self.default_timeout = timeout + if kwargs.get("timeout") is not None: + del kwargs["timeout"] + + super().__init__(*args, **kwargs) + + def send(self, *args, **kwargs): + if kwargs["timeout"] is None: + kwargs["timeout"] = self.default_timeout + + return super().send(*args, **kwargs) + + +def new_session(timeout: int = REQUEST_TIMEOUT, session: requests.Session = None) -> requests.Session: + """ + new_session creates a new requests.Session and overrides the default HTTPAdapter with a TimeoutAdapter. + + :param timeout: the timeout to set the TimeoutAdapter to, defaults to REQUEST_TIMEOUT + :param session: the Session object to attach the Adapter to, defaults to a new session + :return: The created Session + """ + if session is None: + session = requests.Session() + + adapter = TimeoutAdapter(timeout) + session.mount("http://", adapter) + session.mount("https://", adapter) + return session