1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-06-08 11:22:10 +03:00

Merge branch 'develop' into main

This commit is contained in:
Athanasius 2020-08-27 12:53:03 +01:00
commit 364aaf2aef
21 changed files with 2950 additions and 1490 deletions

View File

@ -1,7 +1,9 @@
# This workflow will: # This workflow will:
# #
# install Python dependencies # 1. Store some github context in env vars.
# Run flake8 to add annotations to the PR # 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 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: PR-annotate-flake8 name: PR-annotate-flake8
@ -11,19 +13,68 @@ on:
branches: [ develop ] branches: [ develop ]
jobs: jobs:
build: flake8_annotate:
runs-on: ubuntu-18.04 runs-on: ubuntu-18.04
steps: steps:
- uses: actions/checkout@v2 ##################################################################
# 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: with:
fetch-depth: 0 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 - name: Set up Python 3.7
if: ${{ env.PYFILES != '' }}
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.7 python-version: 3.7
##################################################################
##################################################################
# Perform the annotation
##################################################################
- name: Annotate with Flake8 - name: Annotate with Flake8
# Only if at least one *.py file was affected
if: ${{ env.PYFILES != '' }}
uses: "tayfun/flake8-your-pr@master" uses: "tayfun/flake8-your-pr@master"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
##################################################################

87
.github/workflows/pr-checks.yml vendored Normal file
View File

@ -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
####################################################################

4
.gitignore vendored
View File

@ -13,3 +13,7 @@ appcast_win_*.xml
appcast_mac_*.xml appcast_mac_*.xml
EDMarketConnector.VisualElementsManifest.xml EDMarketConnector.VisualElementsManifest.xml
*.zip *.zip
.idea
.vscode
venv

View File

@ -1,2 +0,0 @@
[tool.autopep8]
max_line_length = 120

View File

