1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-06-03 00:51:11 +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:
#
# install Python dependencies
# Run flake8 to add annotations to the PR
# 1. Store some github context in env vars.
# 2. Only checkout if base and head ref owners are the same.
# 3. Check if any *.py files are in the diff.
# 4. Only then install python and perform the annotation with flake8.
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: PR-annotate-flake8
@ -11,19 +13,68 @@ on:
branches: [ develop ]
jobs:
build:
flake8_annotate:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
##################################################################
# Check if the base and head repos are the same, if not we won't
# be able to add annotations.
##################################################################
- name: Check head and base repo same
env:
BASE_REPO_OWNER: ${{github.event.pull_request.base.repo.owner.login}}
HEAD_REPO_OWNER: ${{github.event.pull_request.head.repo.owner.login}}
BASE_REF: ${{github.base_ref}}
run: |
# Just to be running something
env
##################################################################
##################################################################
# Perform the checkout
##################################################################
- name: Checkout
if: ${{ github.event.pull_request.base.repo.owner.login == github.event.pull_request.head.repo.owner.login }}
uses: actions/checkout@v2
with:
fetch-depth: 0
##################################################################
##################################################################
# flake8-your-pr 'fails' if no *.py files changed
# So we check if any were affected and store that in env
##################################################################
- name: Check for PY files
if: ${{ github.event.pull_request.base.repo.owner.login == github.event.pull_request.head.repo.owner.login }}
run: |
# Checkout might not have happened.
if [ ! -d ".git" ]; then exit 0 ; fi
# Get a list of files ending with ".py", stuff in environment.
# We don't rely on exit status because Workflows run with
# "set -e" so any failure in a pipe is total failure.
PYFILES=$(git diff --name-only "refs/remotes/origin/${BASE_REF}" -- | egrep '.py$' || true)
# Use magic output to store in env for rest of workflow
echo "::set-env name=PYFILES::${PYFILES}"
##################################################################
##################################################################
# Get Python set up
##################################################################
- name: Set up Python 3.7
if: ${{ env.PYFILES != '' }}
uses: actions/setup-python@v2
with:
python-version: 3.7
##################################################################
##################################################################
# Perform the annotation
##################################################################
- name: Annotate with Flake8
# Only if at least one *.py file was affected
if: ${{ env.PYFILES != '' }}
uses: "tayfun/flake8-your-pr@master"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
##################################################################

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
EDMarketConnector.VisualElementsManifest.xml
*.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:
```python
if somethingTrue:
Things_we_then_do()
if something_true:
one_thing_we_do()
```
No:
```python
if somethingTrue: One_thing_we_do()
if something_true: one_thing_we_do()
```
Yes, some existing code still flouts this rule.
@ -224,6 +224,43 @@ No:
* Going forwards please do place [type hints](https://docs.python.org/3/library/typing.html) on the declarations of your functions, both their arguments and return
types.
* Use `logging` not `print()`, and definitely not `sys.stdout.write()`!
`EDMarketConnector.py` sets up a `logging.Logger` for this under the
`appname`, so:
import logging
from config import appname
logger = logging.getLogger(appname)
logger.info(f'Some message with a {variable}')
try:
something
except Exception as e: # Try to be more specific
logger.error(f'Error in ... with ...', exc_info=e)
**DO NOT** use the following, as you might cause a circular import:
from EDMarketConnector import logger
We have implemented a `logging.Filter` that adds support for the following
in `logging.Formatter()` strings:
1. `%(qualname)s` which gets the full `<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/).
---

174
EDMC.py
View File

@ -5,9 +5,9 @@
import argparse
import json
import requests
import sys
import os
from typing import Any, Optional
# workaround for https://github.com/EDCD/EDMarketConnector/issues/568
os.environ["EDMC_NO_UI"] = "1"
@ -39,14 +39,39 @@ import eddn
SERVER_RETRY = 5 # retry pause for Companion servers [s]
EXIT_SUCCESS, EXIT_SERVER, EXIT_CREDENTIALS, EXIT_VERIFICATION, EXIT_LAGGING, EXIT_SYS_ERR = range(6)
JOURNAL_RE = re.compile(r'^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$')
# quick and dirty version comparison assuming "strict" numeric only version numbers
def versioncmp(versionstring):
return list(map(int, versionstring.split('.')))
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
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('-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')
@ -63,11 +88,9 @@ try:
if args.version:
updater = Updater(provider='internal')
newversion: EDMCVersion = updater.check_appcast()
newversion: Optional[EDMCVersion] = updater.check_appcast()
if newversion:
print('{CURRENT} ("{UPDATE}" is available)'.format(
CURRENT=appversion,
UPDATE=newversion.title))
print(f'{appversion} ({newversion.title!r} is available)')
else:
print(appversion)
sys.exit(EXIT_SUCCESS)
@ -76,26 +99,29 @@ try:
# Import and collate from JSON dump
data = json.load(open(args.j))
config.set('querytime', int(getmtime(args.j)))
else:
# Get state from latest Journal file
try:
logdir = config.get('journaldir') or config.default_journal_dir
logfiles = sorted([x for x in os.listdir(logdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)],
key=lambda x: x.split('.')[1:])
logfiles = sorted((x for x in os.listdir(logdir) if JOURNAL_RE.search(x)), key=lambda x: x.split('.')[1:])
logfile = join(logdir, logfiles[-1])
with open(logfile, 'r') as loghandle:
for line in loghandle:
try:
monitor.parse_entry(line)
except:
except Exception:
if __debug__:
print('Invalid journal entry "%s"' % repr(line))
print(f'Invalid journal entry {line!r}')
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)
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)
# Get data from Companion API
@ -103,73 +129,100 @@ try:
cmdrs = config.get('cmdrs') or []
if args.p in cmdrs:
idx = cmdrs.index(args.p)
else:
for idx, cmdr in enumerate(cmdrs):
if cmdr.lower() == args.p.lower():
break
else:
raise companion.CredentialsError()
companion.session.login(cmdrs[idx], monitor.is_beta)
else:
cmdrs = config.get('cmdrs') or []
if monitor.cmdr not in cmdrs:
raise companion.CredentialsError()
companion.session.login(monitor.cmdr, monitor.is_beta)
querytime = int(time())
data = companion.session.station()
config.set('querytime', querytime)
# Validation
if not data.get('commander') or not data['commander'].get('name','').strip():
sys.stderr.write('Who are you?!\n')
if not deep_get(data, 'commander', 'name', default='').strip():
print('Who are you?!', file=sys.stderr)
sys.exit(EXIT_SERVER)
elif (not data.get('lastSystem', {}).get('name') or
(data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked
sys.stderr.write('Where are you?!\n') # Shouldn't happen
elif not deep_get(data, 'lastSystem', 'name') or \
data['commander'].get('docked') and not \
deep_get(data, 'lastStarport', 'name'): # Only care if docked
print('Where are you?!', file=sys.stderr) # Shouldn't happen
sys.exit(EXIT_SERVER)
elif not 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)
elif args.j:
pass # Skip further validation
elif data['commander']['name'] != monitor.cmdr:
sys.stderr.write('Wrong Cmdr\n') # Companion API return doesn't match Journal
print('Wrong Cmdr', file=sys.stderr) # Companion API return doesn't match Journal
sys.exit(EXIT_CREDENTIALS)
elif ((data['lastSystem']['name'] != monitor.system) or
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or
(data['ship']['id'] != monitor.state['ShipID']) or
(data['ship']['name'].lower() != monitor.state['ShipType'])):
sys.stderr.write('Frontier server is lagging\n')
elif data['lastSystem']['name'] != monitor.system or \
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or \
data['ship']['id'] != monitor.state['ShipID'] or \
data['ship']['name'].lower() != monitor.state['ShipType']:
print('Frontier server is lagging', file=sys.stderr)
sys.exit(EXIT_LAGGING)
# stuff we can do when not docked
if args.d:
with open(args.d, 'wb') as h:
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8'))
out = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': '))
with open(args.d, 'wb') as f:
f.write(out.encode("utf-8"))
if args.a:
loadout.export(data, args.a)
if args.e:
edshipyard.export(data, args.e)
if args.l:
stats.export_ships(data, args.l)
if args.t:
stats.export_status(data, args.t)
if data['commander'].get('docked'):
print('%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:
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 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)
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)
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)
else:
sys.exit(EXIT_SUCCESS)
@ -186,31 +239,39 @@ try:
# Fixup anomalies in the commodity data
fixed = companion.fixup(data)
commodity.export(fixed, COMMODITY_DEFAULT, args.m)
else:
sys.stderr.write("Station doesn't have a market\n")
print("Station doesn't have a market", file=sys.stderr)
if args.o:
if data['lastStarport'].get('modules'):
outfitting.export(data, args.o)
else:
sys.stderr.write("Station doesn't supply outfitting\n")
if (args.s or args.n) and not args.j and not data['lastStarport'].get('ships') and data['lastStarport']['services'].get('shipyard'):
else:
print("Station doesn't supply outfitting", file=sys.stderr)
if (args.s or args.n) and not args.j and not \
data['lastStarport'].get('ships') and data['lastStarport']['services'].get('shipyard'):
# Retry for shipyard
sleep(SERVER_RETRY)
data2 = companion.session.station()
if (data2['commander'].get('docked') and # might have undocked while we were waiting for retry in which case station data is unreliable
data2.get('lastSystem', {}).get('name') == monitor.system and
data2.get('lastStarport', {}).get('name') == monitor.station):
data = data2
new_data = companion.session.station()
# might have undocked while we were waiting for retry in which case station data is unreliable
if new_data['commander'].get('docked') and \
deep_get(new_data, 'lastSystem', 'name') == monitor.system and \
deep_get(new_data, 'lastStarport', 'name') == monitor.station:
data = new_data
if args.s:
if data['lastStarport'].get('ships', {}).get('shipyard_list'):
if deep_get(data, 'lastStarport', 'ships', 'shipyard_list'):
shipyard.export(data, args.s)
elif not args.j and monitor.stationservices and 'Shipyard' in monitor.stationservices:
sys.stderr.write("Failed to get shipyard data\n")
print("Failed to get shipyard data", file=sys.stderr)
else:
sys.stderr.write("Station doesn't have a shipyard\n")
print("Station doesn't have a shipyard", file=sys.stderr)
if args.n:
try:
@ -218,17 +279,24 @@ try:
eddn_sender.export_commodities(data, monitor.is_beta)
eddn_sender.export_outfitting(data, monitor.is_beta)
eddn_sender.export_shipyard(data, monitor.is_beta)
except Exception as e:
sys.stderr.write("Failed to send data to EDDN: %s\n" % unicode(e).encode('ascii', 'replace'))
print(f"Failed to send data to EDDN: {str(e)}", file=sys.stderr)
sys.exit(EXIT_SUCCESS)
except companion.ServerError as e:
sys.stderr.write('Server is down\n')
except companion.ServerError:
print('Server is down', file=sys.stderr)
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)
except companion.CredentialsError as e:
sys.stderr.write('Invalid Credentials\n')
except companion.CredentialsError:
print('Invalid Credentials', file=sys.stderr)
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
import sys
from sys import platform
from collections import OrderedDict
from functools import partial
import json
from os import chdir, environ
from os.path import dirname, expanduser, isdir, join
from os.path import dirname, isdir, join
import re
import html
import requests
from time import gmtime, time, localtime, strftime, strptime
import _strptime # Workaround for http://bugs.python.org/issue7980
from calendar import timegm
from time import time, localtime, strftime
import webbrowser
import EDMCLogging
from config import appname, applongname, appversion, appversion_nobuild, copyright, config
if getattr(sys, 'frozen', False):
@ -36,7 +32,6 @@ import tkinter.messagebox
from ttkHyperlinkLabel import HyperlinkLabel
if __debug__:
from traceback import print_exc
if platform != 'win32':
import pdb
import signal
@ -99,10 +94,10 @@ class AppWindow(object):
if platform == 'win32':
self.w.wm_iconbitmap(default='EDMarketConnector.ico')
else:
self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file = join(config.respath, 'EDMarketConnector.png')))
self.theme_icon = tk.PhotoImage(data = 'R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==')
self.theme_minimize = tk.BitmapImage(data = '#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n')
self.theme_close = tk.BitmapImage(data = '#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n')
self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file=join(config.respath, 'EDMarketConnector.png'))) # noqa: E501
self.theme_icon = tk.PhotoImage(data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501
self.theme_minimize = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
self.theme_close = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
frame = tk.Frame(self.w, name=appname.lower())
frame.grid(sticky=tk.NSEW)
@ -139,14 +134,15 @@ class AppWindow(object):
else:
appitem.grid(columnspan=2, sticky=tk.EW)
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED) # Update button in main window
self.theme_button = tk.Label(frame, width = platform == 'darwin' and 32 or 28, state=tk.DISABLED)
# Update button in main window
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED)
self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED)
self.status = tk.Label(frame, name='status', anchor=tk.W)
row = frame.grid_size()[1]
self.button.grid(row=row, columnspan=2, sticky=tk.NSEW)
self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW)
theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row':row, 'columnspan':2, 'sticky':tk.NSEW})
theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': row, 'columnspan': 2, 'sticky': tk.NSEW}) # noqa: E501
self.status.grid(columnspan=2, sticky=tk.EW)
self.button.bind('<Button-1>', self.getandsend)
theme.button_bind(self.theme_button, self.getandsend)
@ -157,7 +153,8 @@ class AppWindow(object):
self.menubar = tk.Menu()
if platform == 'darwin':
# Can't handle (de)iconify if topmost is set, so suppress iconify button
# http://wiki.tcl.tk/13428 and p15 of https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf
# http://wiki.tcl.tk/13428 and p15 of
# https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf
root.call('tk::unsupported::MacWindowStyle', 'style', root, 'document', 'closeBox resizable')
# https://www.tcl.tk/man/tcl/TkCmd/menu.htm
@ -207,7 +204,7 @@ class AppWindow(object):
self.help_menu.add_command(command=self.help_privacy)
self.help_menu.add_command(command=self.help_releases)
self.help_menu.add_command(command=lambda: self.updater.checkForUpdates())
self.help_menu.add_command(command=lambda:not self.help_about.showing and self.help_about(self.w))
self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w))
self.menubar.add_cascade(menu=self.help_menu)
if platform == 'win32':
@ -215,7 +212,9 @@ class AppWindow(object):
self.always_ontop = tk.BooleanVar(value=config.getint('always_ontop'))
self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE)
self.system_menu.add_separator()
self.system_menu.add_checkbutton(label=_('Always on top'), variable = self.always_ontop, command=self.ontop_changed) # Appearance setting
self.system_menu.add_checkbutton(label=_('Always on top'),
variable=self.always_ontop,
command=self.ontop_changed) # Appearance setting
self.menubar.add_cascade(menu=self.system_menu)
self.w.bind('<Control-c>', self.copy)
self.w.protocol("WM_DELETE_WINDOW", self.onexit)
@ -227,7 +226,9 @@ class AppWindow(object):
# Alternate title bar and menu for dark theme
self.theme_menubar = tk.Frame(frame)
self.theme_menubar.columnconfigure(2, weight=1)
theme_titlebar = tk.Label(self.theme_menubar, text=applongname, image=self.theme_icon, cursor='fleur', anchor=tk.W, compound=tk.LEFT)
theme_titlebar = tk.Label(self.theme_menubar, text=applongname,
image=self.theme_icon, cursor='fleur',
anchor=tk.W, compound=tk.LEFT)
theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW)
self.drag_offset = None
theme_titlebar.bind('<Button-1>', self.drag_start)
@ -241,13 +242,22 @@ class AppWindow(object):
theme.button_bind(theme_close, self.onexit, image=self.theme_close)
self.theme_file_menu = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_file_menu.grid(row=1, column=0, padx=5, sticky=tk.W)
theme.button_bind(self.theme_file_menu, lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height()))
theme.button_bind(self.theme_file_menu,
lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(),
e.widget.winfo_rooty()
+ e.widget.winfo_height()))
self.theme_edit_menu = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_edit_menu.grid(row=1, column=1, sticky=tk.W)
theme.button_bind(self.theme_edit_menu, lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height()))
theme.button_bind(self.theme_edit_menu,
lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(),
e.widget.winfo_rooty()
+ e.widget.winfo_height()))
self.theme_help_menu = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_help_menu.grid(row=1, column=2, sticky=tk.W)
theme.button_bind(self.theme_help_menu, lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(), e.widget.winfo_rooty() + e.widget.winfo_height()))
theme.button_bind(self.theme_help_menu,
lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(),
e.widget.winfo_rooty()
+ e.widget.winfo_height()))
tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=5, sticky=tk.EW)
theme.register(self.theme_minimize) # images aren't automatically registered
theme.register(self.theme_close)
@ -255,23 +265,26 @@ class AppWindow(object):
tk.Label(self.blank_menubar).grid()
tk.Label(self.blank_menubar).grid()
tk.Frame(self.blank_menubar, height=2).grid()
theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar), {'row':0, 'columnspan':2, 'sticky':tk.NSEW})
theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar),
{'row': 0, 'columnspan': 2, 'sticky': tk.NSEW})
self.w.resizable(tk.TRUE, tk.FALSE)
# update geometry
if config.get('geometry'):
match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry'))
match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry')) # noqa: W605
if match:
if platform == 'darwin':
if int(match.group(2)) >= 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if int(match.group(2)) >= 0:
self.w.geometry(config.get('geometry'))
elif platform == 'win32':
# Check that the titlebar will be at least partly on screen
import ctypes
from ctypes.wintypes import POINT
# https://msdn.microsoft.com/en-us/library/dd145064
MONITOR_DEFAULTTONULL = 0
if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16), MONITOR_DEFAULTTONULL):
MONITOR_DEFAULTTONULL = 0 # noqa: N806
if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16),
MONITOR_DEFAULTTONULL):
self.w.geometry(config.get('geometry'))
else:
self.w.geometry(config.get('geometry'))
@ -282,9 +295,9 @@ class AppWindow(object):
self.w.bind('<Map>', self.onmap) # Special handling for overrideredict
self.w.bind('<Enter>', self.onenter) # Special handling for transparency
self.w.bind('<FocusIn>', self.onenter) # "
self.w.bind('<Leave>', self.onleave) # "
self.w.bind('<FocusOut>', self.onleave) # "
self.w.bind('<FocusIn>', self.onenter) # Special handling for transparency
self.w.bind('<Leave>', self.onleave) # Special handling for transparency
self.w.bind('<FocusOut>', self.onleave) # Special handling for transparency
self.w.bind('<Return>', self.getandsend)
self.w.bind('<KP_Enter>', self.getandsend)
self.w.bind_all('<<Invoke>>', self.getandsend) # Hotkey monitoring
@ -321,7 +334,6 @@ class AppWindow(object):
self.postprefs(False) # Companion login happens in callback from monitor
# callback after the Preferences dialog is applied
def postprefs(self, dologin=True):
self.prefsdialog = None
@ -337,7 +349,7 @@ class AppWindow(object):
# (Re-)install log monitoring
if not monitor.start(self.w):
self.status['text'] = 'Error: Check %s' % _('E:D journal file location') # Location of the new Journal file in E:D 2.2
self.status['text'] = f'Error: Check {_("E:D journal file location")}'
if dologin and monitor.cmdr:
self.login() # Login if not already logged in with this Cmdr
@ -345,8 +357,8 @@ class AppWindow(object):
# set main window labels, e.g. after language change
def set_labels(self):
self.cmdr_label['text'] = _('Cmdr') + ':' # Main window
self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or # Multicrew role label in main window
_('Ship')) + ':' # Main window
# Multicrew role label in main window
self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window
self.system_label['text'] = _('System') + ':' # Main window
self.station_label['text'] = _('Station') + ':' # Main window
self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window
@ -370,20 +382,20 @@ class AppWindow(object):
self.theme_edit_menu['text'] = _('Edit') # 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(1, label=_('Save Raw Data...')) # Menu item
self.file_menu.entryconfigure(2, label=_('Settings')) # Item in the File menu on Windows
self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows
## Help menu
# Help menu
self.help_menu.entryconfigure(0, label=_('Documentation')) # Help menu item
self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item
self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item
self.help_menu.entryconfigure(3, label=_('Check for Updates...')) # Menu item
self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry
## Edit menu
# Edit menu
self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste
def login(self):
@ -399,7 +411,8 @@ class AppWindow(object):
self.w.update_idletasks()
try:
if companion.session.login(monitor.cmdr, monitor.is_beta):
self.status['text'] = _('Authentication successful') # Successfully authenticated with the Frontier website
# Successfully authenticated with the Frontier website
self.status['text'] = _('Authentication successful')
if platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -409,7 +422,7 @@ class AppWindow(object):
except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e:
self.status['text'] = str(e)
except Exception as e:
if __debug__: print_exc()
logger.debug('Frontier CAPI Auth', exc_info=e)
self.status['text'] = str(e)
self.cooldown()
@ -447,26 +460,36 @@ class AppWindow(object):
# Validation
if not data.get('commander', {}).get('name'):
self.status['text'] = _("Who are you?!") # Shouldn't happen
elif (not data.get('lastSystem', {}).get('name') or
(data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked
elif (not data.get('lastSystem', {}).get('name')
or (data['commander'].get('docked')
and not data.get('lastStarport', {}).get('name'))): # Only care if docked
self.status['text'] = _("Where are you?!") # Shouldn't happen
elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'):
self.status['text'] = _("What are you flying?!") # Shouldn't happen
elif monitor.cmdr and data['commander']['name'] != monitor.cmdr:
raise companion.CmdrError() # Companion API return doesn't match Journal
elif ((auto_update and not data['commander'].get('docked')) or
(data['lastSystem']['name'] != monitor.system) or
((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or
(data['ship']['id'] != monitor.state['ShipID']) or
(data['ship']['name'].lower() != monitor.state['ShipType'])):
# Companion API return doesn't match Journal
raise companion.CmdrError()
elif ((auto_update and not data['commander'].get('docked'))
or (data['lastSystem']['name'] != monitor.system)
or ((data['commander']['docked']
and data['lastStarport']['name'] or None) != monitor.station)
or (data['ship']['id'] != monitor.state['ShipID'])
or (data['ship']['name'].lower() != monitor.state['ShipType'])):
raise companion.ServerLagging()
else:
if __debug__: # Recording
if isdir('dump'):
with open('dump/%s%s.%s.json' % (data['lastSystem']['name'], data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '', strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h:
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8'))
with open('dump/{system}{station}.{timestamp}.json'.format(
system=data['lastSystem']['name'],
station=data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '',
timestamp=strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h:
h.write(json.dumps(data,
ensure_ascii=False,
indent=2,
sort_keys=True,
separators=(',', ': ')).encode('utf-8'))
if not monitor.state['ShipType']: # Started game in SRV or fighter
self.ship['text'] = companion.ship_map.get(data['ship']['name'].lower(), data['ship']['name'])
@ -487,10 +510,13 @@ class AppWindow(object):
if config.getint('output') & (config.OUT_STATION_ANY):
if not data['commander'].get('docked'):
if not self.status['text']:
# Signal as error because the user might actually be docked but the server hosting the Companion API hasn't caught up
# Signal as error because the user might actually be docked
# but the server hosting the Companion API hasn't caught up
self.status['text'] = _("You're not docked at a station!")
play_bad = True
elif (config.getint('output') & config.OUT_MKT_EDDN) and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): # Ignore possibly missing shipyard info
# Ignore possibly missing shipyard info
elif (config.getint('output') & config.OUT_MKT_EDDN)\
and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')):
if not self.status['text']:
self.status['text'] = _("Station doesn't have anything!")
elif not data['lastStarport'].get('commodities'):
@ -523,12 +549,12 @@ class AppWindow(object):
self.login()
except Exception as e: # Including CredentialsError, ServerError
if __debug__: print_exc()
logger.debug('"other" exception', exc_info=e)
self.status['text'] = str(e)
play_bad = True
if not self.status['text']: # no errors
self.status['text'] = strftime(_('Last updated at %H:%M:%S').format(HH='%H', MM='%M', SS='%S'), localtime(querytime))
self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime))
if play_sound and play_bad:
hotkeymgr.play_bad()
@ -538,17 +564,24 @@ class AppWindow(object):
# Try again to get shipyard data and send to EDDN. Don't report errors if can't get or send the data.
try:
data = companion.session.station()
if __debug__:
print('Retry for shipyard - ' + (data['commander'].get('docked') and (data.get('lastStarport', {}).get('ships') and 'Success' or 'Failure') or 'Undocked!'))
if data['commander'].get('docked'):
if data.get('lastStarport', {}).get('ships'):
report = 'Success'
else:
report = 'Failure'
else:
report = 'Undocked!'
logger.debug(f'Retry for shipyard - {report}')
if not data['commander'].get('docked'):
pass # might have undocked while we were waiting for retry in which case station data is unreliable
# might have un-docked while we were waiting for retry in which case station data is unreliable
pass
elif (data.get('lastSystem', {}).get('name') == monitor.system and
data.get('lastStarport', {}).get('name') == monitor.station and
data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')):
self.eddn.export_shipyard(data, monitor.is_beta)
elif tries > 1: # bogus data - retry
self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries-1))
except:
except Exception:
pass
# Handle event(s) from the journal
@ -572,16 +605,19 @@ class AppWindow(object):
# Update main window
self.cooldown()
if monitor.cmdr and monitor.state['Captain']:
self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.state['Captain'])
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.state["Captain"]}'
self.ship_label['text'] = _('Role') + ':' # Multicrew role label in main window
self.ship.configure(state=tk.NORMAL, text=crewroletext(monitor.state['Role']), url=None)
elif monitor.cmdr:
if monitor.group:
self.cmdr['text'] = '%s / %s' % (monitor.cmdr, monitor.group)
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.group}'
else:
self.cmdr['text'] = monitor.cmdr
self.ship_label['text'] = _('Ship') + ':' # Main window
self.ship.configure(text = monitor.state['ShipName'] or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType']) or '',
self.ship.configure(
text=monitor.state['ShipName']
or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType'])
or '',
url=self.shipyard_url)
else:
self.cmdr['text'] = ''
@ -590,7 +626,21 @@ class AppWindow(object):
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.w.update_idletasks()
@ -607,37 +657,48 @@ class AppWindow(object):
# Disable WinSparkle automatic update checks, IFF configured to do so when in-game
if config.getint('disable_autoappupdatecheckingame') and 1:
self.updater.setAutomaticUpdatesCheck(False)
print('Monitor: Disable WinSparkle automatic update checks')
logger.info('Monitor: Disable WinSparkle automatic update checks')
# Can start dashboard monitoring
if not dashboard.start(self.w, monitor.started):
print("Can't start Status monitoring")
logger.info("Can't start Status monitoring")
# Export loadout
if entry['event'] == 'Loadout' and not monitor.state['Captain'] and config.getint('output') & config.OUT_SHIP:
if entry['event'] == 'Loadout' and not monitor.state['Captain']\
and config.getint('output') & config.OUT_SHIP:
monitor.export_ship()
# Plugins
err = plug.notify_journal_entry(monitor.cmdr, monitor.is_beta, monitor.system, monitor.station, entry, monitor.state)
err = plug.notify_journal_entry(monitor.cmdr,
monitor.is_beta,
monitor.system,
monitor.station,
entry,
monitor.state)
if err:
self.status['text'] = err
if not config.getint('hotkey_mute'):
hotkeymgr.play_bad()
# Auto-Update after docking, but not if auth callback is pending
if entry['event'] in ['StartUp', 'Location', 'Docked'] and monitor.station and not config.getint('output') & config.OUT_MKT_MANUAL and config.getint('output') & config.OUT_STATION_ANY and companion.session.state != companion.Session.STATE_AUTH:
if entry['event'] in ('StartUp', 'Location', 'Docked')\
and monitor.station\
and not config.getint('output') & config.OUT_MKT_MANUAL\
and config.getint('output') & config.OUT_STATION_ANY\
and companion.session.state != companion.Session.STATE_AUTH:
self.w.after(int(SERVER_RETRY * 1000), self.getandsend)
if entry['event'] == 'ShutDown':
# Enable WinSparkle automatic update checks
# NB: Do this blindly, in case option got changed whilst in-game
self.updater.setAutomaticUpdatesCheck(True)
print('Monitor: Enable WinSparkle automatic update checks')
logger.info('Monitor: Enable WinSparkle automatic update checks')
# cAPI auth
def auth(self, event=None):
try:
companion.session.auth_callback()
self.status['text'] = _('Authentication successful') # Successfully authenticated with the Frontier website
# Successfully authenticated with the Frontier website
self.status['text'] = _('Authentication successful')
if platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -647,7 +708,7 @@ class AppWindow(object):
except companion.ServerError as e:
self.status['text'] = str(e)
except Exception as e:
if __debug__: print_exc()
logger.debug('Frontier CAPI Auth:', exc_info=e)
self.status['text'] = str(e)
self.cooldown()
@ -696,7 +757,9 @@ class AppWindow(object):
def cooldown(self):
if time() < self.holdofftime:
self.button['text'] = self.theme_button['text'] = _('cooldown {SS}s').format(SS = int(self.holdofftime - time())) # Update button in main window
# Update button in main window
self.button['text'] = self.theme_button['text'] \
= _('cooldown {SS}s').format(SS=int(self.holdofftime - time()))
self.w.after(1000, self.cooldown)
else:
self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window
@ -713,7 +776,7 @@ class AppWindow(object):
def copy(self, event=None):
if monitor.system:
self.w.clipboard_clear()
self.w.clipboard_append(monitor.station and '%s,%s' % (monitor.system, monitor.station) or monitor.system)
self.w.clipboard_append(monitor.station and f'{monitor.system},{monitor.station}' or monitor.system)
def help_general(self, event=None):
webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki')
@ -724,7 +787,7 @@ class AppWindow(object):
def help_releases(self, event=None):
webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases')
class help_about(tk.Toplevel):
class HelpAbout(tk.Toplevel):
showing = False
def __init__(self, parent):
@ -741,8 +804,9 @@ class AppWindow(object):
self.transient(parent)
# position over parent
if platform!='darwin' or parent.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
self.geometry("+%d+%d" % (parent.winfo_rootx(), parent.winfo_rooty()))
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if platform != 'darwin' or parent.winfo_rooty() > 0:
self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}')
# remove decoration
if platform == 'win32':
@ -753,10 +817,6 @@ class AppWindow(object):
frame = ttk.Frame(self)
frame.grid(sticky=tk.NSEW)
PADX = 10
BUTTONX = 12 # indent Checkbuttons and Radiobuttons
PADY = 2 # close spacing
row = 1
############################################################
# applongname
@ -772,8 +832,8 @@ class AppWindow(object):
self.appversion_label = tk.Label(frame, text=appversion)
self.appversion_label.grid(row=row, column=0, sticky=tk.E)
self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=_('Release Notes'),
url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/{VERSION}'.format(
VERSION=appversion_nobuild),
url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/'
f'{appversion_nobuild}',
underline=True)
self.appversion.grid(row=row, column=2, sticky=tk.W)
row += 1
@ -802,7 +862,7 @@ class AppWindow(object):
self.protocol("WM_DELETE_WINDOW", self._destroy)
############################################################
print('Current version is {}'.format(appversion))
logger.info(f'Current version is {appversion}')
def apply(self):
self._destroy()
@ -819,22 +879,35 @@ class AppWindow(object):
try:
data = companion.session.station()
self.status['text'] = ''
default_extension: str = ''
if platform == 'darwin':
default_extension = '.json'
last_system: str = data.get("lastSystem", {}).get("name", "Unknown")
last_starport: str = ''
if data['commander'].get('docked'):
last_starport = '.'+data.get('lastStarport', {}).get('name', 'Unknown')
timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime())
f = tkinter.filedialog.asksaveasfilename(parent=self.w,
defaultextension = platform=='darwin' and '.json' or '',
defaultextension=default_extension,
filetypes=[('JSON', '.json'), ('All Files', '*')],
initialdir=config.get('outdir'),
initialfile = '%s%s.%s.json' % (data.get('lastSystem', {}).get('name', 'Unknown'), data['commander'].get('docked') and '.'+data.get('lastStarport', {}).get('name', 'Unknown') or '', strftime('%Y-%m-%dT%H.%M.%S', localtime())))
initialfile=f'{last_system}{last_starport}.{timestamp}')
if f:
with open(f, 'wb') as h:
h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8'))
h.write(json.dumps(data,
ensure_ascii=False,
indent=2,
sort_keys=True,
separators=(',', ': ')).encode('utf-8'))
except companion.ServerError as e:
self.status['text'] = str(e)
except Exception as e:
if __debug__: print_exc()
logger.debug('"other" exception', exc_info=e)
self.status['text'] = str(e)
def onexit(self, event=None):
if platform!='darwin' or self.w.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if platform != 'darwin' or self.w.winfo_rooty() > 0:
config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+')))
self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen
protocolhandler.close()
@ -852,7 +925,9 @@ class AppWindow(object):
def drag_continue(self, event):
if self.drag_offset:
self.w.geometry('+%d+%d' % (event.x_root - self.drag_offset[0], event.y_root - self.drag_offset[1]))
offset_x = event.x_root - self.drag_offset[0]
offset_y = event.y_root - self.drag_offset[1]
self.w.geometry(f'+{offset_x:d}+{offset_y:d}')
def drag_end(self, event):
self.drag_offset = None
@ -880,66 +955,95 @@ class AppWindow(object):
self.theme_menubar.grid_remove()
self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW)
# Run the app
if __name__ == "__main__":
def enforce_single_instance() -> None:
# Ensure only one copy of the app is running under this user account. OSX does this automatically. Linux TODO.
if platform == 'win32':
import ctypes
from ctypes.wintypes import *
EnumWindows = ctypes.windll.user32.EnumWindows
GetClassName = ctypes.windll.user32.GetClassNameW
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int]
GetWindowText = ctypes.windll.user32.GetWindowTextW
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int]
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd
from ctypes.wintypes import HWND, LPWSTR, LPCWSTR, INT, BOOL, LPARAM
EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806
GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806
GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806
SW_RESTORE = 9
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow
ShowWindow = ctypes.windll.user32.ShowWindow
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync
SW_RESTORE = 9 # noqa: N806
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806
ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806
COINIT_MULTITHREADED = 0
COINIT_APARTMENTTHREADED = 0x2
COINIT_DISABLE_OLE1DDE = 0x4
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx
COINIT_MULTITHREADED = 0 # noqa: N806,F841
COINIT_APARTMENTTHREADED = 0x2 # noqa: N806
COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806
ShellExecute = ctypes.windll.shell32.ShellExecuteW
ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806
ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT]
def WindowTitle(h):
def window_title(h):
if h:
l = GetWindowTextLength(h) + 1
buf = ctypes.create_unicode_buffer(l)
if GetWindowText(h, buf, l):
text_length = GetWindowTextLength(h) + 1
buf = ctypes.create_unicode_buffer(text_length)
if GetWindowText(h, buf, text_length):
return buf.value
return None
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
def enumwindowsproc(hWnd, lParam):
def enumwindowsproc(window_handle, l_param):
# class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576
cls = ctypes.create_unicode_buffer(257)
if GetClassName(hWnd, cls, 257) and cls.value == 'TkTopLevel' and WindowTitle(hWnd) == applongname and GetProcessHandleFromHwnd(hWnd):
if GetClassName(window_handle, cls, 257)\
and cls.value == 'TkTopLevel'\
and window_title(window_handle) == applongname\
and GetProcessHandleFromHwnd(window_handle):
# If GetProcessHandleFromHwnd succeeds then the app is already running as this user
if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler.redirect):
# Browser invoked us directly with auth response. Forward the response to the other app instance.
CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)
ShowWindow(hWnd, SW_RESTORE) # Wait for it to be responsive to avoid ShellExecute recursing
# Wait for it to be responsive to avoid ShellExecute recursing
ShowWindow(window_handle, SW_RESTORE)
ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE)
else:
ShowWindowAsync(hWnd, SW_RESTORE)
SetForegroundWindow(hWnd)
ShowWindowAsync(window_handle, SW_RESTORE)
SetForegroundWindow(window_handle)
sys.exit(0)
return True
EnumWindows(enumwindowsproc, 0)
def test_logging():
logger.debug('Test from EDMarketConnector.py top-level test_logging()')
# Run the app
if __name__ == "__main__":
# Keep this as the very first code run to be as sure as possible of no
# output until after this redirect is done, if needed.
if getattr(sys, 'frozen', False):
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed
import tempfile
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), '%s.log' % appname), 'wt', 1) # unbuffered not allowed for text in python3, so use line buffering
print('%s %s %s' % (applongname, appversion, strftime('%Y-%m-%dT%H:%M:%S', localtime())))
# unbuffered not allowed for text in python3, so use `1 for line buffering
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='wt', buffering=1)
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

