You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
338 lines
13 KiB
338 lines
13 KiB
#!/usr/bin/env python
|
|
"""
|
|
Run any program and show the output. The purpose is to allow VSCode's
|
|
python runner to run a minetest binary (such as if you are using VSCode
|
|
to edit Lua but want to run Minetest to test the Lua).
|
|
|
|
Usage:
|
|
run-any <minetest_executable_path>
|
|
"""
|
|
from __future__ import print_function
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import copy
|
|
import shlex
|
|
from pprint import pformat
|
|
from collections import OrderedDict
|
|
|
|
SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
REPO_DIR = os.path.dirname(SCRIPTS_DIR)
|
|
REPOS_DIR = os.path.dirname(REPO_DIR)
|
|
for try_dirname in ["outputinspector-python", "outputinspector"]:
|
|
try_path = os.path.join(REPOS_DIR, try_dirname)
|
|
if os.path.isfile(os.path.join(try_path, "outputinspector",
|
|
"__init__.py")):
|
|
# ^ Yes, it is in outputinspector/outputinspector/
|
|
# If in same git dir as REPO_DIR rather than installed as a module
|
|
sys.path.insert(0, try_path)
|
|
break
|
|
|
|
if sys.version_info.major < 3:
|
|
ModuleNotFoundError = ImportError
|
|
FileNotFoundError = IOError
|
|
input = raw_input
|
|
|
|
inspector = None
|
|
try:
|
|
import outputinspector
|
|
print("OutputInspector...FOUND.", file=sys.stderr)
|
|
from outputinspector import (
|
|
OutputInspector,
|
|
ROLE_COLLECTED_FILE,
|
|
ROLE_ROW,
|
|
ROLE_COL,
|
|
ROLE_LOWER,
|
|
ROLE_COLLECTED_LINE,
|
|
ROLE_DETAILS,
|
|
)
|
|
inspector = OutputInspector()
|
|
except ModuleNotFoundError as ex:
|
|
print(ex, file=sys.stderr)
|
|
print("INFO: Install outputinspector or clone to %s"
|
|
" for clickable Lua traceback lines"
|
|
% REPOS_DIR, file=sys.stderr)
|
|
|
|
def echo0(*args):
|
|
print(*args, file=sys.stderr)
|
|
return True
|
|
|
|
|
|
def usage():
|
|
echo0(__doc__)
|
|
|
|
|
|
def show_and_return(cmd, enable_collect=False, cwd=None, shell=False):
|
|
"""Show the output of a process while it is running & get return code.
|
|
|
|
Warning: Minetest doesn't start flushing data until the server
|
|
starts. Output from mainmenu is flushed around when server starts
|
|
(or when user exits main menu, whichever comes first). This behavior
|
|
is the same whether or not shell is True.
|
|
|
|
If running minetest, set cmd to [minetest_path, "--logfile", ""]
|
|
and set cwd to minetest directory (not "bin"!).
|
|
|
|
Args:
|
|
cmd (Union[list[string], string]): The command to run, either as
|
|
list of args where first is command and no quotes are
|
|
necessary, or space-separated string formatted the same way
|
|
except with quotes where necessary (if an arg
|
|
contains spaces).
|
|
- If is *not* str and shell is True, cmd is converted to
|
|
str.
|
|
enable_collect (Optional[boolean]): Enable collecting lines
|
|
in the 'lines' key of the return.
|
|
cwd (Optional[string]): Set a new current working dir.
|
|
shell (boolean): If True, run a shell (processes globs and
|
|
shell options that occur within cmd).
|
|
|
|
Returns:
|
|
dict: 'code' is return code of the command.
|
|
"""
|
|
# (See
|
|
# <https://cyberciti.biz/faq/python-run-external-command-and-get-output/>)
|
|
if shell not in [None, True, False]:
|
|
raise ValueError("Expected None, True, or False for shell but got %s"
|
|
% pformat(shell))
|
|
if cwd is None:
|
|
cwd = os.getcwd()
|
|
if shell and not hasattr(cmd, "split"):
|
|
# shell cannot correctly utilize a list/tuple (only first
|
|
# element is used!) so join as string to use all arguments:
|
|
cmd = shlex.join(cmd)
|
|
echo0("Running %s" % pformat(cmd))
|
|
proc = subprocess.Popen(cmd, shell=shell, stderr=subprocess.PIPE,
|
|
stdout=subprocess.PIPE, cwd=cwd)
|
|
code = None
|
|
# Do not wait for finish--start displaying output immediately
|
|
out = {
|
|
'bytes': None, # current line bytes
|
|
'string': "", # current line string (same as bytes if Python 2 running)
|
|
'buffer': "", # cumulative buffer
|
|
}
|
|
if enable_collect:
|
|
out['lines'] = [] # already-shown lines
|
|
err = copy.deepcopy(out) # sets err['lines'] if enable_collect
|
|
if enable_collect:
|
|
if id(err['lines']) == id(out['lines']):
|
|
raise RuntimeError(
|
|
"deepcopy filed. lines are same in both streams"
|
|
)
|
|
out['source'] = proc.stdout
|
|
err['source'] = proc.stderr
|
|
out['stream'] = sys.stdout
|
|
err['stream'] = sys.stderr
|
|
stream_metas = OrderedDict({
|
|
'out': out,
|
|
'err': err,
|
|
})
|
|
|
|
while True:
|
|
for name, meta in stream_metas.items():
|
|
# meta['bytes'] = err['source'].read(1)
|
|
# ^ scrambles letters (mixes stderr and stdout) somehow, so:
|
|
if name == "out":
|
|
meta['bytes'] = proc.stdout.read(1)
|
|
elif name == "err":
|
|
meta['bytes'] = proc.stderr.read(1)
|
|
meta['string'] = meta['bytes']
|
|
if (meta['string'] is not None) and (sys.version_info.major >= 3):
|
|
meta['string'] = meta['bytes'].decode("utf-8")
|
|
meta['buffer'] += meta['string']
|
|
|
|
code = proc.poll() # None if command isn't finished
|
|
if out['string'] == '' and err['string'] == '' and code is not None:
|
|
# If no more to write *and* there is a return code (finished)
|
|
for name, meta in stream_metas.items():
|
|
if len(meta['buffer']) > 0:
|
|
if name == "out":
|
|
print(meta['buffer'].rstrip("\n\r"))
|
|
else:
|
|
echo0(meta['buffer'].rstrip("\n\r"))
|
|
meta['lines'] += this_chunk.split("\n")
|
|
break
|
|
|
|
for _, meta in stream_metas.items():
|
|
# Write out['string'] to stdout then err['string'] to stderr
|
|
if meta['buffer'] == '':
|
|
continue
|
|
lastI = meta['buffer'].rfind("\n")
|
|
if lastI >= 0:
|
|
# Only show finished lines (avoid mixing stdout and
|
|
# stderr on the same line).
|
|
if code is not None:
|
|
# The program ended, so show everything.
|
|
lastI = len(meta['buffer']) - 1
|
|
# ^ -1 since inclusive (+1 below)
|
|
|
|
this_chunk = meta['buffer'][:lastI+1]
|
|
|
|
|
|
if enable_collect:
|
|
# Don't use append (since split always returns a list
|
|
# even if 1-long).
|
|
meta['lines'] += this_chunk.split("\n")
|
|
meta['stream'].write(this_chunk)
|
|
meta['stream'].flush()
|
|
meta['buffer'] = meta['buffer'][lastI+1:]
|
|
# else:
|
|
# echo0("Incomplete line:%s" % meta['buffer'])
|
|
for _, meta in stream_metas.items():
|
|
del meta['source']
|
|
del meta['stream']
|
|
del meta['bytes']
|
|
# del meta['string']
|
|
if len(meta['string']) > 0:
|
|
raise NotImplementedError(
|
|
"The remaining %s wasn't processed: %s"
|
|
% (_, pformat(meta['string']))
|
|
)
|
|
return {
|
|
'code': code,
|
|
'streams': stream_metas,
|
|
}
|
|
|
|
|
|
def main():
|
|
enablePush = True # collect the line in the inspector ui right away
|
|
if len(sys.argv) < 2:
|
|
usage()
|
|
echo0("Error: You didn't specify what program to run.")
|
|
return 1
|
|
# subprocess.call(sys.argv[1])
|
|
path = sys.argv[1]
|
|
if path.lower().endswith(".txt"):
|
|
raise ValueError("expected an executable!")
|
|
cwd = None
|
|
if os.path.isfile(path):
|
|
cwd = os.path.dirname(path)
|
|
if os.path.basename(cwd) == "bin":
|
|
# Minetest must run from minetest not from minetest/bin
|
|
# (especially when minetest is a script in minetest.org
|
|
# builds)
|
|
cwd = os.path.dirname(cwd)
|
|
# else:
|
|
# cwd = os.dirname(hierosoft.which(path)) # TODO: uncomment this case?
|
|
basename = os.path.basename(path)
|
|
project_name = basename.replace("server", "")
|
|
cmd = path
|
|
exeDir = os.path.dirname(path)
|
|
if project_name in ["minetest", "finetest", "trolltest", "multicraft"]:
|
|
cmd = [path, "--logfile", ""]
|
|
targetBaseDir = os.path.dirname(exeDir)
|
|
# minetest/ not minetest/bin/ is the root of paths in tracebacks
|
|
# Do not write debug.txt to cwd, since Python code will read
|
|
# stdout and stderr.
|
|
echo0("Running %s" % shlex.join(cmd))
|
|
else:
|
|
targetBaseDir = exeDir
|
|
echo0("Running %s" % path)
|
|
if inspector:
|
|
OutputInspector.addRoot(targetBaseDir)
|
|
enable_collect = True
|
|
results = show_and_return(
|
|
cmd,
|
|
enable_collect=enable_collect,
|
|
cwd=cwd,
|
|
# TODO: silent=not inspector, # If there is no outputinspector, show in realtime
|
|
)
|
|
# ^ TODO: push to outputinspector in realtime
|
|
if enable_collect:
|
|
# echo0("\nLINES:")
|
|
# for line in results['streams']['out']['lines']:
|
|
# print(line)
|
|
# for line in results['streams']['err']['lines']:
|
|
# echo0(line)
|
|
out = results['streams']['out']
|
|
err = results['streams']['err']
|
|
# else 'lines' lists will not be in the streams!
|
|
|
|
if results['code'] == 0:
|
|
echo0("No errors (return code 0)")
|
|
# return results['code']
|
|
|
|
err_index = len(out['lines'])
|
|
all_lines = out['lines'] + err['lines']
|
|
|
|
if inspector:
|
|
print("\nOutputInspector:")
|
|
vcsode_fmt = 'File "{file}", line {row} <- outputinspector <- {line}'
|
|
# ^ resolves https://github.com/Poikilos/outputinspector/issues/26
|
|
# such as 'File "/home/owner/git/world_clock/worldclocktk/__init__.py", line 232, in <module>'
|
|
fmt = vcsode_fmt
|
|
for line in all_lines:
|
|
inspector.addLine(line, enablePush)
|
|
# NOTE: addLine adds all of the metadata!
|
|
|
|
# inspector.processAllAddedLines() # ONLY post-processing such as TODOs
|
|
# In CLI mode of outputinspector, the line info must be
|
|
# processed since there is no GUI equivalent to lineinfo
|
|
# in this mode.
|
|
# mainListWidget is usually a subclass of tk.Listbox,
|
|
# but in CLI mode, it is using the notk submodule so
|
|
# access the dummy items:
|
|
for i, item in enumerate(inspector._ui.mainListWidget._items):
|
|
# lvi is a QtListViewItem, but in CLI mode it is only
|
|
# a dummy, so do something useful and make a properly-
|
|
# formatted line to be clickable in VSCode.
|
|
|
|
# This would actually open the text editor (!):
|
|
# inspector.on_mainListWidget_itemDoubleClicked(item)
|
|
|
|
# The code below is from on_mainListWidget_itemDoubleClicked:
|
|
actualJump = item.data(ROLE_COLLECTED_FILE).toString()
|
|
filePath = item.data(ROLE_COLLECTED_FILE).toString()
|
|
# FIXME: ^ why does it get the wrong thing?
|
|
# - gets "2023-08-12 20" when file is
|
|
# TODO: ^ Eliminate one of these in this code and
|
|
# in OutputInspector.
|
|
actualJumpLine = item.data(ROLE_COLLECTED_LINE).toString() #data not row!
|
|
citedRowS = (item.data(ROLE_ROW)).toString()
|
|
citedColS = (item.data(ROLE_COL)).toString()
|
|
info = inspector.getLineInfo(line, actualJump,
|
|
actualJumpLine, False)
|
|
if os.path.isfile(filePath): # Should already be unmangled
|
|
# FIXME: this basically always triggers:
|
|
# if info.get('file') is None:
|
|
# raise NotImplementedError("info['file'] and info['file'] = '%s' is missing" % filePath)
|
|
# elif not os.path.isfile(info['file']):
|
|
# raise NotImplementedError("info['file'] = '%s' is missing" % filePath)
|
|
if ((info.get('file') is None) or
|
|
(not os.path.isfile(info['file']))):
|
|
info['file'] = filePath.strip()
|
|
if (info.get('row') is None) or (not info['row'].strip()):
|
|
# FIXME: why isn't this in info......
|
|
info['row'] = item.data(ROLE_ROW).toString().strip()
|
|
|
|
less_info = {}
|
|
for key, value in info.items():
|
|
if value is None:
|
|
continue
|
|
if "{%s}" % key in fmt:
|
|
less_info[key] = str(value).strip()
|
|
showLine = fmt.format(**info)
|
|
if "\n" in showLine:
|
|
raise RuntimeError("Line wasn't clean of newlines.")
|
|
if "\r" in showLine:
|
|
raise RuntimeError("Line wasn't clean of returns.")
|
|
indentI = len(actualJumpLine.lstrip()) - len(actualJumpLine)
|
|
indent = actualJumpLine[:indentI]
|
|
print(indent+showLine, file=sys.stderr)
|
|
else:
|
|
print(actualJumpLine.rstrip(), file=sys.stderr)
|
|
# info has: {'file': '', 'row': '', 'line': '',
|
|
# 'column': '', 'language': '', 'good': 'False',
|
|
# 'lower': 'False', 'master': 'False', 'color': 'Default'}
|
|
|
|
# raise SyntaxError(
|
|
# "{} line(s)".format(len(all_lines))+"\n".join(all_lines)
|
|
# )
|
|
# else output should already have been shown in realtime.
|
|
return results['code']
|
|
|
|
|
|
if __name__ == "__main__":
|
|
code = main()
|
|
#sys.exit(code)
|
|
sys.exit(0) # Don't confuse VSCode. If nonzero, a popup will point here.
|
|
|