@ -184,14 +184,14 @@ Coding Conventions
Yes: Yes:
```python ```python
if somethingTrue: if something_true:
Things_we_then_do() one_thing_we_do()
``` ```
No: No:
```python ```python
if somethingTrue: One_thing_we_do() if something_true: one_thing_we_do()
``` ```
Yes, some existing code still flouts this rule. 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 * 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. 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 `<module>.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/). * In general, please follow [PEP8](https://www.python.org/dev/peps/pep-0008/).
--- ---

176
EDMC.py
View File

@ -5,9 +5,9 @@
import argparse import argparse
import json import json
import requests
import sys import sys
import os import os
from typing import Any, Optional
# workaround for https://github.com/EDCD/EDMarketConnector/issues/568 # workaround for https://github.com/EDCD/EDMarketConnector/issues/568
os.environ["EDMC_NO_UI"] = "1" os.environ["EDMC_NO_UI"] = "1"
@ -39,14 +39,39 @@ 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) 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 # quick and dirty version comparison assuming "strict" numeric only version numbers
def versioncmp(versionstring): def versioncmp(versionstring):
return list(map(int, versionstring.split('.'))) return list(map(int, versionstring.split('.')))
try: 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)})")
current = target
for arg in args:
res = current.get(arg)
if res is None:
return default
current = res
return current
def main():
try:
# arg parsing # 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 = 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('-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('-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('-e', metavar='FILE', help='write ship loadout to FILE in E:D Shipyard plain text format')
@ -63,11 +88,9 @@ try:
if args.version: if args.version:
updater = Updater(provider='internal') updater = Updater(provider='internal')
newversion: EDMCVersion = updater.check_appcast() newversion: Optional[EDMCVersion] = updater.check_appcast()
if newversion: if newversion:
print('{CURRENT} ("{UPDATE}" is available)'.format( print(f'{appversion} ({newversion.title!r} is available)')
CURRENT=appversion,
UPDATE=newversion.title))
else: else:
print(appversion) print(appversion)
sys.exit(EXIT_SUCCESS) sys.exit(EXIT_SUCCESS)
@ -76,26 +99,29 @@ try:
# Import and collate from JSON dump # Import and collate from JSON dump
data = json.load(open(args.j)) data = json.load(open(args.j))
config.set('querytime', int(getmtime(args.j))) config.set('querytime', int(getmtime(args.j)))
else: else:
# Get state from latest Journal file # Get state from latest Journal file
try: try:
logdir = config.get('journaldir') or config.default_journal_dir 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)], logfiles = sorted((x for x in os.listdir(logdir) if JOURNAL_RE.search(x)), key=lambda x: x.split('.')[1:])
key=lambda x: x.split('.')[1:])
logfile = join(logdir, logfiles[-1]) logfile = join(logdir, logfiles[-1])
with open(logfile, 'r') as loghandle: with open(logfile, 'r') as loghandle:
for line in loghandle: for line in loghandle:
try: try:
monitor.parse_entry(line) monitor.parse_entry(line)
except: except Exception:
if __debug__: if __debug__:
print('Invalid journal entry "%s"' % repr(line)) print(f'Invalid journal entry {line!r}')
except Exception as e: except Exception as e:
print("Can't read Journal file: {}\n".format(str(e)), file=sys.stderr) print(f"Can't read Journal file: {str(e)}", file=sys.stderr)
sys.exit(EXIT_SYS_ERR) sys.exit(EXIT_SYS_ERR)
if not monitor.cmdr: if not monitor.cmdr:
sys.stderr.write('Not available while E:D is at the main menu\n') print('Not available while E:D is at the main menu', file=sys.stderr)
sys.exit(EXIT_SYS_ERR) sys.exit(EXIT_SYS_ERR)
# Get data from Companion API # Get data from Companion API
@ -103,73 +129,100 @@ try:
cmdrs = config.get('cmdrs') or [] cmdrs = config.get('cmdrs') or []
if args.p in cmdrs: if args.p in cmdrs:
idx = cmdrs.index(args.p) idx = cmdrs.index(args.p)
else: else:
for idx, cmdr in enumerate(cmdrs): for idx, cmdr in enumerate(cmdrs):
if cmdr.lower() == args.p.lower(): if cmdr.lower() == args.p.lower():
break break
else: else:
raise companion.CredentialsError() raise companion.CredentialsError()
companion.session.login(cmdrs[idx], monitor.is_beta) companion.session.login(cmdrs[idx], monitor.is_beta)
else: else:
cmdrs = config.get('cmdrs') or [] cmdrs = config.get('cmdrs') or []
if monitor.cmdr not in cmdrs: if monitor.cmdr not in cmdrs:
raise companion.CredentialsError() raise companion.CredentialsError()
companion.session.login(monitor.cmdr, monitor.is_beta) companion.session.login(monitor.cmdr, monitor.is_beta)
querytime = int(time()) querytime = int(time())
data = companion.session.station() data = companion.session.station()
config.set('querytime', querytime) config.set('querytime', querytime)
# Validation # Validation
if not data.get('commander') or not data['commander'].get('name','').strip(): if not deep_get(data, 'commander', 'name', default='').strip():
sys.stderr.write('Who are you?!\n') print('Who are you?!', file=sys.stderr)
sys.exit(EXIT_SERVER) 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 elif not deep_get(data, 'lastSystem', 'name') or \
sys.stderr.write('Where are you?!\n') # Shouldn't happen 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) 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 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) sys.exit(EXIT_SERVER)
elif args.j: elif args.j:
pass # Skip further validation pass # Skip further validation
elif data['commander']['name'] != monitor.cmdr: elif data['commander']['name'] != monitor.cmdr:
sys.stderr.write('Wrong Cmdr\n') # Companion API return doesn't match Journal print('Wrong Cmdr', file=sys.stderr) # Companion API return doesn't match Journal
sys.exit(EXIT_CREDENTIALS) sys.exit(EXIT_CREDENTIALS)
elif ((data['lastSystem']['name'] != monitor.system) or
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or elif data['lastSystem']['name'] != monitor.system or \
(data['ship']['id'] != monitor.state['ShipID']) or ((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or \
(data['ship']['name'].lower() != monitor.state['ShipType'])): data['ship']['id'] != monitor.state['ShipID'] or \
sys.stderr.write('Frontier server is lagging\n') data['ship']['name'].lower() != monitor.state['ShipType']:
print('Frontier server is lagging', file=sys.stderr)
sys.exit(EXIT_LAGGING) sys.exit(EXIT_LAGGING)
# stuff we can do when not docked # stuff we can do when not docked
if args.d: if args.d:
with open(args.d, 'wb') as h: out = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': '))
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) with open(args.d, 'wb') as f:
f.write(out.encode("utf-8"))
if args.a: if args.a:
loadout.export(data, args.a) loadout.export(data, args.a)
if args.e: if args.e:
edshipyard.export(data, args.e) edshipyard.export(data, args.e)
if args.l: if args.l:
stats.export_ships(data, args.l) stats.export_ships(data, args.l)
if args.t: if args.t:
stats.export_status(data, args.t) stats.export_status(data, args.t)
if data['commander'].get('docked'): if data['commander'].get('docked'):
print('%s,%s' % (data.get('lastSystem', {}).get('name', 'Unknown'), data.get('lastStarport', {}).get('name', 'Unknown'))) print('{},{}'.format(
deep_get(data, 'lastSystem', 'name', default='Unknown'),
deep_get(data, 'lastStarport', 'name', default='Unknown')
))
else: else:
print(data.get('lastSystem', {}).get('name', 'Unknown')) print(deep_get(data, 'lastSystem', 'name', default='Unknown'))
if (args.m or args.o or args.s or args.n or args.j): if (args.m or args.o or args.s or args.n or args.j):
if not data['commander'].get('docked'): if not data['commander'].get('docked'):
sys.stderr.write("You're not docked at a station!\n") print("You're not docked at a station!", file=sys.stderr)
sys.exit(EXIT_SUCCESS) sys.exit(EXIT_SUCCESS)
elif not data.get('lastStarport', {}).get('name'):
sys.stderr.write("Unknown station!\n") elif not deep_get(data, 'lastStarport', 'name'):
print("Unknown station!", file=sys.stderr)
sys.exit(EXIT_LAGGING) 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") # 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) sys.exit(EXIT_SUCCESS)
else: else:
sys.exit(EXIT_SUCCESS) sys.exit(EXIT_SUCCESS)
@ -186,31 +239,39 @@ try:
# Fixup anomalies in the commodity data # Fixup anomalies in the commodity data
fixed = companion.fixup(data) fixed = companion.fixup(data)
commodity.export(fixed, COMMODITY_DEFAULT, args.m) commodity.export(fixed, COMMODITY_DEFAULT, args.m)
else: else:
sys.stderr.write("Station doesn't have a market\n") print("Station doesn't have a market", file=sys.stderr)
if args.o: if args.o:
if data['lastStarport'].get('modules'): if data['lastStarport'].get('modules'):
outfitting.export(data, args.o) 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'): 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 # Retry for shipyard
sleep(SERVER_RETRY) sleep(SERVER_RETRY)
data2 = companion.session.station() new_data = 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 # might have undocked while we were waiting for retry in which case station data is unreliable
data2.get('lastSystem', {}).get('name') == monitor.system and if new_data['commander'].get('docked') and \
data2.get('lastStarport', {}).get('name') == monitor.station): deep_get(new_data, 'lastSystem', 'name') == monitor.system and \
data = data2 deep_get(new_data, 'lastStarport', 'name') == monitor.station:
data = new_data
if args.s: if args.s:
if data['lastStarport'].get('ships', {}).get('shipyard_list'): if deep_get(data, 'lastStarport', 'ships', 'shipyard_list'):
shipyard.export(data, args.s) shipyard.export(data, args.s)
elif not args.j and monitor.stationservices and 'Shipyard' in monitor.stationservices: elif not args.j and monitor.stationservices and 'Shipyard' in monitor.stationservices:
sys.stderr.write("Failed to get shipyard data\n") print("Failed to get shipyard data", file=sys.stderr)
else: else:
sys.stderr.write("Station doesn't have a shipyard\n") print("Station doesn't have a shipyard", file=sys.stderr)
if args.n: if args.n:
try: try:
@ -218,17 +279,24 @@ try:
eddn_sender.export_commodities(data, monitor.is_beta) eddn_sender.export_commodities(data, monitor.is_beta)
eddn_sender.export_outfitting(data, monitor.is_beta) eddn_sender.export_outfitting(data, monitor.is_beta)
eddn_sender.export_shipyard(data, monitor.is_beta) eddn_sender.export_shipyard(data, monitor.is_beta)
except Exception as e: except Exception as e:
sys.stderr.write("Failed to send data to EDDN: %s\n" % unicode(e).encode('ascii', 'replace')) print(f"Failed to send data to EDDN: {str(e)}", file=sys.stderr)
sys.exit(EXIT_SUCCESS) sys.exit(EXIT_SUCCESS)
except companion.ServerError as e: except companion.ServerError:
sys.stderr.write('Server is down\n') print('Server is down', file=sys.stderr)
sys.exit(EXIT_SERVER) sys.exit(EXIT_SERVER)
except companion.SKUError as e:
sys.stderr.write('Server SKU problem\n') except companion.SKUError:
print('Server SKU problem', file=sys.stderr)
sys.exit(EXIT_SERVER) sys.exit(EXIT_SERVER)
except companion.CredentialsError as e:
sys.stderr.write('Invalid Credentials\n') except companion.CredentialsError:
print('Invalid Credentials', file=sys.stderr)
sys.exit(EXIT_CREDENTIALS) sys.exit(EXIT_CREDENTIALS)
if __name__ == '__main__':
main()

293
EDMCLogging.py Normal file
View File

@ -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:
<file/module>.<classA>[.classB....].<function>
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:
# <https://stackoverflow.com/questions/2203424/python-how-to-retrieve-class-information-from-a-frame-object#2220759>
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 == '<module>':
caller_class_names = '<none>'
caller_qualname = value_dict['__name__']
elif frame_info.function != '':
caller_class_names = '<none>'
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 = '<ERROR in EDMCLogging.caller_class_and_qualname() for "qualname">'
if caller_class_names == '':
print('ALERT! Something went wrong with finding caller class name(s) for logging!')
caller_class_names = '<ERROR in EDMCLogging.caller_class_and_qualname() for "class">'
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.<filename>".
For 'found' plugins we want "<plugins>.<plugin_name>...".
: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'<plugins>.{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.<plugin folder>.' 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

View File

@ -5,19 +5,15 @@ from builtins import str
from builtins import object from builtins import object
import sys import sys
from sys import platform from sys import platform
from collections import OrderedDict
from functools import partial
import json import json
from os import chdir, environ from os import chdir, environ
from os.path import dirname, expanduser, isdir, join from os.path import dirname, isdir, join
import re import re
import html import html
import requests from time import time, localtime, strftime
from time import gmtime, time, localtime, strftime, strptime
import _strptime # Workaround for http://bugs.python.org/issue7980
from calendar import timegm
import webbrowser import webbrowser
import EDMCLogging
from config import appname, applongname, appversion, appversion_nobuild, copyright, config from config import appname, applongname, appversion, appversion_nobuild, copyright, config
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
@ -36,7 +32,6 @@ import tkinter.messagebox
from ttkHyperlinkLabel import HyperlinkLabel from ttkHyperlinkLabel import HyperlinkLabel
if __debug__: if __debug__:
from traceback import print_exc
if platform != 'win32': if platform != 'win32':
import pdb import pdb
import signal import signal
@ -99,10 +94,10 @@ class AppWindow(object):
if platform == 'win32': if platform == 'win32':
self.w.wm_iconbitmap(default='EDMarketConnector.ico') self.w.wm_iconbitmap(default='EDMarketConnector.ico')
else: else:
self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file = join(config.respath, 'EDMarketConnector.png'))) 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==') 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') 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') 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 = tk.Frame(self.w, name=appname.lower())
frame.grid(sticky=tk.NSEW) frame.grid(sticky=tk.NSEW)
@ -118,10 +113,10 @@ class AppWindow(object):
self.system_label.grid(row=3, column=0, sticky=tk.W) self.system_label.grid(row=3, column=0, sticky=tk.W)
self.station_label.grid(row=4, 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.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.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.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.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station')
self.cmdr.grid(row=1, column=1, sticky=tk.EW) self.cmdr.grid(row=1, column=1, sticky=tk.EW)
self.ship.grid(row=2, column=1, sticky=tk.EW) self.ship.grid(row=2, column=1, sticky=tk.EW)
@ -132,38 +127,40 @@ class AppWindow(object):
appitem = plugin.get_app(frame) appitem = plugin.get_app(frame)
if appitem: if appitem:
tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator
if isinstance(appitem, tuple) and len(appitem)==2: if isinstance(appitem, tuple) and len(appitem) == 2:
row = frame.grid_size()[1] row = frame.grid_size()[1]
appitem[0].grid(row=row, column=0, sticky=tk.W) appitem[0].grid(row=row, column=0, sticky=tk.W)
appitem[1].grid(row=row, column=1, sticky=tk.EW) appitem[1].grid(row=row, column=1, sticky=tk.EW)
else: else:
appitem.grid(columnspan=2, sticky=tk.EW) 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 # Update button in main window
self.theme_button = tk.Label(frame, width = platform == 'darwin' and 32 or 28, state=tk.DISABLED) 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) self.status = tk.Label(frame, name='status', anchor=tk.W)
row = frame.grid_size()[1] row = frame.grid_size()[1]
self.button.grid(row=row, columnspan=2, sticky=tk.NSEW) self.button.grid(row=row, columnspan=2, sticky=tk.NSEW)
self.theme_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.status.grid(columnspan=2, sticky=tk.EW)
self.button.bind('<Button-1>', self.getandsend) self.button.bind('<Button-1>', self.getandsend)
theme.button_bind(self.theme_button, self.getandsend) theme.button_bind(self.theme_button, self.getandsend)
for child in frame.winfo_children(): 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() self.menubar = tk.Menu()
if platform=='darwin': if platform == 'darwin':
# Can't handle (de)iconify if topmost is set, so suppress iconify button # 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') root.call('tk::unsupported::MacWindowStyle', 'style', root, 'document', 'closeBox resizable')
# https://www.tcl.tk/man/tcl/TkCmd/menu.htm # https://www.tcl.tk/man/tcl/TkCmd/menu.htm
self.system_menu = tk.Menu(self.menubar, name='apple') 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.w.call('tk::mac::standardAboutPanel'))
self.system_menu.add_command(command=lambda:self.updater.checkForUpdates()) self.system_menu.add_command(command=lambda: self.updater.checkForUpdates())
self.menubar.add_cascade(menu=self.system_menu) self.menubar.add_cascade(menu=self.system_menu)
self.file_menu = tk.Menu(self.menubar, name='file') self.file_menu = tk.Menu(self.menubar, name='file')
self.file_menu.add_command(command=self.save_raw) 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.menubar.add_cascade(menu=self.edit_menu)
self.w.bind('<Command-c>', self.copy) self.w.bind('<Command-c>', self.copy)
self.view_menu = tk.Menu(self.menubar, name='view') 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) self.menubar.add_cascade(menu=self.view_menu)
window_menu = tk.Menu(self.menubar, name='window') window_menu = tk.Menu(self.menubar, name='window')
self.menubar.add_cascade(menu=window_menu) self.menubar.add_cascade(menu=window_menu)
@ -185,17 +182,17 @@ class AppWindow(object):
self.w['menu'] = self.menubar self.w['menu'] = self.menubar
# https://www.tcl.tk/man/tcl/TkCmd/tk_mac.htm # https://www.tcl.tk/man/tcl/TkCmd/tk_mac.htm
self.w.call('set', 'tk::mac::useCompatibilityMetrics', '0') 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::Quit", self.onexit)
self.w.createcommand("::tk::mac::ShowPreferences", lambda:prefs.PreferencesDialog(self.w, self.postprefs)) 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.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.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.resizable(tk.FALSE, tk.FALSE) # Can't be only resizable on one axis
else: else:
self.file_menu = self.view_menu = tk.Menu(self.menubar, tearoff=tk.FALSE) 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=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_separator()
self.file_menu.add_command(command=self.onexit) self.file_menu.add_command(command=self.onexit)
self.menubar.add_cascade(menu=self.file_menu) self.menubar.add_cascade(menu=self.file_menu)
@ -206,16 +203,18 @@ class AppWindow(object):
self.help_menu.add_command(command=self.help_general) 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_privacy)
self.help_menu.add_command(command=self.help_releases) 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: 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: not self.HelpAbout.showing and self.HelpAbout(self.w))
self.menubar.add_cascade(menu=self.help_menu) self.menubar.add_cascade(menu=self.help_menu)
if platform == 'win32': if platform == 'win32':
# Must be added after at least one "real" menu entry # 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 = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE)
self.system_menu.add_separator() 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.menubar.add_cascade(menu=self.system_menu)
self.w.bind('<Control-c>', self.copy) self.w.bind('<Control-c>', self.copy)
self.w.protocol("WM_DELETE_WINDOW", self.onexit) self.w.protocol("WM_DELETE_WINDOW", self.onexit)
@ -227,7 +226,9 @@ class AppWindow(object):
# Alternate title bar and menu for dark theme # Alternate title bar and menu for dark theme
self.theme_menubar = tk.Frame(frame) self.theme_menubar = tk.Frame(frame)
self.theme_menubar.columnconfigure(2, weight=1) 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) theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW)
self.drag_offset = None self.drag_offset = None
theme_titlebar.bind('<Button-1>', self.drag_start) theme_titlebar.bind('<Button-1>', self.drag_start)
@ -241,13 +242,22 @@ class AppWindow(object):
theme.button_bind(theme_close, self.onexit, image=self.theme_close) 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 = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_file_menu.grid(row=1, column=0, padx=5, sticky=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 = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_edit_menu.grid(row=1, column=1, sticky=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 = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_help_menu.grid(row=1, column=2, sticky=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) 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) theme.register(self.theme_close)
@ -255,23 +265,26 @@ class AppWindow(object):
tk.Label(self.blank_menubar).grid() tk.Label(self.blank_menubar).grid()
tk.Label(self.blank_menubar).grid() tk.Label(self.blank_menubar).grid()
tk.Frame(self.blank_menubar, height=2).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) self.w.resizable(tk.TRUE, tk.FALSE)
# update geometry # update geometry
if config.get('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 match:
if platform == 'darwin': 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')) self.w.geometry(config.get('geometry'))
elif platform == 'win32': elif platform == 'win32':
# Check that the titlebar will be at least partly on screen # Check that the titlebar will be at least partly on screen
import ctypes import ctypes
from ctypes.wintypes import POINT from ctypes.wintypes import POINT
# https://msdn.microsoft.com/en-us/library/dd145064 # https://msdn.microsoft.com/en-us/library/dd145064
MONITOR_DEFAULTTONULL = 0 MONITOR_DEFAULTTONULL = 0 # noqa: N806
if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16), MONITOR_DEFAULTTONULL): if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16),
MONITOR_DEFAULTTONULL):
self.w.geometry(config.get('geometry')) self.w.geometry(config.get('geometry'))
else: else:
self.w.geometry(config.get('geometry')) self.w.geometry(config.get('geometry'))
@ -282,9 +295,9 @@ class AppWindow(object):
self.w.bind('<Map>', self.onmap) # Special handling for overrideredict self.w.bind('<Map>', self.onmap) # Special handling for overrideredict
self.w.bind('<Enter>', self.onenter) # Special handling for transparency self.w.bind('<Enter>', self.onenter) # Special handling for transparency
self.w.bind('<FocusIn>', self.onenter) # " self.w.bind('<FocusIn>', self.onenter) # Special handling for transparency
self.w.bind('<Leave>', self.onleave) # " self.w.bind('<Leave>', self.onleave) # Special handling for transparency
self.w.bind('<FocusOut>', self.onleave) # " self.w.bind('<FocusOut>', self.onleave) # Special handling for transparency
self.w.bind('<Return>', self.getandsend) self.w.bind('<Return>', self.getandsend)
self.w.bind('<KP_Enter>', self.getandsend) self.w.bind('<KP_Enter>', self.getandsend)
self.w.bind_all('<<Invoke>>', self.getandsend) # Hotkey monitoring self.w.bind_all('<<Invoke>>', self.getandsend) # Hotkey monitoring
@ -295,7 +308,7 @@ class AppWindow(object):
self.w.bind_all('<<Quit>>', self.onexit) # Updater self.w.bind_all('<<Quit>>', self.onexit) # Updater
# Start a protocol handler to handle cAPI registration. Requires main loop to be running. # 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) # Load updater after UI creation (for WinSparkle)
import update import update
@ -321,23 +334,22 @@ class AppWindow(object):
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 # callback after the Preferences dialog is applied
def postprefs(self, dologin=True): def postprefs(self, dologin=True):
self.prefsdialog = None 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 # Reset links in case plugins changed them
self.ship.configure(url = self.shipyard_url) self.ship.configure(url=self.shipyard_url)
self.system.configure(url = self.system_url) self.system.configure(url=self.system_url)
self.station.configure(url = self.station_url) self.station.configure(url=self.station_url)
# (Re-)install hotkey monitoring # (Re-)install hotkey monitoring
hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods')) hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods'))
# (Re-)install log monitoring # (Re-)install log monitoring
if not monitor.start(self.w): 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: 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
@ -345,8 +357,8 @@ class AppWindow(object):
# set main window labels, e.g. after language change # set main window labels, e.g. after language change
def set_labels(self): def set_labels(self):
self.cmdr_label['text'] = _('Cmdr') + ':' # Main window self.cmdr_label['text'] = _('Cmdr') + ':' # Main window
self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or # Multicrew role label in main window # Multicrew role label in main window
_('Ship')) + ':' # Main window self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window
self.system_label['text'] = _('System') + ':' # Main window self.system_label['text'] = _('System') + ':' # Main window
self.station_label['text'] = _('Station') + ':' # Main window self.station_label['text'] = _('Station') + ':' # Main window
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
@ -370,20 +382,20 @@ class AppWindow(object):
self.theme_edit_menu['text'] = _('Edit') # Menu title self.theme_edit_menu['text'] = _('Edit') # Menu title
self.theme_help_menu['text'] = _('Help') # Menu title self.theme_help_menu['text'] = _('Help') # Menu title
## File menu # File menu
self.file_menu.entryconfigure(0, label=_('Status')) # Menu item self.file_menu.entryconfigure(0, label=_('Status')) # Menu item
self.file_menu.entryconfigure(1, label=_('Save Raw Data...')) # 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(2, label=_('Settings')) # Item in the File menu on Windows
self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows
## Help menu # Help menu
self.help_menu.entryconfigure(0, label=_('Documentation')) # Help menu item 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(1, label=_('Privacy Policy')) # Help menu item
self.help_menu.entryconfigure(2, label=_('Release Notes')) # 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(3, label=_('Check for Updates...')) # Menu item
self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry
## Edit menu # Edit menu
self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste
def login(self): def login(self):
@ -399,7 +411,8 @@ class AppWindow(object):
self.w.update_idletasks() self.w.update_idletasks()
try: try:
if companion.session.login(monitor.cmdr, monitor.is_beta): 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': if platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -409,7 +422,7 @@ class AppWindow(object):
except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e: except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e:
self.status['text'] = str(e) self.status['text'] = str(e)
except Exception as e: except Exception as e:
if __debug__: print_exc() logger.debug('Frontier CAPI Auth', exc_info=e)
self.status['text'] = str(e) self.status['text'] = str(e)
self.cooldown() self.cooldown()
@ -447,26 +460,36 @@ class AppWindow(object):
# Validation # Validation
if not data.get('commander', {}).get('name'): if not data.get('commander', {}).get('name'):
self.status['text'] = _("Who are you?!") # Shouldn't happen self.status['text'] = _("Who are you?!") # Shouldn't happen
elif (not data.get('lastSystem', {}).get('name') or elif (not data.get('lastSystem', {}).get('name')
(data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked 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'] = _("Where are you?!") # Shouldn't happen
elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'): 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: elif monitor.cmdr and data['commander']['name'] != monitor.cmdr:
raise companion.CmdrError() # Companion API return doesn't match Journal # Companion API return doesn't match Journal
elif ((auto_update and not data['commander'].get('docked')) or raise companion.CmdrError()
(data['lastSystem']['name'] != monitor.system) or elif ((auto_update and not data['commander'].get('docked'))
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or or (data['lastSystem']['name'] != monitor.system)
(data['ship']['id'] != monitor.state['ShipID']) or or ((data['commander']['docked']
(data['ship']['name'].lower() != monitor.state['ShipType'])): 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() raise companion.ServerLagging()
else: else:
if __debug__: # Recording if __debug__: # Recording
if isdir('dump'): 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: with open('dump/{system}{station}.{timestamp}.json'.format(
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) 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']) self.ship['text'] = companion.ship_map.get(data['ship']['name'].lower(), data['ship']['name'])
@ -487,16 +510,19 @@ class AppWindow(object):
if config.getint('output') & (config.OUT_STATION_ANY): if config.getint('output') & (config.OUT_STATION_ANY):
if not data['commander'].get('docked'): if not data['commander'].get('docked'):
if not self.status['text']: 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!") self.status['text'] = _("You're not docked at a station!")
play_bad = True 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']: if not self.status['text']:
self.status['text'] = _("Station doesn't have anything!") self.status['text'] = _("Station doesn't have anything!")
elif not data['lastStarport'].get('commodities'): elif not data['lastStarport'].get('commodities'):
if not self.status['text']: if not self.status['text']:
self.status['text'] = _("Station doesn't have a market!") 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 # Fixup anomalies in the commodity data
fixed = companion.fixup(data) fixed = companion.fixup(data)
if config.getint('output') & config.OUT_MKT_CSV: if config.getint('output') & config.OUT_MKT_CSV:
@ -513,7 +539,7 @@ class AppWindow(object):
play_bad = True play_bad = True
else: else:
# Retry once if Companion server is unresponsive # Retry once if Companion server is unresponsive
self.w.after(int(SERVER_RETRY * 1000), lambda:self.getandsend(event, True)) self.w.after(int(SERVER_RETRY * 1000), lambda: self.getandsend(event, True))
return # early exit to avoid starting cooldown count 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
@ -523,12 +549,12 @@ class AppWindow(object):
self.login() self.login()
except Exception as e: # Including CredentialsError, ServerError except Exception as e: # Including CredentialsError, ServerError
if __debug__: print_exc() logger.debug('"other" exception', exc_info=e)
self.status['text'] = str(e) self.status['text'] = str(e)
play_bad = True play_bad = True
if not self.status['text']: # no errors 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)) self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime))
if play_sound and play_bad: if play_sound and play_bad:
hotkeymgr.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 again to get shipyard data and send to EDDN. Don't report errors if can't get or send the data.
try: try:
data = companion.session.station() data = companion.session.station()
if __debug__: if data['commander'].get('docked'):
print('Retry for shipyard - ' + (data['commander'].get('docked') and (data.get('lastStarport', {}).get('ships') and 'Success' or 'Failure') or 'Undocked!')) 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'): 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 elif (data.get('lastSystem', {}).get('name') == monitor.system and
data.get('lastStarport', {}).get('name') == monitor.station and data.get('lastStarport', {}).get('name') == monitor.station and
data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')): data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')):
self.eddn.export_shipyard(data, monitor.is_beta) self.eddn.export_shipyard(data, monitor.is_beta)
elif tries > 1: # bogus data - retry elif tries > 1: # bogus data - retry
self.w.after(int(SERVER_RETRY * 1000), lambda:self.retry_for_shipyard(tries-1)) self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries-1))
except: except Exception:
pass pass
# Handle event(s) from the journal # Handle event(s) from the journal
@ -572,17 +605,20 @@ class AppWindow(object):
# Update main window # Update main window
self.cooldown() self.cooldown()
if monitor.cmdr and monitor.state['Captain']: if monitor.cmdr and monitor.state['Captain']:
self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.state['Captain']) self.cmdr['text'] = f'{monitor.cmdr} / {monitor.state["Captain"]}'
self.ship_label['text'] = _('Role') + ':' # Multicrew role label in main window 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.ship.configure(state=tk.NORMAL, text=crewroletext(monitor.state['Role']), url=None)
elif monitor.cmdr: elif monitor.cmdr:
if monitor.group: if monitor.group:
self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.group) self.cmdr['text'] = f'{monitor.cmdr} / {monitor.group}'
else: else:
self.cmdr['text'] = monitor.cmdr self.cmdr['text'] = monitor.cmdr
self.ship_label['text'] = _('Ship') + ':' # Main window 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 '', self.ship.configure(
url = self.shipyard_url) text=monitor.state['ShipName']
or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType'])
or '',
url=self.shipyard_url)
else: else:
self.cmdr['text'] = '' self.cmdr['text'] = ''
self.ship_label['text'] = _('Ship') + ':' # Main window self.ship_label['text'] = _('Ship') + ':' # Main window
@ -590,7 +626,21 @@ class AppWindow(object):
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']: 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.status['text'] = '' # Periodically clear any old error
self.w.update_idletasks() self.w.update_idletasks()
@ -607,37 +657,48 @@ class AppWindow(object):
# Disable WinSparkle automatic update checks, IFF configured to do so when in-game # Disable WinSparkle automatic update checks, IFF configured to do so when in-game
if config.getint('disable_autoappupdatecheckingame') and 1: if config.getint('disable_autoappupdatecheckingame') and 1:
self.updater.setAutomaticUpdatesCheck(False) self.updater.setAutomaticUpdatesCheck(False)
print('Monitor: Disable WinSparkle automatic update checks') logger.info('Monitor: Disable WinSparkle automatic update checks')
# Can start dashboard monitoring # Can start dashboard monitoring
if not dashboard.start(self.w, monitor.started): if not dashboard.start(self.w, monitor.started):
print("Can't start Status monitoring") logger.info("Can't start Status monitoring")
# Export loadout # 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() monitor.export_ship()
# Plugins # 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: if err:
self.status['text'] = err self.status['text'] = err
if not config.getint('hotkey_mute'): if not config.getint('hotkey_mute'):
hotkeymgr.play_bad() hotkeymgr.play_bad()
# Auto-Update after docking, but not if auth callback is pending # 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) self.w.after(int(SERVER_RETRY * 1000), self.getandsend)
if entry['event'] == 'ShutDown': if entry['event'] == 'ShutDown':
# Enable WinSparkle automatic update checks # Enable WinSparkle automatic update checks
# NB: Do this blindly, in case option got changed whilst in-game # NB: Do this blindly, in case option got changed whilst in-game
self.updater.setAutomaticUpdatesCheck(True) self.updater.setAutomaticUpdatesCheck(True)
print('Monitor: Enable WinSparkle automatic update checks') logger.info('Monitor: Enable WinSparkle automatic update checks')
# cAPI auth # cAPI auth
def auth(self, event=None): def auth(self, event=None):
try: try:
companion.session.auth_callback() 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': if platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -647,7 +708,7 @@ class AppWindow(object):
except companion.ServerError as e: except companion.ServerError as e:
self.status['text'] = str(e) self.status['text'] = str(e)
except Exception as e: except Exception as e:
if __debug__: print_exc() logger.debug('Frontier CAPI Auth:', exc_info=e)
self.status['text'] = str(e) self.status['text'] = str(e)
self.cooldown() self.cooldown()
@ -696,7 +757,9 @@ class AppWindow(object):
def cooldown(self): def cooldown(self):
if time() < self.holdofftime: 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) self.w.after(1000, self.cooldown)
else: 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
@ -713,7 +776,7 @@ class AppWindow(object):
def copy(self, event=None): def copy(self, event=None):
if monitor.system: if monitor.system:
self.w.clipboard_clear() 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): def help_general(self, event=None):
webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki') webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki')
@ -724,7 +787,7 @@ class AppWindow(object):
def help_releases(self, event=None): def help_releases(self, event=None):
webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases') webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases')
class help_about(tk.Toplevel): class HelpAbout(tk.Toplevel):
showing = False showing = False
def __init__(self, parent): def __init__(self, parent):
@ -741,11 +804,12 @@ class AppWindow(object):
self.transient(parent) self.transient(parent)
# position over parent # position over parent
if platform!='darwin' or parent.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
self.geometry("+%d+%d" % (parent.winfo_rootx(), parent.winfo_rooty())) if platform != 'darwin' or parent.winfo_rooty() > 0:
self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}')
# remove decoration # remove decoration
if platform=='win32': if platform == 'win32':
self.attributes('-toolwindow', tk.TRUE) self.attributes('-toolwindow', tk.TRUE)
self.resizable(tk.FALSE, tk.FALSE) self.resizable(tk.FALSE, tk.FALSE)
@ -753,10 +817,6 @@ class AppWindow(object):
frame = ttk.Frame(self) frame = ttk.Frame(self)
frame.grid(sticky=tk.NSEW) frame.grid(sticky=tk.NSEW)
PADX = 10
BUTTONX = 12 # indent Checkbuttons and Radiobuttons
PADY = 2 # close spacing
row = 1 row = 1
############################################################ ############################################################
# applongname # applongname
@ -772,8 +832,8 @@ class AppWindow(object):
self.appversion_label = tk.Label(frame, text=appversion) self.appversion_label = tk.Label(frame, text=appversion)
self.appversion_label.grid(row=row, column=0, sticky=tk.E) self.appversion_label.grid(row=row, column=0, sticky=tk.E)
self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=_('Release Notes'), self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=_('Release Notes'),
url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/{VERSION}'.format( url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/'
VERSION=appversion_nobuild), f'{appversion_nobuild}',
underline=True) underline=True)
self.appversion.grid(row=row, column=2, sticky=tk.W) self.appversion.grid(row=row, column=2, sticky=tk.W)
row += 1 row += 1
@ -798,11 +858,11 @@ class AppWindow(object):
row += 1 row += 1
button = ttk.Button(frame, text=_('OK'), command=self.apply) button = ttk.Button(frame, text=_('OK'), command=self.apply)
button.grid(row=row, column=2, sticky=tk.E) button.grid(row=row, column=2, sticky=tk.E)
button.bind("<Return>", lambda event:self.apply()) button.bind("<Return>", lambda event: self.apply())
self.protocol("WM_DELETE_WINDOW", self._destroy) self.protocol("WM_DELETE_WINDOW", self._destroy)
############################################################ ############################################################
print('Current version is {}'.format(appversion)) logger.info(f'Current version is {appversion}')
def apply(self): def apply(self):
self._destroy() self._destroy()
@ -819,22 +879,35 @@ class AppWindow(object):
try: try:
data = companion.session.station() data = companion.session.station()
self.status['text'] = '' self.status['text'] = ''
f = tkinter.filedialog.asksaveasfilename(parent = self.w, default_extension: str = ''
defaultextension = platform=='darwin' and '.json' or '', if platform == 'darwin':
filetypes = [('JSON', '.json'), ('All Files', '*')], default_extension = '.json'
initialdir = config.get('outdir'), last_system: str = data.get("lastSystem", {}).get("name", "Unknown")
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()))) 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: if f:
with open(f, 'wb') as h: 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: except companion.ServerError as e:
self.status['text'] = str(e) self.status['text'] = str(e)
except Exception as e: except Exception as e:
if __debug__: print_exc() logger.debug('"other" exception', exc_info=e)
self.status['text'] = str(e) self.status['text'] = str(e)
def onexit(self, event=None): 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('+'))) 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() protocolhandler.close()
@ -852,7 +925,9 @@ class AppWindow(object):
def drag_continue(self, event): def drag_continue(self, event):
if self.drag_offset: 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): def drag_end(self, event):
self.drag_offset = None self.drag_offset = None
@ -875,71 +950,100 @@ class AppWindow(object):
self.theme_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) self.theme_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW)
def onleave(self, event=None): 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.w.attributes("-transparentcolor", 'grey4')
self.theme_menubar.grid_remove() self.theme_menubar.grid_remove()
self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) 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. # Ensure only one copy of the app is running under this user account. OSX does this automatically. Linux TODO.
if platform == 'win32': if platform == 'win32':
import ctypes import ctypes
from ctypes.wintypes import * from ctypes.wintypes import HWND, LPWSTR, LPCWSTR, INT, BOOL, LPARAM
EnumWindows = ctypes.windll.user32.EnumWindows EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806
GetClassName = ctypes.windll.user32.GetClassNameW GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806
GetWindowText = ctypes.windll.user32.GetWindowTextW GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806
SW_RESTORE = 9 SW_RESTORE = 9 # noqa: N806
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806
ShowWindow = ctypes.windll.user32.ShowWindow ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806
COINIT_MULTITHREADED = 0 COINIT_MULTITHREADED = 0 # noqa: N806,F841
COINIT_APARTMENTTHREADED = 0x2 COINIT_APARTMENTTHREADED = 0x2 # noqa: N806
COINIT_DISABLE_OLE1DDE = 0x4 COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806
ShellExecute = ctypes.windll.shell32.ShellExecuteW ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806
ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT] ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT]
def WindowTitle(h): def window_title(h):
if h: if h:
l = GetWindowTextLength(h) + 1 text_length = GetWindowTextLength(h) + 1
buf = ctypes.create_unicode_buffer(l) buf = ctypes.create_unicode_buffer(text_length)
if GetWindowText(h, buf, l): if GetWindowText(h, buf, text_length):
return buf.value return buf.value
return None return None
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) @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 # class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576
cls = ctypes.create_unicode_buffer(257) 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 GetProcessHandleFromHwnd succeeds then the app is already running as this user
if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler.redirect): 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. # Browser invoked us directly with auth response. Forward the response to the other app instance.
CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) 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) ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE)
else: else:
ShowWindowAsync(hWnd, SW_RESTORE) ShowWindowAsync(window_handle, SW_RESTORE)
SetForegroundWindow(hWnd) SetForegroundWindow(window_handle)
sys.exit(0) sys.exit(0)
return True return True
EnumWindows(enumwindowsproc, 0) 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): if getattr(sys, 'frozen', False):
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed # By default py2exe tries to write log to dirname(sys.executable) which fails when installed
import tempfile 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 # unbuffered not allowed for text in python3, so use `1 for line buffering
print('%s %s %s' % (applongname, appversion, strftime('%Y-%m-%dT%H:%M:%S', localtime()))) sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='wt', buffering=1)
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 Translations.install(config.get('language') or None) # Can generate errors so wait til log set up