View File

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

View File

@ -48,6 +48,9 @@ breaking with future code changes.**
`from monitor import gamerunning` - in case a plugin needs to know if we
think the game is running.
`import timeout_session` - provides a method called `new_session` that creates a requests.session with a default timeout
on all requests. Recommended to reduce noise in HTTP requests
```python
from ttkHyperlinkLabel import HyperlinkLabel

View File

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

View File

@ -3,11 +3,15 @@ import json
import re
import threading
from operator import itemgetter
from os import listdir, SEEK_SET, SEEK_CUR, SEEK_END
from os import listdir, SEEK_SET, SEEK_END
from os.path import basename, expanduser, isdir, join
from sys import platform
from time import gmtime, localtime, sleep, strftime, strptime, time
from calendar import timegm
from typing import Any, Optional, OrderedDict as OrderedDictT, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
import tkinter
if __debug__:
from traceback import print_exc
@ -18,7 +22,6 @@ from companion import ship_file_name
if platform == 'darwin':
from AppKit import NSWorkspace
from Foundation import NSSearchPathForDirectoriesInDomains, NSApplicationSupportDirectory, NSUserDomainMask
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from fcntl import fcntl
@ -28,7 +31,7 @@ elif platform=='win32':
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import ctypes
from ctypes.wintypes import *
from ctypes.wintypes import BOOL, HWND, LPARAM, LPWSTR
EnumWindows = ctypes.windll.user32.EnumWindows
EnumWindowsProc = ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
@ -47,20 +50,23 @@ else:
# Journal handler
class EDLogs(FileSystemEventHandler):
class EDLogs(FileSystemEventHandler): # type: ignore # See below
# Magic with FileSystemEventHandler can confuse type checkers when they do not have access to every import
_POLL = 1 # Polling is cheap, so do it often
_RE_CANONICALISE = re.compile(r'\$(.+)_name;')
_RE_CATEGORY = re.compile(r'\$MICRORESOURCE_CATEGORY_(.+);')
_RE_LOGFILE = re.compile(r'^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$')
def __init__(self):
# TODO(A_D): A bunch of these should be switched to default values (eg '' for strings) and no longer be Optional
FileSystemEventHandler.__init__(self) # futureproofing - not need for current version of watchdog
self.root = None
self.currentdir = None # The actual logdir that we're monitoring
self.logfile = None
self.currentdir: Optional[str] = None # The actual logdir that we're monitoring
self.logfile: Optional[str] = None
self.observer = None
self.observed = None # a watchdog ObservedWatch, or None if polling
self.thread = None
self.thread: Optional[threading.Thread] = None
self.event_queue = [] # For communicating journal entries back to main thread
# On startup we might be:
@ -71,22 +77,22 @@ class EDLogs(FileSystemEventHandler):
# If 3 we need to inject a special 'StartUp' event since consumers won't see the LoadGame event.
self.live = False
self.game_was_running = False # For generation the "ShutDown" event
self.game_was_running = False # For generation of the "ShutDown" event
# Context for journal handling
self.version = None
self.version: Optional[str] = None
self.is_beta = False
self.mode = None
self.group = None
self.cmdr = None
self.planet = None
self.system = None
self.station = None
self.station_marketid = None
self.stationtype = None
self.coordinates = None
self.systemaddress = None
self.started = None # Timestamp of the LoadGame event
self.mode: Optional[str] = None
self.group: Optional[str] = None
self.cmdr: Optional[str] = None
self.planet: Optional[str] = None
self.system: Optional[str] = None
self.station: Optional[str] = None
self.station_marketid: Optional[int] = None
self.stationtype: Optional[str] = None
self.coordinates: Optional[Tuple[int, int, int]] = None
self.systemaddress: Optional[int] = None
self.started: Optional[int] = None # Timestamp of the LoadGame event
# Cmdr state shared with EDSM and plugins
# If you change anything here update PLUGINS.md documentation!
@ -116,7 +122,7 @@ class EDLogs(FileSystemEventHandler):
'Modules': None,
}
def start(self, root):
def start(self, root: 'tkinter.Tk'):
self.root = root
journal_dir = config.get('journaldir') or config.default_journal_dir
@ -133,15 +139,20 @@ class EDLogs(FileSystemEventHandler):
if self.currentdir and self.currentdir != logdir:
self.stop()
self.currentdir = logdir
# Latest pre-existing logfile - e.g. if E:D is already running. Assumes logs sort alphabetically.
# Do this before setting up the observer in case the journal directory has gone away
try:
logfiles = sorted([x for x in listdir(self.currentdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)],
key=lambda x: x.split('.')[1:])
self.logfile = logfiles and join(self.currentdir, logfiles[-1]) or None
except:
try: # TODO: This should be replaced with something specific ONLY wrapping listdir
logfiles = sorted(
(x for x in listdir(self.currentdir) if self._RE_LOGFILE.search(x)), # type: ignore # config is weird
key=lambda x: x.split('.')[1:]
)
self.logfile = join(self.currentdir, logfiles[-1]) if logfiles else None # type: ignore # config is weird
except Exception:
self.logfile = None
return False
@ -154,6 +165,7 @@ class EDLogs(FileSystemEventHandler):
self.observer = Observer()
self.observer.daemon = True
self.observer.start()
elif polling and self.observer:
self.observer.stop()
self.observer = None
@ -162,8 +174,8 @@ class EDLogs(FileSystemEventHandler):
self.observed = self.observer.schedule(self, self.currentdir)
if __debug__:
print('%s Journal "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir))
print('Start logfile "%s"' % self.logfile)
print('{} Journal {!r}'.format('Polling' if polling else 'Monitoring', self.currentdir))
print('Start logfile {!r}'.format(self.logfile))
if not self.running():
self.thread = threading.Thread(target=self.worker, name='Journal worker')
@ -175,19 +187,32 @@ class EDLogs(FileSystemEventHandler):
def stop(self):
if __debug__:
print('Stopping monitoring Journal')
self.currentdir = None
self.version = self.mode = self.group = self.cmdr = self.planet = self.system = self.station = self.station_marketid = self.stationtype = self.stationservices = self.coordinates = self.systemaddress = None
self.version = None
self.mode = None
self.group = None
self.cmdr = None
self.planet = None
self.system = None
self.station = None
self.station_marketid = None
self.stationtype = None
self.stationservices = None
self.coordinates = None
self.systemaddress = None
self.is_beta = False
if self.observed:
self.observed = None
self.observer.unschedule_all()
self.thread = None # Orphan the worker thread - will terminate at next poll
def close(self):
thread = self.thread
self.stop()
if self.observer:
self.observer.stop()
if self.observer:
self.observer.join()
self.observer = None
@ -197,7 +222,8 @@ class EDLogs(FileSystemEventHandler):
def on_created(self, event):
# watchdog callback, e.g. client (re)started.
if not event.is_directory and re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', basename(event.src_path)):
if not event.is_directory and self._RE_LOGFILE.search(basename(event.src_path)):
self.logfile = event.src_path
def worker(self):
@ -211,13 +237,17 @@ class EDLogs(FileSystemEventHandler):
loghandle = open(logfile, 'rb', 0) # unbuffered
if platform == 'darwin':
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
for line in loghandle:
try:
self.parse_entry(line) # Some events are of interest even in the past
except:
except Exception:
if __debug__:
print('Invalid journal entry "%s"' % repr(line))
logpos = loghandle.tell()
print('Invalid journal entry {!r}'.format(line))
log_pos = loghandle.tell()
else:
loghandle = None
@ -226,7 +256,7 @@ class EDLogs(FileSystemEventHandler):
if self.live:
if self.game_was_running:
# Game is running locally
entry = OrderedDict([
entry: OrderedDictT[str, Any] = OrderedDict([
('timestamp', strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())),
('event', 'StartUp'),
('StarSystem', self.system),
@ -234,19 +264,26 @@ class EDLogs(FileSystemEventHandler):
('SystemAddress', self.systemaddress),
('Population', self.systempopulation),
])
if self.planet:
entry['Body'] = self.planet
entry['Docked'] = bool(self.station)
if self.station:
entry['StationName'] = self.station
entry['StationType'] = self.stationtype
entry['MarketID'] = self.station_marketid
self.event_queue.append(json.dumps(entry, separators=(', ', ':')))
else:
self.event_queue.append(None) # Generate null event to update the display (with possibly out-of-date info)
# Generate null event to update the display (with possibly out-of-date info)
self.event_queue.append(None)
self.live = False
# Watchdog thread
# Watchdog thread -- there is a way to get this by using self.observer.emitters and checking for an attribute:
# watch, but that may have unforseen differences in behaviour.
emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute
while True:
@ -257,33 +294,44 @@ class EDLogs(FileSystemEventHandler):
else:
# Poll
try:
logfiles = sorted([x for x in listdir(self.currentdir) if re.search('^Journal(Beta)?\.[0-9]{12}\.[0-9]{2}\.log$', x)],
key=lambda x: x.split('.')[1:])
newlogfile = logfiles and join(self.currentdir, logfiles[-1]) or None
except:
if __debug__: print_exc()
logfiles = sorted(
(x for x in listdir(self.currentdir) if self._RE_LOGFILE.search(x)),
key=lambda x: x.split('.')[1:]
)
newlogfile = join(self.currentdir, logfiles[-1]) if logfiles else None # type: ignore
except Exception:
if __debug__:
print_exc()
newlogfile = None
if logfile != newlogfile:
logfile = newlogfile
if loghandle:
loghandle.close()
if logfile:
loghandle = open(logfile, 'rb', 0) # unbuffered
if platform == 'darwin':
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
logpos = 0
log_pos = 0
if __debug__:
print('New logfile "%s"' % logfile)
print('New logfile {!r}'.format(logfile))
if logfile:
loghandle.seek(0, SEEK_END) # required to make macOS notice log change over SMB
loghandle.seek(logpos, SEEK_SET) # reset EOF flag
loghandle.seek(log_pos, SEEK_SET) # reset EOF flag # TODO: log_pos reported as possibly unbound
for line in loghandle:
self.event_queue.append(line)
if self.event_queue:
self.root.event_generate('<<JournalEvent>>', when="tail")
logpos = loghandle.tell()
log_pos = loghandle.tell()
sleep(self._POLL)
@ -293,21 +341,28 @@ class EDLogs(FileSystemEventHandler):
if self.game_was_running:
if not self.game_running():
self.event_queue.append('{ "timestamp":"%s", "event":"ShutDown" }' % strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
self.event_queue.append(
'{{ "timestamp":"{}", "event":"ShutDown" }}'.format(strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
)
self.root.event_generate('<<JournalEvent>>', when="tail")
self.game_was_running = False
else:
self.game_was_running = self.game_running()
def parse_entry(self, line):
def parse_entry(self, line: str):
# TODO(A_D): a bunch of these can be simplified to use if itertools.product and filters
if line is None:
return {'event': None} # Fake startup event
try:
entry = json.loads(line, object_pairs_hook=OrderedDict) # Preserve property order because why not?
entry['timestamp'] # we expect this to exist
if entry['event'] == 'Fileheader':
# Preserve property order because why not?
entry: OrderedDictT[str, Any] = json.loads(line, object_pairs_hook=OrderedDict)
entry['timestamp'] # we expect this to exist # TODO: replace with assert? or an if key in check
event_type = entry['event']
if event_type == 'Fileheader':
self.live = False
self.version = entry['gameversion']
self.is_beta = 'beta' in entry['gameversion'].lower()
@ -348,11 +403,14 @@ class EDLogs(FileSystemEventHandler):
'Rebuy': None,
'Modules': None,
}
elif entry['event'] == 'Commander':
elif event_type == 'Commander':
self.live = True # First event in 3.0
elif entry['event'] == 'LoadGame':
elif event_type == 'LoadGame':
self.cmdr = entry['Commander']
self.mode = entry.get('GameMode') # 'Open', 'Solo', 'Group', or None for CQC (and Training - but no LoadGame event)
# 'Open', 'Solo', 'Group', or None for CQC (and Training - but no LoadGame event)
self.mode = entry.get('GameMode')
self.group = entry.get('Group')
self.planet = None
self.system = None
@ -363,7 +421,8 @@ class EDLogs(FileSystemEventHandler):
self.coordinates = None
self.systemaddress = None
self.started = timegm(strptime(entry['timestamp'], '%Y-%m-%dT%H:%M:%SZ'))
self.state.update({ # Don't set Ship, ShipID etc since this will reflect Fighter or SRV if starting in those
# Don't set Ship, ShipID etc since this will reflect Fighter or SRV if starting in those
self.state.update({
'Captain': None,
'Credits': entry['Credits'],
'FID': entry.get('FID'), # From 3.3
@ -375,16 +434,20 @@ class EDLogs(FileSystemEventHandler):
'Statistics': {},
'Role': None,
})
elif entry['event'] == 'NewCommander':
elif event_type == 'NewCommander':
self.cmdr = entry['Name']
self.group = None
elif entry['event'] == 'SetUserShipName':
elif event_type == 'SetUserShipName':
self.state['ShipID'] = entry['ShipID']
if 'UserShipId' in entry: # Only present when changing the ship's ident
self.state['ShipIdent'] = entry['UserShipId']
self.state['ShipName'] = entry.get('UserShipName')
self.state['ShipType'] = self.canonicalise(entry['Ship'])
elif entry['event'] == 'ShipyardBuy':
elif event_type == 'ShipyardBuy':
self.state['ShipID'] = None
self.state['ShipIdent'] = None
self.state['ShipName'] = None
@ -393,7 +456,8 @@ class EDLogs(FileSystemEventHandler):
self.state['ModulesValue'] = None
self.state['Rebuy'] = None
self.state['Modules'] = None
elif entry['event'] == 'ShipyardSwap':
elif event_type == 'ShipyardSwap':
self.state['ShipID'] = entry['ShipID']
self.state['ShipIdent'] = None
self.state['ShipName'] = None
@ -402,9 +466,10 @@ class EDLogs(FileSystemEventHandler):
self.state['ModulesValue'] = None
self.state['Rebuy'] = None
self.state['Modules'] = None
elif (entry['event'] == 'Loadout' and
not 'fighter' in self.canonicalise(entry['Ship']) and
not 'buggy' in self.canonicalise(entry['Ship'])):
elif (event_type == 'Loadout' and
'fighter' not in self.canonicalise(entry['Ship']) and
'buggy' not in self.canonicalise(entry['Ship'])):
self.state['ShipID'] = entry['ShipID']
self.state['ShipIdent'] = entry['ShipIdent']
@ -429,8 +494,10 @@ class EDLogs(FileSystemEventHandler):
module.get('AmmoInClip') == module.get('AmmoInHopper') == 1): # lasers
module.pop('AmmoInClip')
module.pop('AmmoInHopper')
self.state['Modules'][module['Slot']] = module
elif entry['event'] == 'ModuleBuy':
elif event_type == 'ModuleBuy':
self.state['Modules'][entry['Slot']] = {
'Slot': entry['Slot'],
'Item': self.canonicalise(entry['BuyItem']),
@ -439,123 +506,174 @@ class EDLogs(FileSystemEventHandler):
'Health': 1.0,
'Value': entry['BuyPrice'],
}
elif entry['event'] == 'ModuleSell':
elif event_type == 'ModuleSell':
self.state['Modules'].pop(entry['Slot'], None)
elif entry['event'] == 'ModuleSwap':
toitem = self.state['Modules'].get(entry['ToSlot'])
self.state['Modules'][entry['ToSlot']] = self.state['Modules'][entry['FromSlot']]
if toitem:
self.state['Modules'][entry['FromSlot']] = toitem
elif event_type == 'ModuleSwap':
to_item = self.state['Modules'].get(entry['ToSlot'])
to_slot = entry['ToSlot']
from_slot = entry['FromSlot']
modules = self.state['Modules']
modules[to_slot] = modules[from_slot]
if to_item:
modules[from_slot] = to_item
else:
self.state['Modules'].pop(entry['FromSlot'], None)
elif entry['event'] in ['Undocked']:
modules.pop(from_slot, None)
elif event_type == 'Undocked':
self.station = None
self.station_marketid = None
self.stationtype = None
self.stationservices = None
elif entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']:
if entry['event'] in ('Location', 'CarrierJump'):
elif event_type in ('Location', 'FSDJump', 'Docked', 'CarrierJump'):
if event_type in ('Location', 'CarrierJump'):
self.planet = entry.get('Body') if entry.get('BodyType') == 'Planet' else None
elif entry['event'] == 'FSDJump':
elif event_type == 'FSDJump':
self.planet = None
if 'StarPos' in entry:
self.coordinates = tuple(entry['StarPos'])
elif self.system != entry['StarSystem']:
self.coordinates = None # Docked event doesn't include coordinates
self.systemaddress = entry.get('SystemAddress')
if entry['event'] in ['Location', 'FSDJump', 'CarrierJump']:
if event_type in ('Location', 'FSDJump', 'CarrierJump'):
self.systempopulation = entry.get('Population')
(self.system, self.station) = (entry['StarSystem'] == 'ProvingGround' and 'CQC' or entry['StarSystem'],
entry.get('StationName')) # May be None
self.system = 'CQC' if entry['StarSystem'] == 'ProvingGround' else entry['StarSystem']
self.station = entry.get('StationName') # May be None
self.station_marketid = entry.get('MarketID') # May be None
self.stationtype = entry.get('StationType') # May be None
self.stationservices = entry.get('StationServices') # None under E:D < 2.4
elif entry['event'] == 'ApproachBody':
elif event_type == 'ApproachBody':
self.planet = entry['Body']
elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']:
elif event_type in ('LeaveBody', 'SupercruiseEntry'):
self.planet = None
elif entry['event'] in ['Rank', 'Promotion']:
elif event_type in ('Rank', 'Promotion'):
payload = dict(entry)
payload.pop('event')
payload.pop('timestamp')
for k,v in payload.items():
self.state['Rank'][k] = (v,0)
elif entry['event'] == 'Progress':
self.state['Rank'].update({k: (v, 0) for k, v in payload.items()})
elif event_type == 'Progress':
rank = self.state['Rank']
for k, v in entry.items():
if k in self.state['Rank']:
self.state['Rank'][k] = (self.state['Rank'][k][0], min(v, 100)) # perhaps not taken promotion mission yet
elif entry['event'] in ['Reputation', 'Statistics']:
if k in rank:
# perhaps not taken promotion mission yet
rank[k] = (rank[k][0], min(v, 100))
elif event_type in ('Reputation', 'Statistics'):
payload = OrderedDict(entry)
payload.pop('event')
payload.pop('timestamp')
self.state[entry['event']] = payload
self.state[event_type] = payload
elif entry['event'] == 'EngineerProgress':
elif event_type == 'EngineerProgress':
engineers = self.state['Engineers']
if 'Engineers' in entry: # Startup summary
self.state['Engineers'] = { e['Engineer']: (e['Rank'], e.get('RankProgress', 0)) if 'Rank' in e else e['Progress'] for e in entry['Engineers'] }
else: # Promotion
self.state['Engineers'][entry['Engineer']] = (entry['Rank'], entry.get('RankProgress', 0)) if 'Rank' in entry else entry['Progress']
self.state['Engineers'] = {
e['Engineer']: ((e['Rank'], e.get('RankProgress', 0)) if 'Rank' in e else e['Progress'])
for e in entry['Engineers']
}
elif entry['event'] == 'Cargo' and entry.get('Vessel') == 'Ship':
else: # Promotion
engineer = entry['Engineer']
if 'Rank' in entry:
engineers[engineer] = (entry['Rank'], entry.get('RankProgress', 0))
else:
engineers[engineer] = entry['Progress']
elif event_type == 'Cargo' and entry.get('Vessel') == 'Ship':
self.state['Cargo'] = defaultdict(int)
if 'Inventory' not in entry: # From 3.3 full Cargo event (after the first one) is written to a separate file
with open(join(self.currentdir, 'Cargo.json'), 'rb') as h:
# From 3.3 full Cargo event (after the first one) is written to a separate file
if 'Inventory' not in entry:
with open(join(self.currentdir, 'Cargo.json'), 'rb') as h: # type: ignore
entry = json.load(h, object_pairs_hook=OrderedDict) # Preserve property order because why not?
self.state['Cargo'].update({self.canonicalise(x['Name']): x['Count'] for x in entry['Inventory']})
elif entry['event'] in ['CollectCargo', 'MarketBuy', 'BuyDrones', 'MiningRefined']:
elif event_type in ('CollectCargo', 'MarketBuy', 'BuyDrones', 'MiningRefined'):
commodity = self.canonicalise(entry['Type'])
self.state['Cargo'][commodity] += entry.get('Count', 1)
elif entry['event'] in ['EjectCargo', 'MarketSell', 'SellDrones']:
elif event_type in ('EjectCargo', 'MarketSell', 'SellDrones'):
commodity = self.canonicalise(entry['Type'])
self.state['Cargo'][commodity] -= entry.get('Count', 1)
if self.state['Cargo'][commodity] <= 0:
self.state['Cargo'].pop(commodity)
elif entry['event'] == 'SearchAndRescue':
cargo = self.state['Cargo']
cargo[commodity] -= entry.get('Count', 1)
if cargo[commodity] <= 0:
cargo.pop(commodity)
elif event_type == 'SearchAndRescue':
for item in entry.get('Items', []):
commodity = self.canonicalise(item['Name'])
self.state['Cargo'][commodity] -= item.get('Count', 1)
if self.state['Cargo'][commodity] <= 0:
self.state['Cargo'].pop(commodity)
cargo = self.state['Cargo']
cargo[commodity] -= item.get('Count', 1)
if cargo[commodity] <= 0:
cargo.pop(commodity)
elif entry['event'] == 'Materials':
for category in ['Raw', 'Manufactured', 'Encoded']:
elif event_type == 'Materials':
for category in ('Raw', 'Manufactured', 'Encoded'):
self.state[category] = defaultdict(int)
self.state[category].update({ self.canonicalise(x['Name']): x['Count'] for x in entry.get(category, []) })
elif entry['event'] == 'MaterialCollected':
self.state[category].update({
self.canonicalise(x['Name']): x['Count'] for x in entry.get(category, [])
})
elif event_type == 'MaterialCollected':
material = self.canonicalise(entry['Name'])
self.state[entry['Category']][material] += entry['Count']
elif entry['event'] in ['MaterialDiscarded', 'ScientificResearch']:
elif event_type in ('MaterialDiscarded', 'ScientificResearch'):
material = self.canonicalise(entry['Name'])
self.state[entry['Category']][material] -= entry['Count']
if self.state[entry['Category']][material] <= 0:
self.state[entry['Category']].pop(material)
elif entry['event'] == 'Synthesis':
for category in ['Raw', 'Manufactured', 'Encoded']:
state_category = self.state[entry['Category']]
state_category[material] -= entry['Count']
if state_category[material] <= 0:
state_category.pop(material)
elif event_type == 'Synthesis':
for category in ('Raw', 'Manufactured', 'Encoded'):
for x in entry['Materials']:
material = self.canonicalise(x['Name'])
if material in self.state[category]:
self.state[category][material] -= x['Count']
if self.state[category][material] <= 0:
self.state[category].pop(material)
elif entry['event'] == 'MaterialTrade':
category = self.category(entry['Paid']['Category'])
self.state[category][entry['Paid']['Material']] -= entry['Paid']['Quantity']
if self.state[category][entry['Paid']['Material']] <= 0:
self.state[category].pop(entry['Paid']['Material'])
category = self.category(entry['Received']['Category'])
self.state[category][entry['Received']['Material']] += entry['Received']['Quantity']
elif entry['event'] == 'EngineerCraft' or (entry['event'] == 'EngineerLegacyConvert' and not entry.get('IsPreview')):
for category in ['Raw', 'Manufactured', 'Encoded']:
elif event_type == 'MaterialTrade':
category = self.category(entry['Paid']['Category'])
state_category = self.state[category]
paid = entry['Paid']
received = entry['Received']
state_category[paid['Material']] -= paid['Quantity']
if state_category[paid['Material']] <= 0:
state_category.pop(paid['Material'])
category = self.category(received['Category'])
state_category[received['Material']] += received['Quantity']
elif event_type == 'EngineerCraft' or (
event_type == 'EngineerLegacyConvert' and not entry.get('IsPreview')
):
for category in ('Raw', 'Manufactured', 'Encoded'):
for x in entry.get('Ingredients', []):
material = self.canonicalise(x['Name'])
if material in self.state[category]:
self.state[category][material] -= x['Count']
if self.state[category][material] <= 0:
self.state[category].pop(material)
module = self.state['Modules'][entry['Slot']]
assert(module['Item'] == self.canonicalise(entry['Module']))
module['Engineering'] = {
@ -567,48 +685,56 @@ class EDLogs(FileSystemEventHandler):
'Quality': entry['Quality'],
'Modifiers': entry['Modifiers'],
}
if 'ExperimentalEffect' in entry:
module['Engineering']['ExperimentalEffect'] = entry['ExperimentalEffect']
module['Engineering']['ExperimentalEffect_Localised'] = entry['ExperimentalEffect_Localised']
else:
module['Engineering'].pop('ExperimentalEffect', None)
module['Engineering'].pop('ExperimentalEffect_Localised', None)
elif entry['event'] == 'MissionCompleted':
elif event_type == 'MissionCompleted':
for reward in entry.get('CommodityReward', []):
commodity = self.canonicalise(reward['Name'])
self.state['Cargo'][commodity] += reward.get('Count', 1)
for reward in entry.get('MaterialsReward', []):
if 'Category' in reward: # Category not present in E:D 3.0
category = self.category(reward['Category'])
material = self.canonicalise(reward['Name'])
self.state[category][material] += reward.get('Count', 1)
elif entry['event'] == 'EngineerContribution':
elif event_type == 'EngineerContribution':
commodity = self.canonicalise(entry.get('Commodity'))
if commodity:
self.state['Cargo'][commodity] -= entry['Quantity']
if self.state['Cargo'][commodity] <= 0:
self.state['Cargo'].pop(commodity)
material = self.canonicalise(entry.get('Material'))
if material:
for category in ['Raw', 'Manufactured', 'Encoded']:
for category in ('Raw', 'Manufactured', 'Encoded'):
if material in self.state[category]:
self.state[category][material] -= entry['Quantity']
if self.state[category][material] <= 0:
self.state[category].pop(material)
elif entry['event'] == 'TechnologyBroker':
elif event_type == 'TechnologyBroker':
for thing in entry.get('Ingredients', []): # 3.01
for category in ['Cargo', 'Raw', 'Manufactured', 'Encoded']:
for category in ('Cargo', 'Raw', 'Manufactured', 'Encoded'):
item = self.canonicalise(thing['Name'])
if item in self.state[category]:
self.state[category][item] -= thing['Count']
if self.state[category][item] <= 0:
self.state[category].pop(item)
for thing in entry.get('Commodities', []): # 3.02
commodity = self.canonicalise(thing['Name'])
self.state['Cargo'][commodity] -= thing['Count']
if self.state['Cargo'][commodity] <= 0:
self.state['Cargo'].pop(commodity)
for thing in entry.get('Materials', []): # 3.02
material = self.canonicalise(thing['Name'])
category = thing['Category']
@ -616,7 +742,7 @@ class EDLogs(FileSystemEventHandler):
if self.state[category][material] <= 0:
self.state[category].pop(material)
elif entry['event'] == 'JoinACrew':
elif event_type == 'JoinACrew':
self.state['Captain'] = entry['Captain']
self.state['Role'] = 'Idle'
self.planet = None
@ -627,9 +753,11 @@ class EDLogs(FileSystemEventHandler):
self.stationservices = None
self.coordinates = None
self.systemaddress = None
elif entry['event'] == 'ChangeCrewRole':
elif event_type == 'ChangeCrewRole':
self.state['Role'] = entry['Role']
elif entry['event'] == 'QuitACrew':
elif event_type == 'QuitACrew':
self.state['Captain'] = None
self.state['Role'] = None
self.planet = None
@ -641,38 +769,52 @@ class EDLogs(FileSystemEventHandler):
self.coordinates = None
self.systemaddress = None
elif entry['event'] == 'Friends':
if entry['Status'] in ['Online', 'Added']:
elif event_type == 'Friends':
if entry['Status'] in ('Online', 'Added'):
self.state['Friends'].add(entry['Name'])
else:
self.state['Friends'].discard(entry['Name'])
return entry
except:
except Exception:
if __debug__:
print('Invalid journal entry "%s"' % repr(line))
print('Invalid journal entry {!r}'.format(line))
print_exc()
return {'event': None}
# Commodities, Modules and Ships can appear in different forms e.g. "$HNShockMount_Name;", "HNShockMount", and "hnshockmount",
# "$int_cargorack_size6_class1_name;" and "Int_CargoRack_Size6_Class1", "python" and "Python", etc.
# Commodities, Modules and Ships can appear in different forms e.g. "$HNShockMount_Name;", "HNShockMount",
# and "hnshockmount", "$int_cargorack_size6_class1_name;" and "Int_CargoRack_Size6_Class1",
# "python" and "Python", etc.
# This returns a simple lowercased name e.g. 'hnshockmount', 'int_cargorack_size6_class1', 'python', etc
def canonicalise(self, item):
if not item: return ''
def canonicalise(self, item: Optional[str]):
if not item:
return ''
item = item.lower()
match = self._RE_CANONICALISE.match(item)
return match and match.group(1) or item
def category(self, item):
if match:
return match.group(1)
return item
def category(self, item: str):
match = self._RE_CATEGORY.match(item)
return (match and match.group(1) or item).capitalize()
if match:
return match.group(1).capitalize()
return item.capitalize()
def get_entry(self):
if not self.event_queue:
return None
else:
entry = self.parse_entry(self.event_queue.pop(0))
if not self.live and entry['event'] not in [None, 'Fileheader']:
if not self.live and entry['event'] not in (None, 'Fileheader'):
# Game not running locally, but Journal has been updated
self.live = True
if self.station:
@ -687,6 +829,7 @@ class EDLogs(FileSystemEventHandler):
('StarPos', self.coordinates),
('SystemAddress', self.systemaddress),
])
else:
entry = OrderedDict([
('timestamp', strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())),
@ -696,25 +839,28 @@ class EDLogs(FileSystemEventHandler):
('StarPos', self.coordinates),
('SystemAddress', self.systemaddress),
])
self.event_queue.append(json.dumps(entry, separators=(', ', ':')))
elif self.live and entry['event'] == 'Music' and entry.get('MusicTrack') == 'MainMenu':
self.event_queue.append('{ "timestamp":"%s", "event":"ShutDown" }' % strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
self.event_queue.append(
'{{ "timestamp":"{}", "event":"ShutDown" }}'.format(strftime('%Y-%m-%dT%H:%M:%SZ', gmtime()))
)
return entry
def game_running(self):
if platform == 'darwin':
for app in NSWorkspace.sharedWorkspace().runningApplications():
if app.bundleIdentifier() == 'uk.co.frontier.EliteDangerous':
return True
elif platform == 'win32':
def WindowTitle(h):
if h:
l = GetWindowTextLength(h) + 1
buf = ctypes.create_unicode_buffer(l)
if GetWindowText(h, buf, l):
length = GetWindowTextLength(h) + 1
buf = ctypes.create_unicode_buffer(length)
if GetWindowText(h, buf, length):
return buf.value
return None
@ -725,59 +871,80 @@ class EDLogs(FileSystemEventHandler):
if handle: # If GetProcessHandleFromHwnd succeeds then the app is already running as this user
CloseHandle(handle)
return False # stop enumeration
return True
return not EnumWindows(EnumWindowsProc(callback), 0)
return False
# Return a subset of the received data describing the current ship as a Loadout event
def ship(self, timestamped=True):
if not self.state['Modules']:
return None
standard_order = ['ShipCockpit', 'CargoHatch', 'Armour', 'PowerPlant', 'MainEngines', 'FrameShiftDrive', 'LifeSupport', 'PowerDistributor', 'Radar', 'FuelTank']
standard_order = (
'ShipCockpit', 'CargoHatch', 'Armour', 'PowerPlant', 'MainEngines', 'FrameShiftDrive', 'LifeSupport',
'PowerDistributor', 'Radar', 'FuelTank'
)
d = OrderedDict()
if timestamped:
d['timestamp'] = strftime('%Y-%m-%dT%H:%M:%SZ', gmtime())
d['event'] = 'Loadout'
d['Ship'] = self.state['ShipType']
d['ShipID'] = self.state['ShipID']
if self.state['ShipName']:
d['ShipName'] = self.state['ShipName']
if self.state['ShipIdent']:
d['ShipIdent'] = self.state['ShipIdent']
# sort modules by slot - hardpoints, standard, internal
d['Modules'] = []
for slot in sorted(self.state['Modules'], key=lambda x: ('Hardpoint' not in x, x not in standard_order and len(standard_order) or standard_order.index(x), 'Slot' not in x, x)):
for slot in sorted(
self.state['Modules'],
key=lambda x: (
'Hardpoint' not in x,
len(standard_order) if x not in standard_order else standard_order.index(x),
'Slot' not in x,
x
)
):
module = dict(self.state['Modules'][slot])
module.pop('Health', None)
module.pop('Value', None)
d['Modules'].append(module)
return d
return d
# Export ship loadout as a Loadout event
def export_ship(self, filename=None):
# TODO(A_D): Some type checking has been disabled in here due to config.get getting weird outputs
string = json.dumps(self.ship(False), ensure_ascii=False, indent=2, separators=(',', ': ')) # pretty print
if filename:
with open(filename, 'wt') as h:
h.write(string)
return
ship = ship_file_name(self.state['ShipName'], self.state['ShipType'])
regexp = re.compile(re.escape(ship) + '\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt')
oldfiles = sorted([x for x in listdir(config.get('outdir')) if regexp.match(x)])
regexp = re.compile(re.escape(ship) + r'\.\d{4}\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt')
oldfiles = sorted((x for x in listdir(config.get('outdir')) if regexp.match(x))) # type: ignore
if oldfiles:
with open(join(config.get('outdir'), oldfiles[-1]), 'rU') as h:
with open(join(config.get('outdir'), oldfiles[-1]), 'rU') as h: # type: ignore
if h.read() == string:
return # same as last time - don't write
# Write
filename = join(config.get('outdir'), '%s.%s.txt' % (ship, strftime('%Y-%m-%dT%H.%M.%S', localtime(time()))))
filename = join( # type: ignore
config.get('outdir'), '{}.{}.txt'.format(ship, strftime('%Y-%m-%dT%H.%M.%S', localtime(time())))
)
with open(filename, 'wt') as h:
h.write(string)

84
plug.py
View File

@ -7,15 +7,17 @@ import os
import importlib
import sys
import operator
import threading # We don't use it, but plugins might
from traceback import print_exc
import threading # noqa: F401 - We don't use it, but plugins might
from typing import Optional
import logging
import tkinter as tk
import myNotebook as nb
from config import config
from time import time as time
import myNotebook as nb # noqa: N813
from config import config, appname
import EDMCLogging
logger = logging.getLogger(appname)
# Dashboard Flags constants
FlagsDocked = 1 << 0 # on a landing pad
@ -79,7 +81,7 @@ last_error = {
class Plugin(object):
def __init__(self, name, loadfile):
def __init__(self, name: str, loadfile: str, plugin_logger: Optional[logging.Logger]):
"""
Load a single plugin
:param name: module name
@ -90,25 +92,28 @@ class Plugin(object):
self.name = name # Display name.
self.folder = name # basename of plugin folder. None for internal plugins.
self.module = None # None for disabled plugins.
self.logger = plugin_logger
if loadfile:
sys.stdout.write('loading plugin {} from "{}"\n'.format(name.replace('.', '_'), loadfile))
logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"')
try:
module = importlib.machinery.SourceFileLoader('plugin_{}'.format(name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')), loadfile).load_module()
module = importlib.machinery.SourceFileLoader('plugin_{}'.format(
name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')),
loadfile).load_module()
if getattr(module, 'plugin_start3', None):
newname = module.plugin_start3(os.path.dirname(loadfile))
self.name = newname and str(newname) or name
self.module = module
elif getattr(module, 'plugin_start', None):
sys.stdout.write('plugin %s needs migrating\n' % name)
logger.warning(f'plugin {name} needs migrating\n')
PLUGINS_not_py3.append(self)
else:
sys.stdout.write('plugin %s has no plugin_start3() function\n' % name)
except:
print_exc()
logger.error(f'plugin {name} has no plugin_start3() function')
except Exception as e:
logger.exception(f': Failed for Plugin "{name}"')
raise
else:
sys.stdout.write('plugin %s disabled\n' % name)
logger.info(f'plugin {name} disabled')
def _get_func(self, funcname):
"""
@ -136,8 +141,8 @@ class Plugin(object):
elif not isinstance(appitem, tk.Widget):
raise AssertionError
return appitem
except:
print_exc()
except Exception as e:
logger.exception(f'Failed for Plugin "{self.name}"')
return None
def get_prefs(self, parent, cmdr, is_beta):
@ -156,8 +161,8 @@ class Plugin(object):
if not isinstance(frame, nb.Frame):
raise AssertionError
return frame
except:
print_exc()
except Exception as e:
logger.exception(f'Failed for Plugin "{self.name}"')
return None
@ -171,11 +176,11 @@ def load_plugins(master):
for name in sorted(os.listdir(config.internal_plugin_dir)):
if name.endswith('.py') and not name[0] in ['.', '_']:
try:
plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name))
plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir, name), logger)
plugin.folder = None # Suppress listing in Plugins prefs tab
internal.append(plugin)
except:
pass
except Exception as e:
logger.exception(f'Failure loading internal Plugin "{name}"')
PLUGINS.extend(sorted(internal, key=lambda p: operator.attrgetter('name')(p).lower()))
# Add plugin folder to load path so packages can be loaded from plugin folder
@ -189,16 +194,23 @@ def load_plugins(master):
pass
elif name.endswith('.disabled'):
name, discard = name.rsplit('.', 1)
found.append(Plugin(name, None))
found.append(Plugin(name, None, logger))
else:
try:
# Add plugin's folder to load path in case plugin has internal package dependencies
sys.path.append(os.path.join(config.plugin_dir, name))
found.append(Plugin(name, os.path.join(config.plugin_dir, name, 'load.py')))
except:
# Create a logger for this 'found' plugin. Must be before the
# load.py is loaded.
plugin_logger = EDMCLogging.get_plugin_logger(f'{appname}.{name}')
found.append(Plugin(name, os.path.join(config.plugin_dir, name, 'load.py'), plugin_logger))
except Exception as e:
logger.exception(f'Failure loading found Plugin "{name}"')
pass
PLUGINS.extend(sorted(found, key=lambda p: operator.attrgetter('name')(p).lower()))
def provides(fn_name):
"""
Find plugins that provide a function
@ -240,8 +252,8 @@ def notify_stop():
try:
newerror = plugin_stop()
error = error or newerror
except:
print_exc()
except Exception as e:
logger.exception(f'Plugin "{plugin.name}" failed')
return error
@ -257,8 +269,8 @@ def notify_prefs_cmdr_changed(cmdr, is_beta):
if prefs_cmdr_changed:
try:
prefs_cmdr_changed(cmdr, is_beta)
except:
print_exc()
except Exception as e:
logger.exception(f'Plugin "{plugin.name}" failed')
def notify_prefs_changed(cmdr, is_beta):
@ -275,8 +287,8 @@ def notify_prefs_changed(cmdr, is_beta):
if prefs_changed:
try:
prefs_changed(cmdr, is_beta)
except:
print_exc()
except Exception as e:
logger.exception(f'Plugin "{plugin.name}" failed')
def notify_journal_entry(cmdr, is_beta, system, station, entry, state):
@ -298,8 +310,8 @@ def notify_journal_entry(cmdr, is_beta, system, station, entry, state):
# Pass a copy of the journal entry in case the callee modifies it
newerror = journal_entry(cmdr, is_beta, system, station, dict(entry), dict(state))
error = error or newerror
except:
print_exc()
except Exception as e:
logger.exception(f'Plugin "{plugin.name}" failed')
return error
@ -319,8 +331,8 @@ def notify_dashboard_entry(cmdr, is_beta, entry):
# Pass a copy of the status entry in case the callee modifies it
newerror = status(cmdr, is_beta, dict(entry))
error = error or newerror
except:
print_exc()
except Exception as e:
logger.exception(f'Plugin "{plugin.name}" failed')
return error
@ -338,8 +350,8 @@ def notify_newdata(data, is_beta):
try:
newerror = cmdr_data(data, is_beta)
error = error or newerror
except:
print_exc()
except Exception as e:
logger.exception(f'Plugin "{plugin.name}" failed')
return error

View File

@ -7,17 +7,21 @@ import io
# Migrate settings from <= 3.01
from config import config
if not config.get('shipyard_provider') and config.getint('shipyard'):
config.set('shipyard_provider', 'Coriolis')
config.delete('shipyard')
def plugin_start3(plugin_dir):
def plugin_start3(_):
return 'Coriolis'
# Return a URL for the current ship
def shipyard_url(loadout, is_beta):
string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8') # most compact representation
"""Return a URL for the current ship"""
# most compact representation
string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
if not string:
return False
@ -25,4 +29,7 @@ def shipyard_url(loadout, is_beta):
with gzip.GzipFile(fileobj=out, mode='w') as f:
f.write(string)
return (is_beta and 'https://beta.coriolis.io/import?data=' or 'https://coriolis.io/import?data=') + base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D')
encoded = base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D')
url = 'https://beta.coriolis.io/import?data=' if is_beta else 'https://coriolis.io/import?data='
return f'{url}{encoded}'

View File

@ -24,23 +24,27 @@
import sys
from typing import Any, Optional, TYPE_CHECKING
import requests
from config import config
if TYPE_CHECKING:
from tkinter import Tk
STATION_UNDOCKED: str = u'×' # "Station" name to display when not docked = U+00D7
this = sys.modules[__name__] # For holding module globals
STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7
this: Any = sys.modules[__name__] # For holding module globals
# Main window clicks
this.system_link = None
this.system = None
this.system_address = None
this.system_population = None
this.station_link = None
this.station = None
this.station_marketid = None
this.system_link: Optional[str] = None
this.system: Optional[str] = None
this.system_address: Optional[str] = None
this.system_population: Optional[int] = None
this.station_link: 'Optional[Tk]' = None
this.station: Optional[str] = None
this.station_marketid: Optional[int] = None
def system_url(system_name: str) -> str:
@ -61,7 +65,8 @@ def station_url(system_name: str, station_name: str) -> str:
def plugin_start3(plugin_dir):
return 'eddb'
def plugin_app(parent):
def plugin_app(parent: 'Tk'):
this.system_link = parent.children['system'] # system label in main window
this.system = None
this.system_address = None
@ -75,7 +80,6 @@ def prefs_changed(cmdr, is_beta):
# through correctly. We don't want a static string.
pass
def journal_entry(cmdr, is_beta, system, station, entry, state):
# Always update our system address even if we're not currently the provider for system or station, but dont update
# on events that contain "future" data, such as FSDTarget
@ -105,7 +109,15 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
# But only actually change the URL if we are current station provider.
if config.get('station_provider') == 'eddb':
this.station_link['text'] = this.station or (this.system_population and this.system_population > 0 and STATION_UNDOCKED or '')
text = this.station
if not text:
if this.system_population is not None and this.system_population > 0:
text = STATION_UNDOCKED
else:
text = ''
this.station_link['text'] = text
# Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string.
this.station_link.update_idletasks()
@ -113,11 +125,15 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
def cmdr_data(data, is_beta):
# Always store initially, even if we're not the *current* system provider.
if not this.station_marketid:
this.station_marketid = data['commander']['docked'] and data['lastStarport']['id']
if not this.station_marketid and data['commander']['docked']:
this.station_marketid = data['lastStarport']['id']
# Only trust CAPI if these aren't yet set
this.system = this.system or data['lastSystem']['name']
this.station = this.station or data['commander']['docked'] and data['lastStarport']['name']
if not this.system:
this.system = data['lastSystem']['name']
if not this.station and data['commander']['docked']:
this.station = data['lastStarport']['name']
# Override standard URL functions
if config.get('system_provider') == 'eddb':
@ -125,15 +141,17 @@ def cmdr_data(data, is_beta):
# Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string.
this.system_link.update_idletasks()
if config.get('station_provider') == 'eddb':
if data['commander']['docked']:
this.station_link['text'] = this.station
elif data['lastStarport']['name'] and data['lastStarport']['name'] != "":
this.station_link['text'] = STATION_UNDOCKED
else:
this.station_link['text'] = ''
# Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string.
this.station_link.update_idletasks()

View File

@ -1,32 +1,40 @@
# Export to EDDN
from collections import OrderedDict
import itertools
import json
from os import SEEK_SET, SEEK_CUR, SEEK_END
import logging
import pathlib
import re
import sys
import tkinter as tk
from collections import OrderedDict
from os import SEEK_SET
from os.path import exists, join
from platform import system
import re
from typing import TYPE_CHECKING, Any, AnyStr, Dict, Iterator, List, Mapping, MutableMapping, Optional
from typing import OrderedDict as OrderedDictT
from typing import Sequence, TextIO, Tuple, Union
import requests
import sys
import uuid
import tkinter as tk
from ttkHyperlinkLabel import HyperlinkLabel
import myNotebook as nb
import myNotebook as nb # noqa: N813
from companion import category_map
from config import applongname, appname, appversion, config
from myNotebook import Frame
from prefs import prefsVersion
from ttkHyperlinkLabel import HyperlinkLabel
if sys.platform != 'win32':
from fcntl import lockf, LOCK_EX, LOCK_NB
if __debug__:
from traceback import print_exc
from config import applongname, appversion, config
from companion import category_map
if TYPE_CHECKING:
def _(x: str) -> str:
return x
logger = logging.getLogger(appname)
this = sys.modules[__name__] # For holding module globals
this: Any = sys.modules[__name__] # For holding module globals
# Track location to add to Journal events
this.systemaddress = None
@ -35,130 +43,180 @@ this.planet = None
# Avoid duplicates
this.marketId = None
this.commodities = this.outfitting = this.shipyard = None
this.commodities = None
this.outfitting: Optional[Tuple[bool, MutableMapping[str, Any]]] = None
this.shipyard = None
HORIZ_SKU = 'ELITE_HORIZONS_V_PLANETARY_LANDINGS'
class EDDN(object):
# TODO: a good few of these methods are static or could be classmethods. they should be created as such.
### SERVER = 'http://localhost:8081' # testing
class EDDN:
# SERVER = 'http://localhost:8081' # testing
SERVER = 'https://eddn.edcd.io:4430'
UPLOAD = '%s/upload/' % SERVER
UPLOAD = f'{SERVER}/upload/'
REPLAYPERIOD = 400 # Roughly two messages per second, accounting for send delays [ms]
REPLAYFLUSH = 20 # Update log on disk roughly every 10 seconds
TIMEOUT = 10 # requests timeout
MODULE_RE = re.compile('^Hpt_|^Int_|Armour_', re.IGNORECASE)
MODULE_RE = re.compile(r'^Hpt_|^Int_|Armour_', re.IGNORECASE)
CANONICALISE_RE = re.compile(r'\$(.+)_name;')
def __init__(self, parent):
self.parent = parent
def __init__(self, parent: tk.Tk):
self.parent: tk.Tk = parent
self.session = requests.Session()
self.replayfile = None # For delayed messages
self.replaylog = []
self.replayfile: Optional[TextIO] = None # For delayed messages
self.replaylog: List[str] = []
def load(self):
def load_journal_replay(self) -> bool:
"""
Load cached journal entries from disk
:return: a bool indicating success
"""
# Try to obtain exclusive access to the journal cache
filename = join(config.app_dir, 'replay.jsonl')
try:
try:
# Try to open existing file
self.replayfile = open(filename, 'r+', buffering=1)
except:
except Exception:
if exists(filename):
raise # Couldn't open existing file
else:
self.replayfile = open(filename, 'w+', buffering=1) # Create file
if sys.platform != 'win32': # open for writing is automatically exclusive on Windows
lockf(self.replayfile, LOCK_EX | LOCK_NB)
except:
if __debug__: print_exc()
except Exception as e:
logger.debug('Failed opening "replay.jsonl"', exc_info=e)
if self.replayfile:
self.replayfile.close()
self.replayfile = None
return False
self.replaylog = [line.strip() for line in self.replayfile]
return True
def flush(self):
"""
flush flushes the replay file, clearing any data currently there that is not in the replaylog list
"""
self.replayfile.seek(0, SEEK_SET)
self.replayfile.truncate()
for line in self.replaylog:
self.replayfile.write('%s\n' % line)
self.replayfile.write(f'{line}\n')
self.replayfile.flush()
def close(self):
"""
close closes the replay file
"""
if self.replayfile:
self.replayfile.close()
self.replayfile = None
def send(self, cmdr, msg):
uploaderID = cmdr
def send(self, cmdr: str, msg: Mapping[str, Any]) -> None:
"""
Send sends an update to EDDN
msg = OrderedDict([
:param cmdr: the CMDR to use as the uploader ID
:param msg: the payload to send
"""
uploader_id = cmdr
to_send: OrderedDictT[str, str] = OrderedDict([
('$schemaRef', msg['$schemaRef']),
('header', OrderedDict([
('softwareName', '%s [%s]' % (applongname, sys.platform=='darwin' and "Mac OS" or system())),
('softwareName', f'{applongname} [{system() if sys.platform != "darwin" else "Mac OS"}]'),
('softwareVersion', appversion),
('uploaderID', uploaderID),
('uploaderID', uploader_id),
])),
('message', msg['message']),
])
r = self.session.post(self.UPLOAD, data=json.dumps(msg), timeout=self.TIMEOUT)
if __debug__ and r.status_code != requests.codes.ok:
print('Status\t%s' % r.status_code)
print('URL\t%s' % r.url)
print('Headers\t%s' % r.headers)
print('Content:\n%s' % r.text)
r = self.session.post(self.UPLOAD, data=json.dumps(to_send), timeout=self.TIMEOUT)
if r.status_code != requests.codes.ok:
logger.debug(f':\nStatus\t{r.status_code}URL\t{r.url}Headers\t{r.headers}Content:\n{r.text}')
r.raise_for_status()
def sendreplay(self):
def sendreplay(self) -> None:
"""
sendreplay updates EDDN with cached journal lines
"""
if not self.replayfile:
return # Probably closing app
status = self.parent.children['status']
status: Dict[str, Any] = self.parent.children['status']
if not self.replaylog:
status['text'] = ''
return
localized: str = _('Sending data to EDDN...')
if len(self.replaylog) == 1:
status['text'] = _('Sending data to EDDN...')
status['text'] = localized
else:
status['text'] = '%s [%d]' % (_('Sending data to EDDN...').replace('...',''), len(self.replaylog))
status['text'] = f'{localized.replace("...", "")} [{len(self.replaylog)}]'
self.parent.update_idletasks()
try:
cmdr, msg = json.loads(self.replaylog[0], object_pairs_hook=OrderedDict)
except:
except json.JSONDecodeError as e:
# Couldn't decode - shouldn't happen!
if __debug__:
print(self.replaylog[0])
print_exc()
self.replaylog.pop(0) # Discard and continue
logger.debug(f'\n{self.replaylog[0]}\n', exc_info=e)
# Discard and continue
self.replaylog.pop(0)
else:
# Rewrite old schema name
if msg['$schemaRef'].startswith('http://schemas.elite-markets.net/eddn/'):
msg['$schemaRef'] = 'https://eddn.edcd.io/schemas/' + msg['$schemaRef'][38:]
msg['$schemaRef'] = str(msg['$schemaRef']).replace(
'http://schemas.elite-markets.net/eddn/',
'https://eddn.edcd.io/schemas/'
)
try:
self.send(cmdr, msg)
self.replaylog.pop(0)
if not len(self.replaylog) % self.REPLAYFLUSH:
self.flush()
except requests.exceptions.RequestException as e:
if __debug__: print_exc()
logger.debug('Failed sending', exc_info=e)
status['text'] = _("Error: Can't connect to EDDN")
return # stop sending
except Exception as e:
if __debug__: print_exc()
logger.debug('Failed sending', exc_info=e)
status['text'] = str(e)
return # stop sending
self.parent.after(self.REPLAYPERIOD, self.sendreplay)
def export_commodities(self, data, is_beta):
commodities = []
def export_commodities(self, data: Mapping[str, Any], is_beta: bool) -> None:
"""
export_commodities updates EDDN with the commodities on the current (lastStarport) station.
Once the send is complete, this.commodities is updated with the new data.
:param data: a dict containing the starport data
:param is_beta: whether or not we're currently in beta mode
"""
commodities: List[OrderedDictT[str, Any]] = []
for commodity in data['lastStarport'].get('commodities') or []:
if (category_map.get(commodity['categoryname'], True) and # Check marketable
not commodity.get('legality')): # check not prohibited
# Check 'marketable' and 'not prohibited'
if (category_map.get(commodity['categoryname'], True)
and not commodity.get('legality')):
commodities.append(OrderedDict([
('name', commodity['name'].lower()),
('meanPrice', int(commodity['meanPrice'])),
@ -169,40 +227,67 @@ class EDDN(object):
('demand', int(commodity['demand'])),
('demandBracket', commodity['demandBracket']),
]))
if commodity['statusFlags']:
commodities[-1]['statusFlags'] = commodity['statusFlags']
commodities.sort(key=lambda c: c['name'])
if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it
message = OrderedDict([
message: OrderedDictT[str, Any] = OrderedDict([
('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']),
('stationName', data['lastStarport']['name']),
('marketId', data['lastStarport']['id']),
('commodities', commodities),
])
if 'economies' in data['lastStarport']:
message['economies'] = sorted(list([x for x in (data['lastStarport']['economies'] or {}).values()]), key=lambda x: x['name'])
message['economies'] = sorted(
(x for x in (data['lastStarport']['economies'] or {}).values()), key=lambda x: x['name']
)
if 'prohibited' in data['lastStarport']:
message['prohibited'] = sorted(list([x for x in (data['lastStarport']['prohibited'] or {}).values()]))
message['prohibited'] = sorted(x for x in (data['lastStarport']['prohibited'] or {}).values())
self.send(data['commander']['name'], {
'$schemaRef' : 'https://eddn.edcd.io/schemas/commodity/3' + (is_beta and '/test' or ''),
'$schemaRef': f'https://eddn.edcd.io/schemas/commodity/3{"/test" if is_beta else ""}',
'message': message,
})
this.commodities = commodities
def export_outfitting(self, data, is_beta):
economies = data['lastStarport'].get('economies') or {}
modules = data['lastStarport'].get('modules') or {}
ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] }
# Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"), prison or rescue Megaships, or under Pirate Attack etc
horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or
any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or
any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values())))
outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['name'].lower()) for module in modules.values() if self.MODULE_RE.search(module['name']) and module.get('sku') in [None, 'ELITE_HORIZONS_V_PLANETARY_LANDINGS'] and module['name'] != 'Int_PlanetApproachSuite'])
if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it
def export_outfitting(self, data: Mapping[str, Any], is_beta: bool) -> None:
"""
export_outfitting updates EDDN with the current (lastStarport) station's outfitting options, if any.
Once the send is complete, this.outfitting is updated with the given data.
:param data: dict containing the outfitting data
:param is_beta: whether or not we're currently in beta mode
"""
modules: Dict[str, Any] = data['lastStarport'].get('modules') or {}
# Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"),
# prison or rescue Megaships, or under Pirate Attack etc
horizons: bool = is_horizons(
data['lastStarport'].get('economies', {}),
modules,
data['lastStarport'].get('ships', {'shipyard_list': {}, 'unavailable_list': []})
)
to_search: Iterator[Mapping[str, Any]] = filter(
lambda m: self.MODULE_RE.search(m['name']) and m.get('sku') in (None, HORIZ_SKU) and
m['name'] != 'Int_PlanetApproachSuite',
modules.values()
)
outfitting: List[str] = sorted(
self.MODULE_RE.sub(lambda match: match.group(0).capitalize(), mod['name'].lower()) for mod in to_search
)
# Don't send empty modules list - schema won't allow it
if outfitting and this.outfitting != (horizons, outfitting):
self.send(data['commander']['name'], {
'$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''),
'$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']),
@ -212,19 +297,34 @@ class EDDN(object):
('modules', outfitting),
]),
})
this.outfitting = (horizons, outfitting)
def export_shipyard(self, data, is_beta):
economies = data['lastStarport'].get('economies') or {}
modules = data['lastStarport'].get('modules') or {}
ships = data['lastStarport'].get('ships') or { 'shipyard_list': {}, 'unavailable_list': [] }
horizons = (any(economy['name'] == 'Colony' for economy in economies.values()) or
any(module.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for module in modules.values()) or
any(ship.get('sku') == 'ELITE_HORIZONS_V_PLANETARY_LANDINGS' for ship in list((ships['shipyard_list'] or {}).values())))
shipyard = sorted([ship['name'].lower() for ship in list((ships['shipyard_list'] or {}).values()) + ships['unavailable_list']])
if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
def export_shipyard(self, data: Dict[str, Any], is_beta: bool) -> None:
"""
export_shipyard updates EDDN with the current (lastStarport) station's outfitting options, if any.
once the send is complete, this.shipyard is updated to the new data.
:param data: dict containing the shipyard data
:param is_beta: whether or not we are in beta mode
"""
ships: Dict[str, Any] = data['lastStarport'].get('ships', {'shipyard_list': {}, 'unavailable_list': []})
horizons: bool = is_horizons(
data['lastStarport'].get('economies', {}),
data['lastStarport'].get('modules', {}),
ships
)
shipyard: List[Mapping[str, Any]] = sorted(
itertools.chain(
(ship['name'].lower() for ship in (ships['shipyard_list'] or {}).values()),
ships['unavailable_list']
)
)
# Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
if shipyard and this.shipyard != (horizons, shipyard):
self.send(data['commander']['name'], {
'$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''),
'$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', data['timestamp']),
('systemName', data['lastSystem']['name']),
@ -234,11 +334,20 @@ class EDDN(object):
('ships', shipyard),
]),
})
this.shipyard = (horizons, shipyard)
def export_journal_commodities(self, cmdr, is_beta, entry):
items = entry.get('Items') or []
commodities = sorted([OrderedDict([
def export_journal_commodities(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
export_journal_commodities updates EDDN with the commodities list on the current station (lastStarport) from
data in the journal. As a side effect, it also updates this.commodities with the data
:param cmdr: The commander to send data under
:param is_beta: whether or not we're in beta mode
:param entry: the journal entry containing the commodities data
"""
items: List[Mapping[str, Any]] = entry.get('Items') or []
commodities: Sequence[OrderedDictT[AnyStr, Any]] = sorted((OrderedDict([
('name', self.canonicalise(commodity['Name'])),
('meanPrice', commodity['MeanPrice']),
('buyPrice', commodity['BuyPrice']),
@ -247,11 +356,11 @@ class EDDN(object):
('sellPrice', commodity['SellPrice']),
('demand', commodity['Demand']),
('demandBracket', commodity['DemandBracket']),
]) for commodity in items], key = lambda c: c['name'])
]) for commodity in items), key=lambda c: c['name'])
if commodities and this.commodities != commodities: # Don't send empty commodities list - schema won't allow it
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([
('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']),
@ -260,15 +369,30 @@ class EDDN(object):
('commodities', commodities),
]),
})
this.commodities = commodities
def export_journal_outfitting(self, cmdr, is_beta, entry):
modules = entry.get('Items') or []
horizons = entry.get('Horizons', False)
outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['Name']) for module in modules if module['Name'] != 'int_planetapproachsuite'])
if outfitting and this.outfitting != (horizons, outfitting): # Don't send empty modules list - schema won't allow it
this.commodities: OrderedDictT[str, Any] = commodities
def export_journal_outfitting(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
export_journal_outfitting updates EDDN with station outfitting based on a journal entry. As a side effect,
it also updates this.outfitting with the data
:param cmdr: The commander to send data under
:param is_beta: Whether or not we're in beta mode
:param entry: The relevant journal entry
"""
modules: List[Mapping[str, Any]] = entry.get('Items', [])
horizons: bool = entry.get('Horizons', False)
# outfitting = sorted([self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), module['Name'])
# for module in modules if module['Name'] != 'int_planetapproachsuite'])
outfitting: List[str] = sorted(
self.MODULE_RE.sub(lambda m: m.group(0).capitalize(), mod['Name']) for mod in
filter(lambda m: m['Name'] != 'int_planetapproachsuite', modules)
)
# Don't send empty modules list - schema won't allow it
if outfitting and this.outfitting != (horizons, outfitting):
self.send(cmdr, {
'$schemaRef' : 'https://eddn.edcd.io/schemas/outfitting/2' + (is_beta and '/test' or ''),
'$schemaRef': f'https://eddn.edcd.io/schemas/outfitting/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']),
@ -278,15 +402,25 @@ class EDDN(object):
('modules', outfitting),
]),
})
this.outfitting = (horizons, outfitting)
def export_journal_shipyard(self, cmdr, is_beta, entry):
ships = entry.get('PriceList') or []
horizons = entry.get('Horizons', False)
shipyard = sorted([ship['ShipType'] for ship in ships])
if shipyard and this.shipyard != (horizons, shipyard): # Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
def export_journal_shipyard(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
export_journal_shipyard updates EDDN with station shipyard data based on a journal entry. As a side effect,
this.shipyard is updated with the data.
:param cmdr: the commander to send this update under
:param is_beta: Whether or not we're in beta mode
:param entry: the relevant journal entry
"""
ships: List[Mapping[str, Any]] = entry.get('PriceList') or []
horizons: bool = entry.get('Horizons', False)
shipyard = sorted(ship['ShipType'] for ship in ships)
# Don't send empty ships list - shipyard data is only guaranteed present if user has visited the shipyard.
if shipyard and this.shipyard != (horizons, shipyard):
self.send(cmdr, {
'$schemaRef' : 'https://eddn.edcd.io/schemas/shipyard/2' + (is_beta and '/test' or ''),
'$schemaRef': f'https://eddn.edcd.io/schemas/shipyard/2{"/test" if is_beta else ""}',
'message': OrderedDict([
('timestamp', entry['timestamp']),
('systemName', entry['StarSystem']),
@ -296,130 +430,206 @@ class EDDN(object):
('ships', shipyard),
]),
})
this.shipyard = (horizons, shipyard)
def export_journal_entry(self, cmdr, is_beta, entry):
def export_journal_entry(self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]) -> None:
"""
export_journal_entry updates EDDN with a line from the journal. Additionally if additional lines are cached,
it may send those as well.
:param cmdr: the commander under which this upload is made
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
msg = {
'$schemaRef' : 'https://eddn.edcd.io/schemas/journal/1' + (is_beta and '/test' or ''),
'$schemaRef': f'https://eddn.edcd.io/schemas/journal/1{"/test" if is_beta else ""}',
'message': entry
}
if self.replayfile or self.load():
if self.replayfile or self.load_journal_replay():
# Store the entry
self.replaylog.append(json.dumps([cmdr, msg]))
self.replayfile.write('%s\n' % self.replaylog[-1])
self.replayfile.write(f'{self.replaylog[-1]}\n')
if (entry['event'] == 'Docked' or
(entry['event'] == 'Location' and entry['Docked']) or
not (config.getint('output') & config.OUT_SYS_DELAY)):
if (
entry['event'] == 'Docked' or (entry['event'] == 'Location' and entry['Docked']) or not
(config.getint('output') & config.OUT_SYS_DELAY)
):
self.parent.after(self.REPLAYPERIOD, self.sendreplay) # Try to send this and previous entries
else:
# Can't access replay file! Send immediately.
status = self.parent.children['status']
status: MutableMapping[str, str] = self.parent.children['status']
status['text'] = _('Sending data to EDDN...')
self.parent.update_idletasks()
self.send(cmdr, msg)
status['text'] = ''
def canonicalise(self, item):
def canonicalise(self, item: str) -> str:
match = self.CANONICALISE_RE.match(item)
return match and match.group(1) or item
# Plugin callbacks
def plugin_start3(plugin_dir):
def plugin_start3(plugin_dir: str) -> str:
return 'EDDN'
def plugin_app(parent):
def plugin_app(parent: tk.Tk) -> None:
this.parent = parent
this.eddn = EDDN(parent)
# Try to obtain exclusive lock on journal cache, even if we don't need it yet
if not this.eddn.load():
this.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing
if not this.eddn.load_journal_replay():
# Shouldn't happen - don't bother localizing
this.status['text'] = 'Error: Is another copy of this app already running?'
def plugin_prefs(parent, cmdr, is_beta):
PADX = 10
BUTTONX = 12 # indent Checkbuttons and Radiobuttons
PADY = 2 # close spacing
def plugin_prefs(parent, cmdr: str, is_beta: bool) -> Frame:
PADX = 10 # noqa: N806
BUTTONX = 12 # noqa: N806 # indent Checkbuttons and Radiobuttons
if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.getint('output'))):
output = (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN) # default settings
output: int = (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN) # default settings
else:
output = config.getint('output')
output: int = config.getint('output')
eddnframe = nb.Frame(parent)
HyperlinkLabel(eddnframe, text='Elite Dangerous Data Network', background=nb.Label().cget('background'), url='https://github.com/EDSM-NET/EDDN/wiki', underline=True).grid(padx=PADX, sticky=tk.W) # Don't translate
HyperlinkLabel(
eddnframe,
text='Elite Dangerous Data Network',
background=nb.Label().cget('background'),
url='https://github.com/EDSM-NET/EDDN/wiki',
underline=True
).grid(padx=PADX, sticky=tk.W) # Don't translate
this.eddn_station = tk.IntVar(value=(output & config.OUT_MKT_EDDN) and 1)
this.eddn_station_button = nb.Checkbutton(eddnframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=this.eddn_station, command=prefsvarchanged) # Output setting
this.eddn_station_button = nb.Checkbutton(
eddnframe,
text=_('Send station data to the Elite Dangerous Data Network'),
variable=this.eddn_station,
command=prefsvarchanged
) # Output setting
this.eddn_station_button.grid(padx=BUTTONX, pady=(5, 0), sticky=tk.W)
this.eddn_system = tk.IntVar(value=(output & config.OUT_SYS_EDDN) and 1)
this.eddn_system_button = nb.Checkbutton(eddnframe, text=_('Send system and scan data to the Elite Dangerous Data Network'), variable=this.eddn_system, command=prefsvarchanged) # Output setting new in E:D 2.2
# 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)
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
# Output setting under 'Send system and scan data to the Elite Dangerous Data Network' new in E:D 2.2
this.eddn_delay_button = nb.Checkbutton(
eddnframe,
text=_('Delay sending until docked'),
variable=this.eddn_delay
)
this.eddn_delay_button.grid(padx=BUTTONX, sticky=tk.W)
return eddnframe
def prefsvarchanged(event=None):
def prefsvarchanged(event=None) -> None:
this.eddn_station_button['state'] = tk.NORMAL
this.eddn_system_button['state'] = tk.NORMAL
this.eddn_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.set(
'output',
(config.getint('output') & (config.OUT_MKT_TD | config.OUT_MKT_CSV | config.OUT_SHIP | config.OUT_MKT_MANUAL)) +
(this.eddn_station.get() and config.OUT_MKT_EDDN) +
(this.eddn_system.get() and config.OUT_SYS_EDDN) +
(this.eddn_delay.get() and config.OUT_SYS_DELAY))
(this.eddn_delay.get() and config.OUT_SYS_DELAY)
)
def plugin_stop():
def plugin_stop() -> None:
this.eddn.close()
def journal_entry(cmdr, is_beta, system, station, entry, state):
def journal_entry(
cmdr: str, is_beta: bool, system: str, station: str, entry: MutableMapping[str, Any], state: Mapping[str, Any]
) -> Optional[str]:
# Recursively filter '*_Localised' keys from dict
def filter_localised(d):
filtered = OrderedDict()
def filter_localised(d: Mapping[str, Any]) -> OrderedDictT[str, Any]:
filtered: OrderedDictT[str, Any] = OrderedDict()
for k, v in d.items():
if k.endswith('_Localised'):
pass
elif hasattr(v, 'items'): # dict -> recurse
filtered[k] = filter_localised(v)
elif isinstance(v, list): # list of dicts -> recurse
filtered[k] = [filter_localised(x) if hasattr(x, 'items') else x for x in v]
else:
filtered[k] = v
return filtered
# Track location
if entry['event'] in ['Location', 'FSDJump', 'Docked', 'CarrierJump']:
if entry['event'] in ('Location', 'FSDJump', 'Docked', 'CarrierJump'):
if entry['event'] in ('Location', 'CarrierJump'):
this.planet = entry.get('Body') if entry.get('BodyType') == 'Planet' else None
this.planet: Optional[str] = entry.get('Body') if entry.get('BodyType') == 'Planet' else None
elif entry['event'] == 'FSDJump':
this.planet = None
this.planet: Optional[str] = None
if 'StarPos' in entry:
this.coordinates = tuple(entry['StarPos'])
this.coordinates: Optional[Tuple[int, int, int]] = tuple(entry['StarPos'])
elif this.systemaddress != entry.get('SystemAddress'):
this.coordinates = None # Docked event doesn't include coordinates
this.systemaddress = entry.get('SystemAddress')
this.coordinates: Optional[Tuple[int, int, int]] = None # Docked event doesn't include coordinates
this.systemaddress: Optional[str] = entry.get('SystemAddress')
elif entry['event'] == 'ApproachBody':
this.planet = entry['Body']
elif entry['event'] in ['LeaveBody', 'SupercruiseEntry']:
elif entry['event'] in ('LeaveBody', 'SupercruiseEntry'):
this.planet = None
# Send interesting events to EDDN, but not when on a crew
if (config.getint('output') & config.OUT_SYS_EDDN and not state['Captain'] and
(entry['event'] in ('Location', 'FSDJump', 'Docked', 'Scan', 'SAASignalsFound', 'CarrierJump')) and
('StarPos' in entry or this.coordinates)):
# strip out properties disallowed by the schema
for thing in ['ActiveFine', 'CockpitBreach', 'BoostUsed', 'FuelLevel', 'FuelUsed', 'JumpDist', 'Latitude', 'Longitude', 'Wanted']:
for thing in (
'ActiveFine',
'CockpitBreach',
'BoostUsed',
'FuelLevel',
'FuelUsed',
'JumpDist',
'Latitude',
'Longitude',
'Wanted'
):
entry.pop(thing, None)
if 'Factions' in entry:
# Filter faction state. `entry` is a shallow copy so replace 'Factions' value rather than modify in-place.
entry['Factions'] = [ {k: v for k, v in f.items() if k not in ['HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction']} for f in entry['Factions']]
# Filter faction state to comply with schema restrictions regarding personal data. `entry` is a shallow copy
# so replace 'Factions' value rather than modify in-place.
entry['Factions'] = [
{
k: v for k, v in f.items() if k not in (
'HappiestSystem', 'HomeSystem', 'MyReputation', 'SquadronFaction'
)
}
for f in entry['Factions']
]
# add planet to Docked event for planetary stations if known
if entry['event'] == 'Docked' and this.planet:
@ -429,50 +639,66 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
# add mandatory StarSystem, StarPos and SystemAddress properties to Scan events
if 'StarSystem' not in entry:
if not system:
return("system is None, can't add StarSystem")
logger.warn("system is None, can't add StarSystem")
return "system is None, can't add StarSystem"
entry['StarSystem'] = system
if 'StarPos' not in entry:
if not this.coordinates:
return("this.coordinates is None, can't add StarPos")
logger.warn("this.coordinates is None, can't add StarPos")
return "this.coordinates is None, can't add StarPos"
entry['StarPos'] = list(this.coordinates)
if 'SystemAddress' not in entry:
if not this.systemaddress:
return("this.systemaddress is None, can't add SystemAddress")
logger.warn("this.systemaddress is None, can't add SystemAddress")
return "this.systemaddress is None, can't add SystemAddress"
entry['SystemAddress'] = this.systemaddress
try:
this.eddn.export_journal_entry(cmdr, is_beta, filter_localised(entry))
except requests.exceptions.RequestException as e:
if __debug__: print_exc()
logger.debug('Failed in export_journal_entry', exc_info=e)
return _("Error: Can't connect to EDDN")
except Exception as e:
if __debug__: print_exc()
logger.debug('Failed in export_journal_entry', exc_info=e)
return str(e)
elif (config.getint('output') & config.OUT_MKT_EDDN and not state['Captain'] and
entry['event'] in ['Market', 'Outfitting', 'Shipyard']):
entry['event'] in ('Market', 'Outfitting', 'Shipyard')):
try:
if this.marketId != entry['MarketID']:
this.commodities = this.outfitting = this.shipyard = None
this.marketId = entry['MarketID']
with open(join(config.get('journaldir') or config.default_journal_dir, '%s.json' % entry['event']), 'rb') as h:
entry = json.load(h)
path = pathlib.Path(str(config.get('journaldir') or config.default_journal_dir)) / f'{entry["event"]}.json'
with path.open('rb') as f:
entry = json.load(f)
if entry['event'] == 'Market':
this.eddn.export_journal_commodities(cmdr, is_beta, entry)
elif entry['event'] == 'Outfitting':
this.eddn.export_journal_outfitting(cmdr, is_beta, entry)
elif entry['event'] == 'Shipyard':
this.eddn.export_journal_shipyard(cmdr, is_beta, entry)
except requests.exceptions.RequestException as e:
if __debug__: print_exc()
logger.debug(f'Failed exporting {entry["event"]}', exc_info=e)
return _("Error: Can't connect to EDDN")
except Exception as e:
if __debug__: print_exc()
logger.debug(f'Failed exporting {entry["event"]}', exc_info=e)
return str(e)
def cmdr_data(data, is_beta):
def cmdr_data(data: Mapping[str, Any], is_beta: bool) -> str:
if data['commander'].get('docked') and config.getint('output') & config.OUT_MKT_EDDN:
try:
if this.marketId != data['lastStarport']['id']:
@ -484,6 +710,7 @@ def cmdr_data(data, is_beta):
if not old_status:
status['text'] = _('Sending data to EDDN...')
status.update_idletasks()
this.eddn.export_commodities(data, is_beta)
this.eddn.export_outfitting(data, is_beta)
this.eddn.export_shipyard(data, is_beta)
@ -492,9 +719,20 @@ def cmdr_data(data, is_beta):
status.update_idletasks()
except requests.RequestException as e:
if __debug__: print_exc()
logger.debug('Failed exporting data', exc_info=e)
return _("Error: Can't connect to EDDN")
except Exception as e:
if __debug__: print_exc()
logger.debug('Failed exporting data', exc_info=e)
return str(e)
MAP_STR_ANY = Mapping[str, Any]
def is_horizons(economies: MAP_STR_ANY, modules: MAP_STR_ANY, ships: MAP_STR_ANY) -> bool:
return (
any(economy['name'] == 'Colony' for economy in economies.values()) or
any(module.get('sku') == HORIZ_SKU for module in modules.values()) or
any(ship.get('sku') == HORIZ_SKU for ship in (ships['shipyard_list'] or {}).values())
)

View File

@ -16,16 +16,16 @@ import requests
import sys
from queue import Queue
from threading import Thread
import logging
import tkinter as tk
from ttkHyperlinkLabel import HyperlinkLabel
import myNotebook as nb
import myNotebook as nb # noqa: N813
from config import appname, applongname, appversion, config
import plug
if __debug__:
from traceback import print_exc
logger = logging.getLogger(appname)
EDSM_POLL = 0.1
_TIMEOUT = 20
@ -371,23 +371,28 @@ def worker():
r.raise_for_status()
reply = r.json()
(msgnum, msg) = reply['msgnum'], reply['msg']
# 1xx = OK, 2xx = fatal error, 3&4xx not generated at top-level, 5xx = error but events saved for later processing
# 1xx = OK
# 2xx = fatal error
# 3&4xx not generated at top-level
# 5xx = error but events saved for later processing
if msgnum // 100 == 2:
print('EDSM\t%s %s\t%s' % (msgnum, msg, json.dumps(pending, separators = (',', ': '))))
logger.warning(f'EDSM\t{msgnum} {msg}\t{json.dumps(pending, separators = (",", ": "))}')
plug.show_error(_('Error: EDSM {MSG}').format(MSG=msg))
else:
for e, r in zip(pending, reply['events']):
if not closing and e['event'] in ['StartUp', 'Location', 'FSDJump', 'CarrierJump']:
# Update main window's system status
this.lastlookup = r
this.system_link.event_generate('<<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:
print('EDSM\t%s %s\t%s' % (r['msgnum'], r['msg'], json.dumps(e, separators = (',', ': '))))
logger.warning(f'EDSM\t{r["msgnum"]} {r["msg"]}\t'
f'{json.dumps(e, separators = (",", ": "))}')
pending = []
break
except:
if __debug__: print_exc()
except Exception as e:
logger.debug('Sending API events', exc_info=e)
retrying += 1
else:
plug.show_error(_("Error: Can't connect to EDSM"))

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-polyfill==1.0.2
flake8-json
flake8-isort==3.0.1
pep8-naming==0.11.1
# 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