diff --git a/.gitignore b/.gitignore index c77caf0..5c9af13 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__ build/ *.egg-info/ dist/ +locale/*/LC_MESSAGES/*.mo diff --git a/README.md b/README.md index f0a767f..f8c723c 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,56 @@ [![Build Status](https://travis-ci.org/cs50/submit50.svg?branch=master)](https://travis-ci.org/cs50/submit50) +# Usage + +## English + +``` +submit50 problem +``` + +### Spanish + +``` +LANGUAGE=es submit50 problem +``` + +# Internationalizing + +## Creating PO for language XX + +``` +xgettext submit50.py +sed -i -e '1,6d' messages.po +sed -i -e '3,10d' messages.po +sed -i 's/CHARSET/UTF-8/' messages.po +vim messages.po # translate strings to XX +msgfmt messages.po +mkdir -p locale/XX/LC_MESSAGES +mv messages.mo messages.po locale/XX/LC_MESSAGES/ +``` + +## Updating PO for language XX + +Source: https://stackoverflow.com/a/7497395 + +``` +echo "" > messages.po +find . -type f -iname "*.py" | xgettext -j -f - +msgmerge -N locale/XX/LC_MESSAGES/messages.po messages.po > new.po +mv new.po messages.po +msgfmt messages.po +mv -f messages.mo messages.po locale/XX/LC_MESSAGES/ +``` + # Contributing ``` pip install -e . ``` -TODO... - -# TODO - -* Client - * Remove tag once handled by server. -* Server - * Tag latest commit. - * Release latest commit. - * Update default branch. - -# CHANGELOG - -* 2.1.4 - * Fix problem with two-factor authentication. - * Avoid printing "Submission cancelled" when it's inappropriate. -* 2.1.3 - * Canonicalizd username after login to allow login via email. - * Generalized finding path to python3. -* 2.1.2 - * Added more error handling to `submit50 --checkout`. - * Updated messages displayed to user to direct them to CS50.me. -* 2.1.1 - * Suppressed student compile flags while installing getch. -* 2.1.0 - * TBD -* 2.0.0 - * Ported to Python. - * Added support for two-factor authentication. - * Added course identifier as prefix to branches and tags. - * Added support for --checkout of specific usernames. - * Hid diagnostic output unless --verbose flag is used. - * Added check for version file. -* 1.1.0 - * Added support for expecting and ignoring files. - * Added support for logging in via email address. - * Added check for whether problem exists. - * Fixed bugs whereby spaces or dollar signs in usernames broke login. - * Fixed bug whereby pushes would fail if email address not yet confirmed. -* 1.0.3 - * Initial commit. +TODO + +# References + +- https://books.google.com/books?id=kQom0WiUbZQC&pg=PA215&lpg=PA215&dq=python+gettext+class&source=bl&ots=mttyXZyZan&sig=OENd8tbqVpxWIpRIWrE84hQY8jo&hl=en&sa=X&ved=0ahUKEwjTnY3WmJzVAhWJMj4KHR_PBR8Q6AEIWTAH#v=onepage&q=python%20gettext%20class&f=false +- https://stackoverflow.com/questions/7496156/gettext-how-to-update-po-and-pot-files-after-the-source-is-modified diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po new file mode 100644 index 0000000..2b521a8 --- /dev/null +++ b/locale/es/LC_MESSAGES/messages.po @@ -0,0 +1,150 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: submit50.py:44 +msgid "You have an old version of python. Install version 2.7 or higher." +msgstr "" +"Tienes una versión antigua de python. Instala la versión 2.7 o superior." + +#: submit50.py:115 +msgid "show commands being executed" +msgstr "mostrar comandos que se están ejecutando" + +#: submit50.py:116 +msgid "problem to submit" +msgstr "problema para entregar" + +#: submit50.py:177 +msgid "GitHub username: " +msgstr "Nombre de usuario de GitHub: " + +#: submit50.py:183 +msgid "GitHub password: " +msgstr "Contraseña de GitHub: " + +#: submit50.py:217 +msgid "Invalid username and/or password." +msgstr "Nombre de usuario y / o contraseña es inválido." + +#: submit50.py:221 +msgid "Could not authenticate user." +msgstr "No se pudo autenticar usuario." + +#: submit50.py:268 +msgid "Could not connect to GitHub." +msgstr "No se pudo conectar a GitHub" + +#: submit50.py:272 +msgid "Sorry, something's wrong! Let sysadmins@cs50.harvard.edu know!" +msgstr "¡Lo siento, algo está mal! ¡Déjale saber a sysadmins@cs50.harvard.edu!" + +#: submit50.py:277 submit50.py:295 +msgid "Submission cancelled." +msgstr "La entrega se canceló." + +#: submit50.py:386 +msgid "You don't have git. Install git, then re-run submit50!" +msgstr "No tienes git. ¡Instala git, y luego vuelve a ejecutar submit50!" + +#: submit50.py:390 +msgid "" +"You have an old version of git. Install version 2.7 or later, then re-run " +"submit50!" +msgstr "" +"Tienes una versión antigua de git. ¡Instala la versión 2.7 o superior, y " +"vuelve a ejecutar submit50!" + +#: submit50.py:404 +msgid "" +"You have an unknown version of submit50. Email sysadmins@cs50.harvard.edu!" +msgstr "" +"Tienes una versión desconocida de submit50. ¡Escríbele a sysadmins@cs50." +"harvard.edu!" + +#: submit50.py:408 +msgid "" +"You have an old version of submit50. Run update50, then re-run submit50!" +msgstr "" +"Tienes una versión antigua de submit50. ¡Ejecuta update50, y luego vuelve a " +"ejecutar submit50!" + +#: submit50.py:425 +msgid "Invalid problem. Did you mean to submit something else?" +msgstr "El problema no es válido. ¿Quisiste entregar algo más?" + +#: submit50.py:441 +msgid "You seem to be missing these files:" +msgstr "Parece que faltan estos archivos:" + +#: submit50.py:444 +msgid "Ensure you have the required files before submitting." +msgstr "Asegúrate de tener los archivos necesarios antes de entregar." + +#: submit50.py:447 +msgid "Authenticating" +msgstr "Autenticando" + +#: submit50.py:465 +msgid "Preparing" +msgstr "Preparando" + +#: submit50.py:472 +msgid "" +"Looks like submit50 isn't enabled for your account yet. Log into https://" +"cs50.me/ in a browser, click \"Authorize application\", and re-run submit50 " +"here!" +msgstr "" +"Parece que submit50 todavía no está habilitado para tu cuenta. Inicie sesión " +"en https://cs50.me/ en un navegador, haga clic en \"Autorizar la aplicación" +"\", y vuelva a ejecutar submit50 ¡aquí!" + +#: submit50.py:475 +msgid "" +"Looks like you have the wrong username in ~/.gitconfig or submit50 isn't yet " +"enabled for your account. Double-check ~/.gitconfig and then log into " +"https://cs50.me/ in a browser, click \"Authorize application\" if prompted, " +"and re-run submit50 here." +msgstr "" +"Parece que tienes el nombre de usuario incorrecto en ~ /.gitconfig o " +"submit50 aún no está habilitado para tu cuenta. Vuelve a chequear ~/." +"gitconfig y luego ingresa en https://cs50.me/ en un navegador, haga clic en " +"\"Autorizar aplicación\" si se te solicita, y vuelve a ejecutar submit50 " +"aquí." + +#: submit50.py:536 +msgid "No files in this directory are expected for submission." +msgstr "Ningún archivo en este directorio se espera para la entrega." + +#: submit50.py:537 +msgid "Files that will be submitted:" +msgstr "Archivos que se van a entregar:" + +#: submit50.py:543 +msgid "Files that won't be submitted:" +msgstr "Archivos que no se van a entregar:" + +#: submit50.py:548 +msgid "" +"Keeping in mind the course's policy on academic honesty, are you sure you " +"want to submit these files? " +msgstr "" +"Teniendo en cuenta la política de honestidad académica del curso, ¿estás " +"seguro que quieres enviar estos archivos? " + +#: submit50.py:551 +msgid "No files were submitted." +msgstr "" + +#: submit50.py:554 +msgid "Submitting" +msgstr "Entregando" + +#: submit50.py:561 +msgid "Submitted {}! See https://cs50.me/submissions/{}." +msgstr "¡Se entregó {}! Puedes ver https://cs50.me/submissions/{}." + +#: submit50.py:608 +msgid "Could not complete two-factor authentication." +msgstr "No se pudo completar la autenticación de dos factores." diff --git a/setup.py b/setup.py index b5d7940..8d89e14 100644 --- a/setup.py +++ b/setup.py @@ -61,5 +61,5 @@ class CustomInstall(install): "console_scripts": ["submit50=submit50:main"] }, url="https://github.com/cs50/submit50", - version="2.3.0" + version="2.3.1" ) diff --git a/submit50.py b/submit50.py index 9451867..999688b 100644 --- a/submit50.py +++ b/submit50.py @@ -4,6 +4,7 @@ import atexit import datetime import distutils +import gettext import itertools import json import os @@ -32,15 +33,20 @@ from six.moves import urllib from threading import Thread +# Internationalization +gettext.bindtextdomain("messages", os.path.join(sys.path[0], "locale")) +gettext.textdomain("messages") +_ = gettext.gettext + +# globals # require python 2.7+ if sys.version_info < (2, 7): - sys.exit("You have an old version of python. Install version 2.7 or higher.") + sys.exit(_("You have an old version of python. Install version 2.7 or higher.")) if sys.version_info < (3, 0): input = raw_input if not hasattr(shlex, "quote"): shlex.quote = pipes.quote -# globals ORG = "submit50" timestamp = None @@ -106,8 +112,8 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("-v", "--verbose", action="store_true", - help="show commands being executed") - parser.add_argument("problem", help="problem to submit") + help=_("show commands being executed")) + parser.add_argument("problem", help=_("problem to submit")) args = vars(parser.parse_args()) # submit50 -v @@ -125,19 +131,19 @@ def main(): def authenticate(org): """Authenticate user.""" - # cache credentials in ~/.git-credential-cache/submit50 + # cache credentials in ~/.git-credential-cache/:org cache = os.path.expanduser("~/.git-credential-cache") try: os.mkdir(cache, 0o700) except: pass - socket = os.path.join(cache, ORG) + authenticate.SOCKET = os.path.join(cache, ORG) # check cache, then config for credentials - credentials = run("git -c credential.helper='cache --socket {}' credential fill".format(socket), + credentials = run("git -c credential.helper='cache --socket {}' credential fill".format(authenticate.SOCKET), lines=[""]*3, quiet=True) - run("git credential-cache --socket {} exit".format(socket)) + run("git credential-cache --socket {} exit".format(authenticate.SOCKET)) matches = re.search("^username=([^\r]+)\r\npassword=([^\r]+)\r?$", credentials, re.MULTILINE) if matches: username = matches.group(1) @@ -162,19 +168,19 @@ def rlinput(prompt, prefill=""): readline.set_startup_hook() # prompt for credentials - spin(False) # because not using cprint herein + progress(False) # because not using cprint herein if not password: # prompt for username, prefilling if possible while True: - spin(False) - username = rlinput("GitHub username: ", username).strip() + progress(False) + username = rlinput(_("GitHub username: "), username).strip() if username: break # prompt for password while True: - print("GitHub password: ", end="", flush=True) + print(_("GitHub password: "), end="", flush=True) password = str() while True: ch = getch() @@ -208,11 +214,11 @@ def rlinput(prompt, prefill=""): # check if incorrect password if res.status_code == 401: - raise Error("Invalid username and/or password.") + raise Error(_("Invalid username and/or password.")) # check for other error elif res.status_code != 200: - raise Error("Could not authenticate user.") + raise Error(_("Could not authenticate user.")) # canonicalize (capitalization of) username, # especially if user logged in via email address @@ -221,7 +227,7 @@ def rlinput(prompt, prefill=""): # cache credentials for 1 week timeout = int(datetime.timedelta(weeks=1).total_seconds()) run("git -c credential.helper='cache --socket {} --timeout {}' " - "-c credentialcache.ignoresighup=true credential approve".format(socket, timeout), + "-c credentialcache.ignoresighup=true credential approve".format(authenticate.SOCKET, timeout), lines=["username={}".format(username), "password={}".format(password), "", ""], quiet=True) @@ -229,14 +235,17 @@ def rlinput(prompt, prefill=""): return (username, password, email) +authenticate.SOCKET = None + + def cprint(text="", color=None, on_color=None, attrs=None, **kwargs): """Colorizes text (and wraps to terminal's width).""" - # stop spinner (if spinning) - spin(False) + # update progress + progress(False) # assume 80 in case not running in a terminal - columns, _ = get_terminal_size() + columns, lines = get_terminal_size() if columns == 0: columns = 80 # because get_terminal_size's default fallback doesn't work in pipes # only python3 supports "flush" keyword argument @@ -251,17 +260,21 @@ def cprint(text="", color=None, on_color=None, attrs=None, **kwargs): def excepthook(type, value, tb): """Report an exception.""" excepthook.ignore = False - spin(False) + progress(False) teardown() if type is Error and str(value): cprint(str(value), "yellow") elif type is requests.exceptions.ConnectionError: - cprint("Could not connect to GitHub.", "yellow") + cprint(_("Could not connect to GitHub."), "yellow") else: if run.verbose: traceback.print_exception(type, value, tb) - cprint("Sorry, something's wrong! Let sysadmins@cs50.harvard.edu know!", "yellow") - cprint("Submission cancelled.", "red") + cprint(_("Sorry, something's wrong! Let sysadmins@cs50.harvard.edu know!"), "yellow") + try: + run("git credential-cache --socket {} exit".format(authenticate.SOCKET)) + except Exception: + pass + cprint(_("Submission cancelled."), "red") sys.excepthook = excepthook @@ -270,11 +283,16 @@ def excepthook(type, value, tb): def handler(number, frame): """Handle SIGINT.""" os.system("stty sane") # in case signalled from input_with_prefill - if spin.spinning: - spin(False) + if progress.progressing: + progress(False) else: cprint() - cprint("Submission cancelled.", "red") + try: + run("git credential-cache --socket {} exit".format(authenticate.SOCKET)) + except Exception: + pass + teardown() + cprint(_("Submission cancelled."), "red") os._exit(0) @@ -326,55 +344,58 @@ def run(command, cwd=None, env=None, lines=[], password=None, quiet=False): run.verbose = False -def spin(message=""): - """Display a spinning message.""" +def progress(message=""): + """Display a progress bar as dots.""" - # don't spin in verbose mode + # don't show in verbose mode if run.verbose: if message != False: print(message + "...") return - # stop spinning if already spinning - if spin.spinning: - spin.spinning = False - spin.thread.join() + # stop progressing if already progressing + if progress.progressing: + progress.progressing = False + progress.thread.join() + sys.stdout.write("\n") + sys.stdout.flush() - # start spinning if message passed + # display dots if message passed if message != False: - def spin_helper(): # https://stackoverflow.com/a/4995896 - spinner = itertools.cycle(["-", "\\", "|", "/"]) - sys.stdout.write(message + "... ") + def progress_helper(): + sys.stdout.write(message + "...") sys.stdout.flush() - while spin.spinning: - sys.stdout.write(next(spinner)) + while progress.progressing: + sys.stdout.write(".") sys.stdout.flush() - sys.stdout.write("\b") - time.sleep(0.1) - sys.stdout.write("\033[2K\r") - sys.stdout.flush() - spin.spinning = True - spin.thread = Thread(target=spin_helper) - spin.thread.start() + time.sleep(0.5) + progress.progressing = True + progress.thread = Thread(target=progress_helper) + progress.thread.start() -spin.spinning = False +progress.progressing = False def submit(org, problem): """Submit problem.""" + # check announcements + res = requests.get("https://cs50.me/status/submit50") + if res.status_code == 200 and res.text.strip(): + raise Error(res.text.strip()) + # require git 2.7+, so that credential-cache--daemon ignores SIGHUP # https://github.com/git/git/blob/v2.7.0/credential-cache--daemon.c if not which("git"): - raise Error("You don't have git. Install git, then re-run submit50!.") + raise Error(_("You don't have git. Install git, then re-run submit50!")) version = subprocess.check_output(["git", "--version"]).decode("utf-8") matches = re.search(r"^git version (\d+\.\d+\.\d+).*$", version) if not matches or StrictVersion(matches.group(1)) < StrictVersion("2.7.0"): - raise Error("You have an old version of git. Install version 2.7 or later, then re-run submit50!") + raise Error(_("You have an old version of git. Install version 2.7 or later, then re-run submit50!")) - # update spinner - spin("Connecting") + # update progress + progress("Connecting") # compute timestamp global timestamp @@ -385,12 +406,12 @@ def submit(org, problem): # check version res = requests.get("https://cs50.me/versions/submit50") if res.status_code != 200: - raise Error("You have an unknown version of submit50. " + - "Email sysadmins@cs50.harvard.edu!") + raise Error(_("You have an unknown version of submit50. " + "Email sysadmins@cs50.harvard.edu!")) version_required = res.text.strip() if parse_version(version_required) > parse_version(get_distribution("submit50").version): - raise Error("You have an old version of submit50. " + - "Run update50, then re-run submit50!") + raise Error(_("You have an old version of submit50. " + "Run update50, then re-run submit50!")) # assume cs50/ problem if problem name begins with a year branch = problem @@ -398,7 +419,7 @@ def submit(org, problem): branch = os.path.join("cs50", problem) # ensure problem exists - _, submit.EXCLUDE = tempfile.mkstemp() + file, submit.EXCLUDE = tempfile.mkstemp() url = "https://cs50.me/excludes/{}/".format(branch) try: urllib.request.urlretrieve(url, filename=submit.EXCLUDE) @@ -406,7 +427,7 @@ def submit(org, problem): except Exception as e: if run.verbose: cprint(str(e)) - e = Error("Invalid problem. Did you mean to submit something else?") + e = Error(_("Invalid problem. Did you mean to submit something else?")) e.__cause__ = None raise e @@ -422,13 +443,13 @@ def submit(org, problem): elif not os.path.isfile(pattern): missing.append(pattern) if missing: - cprint("You seem to be missing these files:") + cprint(_("You seem to be missing these files:")) for pattern in missing: cprint(" {}".format(pattern)) - raise Error("Ensure you have the required files before submitting.") + raise Error(_("Ensure you have the required files before submitting.")) - # update spinner - spin("Authenticating") + # update progress + progress(_("Authenticating")) # authenticate user via SSH try: @@ -437,7 +458,7 @@ def submit(org, problem): email = "{}@users.noreply.github.com".format(username) repo = "git@github.com:{}/{}.git".format(org, username) with open(os.devnull, "w") as DEVNULL: - spin(False) + progress(False) assert subprocess.call(["ssh", "git@github.com"], stderr=DEVNULL) == 1 # successfully authenticated # authenticate user via HTTPS @@ -445,23 +466,32 @@ def submit(org, problem): username, password, email = authenticate(org) repo = "https://{}@github.com/{}/{}".format(username, org, username) - # update spinner - spin("Preparing") + # update progress + progress(_("Preparing")) # clone repository try: run("git clone --bare {} {}".format(shlex.quote(repo), shlex.quote(run.GIT_DIR)), password=password) except: if password: - e = Error("Looks like submit50 isn't enabled for your account yet. " + - "Log into https://cs50.me/ in a browser, click \"Authorize application\", and re-run submit50 here!") + e = Error(_("Looks like submit50 isn't enabled for your account yet. " + "Log into https://cs50.me/ in a browser, click \"Authorize application\", and re-run submit50 here!")) else: - e = Error("Looks like you have the wrong username in ~/.gitconfig or submit50 isn't yet enabled for your account. " + - "Double-check ~/.gitconfig and then log into https://cs50.me/ in a browser, " + - "click \"Authorize application\" if prompted, and re-run submit50 here.") + e = Error(_("Looks like you have the wrong username in ~/.gitconfig or submit50 isn't yet enabled for your account. " + "Double-check ~/.gitconfig and then log into https://cs50.me/ in a browser, " + "click \"Authorize application\" if prompted, and re-run submit50 here.")) e.__cause__ = None raise e + # check out .gitattributes, if any, temporarily shadowing student's, if any + if os.path.isfile(".gitattributes"): + submit.ATTRIBUTES = ".gitattributes.{}".format(round(time.time())) + os.rename(".gitattributes", submit.ATTRIBUTES) + try: + run("git checkout --force {} .gitattributes".format(branch)) + except Exception: + pass + # set options tag = "{}@{}".format(branch, timestamp) run("git config user.email {}".format(shlex.quote(email))) @@ -471,50 +501,92 @@ def submit(org, problem): # patterns of file names to exclude run("git config core.excludesFile {}".format(shlex.quote(submit.EXCLUDE))) + # blocklist for git-lfs + # https://github.com/git-lfs/git-lfs/blob/master/commands/command_track.go + with open("{}/info/exclude".format(run.GIT_DIR), "w") as file: + file.write(".git*\n") + file.write(".lfs*\n") + # adds, modifies, and removes index entries to match the working tree run("git add --all") # get file lists files = run("git ls-files").split() - other = run("git ls-files --other").split() + other = run("git ls-files --exclude-standard --other").split() + + # check for large files > 100 MB (and huge files > 2 GB) + # https://help.github.com/articles/conditions-for-large-files/ + # https://help.github.com/articles/about-git-large-file-storage/ + large, huge = [], [] + for file in files: + size = os.path.getsize(file) + if size > (100 * 1024 * 1024): + large.append(file) + elif size > (2 * 1024 * 1024 * 1024): + huge.append(file) + if len(huge) > 0: + raise Error("These files are too large to be submitted:\n{}\n" + "Remove these files from your directory " + "and then re-run submit50!".format("\n".join(huge))) + elif len(large) > 0: + if not which("git-lfs"): + raise Error("These files are too large to be submitted:\n{}\n" + "Install git-lfs (or remove these files from your directory) " + "and then re-run submit50!".format("\n".join(large))) + run("git lfs install --local") + run("git config credential.helper cache") # for pre-push hook + for file in large: + run("git lfs track {}".format(file)) + run("git add --force .gitattributes") # files that will be submitted if len(files) == 0: - raise Error("No files in this directory are expected for submission.") - cprint("Files that will be submitted:", "green") + raise Error(_("No files in this directory are expected for submission.")) + cprint(_("Files that will be submitted:"), "green") for f in files: cprint("./{}".format(f), "green") # files that won't be submitted if len(other) != 0: - cprint("Files that won't be submitted:", "yellow") + cprint(_("Files that won't be submitted:"), "yellow") for f in other: cprint("./{}".format(f), "yellow") # prompt for academic honesty - cprint("Keeping in mind the course's policy on academic honesty, " + - "are you sure you want to submit these files?", end=" ") - if not re.match("^\s*(?:y|yes)\s*$", input(), re.I): - raise Error("No files were submitted.") + answer = input(_("Keeping in mind the course's policy on academic honesty, " + "are you sure you want to submit these files? ")) + if not re.match("^\s*(?:y|yes)\s*$", answer, re.I): + raise Error(_("No files were submitted.")) - # restart spinner - spin("Submitting") + # update progress + progress(_("Submitting")) # push branch run("git commit --allow-empty --message='{}'".format(timestamp)) run("git push origin 'refs/heads/{}'".format(branch), password=password) # successful submission - cprint("Submitted {}! ".format(problem) + - "See https://cs50.me/submissions/{}.".format(branch), + cprint(_("Submitted {}! " + "See https://cs50.me/submissions/{}.").format(problem, branch), "green") +submit.ATTRIBUTES = None submit.EXCLUDE = None def teardown(): - """Delete temporary directory and temporary file.""" + """Delete temporary directory and temporary file, restore any attributes.""" + if os.path.isfile(".gitattributes"): + try: + os.remove(".gitattributes") + except Exception: + pass + if submit.ATTRIBUTES: + try: + os.rename(submit.ATTRIBUTES, ".gitattributes") + except Exception: + pass shutil.rmtree(run.GIT_DIR, ignore_errors=True) if submit.EXCLUDE: try: @@ -541,7 +613,7 @@ def two_factor(org, username, password): if res.status_code == 201 and "token" in res.json(): return res.json()["token"] else: - raise Error("Could not complete two-factor authentication.") + raise Error(_("Could not complete two-factor authentication.")) if __name__ == "__main__":