View File

@ -236,6 +236,7 @@
"Language" = "Idioma"; "Language" = "Idioma";
/* [EDMarketConnector.py] - Leave '%H:%M:%S' as-is */ /* [EDMarketConnector.py] - Leave '%H:%M:%S' as-is */
>>>>>>> develop
"Last updated at %H:%M:%S" = "Última actualización: %H:%M:%S"; "Last updated at %H:%M:%S" = "Última actualización: %H:%M:%S";
/* Federation rank. [stats.py] */ /* Federation rank. [stats.py] */

View File

@ -48,6 +48,9 @@ breaking with future code changes.**
`from monitor import gamerunning` - in case a plugin needs to know if we `from monitor import gamerunning` - in case a plugin needs to know if we
think the game is running. 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 ```python
from ttkHyperlinkLabel import HyperlinkLabel from ttkHyperlinkLabel import HyperlinkLabel

View File

@ -4,9 +4,10 @@ from builtins import object
import base64 import base64
import csv import csv
import requests import requests
from typing import TYPE_CHECKING
# TODO: see https://github.com/EDCD/EDMarketConnector/issues/569 # 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 from email.utils import parsedate
import hashlib import hashlib
import numbers import numbers
@ -14,14 +15,15 @@ import os
from os.path import join from os.path import join
import random import random
import time import time
from traceback import print_exc
import urllib.parse import urllib.parse
import webbrowser import webbrowser
from config import appname, appversion, config from config import appname, appversion, config
from protocol import protocolhandler from protocol import protocolhandler
import logging
logger = logging.getLogger(appname)
from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
_ = lambda x: x # noqa # to make flake8 stop complaining that the hacked in _ method doesnt exist _ = 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') return data.get('access_token')
else: 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) self.dump(r)
except Exception: except Exception as e:
print('Auth\tCan\'t refresh token for {}'.format(self.cmdr)) logger.exception(f"Frontier CAPI Auth: Can't refresh token for \"{self.cmdr}\"")
print_exc()
else: else:
print('Auth\tNo token for {}'.format(self.cmdr)) logger.error(f"Frontier CAPI Auth: No token for \"{self.cmdr}\"")
# New request # New request
print('Auth\tNew authorization request') logger.info('Frontier CAPI Auth: New authorization request')
v = random.SystemRandom().getrandbits(8 * 32) 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) 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/ # Won't work under IE: https://blogs.msdn.microsoft.com/ieinternals/2011/07/13/understanding-protocols/
webbrowser.open( 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}{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, server_auth=SERVER_AUTH,
url_auth=URL_AUTH, url_auth=URL_AUTH,
client_id=CLIENT_ID, 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, state=self.state,
redirect=protocolhandler.redirect redirect=protocolhandler.redirect
) )
@ -228,16 +229,16 @@ class Auth(object):
# Handle OAuth authorization code callback. # Handle OAuth authorization code callback.
# Returns access token if successful, otherwise raises CredentialsError # Returns access token if successful, otherwise raises CredentialsError
if '?' not in payload: 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 raise CredentialsError('malformed payload') # Not well formed
data = urllib.parse.parse_qs(payload[(payload.index('?') + 1):]) data = urllib.parse.parse_qs(payload[(payload.index('?') + 1):])
if not self.state or not data.get('state') or data['state'][0] != self.state: 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 raise CredentialsError('Unexpected response from authorization {!r}'.format(payload)) # Unexpected reply
if not data.get('code'): 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( error = next(
(data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',) (data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',)
) )
@ -256,7 +257,7 @@ class Auth(object):
r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=auth_timeout) r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=auth_timeout)
data = r.json() data = r.json()
if r.status_code == requests.codes.ok: 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') cmdrs = config.get('cmdrs')
idx = cmdrs.index(self.cmdr) idx = cmdrs.index(self.cmdr)
tokens = config.get('fdev_apikeys') or [] tokens = config.get('fdev_apikeys') or []
@ -268,21 +269,20 @@ class Auth(object):
return data.get('access_token') return data.get('access_token')
except Exception as e: except Exception as e:
print('Auth\tCan\'t get token for {}'.format(self.cmdr)) logger.exception(f"Frontier CAPI Auth: Can't get token for \"{self.cmdr}\"")
print_exc()
if r: if r:
self.dump(r) self.dump(r)
raise CredentialsError('unable to get token') from e 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) self.dump(r)
error = next((data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',)) error = next((data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',))
raise CredentialsError('Error: {!r}'.format(error)[0]) raise CredentialsError('Error: {!r}'.format(error)[0])
@staticmethod @staticmethod
def invalidate(cmdr): def invalidate(cmdr):
print('Auth\tInvalidated token for {}'.format(cmdr)) logger.info(f'Frontier CAPI Auth: Invalidated token for "{cmdr}"')
cmdrs = config.get('cmdrs') cmdrs = config.get('cmdrs')
idx = cmdrs.index(cmdr) idx = cmdrs.index(cmdr)
tokens = config.get('fdev_apikeys') or [] tokens = config.get('fdev_apikeys') or []
@ -292,9 +292,9 @@ class Auth(object):
config.save() # Save settings now for use by command-line app config.save() # Save settings now for use by command-line app
def dump(self, r): 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('=', '') return base64.urlsafe_b64encode(text).decode().replace('=', '')
@ -379,10 +379,8 @@ class Session(object):
r = self.session.get(self.server + endpoint, timeout=timeout) r = self.session.get(self.server + endpoint, timeout=timeout)
except Exception as e: except Exception as e:
if __debug__: logger.debug('Attempting GET', exc_info=e)
print_exc() raise ServerError(f'unable to get endpoint {endpoint}') from e
raise ServerError('unable to get endpoint {}'.format(endpoint)) from e
if r.url.startswith(SERVER_AUTH): if r.url.startswith(SERVER_AUTH):
# Redirected back to Auth server - force full re-authentication # 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 data = r.json() # May also fail here if token expired since response is empty
except (requests.HTTPError, ValueError) as e: except (requests.HTTPError, ValueError) as e:
print_exc() logger.exception('Frontier CAPI Auth: GET ')
self.dump(r) self.dump(r)
self.close() self.close()
@ -410,6 +408,7 @@ class Session(object):
self.invalidate() self.invalidate()
self.retrying = False self.retrying = False
self.login() self.login()
logger.error('Frontier CAPI Auth: query failed after refresh')
raise CredentialsError('query failed after refresh') from e raise CredentialsError('query failed after refresh') from e
elif self.login(): # Maybe our token expired. Re-authorize in any case elif self.login(): # Maybe our token expired. Re-authorize in any case
@ -418,6 +417,7 @@ class Session(object):
else: else:
self.retrying = False self.retrying = False
logger.error('Frontier CAPI Auth: HTTP error or invalid JSON')
raise CredentialsError('HTTP error or invalid JSON') from e raise CredentialsError('HTTP error or invalid JSON') from e
self.retrying = False self.retrying = False
@ -461,9 +461,8 @@ class Session(object):
try: try:
self.session.close() self.session.close()
except Exception: except Exception as e:
if __debug__: logger.debug('Frontier CAPI Auth: closing', exc_info=e)
print_exc()
self.session = None self.session = None
@ -473,7 +472,7 @@ class Session(object):
Auth.invalidate(self.credentials['cmdr']) Auth.invalidate(self.credentials['cmdr'])
def dump(self, r): 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 # Returns a shallow copy of the received data suitable for export to older tools
# English commodity names and anomalies fixed up # English commodity names and anomalies fixed up
@ -497,15 +496,7 @@ def fixup(data):
# But also see https://github.com/Marginal/EDMarketConnector/issues/32 # But also see https://github.com/Marginal/EDMarketConnector/issues/32
for thing in ('buyPrice', 'sellPrice', 'demand', 'demandBracket', 'stock', 'stockBracket'): for thing in ('buyPrice', 'sellPrice', 'demand', 'demandBracket', 'stock', 'stockBracket'):
if not isinstance(commodity.get(thing), numbers.Number): if not isinstance(commodity.get(thing), numbers.Number):
if __debug__: logger.debug(f'Invalid {thing}:{commodity.get(thing)} ({type(commodity.get(thing))}) for {commodity.get("name", "")}') # noqa: E501
print(
'Invalid {!r}:{!r} ({}) for {!r}'.format(
thing,
commodity.get(thing),
type(commodity.get(thing)),
commodity.get('name', '')
)
)
break break
else: else:
@ -520,20 +511,16 @@ def fixup(data):
pass pass
elif not commodity.get('categoryname'): elif not commodity.get('categoryname'):
if __debug__: logger.debug(f'Missing "categoryname" for {commodity.get("name", "")}')
print('Missing "categoryname" for {!r}'.format(commodity.get('name', '')))
elif not commodity.get('name'): elif not commodity.get('name'):
if __debug__: logger.debug(f'Missing "name" for a commodity in {commodity.get("categoryname", "")}')
print('Missing "name" for a commodity in {!r}'.format(commodity.get('categoryname', '')))
elif not commodity['demandBracket'] in range(4): elif not commodity['demandBracket'] in range(4):
if __debug__: logger.debug(f'Invalid "demandBracket":{commodity["demandBracket"]} for {commodity["name"]}')
print('Invalid "demandBracket":{!r} for {!r}'.format(commodity['demandBracket'], commodity['name']))
elif not commodity['stockBracket'] in range(4): elif not commodity['stockBracket'] in range(4):
if __debug__: logger.debug(f'Invalid "stockBracket":{commodity["stockBracket"]} for {commodity["name"]}')
print('Invalid "stockBracket":{!r} for {!r}'.format(commodity['stockBracket'], commodity['name']))
else: else:
# Rewrite text fields # Rewrite text fields

File diff suppressed because it is too large Load Diff

152
plug.py
View File

@ -7,49 +7,51 @@ import os
import importlib import importlib
import sys import sys
import operator import operator
import threading # We don't use it, but plugins might import threading # noqa: F401 - We don't use it, but plugins might
from traceback import print_exc from typing import Optional
import logging
import tkinter as tk import tkinter as tk
import myNotebook as nb
from config import config import myNotebook as nb # noqa: N813
from time import time as time
from config import config, appname
import EDMCLogging
logger = logging.getLogger(appname)
# Dashboard Flags constants # Dashboard Flags constants
FlagsDocked = 1<<0 # on a landing pad FlagsDocked = 1 << 0 # on a landing pad
FlagsLanded = 1<<1 # on planet surface FlagsLanded = 1 << 1 # on planet surface
FlagsLandingGearDown = 1<<2 FlagsLandingGearDown = 1 << 2
FlagsShieldsUp = 1<<3 FlagsShieldsUp = 1 << 3
FlagsSupercruise = 1<<4 FlagsSupercruise = 1 << 4
FlagsFlightAssistOff = 1<<5 FlagsFlightAssistOff = 1 << 5
FlagsHardpointsDeployed = 1<<6 FlagsHardpointsDeployed = 1 << 6
FlagsInWing = 1<<7 FlagsInWing = 1 << 7
FlagsLightsOn = 1<<8 FlagsLightsOn = 1 << 8
FlagsCargoScoopDeployed = 1<<9 FlagsCargoScoopDeployed = 1 << 9
FlagsSilentRunning = 1<<10 FlagsSilentRunning = 1 << 10
FlagsScoopingFuel = 1<<11 FlagsScoopingFuel = 1 << 11
FlagsSrvHandbrake = 1<<12 FlagsSrvHandbrake = 1 << 12
FlagsSrvTurret = 1<<13 # using turret view FlagsSrvTurret = 1 << 13 # using turret view
FlagsSrvUnderShip = 1<<14 # turret retracted FlagsSrvUnderShip = 1 << 14 # turret retracted
FlagsSrvDriveAssist = 1<<15 FlagsSrvDriveAssist = 1 << 15
FlagsFsdMassLocked = 1<<16 FlagsFsdMassLocked = 1 << 16
FlagsFsdCharging = 1<<17 FlagsFsdCharging = 1 << 17
FlagsFsdCooldown = 1<<18 FlagsFsdCooldown = 1 << 18
FlagsLowFuel = 1<<19 # <25% FlagsLowFuel = 1 << 19 # <25%
FlagsOverHeating = 1<<20 # > 100% FlagsOverHeating = 1 << 20 # > 100%
FlagsHasLatLong = 1<<21 FlagsHasLatLong = 1 << 21
FlagsIsInDanger = 1<<22 FlagsIsInDanger = 1 << 22
FlagsBeingInterdicted = 1<<23 FlagsBeingInterdicted = 1 << 23
FlagsInMainShip = 1<<24 FlagsInMainShip = 1 << 24
FlagsInFighter = 1<<25 FlagsInFighter = 1 << 25
FlagsInSRV = 1<<26 FlagsInSRV = 1 << 26
FlagsAnalysisMode = 1<<27 # Hud in Analysis mode FlagsAnalysisMode = 1 << 27 # Hud in Analysis mode
FlagsNightVision = 1<<28 FlagsNightVision = 1 << 28
FlagsAverageAltitude = 1<<29 # Altitude from Average radius FlagsAverageAltitude = 1 << 29 # Altitude from Average radius
FlagsFsdJump = 1<<30 FlagsFsdJump = 1 << 30
FlagsSrvHighBeam = 1<<31 FlagsSrvHighBeam = 1 << 31
# Dashboard GuiFocus constants # Dashboard GuiFocus constants
GuiFocusNoFocus = 0 GuiFocusNoFocus = 0
@ -79,7 +81,7 @@ last_error = {
class Plugin(object): class Plugin(object):
def __init__(self, name, loadfile): def __init__(self, name: str, loadfile: str, plugin_logger: Optional[logging.Logger]):
""" """
Load a single plugin Load a single plugin
:param name: module name :param name: module name
@ -90,25 +92,28 @@ class Plugin(object):
self.name = name # Display name. self.name = name # Display name.
self.folder = name # basename of plugin folder. None for internal plugins. self.folder = name # basename of plugin folder. None for internal plugins.
self.module = None # None for disabled plugins. self.module = None # None for disabled plugins.
self.logger = plugin_logger
if loadfile: if loadfile:
sys.stdout.write('loading plugin {} from "{}"\n'.format(name.replace('.', '_'), loadfile)) logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"')
try: 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): if getattr(module, 'plugin_start3', None):
newname = module.plugin_start3(os.path.dirname(loadfile)) newname = module.plugin_start3(os.path.dirname(loadfile))
self.name = newname and str(newname) or name self.name = newname and str(newname) or name
self.module = module self.module = module
elif getattr(module, 'plugin_start', None): 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) PLUGINS_not_py3.append(self)
else: else:
sys.stdout.write('plugin %s has no plugin_start3() function\n' % name) logger.error(f'plugin {name} has no plugin_start3() function')
except: except Exception as e:
print_exc() logger.exception(f': Failed for Plugin "{name}"')
raise raise
else: else:
sys.stdout.write('plugin %s disabled\n' % name) logger.info(f'plugin {name} disabled')
def _get_func(self, funcname): def _get_func(self, funcname):
""" """
@ -136,8 +141,8 @@ class Plugin(object):
elif not isinstance(appitem, tk.Widget): elif not isinstance(appitem, tk.Widget):
raise AssertionError raise AssertionError
return appitem return appitem
except: except Exception as e:
print_exc() logger.exception(f'Failed for Plugin "{self.name}"')
return None return None
def get_prefs(self, parent, cmdr, is_beta): def get_prefs(self, parent, cmdr, is_beta):
@ -156,8 +161,8 @@ class Plugin(object):
if not isinstance(frame, nb.Frame): if not isinstance(frame, nb.Frame):
raise AssertionError raise AssertionError
return frame return frame
except: except Exception as e:
print_exc() logger.exception(f'Failed for Plugin "{self.name}"')
return None return None
@ -171,12 +176,12 @@ def load_plugins(master):
for name in sorted(os.listdir(config.internal_plugin_dir)): for name in sorted(os.listdir(config.internal_plugin_dir)):
if name.endswith('.py') and not name[0] in ['.', '_']: if name.endswith('.py') and not name[0] in ['.', '_']:
try: try:
plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name)) plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name), logger)
plugin.folder = None # Suppress listing in Plugins prefs tab plugin.folder = None # Suppress listing in Plugins prefs tab
internal.append(plugin) internal.append(plugin)
except: except Exception as e:
pass logger.exception(f'Failure loading internal Plugin "{name}"')
PLUGINS.extend(sorted(internal, key = lambda p: operator.attrgetter('name')(p).lower())) 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 # Add plugin folder to load path so packages can be loaded from plugin folder
sys.path.append(config.plugin_dir) sys.path.append(config.plugin_dir)
@ -189,15 +194,22 @@ def load_plugins(master):
pass pass
elif name.endswith('.disabled'): elif name.endswith('.disabled'):
name, discard = name.rsplit('.', 1) name, discard = name.rsplit('.', 1)
found.append(Plugin(name, None)) found.append(Plugin(name, None, logger))
else: else:
try: try:
# Add plugin's folder to load path in case plugin has internal package dependencies # Add plugin's folder to load path in case plugin has internal package dependencies
sys.path.append(os.path.join(config.plugin_dir, name)) 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 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): def provides(fn_name):
""" """
@ -240,8 +252,8 @@ def notify_stop():
try: try:
newerror = plugin_stop() newerror = plugin_stop()
error = error or newerror error = error or newerror
except: except Exception as e:
print_exc() logger.exception(f'Plugin "{plugin.name}" failed')
return error return error
@ -257,8 +269,8 @@ def notify_prefs_cmdr_changed(cmdr, is_beta):
if prefs_cmdr_changed: if prefs_cmdr_changed:
try: try:
prefs_cmdr_changed(cmdr, is_beta) prefs_cmdr_changed(cmdr, is_beta)
except: except Exception as e:
print_exc() logger.exception(f'Plugin "{plugin.name}" failed')
def notify_prefs_changed(cmdr, is_beta): def notify_prefs_changed(cmdr, is_beta):
@ -275,8 +287,8 @@ def notify_prefs_changed(cmdr, is_beta):
if prefs_changed: if prefs_changed:
try: try:
prefs_changed(cmdr, is_beta) prefs_changed(cmdr, is_beta)
except: except Exception as e:
print_exc() logger.exception(f'Plugin "{plugin.name}" failed')
def notify_journal_entry(cmdr, is_beta, system, station, entry, state): 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 # 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)) newerror = journal_entry(cmdr, is_beta, system, station, dict(entry), dict(state))
error = error or newerror error = error or newerror
except: except Exception as e:
print_exc() logger.exception(f'Plugin "{plugin.name}" failed')
return error 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 # Pass a copy of the status entry in case the callee modifies it
newerror = status(cmdr, is_beta, dict(entry)) newerror = status(cmdr, is_beta, dict(entry))
error = error or newerror error = error or newerror
except: except Exception as e:
print_exc() logger.exception(f'Plugin "{plugin.name}" failed')
return error return error
@ -338,8 +350,8 @@ def notify_newdata(data, is_beta):
try: try:
newerror = cmdr_data(data, is_beta) newerror = cmdr_data(data, is_beta)
error = error or newerror error = error or newerror
except: except Exception as e:
print_exc() logger.exception(f'Plugin "{plugin.name}" failed')
return error return error

View File

@ -7,17 +7,21 @@ import io
# Migrate settings from <= 3.01 # Migrate settings from <= 3.01
from config import config from config import config
if not config.get('shipyard_provider') and config.getint('shipyard'): if not config.get('shipyard_provider') and config.getint('shipyard'):
config.set('shipyard_provider', 'Coriolis') config.set('shipyard_provider', 'Coriolis')
config.delete('shipyard') config.delete('shipyard')
def plugin_start3(plugin_dir): def plugin_start3(_):
return 'Coriolis' return 'Coriolis'
# Return a URL for the current ship
def shipyard_url(loadout, is_beta): 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: if not string:
return False return False
@ -25,4 +29,7 @@ def shipyard_url(loadout, is_beta):
with gzip.GzipFile(fileobj=out, mode='w') as f: with gzip.GzipFile(fileobj=out, mode='w') as f:
f.write(string) 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}'

View File

@ -24,23 +24,27 @@
import sys import sys
from typing import Any, Optional, TYPE_CHECKING
import requests import requests
from config import config 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 # Main window clicks
this.system_link = None this.system_link: Optional[str] = None
this.system = None this.system: Optional[str] = None
this.system_address = None this.system_address: Optional[str] = None
this.system_population = None this.system_population: Optional[int] = None
this.station_link = None this.station_link: 'Optional[Tk]' = None
this.station = None this.station: Optional[str] = None
this.station_marketid = None this.station_marketid: Optional[int] = None
def system_url(system_name: str) -> str: 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): def plugin_start3(plugin_dir):
return 'eddb' return 'eddb'
def plugin_app(parent):
def plugin_app(parent: 'Tk'):
this.system_link = parent.children['system'] # system label in main window this.system_link = parent.children['system'] # system label in main window
this.system = None this.system = None
this.system_address = None this.system_address = None
this.station = None this.station = None
this.station_marketid = None # Frontier MarketID this.station_marketid = None # Frontier MarketID
this.station_link = parent.children['station'] # station label in main window 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): def prefs_changed(cmdr, is_beta):
# Do *NOT* set 'url' here, as it's set to a function that will call # Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string. # through correctly. We don't want a static string.
pass pass
def journal_entry(cmdr, is_beta, system, station, entry, state): 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 # 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 # 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. # But only actually change the URL if we are current station provider.
if config.get('station_provider') == 'eddb': 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 # Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string. # through correctly. We don't want a static string.
this.station_link.update_idletasks() 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): def cmdr_data(data, is_beta):
# Always store initially, even if we're not the *current* system provider. # Always store initially, even if we're not the *current* system provider.
if not this.station_marketid: if not this.station_marketid and data['commander']['docked']:
this.station_marketid = data['commander']['docked'] and data['lastStarport']['id'] this.station_marketid = data['lastStarport']['id']
# Only trust CAPI if these aren't yet set # Only trust CAPI if these aren't yet set
this.system = this.system or data['lastSystem']['name'] if not this.system:
this.station = this.station or data['commander']['docked'] and data['lastStarport']['name'] this.system = data['lastSystem']['name']
if not this.station and data['commander']['docked']:
this.station = data['lastStarport']['name']
# Override standard URL functions # Override standard URL functions
if config.get('system_provider') == 'eddb': 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 # Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string. # through correctly. We don't want a static string.
this.system_link.update_idletasks() this.system_link.update_idletasks()
if config.get('station_provider') == 'eddb': if config.get('station_provider') == 'eddb':
if data['commander']['docked']: if data['commander']['docked']:
this.station_link['text'] = this.station this.station_link['text'] = this.station
elif data['lastStarport']['name'] and data['lastStarport']['name'] != "": elif data['lastStarport']['name'] and data['lastStarport']['name'] != "":
this.station_link['text'] = STATION_UNDOCKED this.station_link['text'] = STATION_UNDOCKED
else: else:
this.station_link['text'] = '' this.station_link['text'] = ''
# Do *NOT* set 'url' here, as it's set to a function that will call # Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string. # through correctly. We don't want a static string.
this.station_link.update_idletasks() this.station_link.update_idletasks()

View File

@ -1,32 +1,40 @@
# Export to EDDN # Export to EDDN
from collections import OrderedDict import itertools
import json 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 os.path import exists, join
from platform import system 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 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 prefs import prefsVersion
from ttkHyperlinkLabel import HyperlinkLabel
if sys.platform != 'win32': if sys.platform != 'win32':
from fcntl import lockf, LOCK_EX, LOCK_NB from fcntl import lockf, LOCK_EX, LOCK_NB
if __debug__:
from traceback import print_exc
from config import applongname, appversion, config if TYPE_CHECKING:
from companion import category_map 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 # Track location to add to Journal events
this.systemaddress = None this.systemaddress = None
@ -35,130 +43,180 @@ this.planet = None
# Avoid duplicates # Avoid duplicates
this.marketId = None 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' SERVER = 'https://eddn.edcd.io:4430'
UPLOAD = '%s/upload/' % SERVER UPLOAD = f'{SERVER}/upload/'
REPLAYPERIOD = 400 # Roughly two messages per second, accounting for send delays [ms] REPLAYPERIOD = 400 # Roughly two messages per second, accounting for send delays [ms]
REPLAYFLUSH = 20 # Update log on disk roughly every 10 seconds REPLAYFLUSH = 20 # Update log on disk roughly every 10 seconds
TIMEOUT= 10 # requests timeout TIMEOUT = 10 # requests timeout
MODULE_RE = re.compile('^Hpt_|^Int_|Armour_', re.IGNORECASE) MODULE_RE = re.compile(r'^Hpt_|^Int_|Armour_', re.IGNORECASE)
CANONICALISE_RE = re.compile(r'\$(.+)_name;') CANONICALISE_RE = re.compile(r'\$(.+)_name;')
def __init__(self, parent): def __init__(self, parent: tk.Tk):
self.parent = parent self.parent: tk.Tk = parent
self.session = requests.Session() self.session = requests.Session()
self.replayfile = None # For delayed messages self.replayfile: Optional[TextIO] = None # For delayed messages
self.replaylog = [] 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 # Try to obtain exclusive access to the journal cache
filename = join(config.app_dir, 'replay.jsonl') filename = join(config.app_dir, 'replay.jsonl')
try: try:
try: try:
# Try to open existing file # Try to open existing file
self.replayfile = open(filename, 'r+', buffering=1) self.replayfile = open(filename, 'r+', buffering=1)
except:
except Exception:
if exists(filename): if exists(filename):
raise # Couldn't open existing file raise # Couldn't open existing file
else: else:
self.replayfile = open(filename, 'w+', buffering=1) # Create file self.replayfile = open(filename, 'w+', buffering=1) # Create file
if sys.platform != 'win32': # open for writing is automatically exclusive on Windows if sys.platform != 'win32': # open for writing is automatically exclusive on Windows
lockf(self.replayfile, LOCK_EX|LOCK_NB) lockf(self.replayfile, LOCK_EX | LOCK_NB)
except:
if __debug__: print_exc() except Exception as e:
logger.debug('Failed opening "replay.jsonl"', exc_info=e)
if self.replayfile: if self.replayfile:
self.replayfile.close() self.replayfile.close()
self.replayfile = None self.replayfile = None
return False return False
self.replaylog = [line.strip() for line in self.replayfile] self.replaylog = [line.strip() for line in self.replayfile]
return True return True
def flush(self): 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.seek(0, SEEK_SET)
self.replayfile.truncate() self.replayfile.truncate()
for line in self.replaylog: for line in self.replaylog:
self.replayfile.write('%s\n' % line) self.replayfile.write(f'{line}\n')
self.replayfile.flush() self.replayfile.flush()
def close(self): def close(self):
"""
close closes the replay file
"""
if self.replayfile: if self.replayfile:
self.replayfile.close() self.replayfile.close()
self.replayfile = None self.replayfile = None
def send(self, cmdr, msg): def send(self, cmdr: str, msg: Mapping[str, Any]) -> None:
uploaderID = cmdr """
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']), ('$schemaRef', msg['$schemaRef']),
('header', OrderedDict([ ('header', OrderedDict([
('softwareName', '%s [%s]' % (applongname, sys.platform=='darwin' and "Mac OS" or system())), ('softwareName', f'{applongname} [{system() if sys.platform != "darwin" else "Mac OS"}]'),
('softwareVersion', appversion), ('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) r = self.session.post(self.UPLOAD, data=json.dumps(to_send), timeout=self.TIMEOUT)
if __debug__ and r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
print('Status\t%s' % r.status_code) logger.debug(f':\nStatus\t{r.status_code}URL\t{r.url}Headers\t{r.headers}Content:\n{r.text}')
print('URL\t%s' % r.url)
print('Headers\t%s' % r.headers)
print('Content:\n%s' % r.text)
r.raise_for_status() r.raise_for_status()
def sendreplay(self): def sendreplay(self) -> None:
"""
sendreplay updates EDDN with cached journal lines
"""
if not self.replayfile: 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: if not self.replaylog:
status['text'] = '' status['text'] = ''
return return
localized: str = _('Sending data to EDDN...')
if len(self.replaylog) == 1: if len(self.replaylog) == 1:
status['text'] = _('Sending data to EDDN...') status['text'] = localized
else: 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() self.parent.update_idletasks()
try: try:
cmdr, msg = json.loads(self.replaylog[0], object_pairs_hook=OrderedDict) cmdr, msg = json.loads(self.replaylog[0], object_pairs_hook=OrderedDict)
except:
except json.JSONDecodeError as e:
# Couldn't decode - shouldn't happen! # Couldn't decode - shouldn't happen!
if __debug__: logger.debug(f'\n{self.replaylog[0]}\n', exc_info=e)
print(self.replaylog[0]) # Discard and continue
print_exc() self.replaylog.pop(0)
self.replaylog.pop(0) # Discard and continue
else: else:
# Rewrite old schema name # Rewrite old schema name
if msg['$schemaRef'].startswith('http://schemas.elite-markets.net/eddn/'): 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: try:
self.send(cmdr, msg) self.send(cmdr, msg)
self.replaylog.pop(0) self.replaylog.pop(0)
if not len(self.replaylog) % self.REPLAYFLUSH: if not len(self.replaylog) % self.REPLAYFLUSH:
self.flush() self.flush()
except requests.exceptions.RequestException as e: 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") status['text'] = _("Error: Can't connect to EDDN")
return # stop sending return # stop sending
except Exception as e: except Exception as e:
if __debug__: print_exc() logger.debug('Failed sending', exc_info=e)
status['text'] = str(e) status['text'] = str(e)
return # stop sending return # stop sending
self.parent.after(self.REPLAYPERIOD, self.sendreplay) self.parent.after(self.REPLAYPERIOD, self.sendreplay)
def export_commodities(self, data, is_beta): def export_commodities(self, data: Mapping[str, Any], is_beta: bool) -> None:
commodities = [] """
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 []: for commodity in data['lastStarport'].get('commodities') or []:
if (category_map.get(commodity['categoryname'], True) and # Check marketable # Check 'marketable' and 'not prohibited'
not commodity.get('legality')): # check not prohibited if (category_map.get(commodity['categoryname'], True)
and not commodity.get('legality')):
commodities.append(OrderedDict([ commodities.append(OrderedDict([
('name', commodity['name'].lower()), ('name', commodity['name'].lower()),
('meanPrice', int(commodity['meanPrice'])), ('meanPrice', int(commodity['meanPrice'])),
@ -169,41 +227,68 @@ class EDDN(object):
('demand', int(commodity['demand'])), ('demand', int(commodity['demand'])),
('demandBracket', commodity['demandBracket']), ('demandBracket', commodity['demandBracket']),
])) ]))
if commodity['statusFlags']: if commodity['statusFlags']:
commodities[-1]['statusFlags'] = commodity['statusFlags'] commodities[-1]['statusFlags'] = commodity['statusFlags']
commodities.sort(key = lambda c: c['name'])
commodities.sort(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
message = OrderedDict([ message: OrderedDictT[str, Any] = OrderedDict([
('timestamp', data['timestamp']), ('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']), ('systemName', data['lastSystem']['name']),
('stationName', data['lastStarport']['name']), ('stationName', data['lastStarport']['name']),
('marketId', data['lastStarport']['id']), ('marketId', data['lastStarport']['id']),
('commodities', commodities), ('commodities', commodities),
]) ])
if 'economies' in data['lastStarport']: 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']: 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'], { self.send(data['commander']['name'], {
'$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''), '$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}',
'message' : message, 'message': message,
}) })
this.commodities = commodities this.commodities = commodities
def export_outfitting(self, data, is_beta): def export_outfitting(self, data: Mapping[str, Any], is_beta: bool) -> None:
economies = data['lastStarport'].get('economies') or {} """
modules = data['lastStarport'].get('modules') or {} export_outfitting updates EDDN with the current (lastStarport) station's outfitting options, if any.
ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] } Once the send is complete, this.outfitting is updated with the given data.
# 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 :param data: dict containing the outfitting data
any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or :param is_beta: whether or not we're currently in beta mode
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']) modules: Dict[str, Any] = data['lastStarport'].get('modules') or {}
if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it
# 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'], { self.send(data['commander']['name'], {
'$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''), '$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
'message' : OrderedDict([ 'message': OrderedDict([
('timestamp', data['timestamp']), ('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']), ('systemName', data['lastSystem']['name']),
('stationName', data['lastStarport']['name']), ('stationName', data['lastStarport']['name']),
@ -212,20 +297,35 @@ class EDDN(object):
('modules', outfitting), ('modules', outfitting),
]), ]),
}) })
this.outfitting = (horizons, outfitting) this.outfitting = (horizons, outfitting)
def export_shipyard(self, data, is_beta): def export_shipyard(self, data: Dict[str, Any], is_beta: bool) -> None:
economies = data['lastStarport'].get('economies') or {} """
modules = data['lastStarport'].get('modules') or {} export_shipyard updates EDDN with the current (lastStarport) station's outfitting options, if any.
ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] } once the send is complete, this.shipyard is updated to the new data.
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 :param data: dict containing the shipyard data
any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values()))) :param is_beta: whether or not we are in beta mode
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. 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'], { self.send(data['commander']['name'], {
'$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''), '$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
'message' : OrderedDict([ 'message': OrderedDict([
('timestamp', data['timestamp']), ('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']), ('systemName', data['lastSystem']['name']),
('stationName', data['lastStarport']['name']), ('stationName', data['lastStarport']['name']),
@ -234,11 +334,20 @@ class EDDN(object):
('ships', shipyard), ('ships', shipyard),
]), ]),
}) })
this.shipyard = (horizons, shipyard) this.shipyard = (horizons, shipyard)
def export_journal_commodities(self, cmdr, is_beta, entry): def export_journal_commodities(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
items = entry.get('Items') or [] """
commodities = sorted([OrderedDict([ 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'])), ('name', self.canonicalise(commodity['Name'])),
('meanPrice', commodity['MeanPrice']), ('meanPrice', commodity['MeanPrice']),
('buyPrice', commodity['BuyPrice']), ('buyPrice', commodity['BuyPrice']),
@ -247,12 +356,12 @@ class EDDN(object):
('sellPrice', commodity['SellPrice']), ('sellPrice', commodity['SellPrice']),
('demand', commodity['Demand']), ('demand', commodity['Demand']),
('demandBracket', commodity['DemandBracket']), ('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, { self.send(cmdr, {
'$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''), '$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}',
'message' : OrderedDict([ 'message': OrderedDict([
('timestamp', entry['timestamp']), ('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']), ('systemName', entry['StarSystem']),
('stationName', entry['StationName']), ('stationName', entry['StationName']),
@ -260,16 +369,31 @@ class EDDN(object):
('commodities', commodities), ('commodities', commodities),
]), ]),
}) })
this.commodities = commodities
def export_journal_outfitting(self, cmdr, is_beta, entry): this.commodities: OrderedDictT[str, Any] = commodities
modules = entry.get('Items') or []
horizons = entry.get('Horizons', False) def export_journal_outfitting(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
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 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, { self.send(cmdr, {
'$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''), '$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
'message' : OrderedDict([ 'message': OrderedDict([
('timestamp', entry['timestamp']), ('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']), ('systemName', entry['StarSystem']),
('stationName', entry['StationName']), ('stationName', entry['StationName']),
@ -278,16 +402,26 @@ class EDDN(object):
('modules', outfitting), ('modules', outfitting),
]), ]),
}) })
this.outfitting = (horizons, outfitting) this.outfitting = (horizons, outfitting)
def export_journal_shipyard(self, cmdr, is_beta, entry): def export_journal_shipyard(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
ships = entry.get('PriceList') or [] """
horizons = entry.get('Horizons', False) export_journal_shipyard updates EDDN with station shipyard data based on a journal entry. As a side effect,
shipyard = sorted([ship['ShipType'] for ship in ships]) this.shipyard is updated with the data.
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.
: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, { self.send(cmdr, {
'$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''), '$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
'message' : OrderedDict([ 'message': OrderedDict([
('timestamp', entry['timestamp']), ('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']), ('systemName', entry['StarSystem']),
('stationName', entry['StationName']), ('stationName', entry['StationName']),
@ -296,130 +430,206 @@ class EDDN(object):
('ships', shipyard), ('ships', shipyard),
]), ]),
}) })
this.shipyard = (horizons, 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 = { msg = {
'$schemaRef' : 'https://eddn.edcd.io/schemas/journal/1' + (is_beta and '/test' or ''), '$schemaRef': f'https://eddn.edcd.io/schemas/journal/1{"/test" if is_beta else ""}',
'message' : entry 'message': entry
} }
if self.replayfile or self.load():
if self.replayfile or self.load_journal_replay():
# Store the entry # Store the entry
self.replaylog.append(json.dumps([cmdr, msg])) 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 if (
(entry['event'] == 'Location' and entry['Docked']) or entry['event'] == 'Docked' or (entry['event'] == 'Location' and entry['Docked']) or not
not (config.getint('output') & config.OUT_SYS_DELAY)): (config.getint('output') & config.OUT_SYS_DELAY)
):
self.parent.after(self.REPLAYPERIOD, self.sendreplay) # Try to send this and previous entries self.parent.after(self.REPLAYPERIOD, self.sendreplay) # Try to send this and previous entries
else: else:
# Can't access replay file! Send immediately. # 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...') status['text'] = _('Sending data to EDDN...')
self.parent.update_idletasks() self.parent.update_idletasks()
self.send(cmdr, msg) self.send(cmdr, msg)
status['text'] = '' status['text'] = ''
def canonicalise(self, item): def canonicalise(self, item: str) -> str:
match = self.CANONICALISE_RE.match(item) match = self.CANONICALISE_RE.match(item)
return match and match.group(1) or item return match and match.group(1) or item
# Plugin callbacks # Plugin callbacks
def plugin_start3(plugin_dir): def plugin_start3(plugin_dir: str) -> str:
return 'EDDN' return 'EDDN'
def plugin_app(parent):
def plugin_app(parent: tk.Tk) -> None:
this.parent = parent this.parent = parent
this.eddn = EDDN(parent) this.eddn = EDDN(parent)
# Try to obtain exclusive lock on journal cache, even if we don't need it yet # Try to obtain exclusive lock on journal cache, even if we don't need it yet
if not this.eddn.load(): if not this.eddn.load_journal_replay():
this.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing # 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 def plugin_prefs(parent, cmdr: str, is_beta: bool) -> Frame:
BUTTONX = 12 # indent Checkbuttons and Radiobuttons PADX = 10 # noqa: N806
PADY = 2 # close spacing BUTTONX = 12 # noqa: N806 # indent Checkbuttons and Radiobuttons
if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.getint('output'))): 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: else:
output = config.getint('output') output: int = config.getint('output')
eddnframe = nb.Frame(parent) 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 HyperlinkLabel(
this.eddn_station= tk.IntVar(value = (output & config.OUT_MKT_EDDN) and 1) eddnframe,
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 text='Elite Dangerous Data Network',
this.eddn_station_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) background=nb.Label().cget('background'),
this.eddn_system = tk.IntVar(value = (output & config.OUT_SYS_EDDN) and 1) url='https://github.com/EDSM-NET/EDDN/wiki',
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 underline=True
this.eddn_system_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) ).grid(padx=PADX, sticky=tk.W) # Don't translate
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 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) this.eddn_delay_button.grid(padx=BUTTONX, sticky=tk.W)
return eddnframe return eddnframe
def prefsvarchanged(event=None):
def prefsvarchanged(event=None) -> None:
this.eddn_station_button['state'] = tk.NORMAL 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 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', def prefs_changed(cmdr: str, is_beta: bool) -> None:
(config.getint('output') & (config.OUT_MKT_TD | config.OUT_MKT_CSV | config.OUT_SHIP |config.OUT_MKT_MANUAL)) + 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_station.get() and config.OUT_MKT_EDDN) +
(this.eddn_system.get() and config.OUT_SYS_EDDN) + (this.eddn_system.get() and config.OUT_SYS_EDDN) +
(this.eddn_delay.get() and config.OUT_SYS_DELAY)) (this.eddn_delay.get() and config.OUT_SYS_DELAY)
)
def plugin_stop():
def plugin_stop() -> None:
this.eddn.close() 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 # Recursively filter '*_Localised' keys from dict
def filter_localised(d): def filter_localised(d: Mapping[str, Any]) -> OrderedDictT[str, Any]:
filtered = OrderedDict() filtered: OrderedDictT[str, Any] = OrderedDict()
for k, v in d.items(): for k, v in d.items():
if k.endswith('_Localised'): if k.endswith('_Localised'):
pass pass
elif hasattr(v, 'items'): # dict -> recurse elif hasattr(v, 'items'): # dict -> recurse
filtered[k] = filter_localised(v) 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] filtered[k] = [filter_localised(x) if hasattr(x, 'items') else x for x in v]
else: else:
filtered[k] = v filtered[k] = v
return filtered return filtered
# Track location # Track location
if entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']: if entry['event'] in ('Location', 'FSDJump', 'Docked', 'CarrierJump'):
if entry['event'] in ('Location', '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': elif entry['event'] == 'FSDJump':
this.planet = None this.planet: Optional[str] = None
if 'StarPos' in entry: 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'): elif this.systemaddress != entry.get('SystemAddress'):
this.coordinates = None # Docked event doesn't include coordinates this.coordinates: Optional[Tuple[int, int, int]] = None # Docked event doesn't include coordinates
this.systemaddress = entry.get('SystemAddress')
this.systemaddress: Optional[str] = entry.get('SystemAddress')
elif entry['event'] == 'ApproachBody': elif entry['event'] == 'ApproachBody':
this.planet = entry['Body'] this.planet = entry['Body']
elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']:
elif entry['event'] in ('LeaveBody', 'SupercruiseEntry'):
this.planet = None this.planet = None
# Send interesting events to EDDN, but not when on a crew # Send interesting events to EDDN, but not when on a crew
if (config.getint('output') & config.OUT_SYS_EDDN and not state['Captain'] and if (config.getint('output') & config.OUT_SYS_EDDN and not state['Captain'] and
(entry['event'] in ('Location', 'FSDJump', 'Docked', 'Scan', 'SAASignalsFound', 'CarrierJump')) 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 # 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) entry.pop(thing, None)
if 'Factions' in entry: if 'Factions' in entry:
# Filter faction state. `entry` is a shallow copy so replace 'Factions' value rather than modify in-place. # Filter faction state to comply with schema restrictions regarding personal data. `entry` is a shallow copy
entry['Factions'] = [ {k: v for k, v in f.items() if k not in ['HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction']} for f in entry['Factions']] # 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 # add planet to Docked event for planetary stations if known
if entry['event'] == 'Docked' and this.planet: 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 # add mandatory StarSystem, StarPos and SystemAddress properties to Scan events
if 'StarSystem' not in entry: if 'StarSystem' not in entry:
if not system: 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 entry['StarSystem'] = system
if 'StarPos' not in entry: if 'StarPos' not in entry:
if not this.coordinates: 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) entry['StarPos'] = list(this.coordinates)
if 'SystemAddress' not in entry: if 'SystemAddress' not in entry:
if not this.systemaddress: 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 entry['SystemAddress'] = this.systemaddress
try: try:
this.eddn.export_journal_entry(cmdr, is_beta, filter_localised(entry)) this.eddn.export_journal_entry(cmdr, is_beta, filter_localised(entry))
except requests.exceptions.RequestException as e: 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") return _("Error: Can't connect to EDDN")
except Exception as e: except Exception as e:
if __debug__: print_exc() logger.debug('Failed in export_journal_entry', exc_info=e)
return str(e) return str(e)
elif (config.getint('output') & config.OUT_MKT_EDDN and not state['Captain'] and 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: try:
if this.marketId != entry['MarketID']: if this.marketId != entry['MarketID']:
this.commodities = this.outfitting = this.shipyard = None this.commodities = this.outfitting = this.shipyard = None
this.marketId = entry['MarketID'] this.marketId = entry['MarketID']
with open(join(config.get('journaldir') or config.default_journal_dir, '%s.json' % entry['event']), 'rb') as h: path = pathlib.Path(str(config.get('journaldir') or config.default_journal_dir)) / f'{entry["event"]}.json'
entry = json.load(h) with path.open('rb') as f:
entry = json.load(f)
if entry['event'] == 'Market': if entry['event'] == 'Market':
this.eddn.export_journal_commodities(cmdr, is_beta, entry) this.eddn.export_journal_commodities(cmdr, is_beta, entry)
elif entry['event'] == 'Outfitting': elif entry['event'] == 'Outfitting':
this.eddn.export_journal_outfitting(cmdr, is_beta, entry) this.eddn.export_journal_outfitting(cmdr, is_beta, entry)
elif entry['event'] == 'Shipyard': elif entry['event'] == 'Shipyard':
this.eddn.export_journal_shipyard(cmdr, is_beta, entry) this.eddn.export_journal_shipyard(cmdr, is_beta, entry)
except requests.exceptions.RequestException as e: 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") return _("Error: Can't connect to EDDN")
except Exception as e: except Exception as e:
if __debug__: print_exc() logger.debug(f'Failed exporting {entry["event"]}', exc_info=e)
return str(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: if data['commander'].get('docked') and config.getint('output') & config.OUT_MKT_EDDN:
try: try:
if this.marketId != data['lastStarport']['id']: if this.marketId != data['lastStarport']['id']:
@ -484,6 +710,7 @@ def cmdr_data(data, is_beta):
if not old_status: if not old_status:
status['text'] = _('Sending data to EDDN...') status['text'] = _('Sending data to EDDN...')
status.update_idletasks() status.update_idletasks()
this.eddn.export_commodities(data, is_beta) this.eddn.export_commodities(data, is_beta)
this.eddn.export_outfitting(data, is_beta) this.eddn.export_outfitting(data, is_beta)
this.eddn.export_shipyard(data, is_beta) this.eddn.export_shipyard(data, is_beta)
@ -492,9 +719,20 @@ def cmdr_data(data, is_beta):
status.update_idletasks() status.update_idletasks()
except requests.RequestException as e: except requests.RequestException as e:
if __debug__: print_exc() logger.debug('Failed exporting data', exc_info=e)
return _("Error: Can't connect to EDDN") return _("Error: Can't connect to EDDN")
except Exception as e: except Exception as e:
if __debug__: print_exc() logger.debug('Failed exporting data', exc_info=e)
return str(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())
)

View File

@ -16,16 +16,16 @@ import requests
import sys import sys
from queue import Queue from queue import Queue
from threading import Thread from threading import Thread
import logging
import tkinter as tk import tkinter as tk
from ttkHyperlinkLabel import HyperlinkLabel from ttkHyperlinkLabel import HyperlinkLabel
import myNotebook as nb import myNotebook as nb # noqa: N813
from config import appname, applongname, appversion, config from config import appname, applongname, appversion, config
import plug import plug
if __debug__: logger = logging.getLogger(appname)
from traceback import print_exc
EDSM_POLL = 0.1 EDSM_POLL = 0.1
_TIMEOUT = 20 _TIMEOUT = 20
@ -371,23 +371,28 @@ def worker():
r.raise_for_status() r.raise_for_status()
reply = r.json() reply = r.json()
(msgnum, msg) = reply['msgnum'], reply['msg'] (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: 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)) plug.show_error(_('Error: EDSM {MSG}').format(MSG=msg))
else: else:
for e, r in zip(pending, reply['events']): for e, r in zip(pending, reply['events']):
if not closing and e['event'] in ['StartUp', 'Location', 'FSDJump', 'CarrierJump']: if not closing and e['event'] in ['StartUp', 'Location', 'FSDJump', 'CarrierJump']:
# Update main window's system status # Update main window's system status
this.lastlookup = r this.lastlookup = r
this.system_link.event_generate('<<EDSMStatus>>', when="tail") # calls update_status in main thread # calls update_status in main thread
this.system_link.event_generate('<<EDSMStatus>>', when="tail")
elif r['msgnum'] // 100 != 1: 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 = [] pending = []
break break
except: except Exception as e:
if __debug__: print_exc() logger.debug('Sending API events', exc_info=e)
retrying += 1 retrying += 1
else: else:
plug.show_error(_("Error: Can't connect to EDSM")) plug.show_error(_("Error: Can't connect to EDSM"))

File diff suppressed because it is too large Load Diff

6
pyproject.toml Normal file
View File

@ -0,0 +1,6 @@
[tool.autopep8]
max_line_length = 120
[tool.isort]
multi_line_output = 5
line_length = 119

View File

@ -6,6 +6,7 @@ flake8-comprehensions==3.2.3
flake8-pep3101==1.3.0 flake8-pep3101==1.3.0
flake8-polyfill==1.0.2 flake8-polyfill==1.0.2
flake8-json flake8-json
flake8-isort==3.0.1
pep8-naming==0.11.1 pep8-naming==0.11.1
# Code formatting tools # Code formatting tools

40
timeout_session.py Normal file
View File

@ -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