diff --git a/help50 b/help50 deleted file mode 100755 index bcda983..0000000 --- a/help50 +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python -import io -import os -import shlex -import shutil -import signal -import sys -import textwrap -import tempfile - -from argparse import ArgumentParser, REMAINDER -import requests -import termcolor -import pexpect - - -def main(): - def handler(signum, frame): - """Return 1 on Ctrl-C""" - print("") - sys.exit(1) - - signal.signal(signal.SIGINT, handler) - - parser = ArgumentParser(description="A command-line tool that helps " - "students understand error messages.") - parser.add_argument("-i", "--interactive", - help="allow error messages to be written to " - "stdin interactively", - action="store_true") - parser.add_argument("command", nargs=REMAINDER, - default=[], help="command to be run") - args = parser.parse_args() - - - if args.command: - # Capture stdout and stderr from process, and print it out - with tempfile.TemporaryFile(mode="r+b") as temp: - proc = pexpect.spawn(f"bash -c \"{' '.join(shlex.quote(word) for word in args.command)}\"", env=os.environ) - proc.logfile_read = temp - proc.interact() - proc.close() - - temp.seek(0) - script = temp.read().decode().replace("\r\n", "\n") - - # Interactive stdin - elif args.interactive: - script = sys.stdin.read() - - # Non-interactive stdin - elif not sys.stdin.isatty(): - with io.BytesIO() as iobytes: - tee(sys.stdin.buffer, iobytes, sys.stdout.buffer) - script = iobytes.getvalue().decode("utf-8") - - # Disallow interactive without `-i` input (as potentially confusing) - else: - print("Careful, you forgot to tell me with which command you " - "need help!") - sys.exit(1) - - termcolor.cprint("\nAsking for help...", "yellow", end="") - sys.stdout.flush() - - data = { - "cmd": " ".join(sys.argv[1:]), - "format": "ans", - "script": script, - "username": os.environ.get("C9_USER") - } - - # Read help50 url and from environment variable, - # defaulting to help.cs50.net - app_url = os.environ.get("APP_URL") or "https://help.cs50.net/" - - # Get number of columns in terminal, defaulting to 80 - columns, _ = shutil.get_terminal_size() - if columns == 0: columns = 80 - - # Connect to server and print response, showing error message if unable to - try: - # Connect to server - r = requests.post(app_url, data) - except requests.exceptions.RequestException: - termcolor.cprint(wrap("Ack, there seems to be a bug in help50! " - "Please let sysadmins@cs50.harvard.edu know " - "with which error you need help!", - columns), - "yellow", end="") - sys.exit(1) - else: - # Overwrite previous line with spaces - print("\r{}\r".format(" " * columns), end="") - termcolor.cprint(wrap(r.text.encode("utf-8").decode("unicode_escape"), - columns), - "yellow", end="") - - -def tee(input, *outputs): - """Executes command, piping stdout and stderr to *outputs.""" - while True: - try: - byte = input.read(1) - except EOFError: - byte = None - - if not byte: - break - - for output in outputs: - output.write(byte) - output.flush() - - -def wrap(string, columns): - """ Wraps a string to the specified number of columns, - preserving blank lines. - """ - return "\n".join( - ("\n".join(textwrap.wrap(line, columns)) - for line in string.split("\n"))) - - -if __name__ == "__main__": - main() diff --git a/help50/__init__.py b/help50/__init__.py new file mode 100644 index 0000000..e1baa0b --- /dev/null +++ b/help50/__init__.py @@ -0,0 +1,45 @@ + +from pkg_resources import get_distribution, DistributionNotFound +import os + +# https://stackoverflow.com/questions/17583443/what-is-the-correct-way-to-share-package-version-with-setup-py-and-the-package +try: + _dist = get_distribution("help50") + # Normalize path for cross-OS compatibility. + _dist_loc = os.path.normcase(_dist.location) + _here = os.path.normcase(__file__) + if not _here.startswith(os.path.join(_dist_loc, "help50")): + # This version is not installed, but another version is. + raise DistributionNotFound +except DistributionNotFound: + __version__ = "locally installed, no version information available" +else: + __version__ = _dist.version + + +import collections + +HELPERS = collections.defaultdict(list) +PREPROCESSORS = collections.defaultdict(list) + + +def helper(*domains): + """ Decorator that indicateas that a given function is a helper. """ + def decorator(func): + for domain in domains: + HELPERS[domain].append(func) + return func + return decorator + + +def preprocessor(*domains): + """ Decorator that indicateas that a given function is a preprocessor. """ + def decorator(func): + for domain in domains: + PREPROCESSORS[domain].append(func) + return func + return decorator + + +class Error(Exception): + pass diff --git a/help50/__main__.py b/help50/__main__.py new file mode 100644 index 0000000..44c567d --- /dev/null +++ b/help50/__main__.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +import io +import os +import re +import shlex +import sys +import tempfile +import traceback + +from argparse import ArgumentParser, REMAINDER +import lib50 +import termcolor +import pexpect + +from . import __version__, internal, Error + + +def excepthook(cls, exc, tb): + if (issubclass(cls, lib50.Error) or issubclass(cls, Error)) and exc.args: + termcolor.cprint(str(exc), "red", file=sys.stderr) + elif issubclass(cls, FileNotFoundError): + termcolor.cprint(f"{exc.filename} not found", "red", file=sys.stderr) + elif issubclass(cls, KeyboardInterrupt): + print() + elif not issubclass(cls, Exception): + return + else: + termcolor.cprint("Sorry, something's wrong! Let sysadmins@cs50.harvard.edu know!", "red", file=sys.stderr) + + if excepthook.verbose: + traceback.print_exception(cls, exc, tb) + + sys.exit(1) + + +excepthook.verbose = True +sys.excepthook = excepthook + + +def render_help(help): + """ + Display help message to student. + + `help` should be a 2-tuple whose first element is the part of the error + message that is being translated and whose second element is the + translation itself. + """ + if help is None: + termcolor.cprint("Sorry, help50 does not yet know how to help with this!", "yellow") + else: + for line in help[0]: + termcolor.cprint(line, "grey", "on_yellow") + print() + termcolor.cprint(re.sub(r"`([^`]+)`", r"\033[1m\1\033[22m", " " .join(help[1])), "yellow") + + +def main(): + parser = ArgumentParser(prog="help50", + description="A command-line tool that helps " + "students understand error messages.") + parser.add_argument("-s", "--slug", help="identifier indicating from where to download helpers", default="cs50/helpers/master") + parser.add_argument("-d", "--dev", help="slug will be treated as a local path, useful for developing helpers (implies --verbose)", action="store_true") + parser.add_argument("-i", "--interactive", help="read command output from stdin instead of running a command", action="store_true") + parser.add_argument("-v", "--verbose", help="display the full tracebacks of any errors", action="store_true") + parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument("command", nargs=REMAINDER, + default=[], help="command to be run") + args = parser.parse_args() + + if args.dev: + args.verbose = True + + excepthook.verbose = args.verbose + + + if args.interactive: + script = sys.stdin.read() + elif args.command: + # Capture stdout and stderr from process, and print it out + with tempfile.TemporaryFile(mode="r+b") as temp: + env = os.environ.copy() + # Hack to prevent some programs from wrapping their error messages + env["COLUMNS"] = "5050" + proc = pexpect.spawn(f"bash -lc \"{' '.join(shlex.quote(word) for word in args.command)}\"", env=env) + proc.logfile_read = temp + proc.interact() + proc.close() + + temp.seek(0) + script = temp.read().decode().replace("\r\n", "\n") + else: + raise Error("Careful, you forgot to tell me with which command you " + "need help!") + termcolor.cprint("\nAsking for help...\n", "yellow") + + try: + helpers_dir = args.slug if args.dev else lib50.local(args.slug) + except lib50.Error: + raise Error("Failed to fetch helpers, please ensure that you are connected to the internet!") + + internal.load_helpers(helpers_dir) + render_help(internal.get_help(script)) + + +if __name__ == "__main__": + main() diff --git a/help50/internal.py b/help50/internal.py new file mode 100644 index 0000000..fe596aa --- /dev/null +++ b/help50/internal.py @@ -0,0 +1,79 @@ +import contextlib +import os +import re +import sys + +import lib50 + +from . import HELPERS, PREPROCESSORS + +lib50.set_local_path(os.environ.get("HELP50_PATH", "~/.local/share/help50")) +CONFIG_LOADER = lib50.config.Loader("help50") + + +@contextlib.contextmanager +def _syspath(newpath): + """ Useful contextmanager that temporarily replaces sys.path. """ + oldpath = sys.path + sys.path = newpath + try: + yield + finally: + sys.path = oldpath + + +def load_config(dir): + """ Read cs50 YAML file and apply default configuration to unspecified fields. """ + options = { "helpers": ["helpers"] } + try: + config_file = lib50.config.get_config_filepath(dir) + + with open(config_file) as f: + config = CONFIG_LOADER.load(f.read()) + except lib50.InvalidConfigError: + raise Error("Failed to parse help50 config, please let sysadmins@cs50.harvard.edu know!") + + + if isinstance(config, dict): + options.update(config) + + if isinstance(options["helpers"], str): + options["helpers"] = [options["helpers"]] + + return options + + +def load_helpers(helpers_dir): + """ Download helpers to a local directory via lib50. """ + + config = load_config(helpers_dir) + for helper in config["helpers"]: + with _syspath([str(helpers_dir)]): + try: + __import__(helper) + except ImportError: + raise Error("Failed to load helpers, please let sysadmins@cs50.harvard.edu know!") + + +def get_help(output): + """ + Given an error message, try every helper registered with help50 and return the output of the + first one that matches. + """ + # Strip ANSI codes + output = re.sub(r"\x1B\[[0-?]*[ -/]*[@-~]", "", output) + + for domain in HELPERS.keys(): + processed = output + for pre in PREPROCESSORS.get(domain, []): + processed = pre(processed) + lines = processed.splitlines() + for i in range(len(lines)): + slice = lines[i:] + for helper in HELPERS.get(domain, []): + try: + before, after = helper(slice) + except TypeError: + pass + else: + return before, after diff --git a/setup.py b/setup.py index 690af3c..7dcfc73 100644 --- a/setup.py +++ b/setup.py @@ -12,11 +12,14 @@ ], license="GPLv3", description="This is help50, a command-line tool that helps students understand error messages.", - install_requires=["argparse", "requests", "pexpect", "termcolor"], + install_requires=["pexpect", "termcolor", "lib50>=1.1.10"], keywords="help50", name="help50", - scripts=["help50"], + packages=["help50"], + entry_points={ + "console_scripts": ["help50=help50.__main__:main"] + }, py_requires="3.6", url="https://github.com/cs50/help50", - version="2.0.1" + version="3.0.0" )