-
Notifications
You must be signed in to change notification settings - Fork 156
/
EDMC.py
executable file
·506 lines (410 loc) · 19.8 KB
/
EDMC.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
#!/usr/bin/env python3
"""
EDMC.py - Command-line interface. Requires prior setup through the GUI.
Copyright (c) EDCD, All Rights Reserved
Licensed under the GNU General Public License.
See LICENSE file.
"""
from __future__ import annotations
import argparse
import json
import locale
import os
import queue
import sys
from time import sleep, time
from typing import TYPE_CHECKING, Any
# isort: off
os.environ["EDMC_NO_UI"] = "1"
# See EDMCLogging.py docs.
# workaround for https://github.com/EDCD/EDMarketConnector/issues/568
from EDMCLogging import edmclogger, logger, logging
if TYPE_CHECKING:
from logging import TRACE # type: ignore # noqa: F401 # needed to make mypy happy
edmclogger.set_channels_loglevel(logging.INFO)
# isort: on
import collate
import commodity
import companion
import edshipyard
from l10n import translations as tr
import loadout
import outfitting
import shipyard
import stats
from commodity import COMMODITY_DEFAULT
from config import appcmdname, appversion, config
from monitor import monitor
from update import EDMCVersion, Updater, check_for_fdev_updates
sys.path.append(config.internal_plugin_dir)
# This import must be after the sys.path.append.
# The sys.path.append has to be after `import sys` and `from config import config`
# isort: off
import eddn # noqa: E402
# isort: on
def log_locale(prefix: str) -> None:
"""Log the current state of locale settings."""
logger.debug(f'''Locale: {prefix}
Locale LC_COLLATE: {locale.getlocale(locale.LC_COLLATE)}
Locale LC_CTYPE: {locale.getlocale(locale.LC_CTYPE)}
Locale LC_MONETARY: {locale.getlocale(locale.LC_MONETARY)}
Locale LC_NUMERIC: {locale.getlocale(locale.LC_NUMERIC)}
Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}'''
)
tr.install_dummy()
SERVER_RETRY = 5 # retry pause for Companion servers [s]
EXIT_SUCCESS, EXIT_SERVER, EXIT_CREDENTIALS, EXIT_VERIFICATION, EXIT_LAGGING, EXIT_SYS_ERR, EXIT_ARGS, \
EXIT_JOURNAL_READ_ERR, EXIT_COMMANDER_UNKNOWN = range(9)
def versioncmp(versionstring) -> list:
"""Quick and dirty version comparison assuming "strict" numeric only version numbers."""
return list(map(int, versionstring.split('.')))
def deep_get(target: dict | companion.CAPIData, *args: str, default=None) -> Any:
"""
Walk into a dict and return the specified deep value.
Example usage:
>>> thing = {'a': {'b': {'c': 'foo'} } }
>>> deep_get(thing, ('a', 'b', 'c'), None)
'foo'
>>> deep_get(thing, ('a', 'b'), None)
{'c': 'foo'}
>>> deep_get(thing, ('a', 'd'), None)
None
:param target: The dict to walk into for the desired value.
:param args: The list of keys to walk down through.
:param default: What to return if the target has no value.
:return: The value at the target deep key.
"""
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(): # noqa: C901, CCR001
"""Run the main code of the program."""
try:
# arg parsing
parser = argparse.ArgumentParser(
prog=appcmdname,
description='Prints the current system and station (if docked) to stdout and optionally writes player '
'status, ship locations, ship loadout and/or station data to file. '
'Requires prior setup through the accompanying GUI app.'
)
parser.add_argument('-v', '--version', help='print program version and exit', action='store_const', const=True)
group_loglevel = parser.add_mutually_exclusive_group()
group_loglevel.add_argument('--loglevel',
metavar='loglevel',
help='Set the logging loglevel to one of: '
'CRITICAL, ERROR, WARNING, INFO, DEBUG, TRACE',
)
parser.add_argument(
'--trace',
help='Set the Debug logging loglevel to TRACE',
action='store_true',
)
parser.add_argument(
'--trace-on',
help='Mark the selected trace logging as active. "*" or "all" is equivalent to --trace-all',
action='append',
)
parser.add_argument(
"--trace-all",
help='Force trace level logging, with all possible --trace-on values active.',
action='store_true'
)
parser.add_argument('-a', metavar='FILE', help='write ship loadout to FILE in Companion API json format')
parser.add_argument('-e', metavar='FILE', help='write ship loadout to FILE in E:D Shipyard plain text format')
parser.add_argument('-l', metavar='FILE', help='write ship locations to FILE in CSV format')
parser.add_argument('-m', metavar='FILE', help='write station commodity market data to FILE in CSV format')
parser.add_argument('-o', metavar='FILE', help='write station outfitting data to FILE in CSV format')
parser.add_argument('-s', metavar='FILE', help='write station shipyard data to FILE in CSV format')
parser.add_argument('-t', metavar='FILE', help='write player status to FILE in CSV format')
parser.add_argument('-d', metavar='FILE', help='write raw JSON data to FILE')
parser.add_argument('-n', action='store_true', help='send data to EDDN')
parser.add_argument('-p', metavar='CMDR', help='Returns data from the specified player account')
parser.add_argument('-j', help=argparse.SUPPRESS) # Import JSON dump
args = parser.parse_args()
if args.version:
updater = Updater()
newversion: EDMCVersion | None = updater.check_appcast()
if newversion:
# LANG: Update Available Text
newverstr: str = tr.tl("{NEWVER} is available").format(NEWVER=newversion.title)
print(f'{appversion()} ({newverstr})')
else:
print(appversion())
return
level_to_set: int | None = None
if args.trace or args.trace_on:
level_to_set = logging.TRACE # type: ignore # it exists
logger.info('Setting TRACE level debugging due to either --trace or a --trace-on')
if args.trace_all or (args.trace_on and ('*' in args.trace_on or 'all' in args.trace_on)):
level_to_set = logging.TRACE_ALL # type: ignore # it exists
logger.info('Setting TRACE_ALL level debugging due to either --trace-all or a --trace-on *|all')
if level_to_set is not None:
logger.setLevel(level_to_set)
edmclogger.set_channels_loglevel(level_to_set)
elif args.loglevel:
if args.loglevel not in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'TRACE'):
print('loglevel must be one of: CRITICAL, ERROR, WARNING, INFO, DEBUG, TRACE', file=sys.stderr)
sys.exit(EXIT_ARGS)
edmclogger.set_channels_loglevel(args.loglevel)
logger.debug(f'Startup v{appversion()} : Running on Python v{sys.version}')
logger.debug(f'''Platform: {sys.platform}
argv[0]: {sys.argv[0]}
exec_prefix: {sys.exec_prefix}
executable: {sys.executable}
sys.path: {sys.path}'''
)
if args.trace_on and len(args.trace_on) > 0:
import config as conf_module
conf_module.trace_on = [x.casefold() for x in args.trace_on] # duplicate the list just in case
for d in conf_module.trace_on:
logger.info(f'marked {d} for TRACE')
log_locale('Initial Locale')
if args.j:
logger.debug('Import and collate from JSON dump')
# Import and collate from JSON dump
#
# Try twice, once with the system locale and once enforcing utf-8. If the file was made on the current
# system, chances are its the current locale, and not utf-8. Otherwise if it was copied, its probably
# utf8. Either way, try the system FIRST because reading something like cp1251 in UTF-8 results in garbage
# but the reverse results in an exception.
json_file = os.path.abspath(args.j)
try:
with open(json_file) as file_handle:
data = json.load(file_handle)
except UnicodeDecodeError:
with open(json_file, encoding='utf-8') as file_handle:
data = json.load(file_handle)
config.set('querytime', int(os.path.getmtime(args.j)))
else:
# Get state from latest Journal file
logger.debug('Getting state from latest journal file')
try:
monitor.currentdir = config.get_str('journaldir', default=config.default_journal_dir)
if not monitor.currentdir:
monitor.currentdir = config.default_journal_dir
logger.debug(f'logdir = "{monitor.currentdir}"')
logfile = monitor.journal_newest_filename(monitor.currentdir)
if logfile is None:
raise ValueError("None from monitor.journal_newest_filename")
logger.debug(f'Using logfile "{logfile}"')
with open(logfile, 'rb', 0) as loghandle:
for line in loghandle:
try:
monitor.parse_entry(line)
except Exception:
logger.debug(f'Invalid journal entry {line!r}')
except Exception:
logger.exception("Can't read Journal file")
sys.exit(EXIT_JOURNAL_READ_ERR)
if not monitor.cmdr:
logger.error('Not available while E:D is at the main menu')
sys.exit(EXIT_COMMANDER_UNKNOWN)
# Get data from Companion API
if args.p:
logger.debug(f'Attempting to use commander "{args.p}"')
cmdrs = config.get_list('cmdrs', default=[])
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:
logger.debug(f'Attempting to use commander "{monitor.cmdr}" from Journal File')
cmdrs = config.get_list('cmdrs', default=[])
if monitor.cmdr not in cmdrs:
raise companion.CredentialsError()
companion.session.login(monitor.cmdr, monitor.is_beta)
###################################################################
# Initiate CAPI queries
querytime = int(time())
companion.session.station(query_time=querytime)
# Wait for the response
_capi_request_timeout = 60
try:
capi_response = companion.session.capi_response_queue.get(
block=True, timeout=_capi_request_timeout
)
except queue.Empty:
logger.error(f'CAPI requests timed out after {_capi_request_timeout} seconds')
sys.exit(EXIT_SERVER)
###################################################################
# noinspection DuplicatedCode
if isinstance(capi_response, companion.EDMCCAPIFailedRequest):
logger.trace_if('capi.worker', f'Failed Request: {capi_response.message}')
if capi_response.exception:
raise capi_response.exception
raise ValueError(capi_response.message)
logger.trace_if('capi.worker', 'Answer is not a Failure')
if not isinstance(capi_response, companion.EDMCCAPIResponse):
raise ValueError(f"Response was neither CAPIFailedRequest nor EDMCAPIResponse: {type(capi_response)}")
data = capi_response.capi_data
config.set('querytime', querytime)
# Validation
if not deep_get(data, 'commander', 'name', default='').strip():
logger.error("No data['command']['name'] from CAPI")
sys.exit(EXIT_SERVER)
elif (
not deep_get(data, 'lastSystem', 'name') or
data['commander'].get('docked') and not deep_get(data, 'lastStarport', 'name')
): # Only care if docked
logger.error("No data['lastSystem']['name'] from CAPI")
sys.exit(EXIT_SERVER)
elif not deep_get(data, 'ship', 'modules') or not deep_get(data, 'ship', 'name', default=''):
logger.error("No data['ship']['modules'] from CAPI")
sys.exit(EXIT_SERVER)
elif args.j:
pass # Skip further validation
elif data['commander']['name'] != monitor.cmdr:
raise companion.CmdrError()
elif (
data['lastSystem']['name'] != monitor.state['SystemName']
or (
(data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.state['StationName']
)
or data['ship']['id'] != monitor.state['ShipID']
or data['ship']['name'].lower() != monitor.state['ShipType']
):
raise companion.ServerLagging()
# stuff we can do when not docked
if args.d:
logger.debug(f'Writing raw JSON data to "{args.d}"')
out = json.dumps(dict(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:
logger.debug(f'Writing Ship Loadout in Companion API JSON format to "{args.a}"')
loadout.export(data, args.a)
if args.e:
logger.debug(f'Writing Ship Loadout in ED Shipyard plain text format to "{args.e}"')
edshipyard.export(data, args.e)
if args.l:
logger.debug(f'Writing Ship Locations in CSV format to "{args.l}"')
stats.export_ships(data, args.l)
if args.t:
logger.debug(f'Writing Player Status in CSV format to "{args.t}"')
stats.export_status(data, args.t)
if data['commander'].get('docked'):
print(f'{deep_get(data, "lastSystem", "name", default="Unknown")},'
f'{deep_get(data, "lastStarport", "name", default="Unknown")}'
)
else:
print(deep_get(data, 'lastSystem', 'name', default='Unknown'))
if args.m or args.o or args.s or args.n or args.j:
if not data['commander'].get('docked'):
logger.error("Can't use -m, -o, -s, -n or -j because you're not currently docked!")
return
if not deep_get(data, 'lastStarport', 'name'):
logger.error("No data['lastStarport']['name'] from CAPI")
sys.exit(EXIT_LAGGING)
# Ignore possibly missing shipyard info
if not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')):
logger.error("No commodities or outfitting (modules) in CAPI data")
return
else:
return
# Finally - the data looks sane and we're docked at a station
if args.j:
logger.debug('Importing data from the CAPI return...')
# Collate from JSON dump
collate.addcommodities(data)
collate.addmodules(data)
collate.addships(data)
if args.m:
logger.debug(f'Writing Station Commodity Market Data in CSV format to "{args.m}"')
if data['lastStarport'].get('commodities'):
# Fixup anomalies in the commodity data
fixed = companion.fixup(data)
commodity.export(fixed, COMMODITY_DEFAULT, args.m)
else:
logger.error("Station doesn't have a market")
if args.o:
if data['lastStarport'].get('modules'):
logger.debug(f'Writing Station Outfitting in CSV format to "{args.o}"')
outfitting.export(data, args.o)
else:
logger.error("Station doesn't supply outfitting")
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)
companion.session.station(int(time()))
# Wait for the response
_capi_request_timeout = 60
try:
capi_response = companion.session.capi_response_queue.get(
block=True, timeout=_capi_request_timeout
)
except queue.Empty:
logger.error(f'CAPI requests timed out after {_capi_request_timeout} seconds')
sys.exit(EXIT_SERVER)
if isinstance(capi_response, companion.EDMCCAPIFailedRequest):
logger.error(f'Failed Request: {capi_response.message}')
sys.exit(EXIT_SERVER)
new_data = capi_response.capi_data
# 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.state['SystemName']
and deep_get(new_data, 'lastStarport', 'name') == monitor.state['StationName']
):
data = new_data
if args.s:
if deep_get(data, 'lastStarport', 'ships', 'shipyard_list'):
logger.debug(f'Writing Station Shipyard in CSV format to "{args.s}"')
shipyard.export(data, args.s)
elif not args.j and monitor.stationservices and 'Shipyard' in monitor.stationservices:
logger.error('Failed to get shipyard data')
else:
logger.error("Station doesn't have a shipyard")
if args.n:
try:
eddn_sender = eddn.EDDN(None)
logger.debug('Sending Market, Outfitting and Shipyard data to EDDN...')
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:
logger.exception('Failed to send data to EDDN')
except companion.ServerConnectionError:
logger.exception('Exception while contacting server')
sys.exit(EXIT_SERVER)
except companion.ServerError:
logger.exception('Frontier CAPI Server returned an error')
sys.exit(EXIT_SERVER)
except companion.CredentialsError:
logger.error('Frontier CAPI Server: Invalid Credentials')
sys.exit(EXIT_CREDENTIALS)
# Companion API problem
except companion.ServerLagging:
logger.error(
'Mismatch(es) between CAPI and Journal for at least one of: '
'StarSystem, Last Star Port, Ship ID or Ship Name/Type'
)
sys.exit(EXIT_SERVER)
except companion.CmdrError: # Companion API return doesn't match Journal
logger.error(
f'Commander "{data["commander"]["name"]}" from CAPI doesn\'t match '
f'"{monitor.cmdr}" from Journal'
)
sys.exit(EXIT_SERVER)
except Exception:
logger.exception('"other" exception')
sys.exit(EXIT_SERVER)
if __name__ == '__main__':
try:
check_for_fdev_updates(silent=True)
main()
except KeyboardInterrupt:
logger.info("Ctrl+C Detected, Attempting Clean Shutdown")
logger.debug('Exiting')
sys.exit(EXIT_SUCCESS)