diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ff52931..3c97723 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,16 +3,25 @@ jobs: test-and-deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.14" - name: Run tests run: | - pip install babel + pip install babel pytest pip install . submit50 --help python setup.py compile_catalog + pytest -q + - name: Run tests against the minimum supported lib50 + run: | + # Exercise the oldest lib50 that setup.py admits, so an API drift (e.g. a missing + # push() kwarg) fails here instead of on students' machines. + floor=$(python -c "import re; print(re.search(r'lib50>=([\d.]+)', open('setup.py').read()).group(1))") + pip install "lib50==$floor" + pytest -q + pip install --upgrade "lib50<4" - name: Install pypa/build run: python -m pip install build --user - name: Build a binary wheel and a source tarball @@ -27,11 +36,11 @@ jobs: - name: Extract program version id: program_version run: | - echo ::set-output name=version::$(submit50 --version | cut --delimiter ' ' --fields 2) + echo "version=$(submit50 --version | cut --delimiter ' ' --fields 2)" >> $GITHUB_OUTPUT - name: Create Release if: ${{ github.ref == 'refs/heads/main' }} - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ github.token }} script: | diff --git a/setup.py b/setup.py index 57eaec9..fee582f 100755 --- a/setup.py +++ b/setup.py @@ -16,16 +16,17 @@ }, description="This is submit50, with which you can submit solutions to problems for CS50.", long_description="This is submit50, with which you can submit solutions to problems for CS50.", - install_requires=["lib50>=3,<4", "packaging", "pytz", "requests>=2.19", "setuptools", "termcolor>=1.1"], + # lib50 >= 3.1.2 is the first release whose push() accepts auth_method + install_requires=["lib50>=3.1.2,<4", "packaging", "pytz", "requests>=2.19", "setuptools", "termcolor>=1.1"], keywords=["submit", "submit50"], name="submit50", - python_requires=">=3.6", + python_requires=">=3.8", license="GPLv3", packages=["submit50"], url="https://github.com/cs50/submit50", entry_points={ "console_scripts": ["submit50=submit50.__main__:main"] }, - version="3.2.1", + version="3.2.2", include_package_data=True ) diff --git a/submit50/__main__.py b/submit50/__main__.py index 0a3a72e..16da9c2 100755 --- a/submit50/__main__.py +++ b/submit50/__main__.py @@ -68,7 +68,7 @@ def check_version(package_name=__package__, timeout=5): # Retrieve version info res = requests.get(f"{SUBMIT_URL}/versions/submit50", timeout=timeout) if res.status_code != 200: - raise Error(_("Could not connect to submit.cs50.io." + raise Error(_("Could not connect to submit.cs50.io. " "Please visit our status page https://cs50.statuspage.io for more information.")) # Get the minimum required version from submit.cs50.io @@ -161,17 +161,17 @@ def prompt(honesty, included, excluded): honesty_question = str(honesty) # Get the user's answer - # If in R Studio environment, answer is always yes + # If in R Studio environment, the answer is always yes (skip the localized regex) if os.getenv("RSTUDIO") == "1": - answer = "yes" - else: - answer = input(honesty_question) + return True + + answer = input(honesty_question) except EOFError: answer = None print() # If no answer given, or yes is not given, don't continue - if not answer or not re.match(f"^\s*(?:{_('y|yes')})\s*$", answer, re.I): + if not answer or not re.match(rf"^\s*(?:{_('y|yes')})\s*$", answer, re.I): return False # Otherwise, do continue @@ -191,7 +191,7 @@ def check_slug_year(slug): cprint(suggested_slug, "yellow") # Ask if they want to continue - if not re.match(f"^\s*(?:{_('y|yes')})\s*$", input(_("Do you want to continue with this submission (yes/no)? ")), re.I): + if not re.match(rf"^\s*(?:{_('y|yes')})\s*$", input(_("Do you want to continue with this submission (yes/no)? ")), re.I): raise Error(_("User aborted submission.")) except ValueError: @@ -243,6 +243,12 @@ def main(): '\ninfo: adds all commands run.' '\ndebug: adds the output of all commands run.') ) + parser.add_argument("--https", + action="store_true", + help=_("force authentication via HTTPS")) + parser.add_argument("--ssh", + action="store_true", + help=_("force authentication via SSH")) parser.add_argument( "-V", "--version", action="version", @@ -260,9 +266,35 @@ def main(): check_announcements() check_version() check_slug_year(args.slug) - - user_name, commit_hash, message = lib50.push("submit50", args.slug, CONFIG_LOADER, prompt=prompt) + + # Decide whether to force HTTPS or SSH authentication + auth_method = resolve_auth_method(args.https, args.ssh) + + try: + user_name, commit_hash, message = lib50.push("submit50", args.slug, CONFIG_LOADER, prompt=prompt, auth_method=auth_method) + except lib50.ConnectionError as e: + # lib50 raises a bare ConnectionError when a forced SSH login fails (no HTTPS fallback); + # give the user something more actionable than the generic status-page message + if auth_method == "ssh" and not str(e): + raise Error(_("SSH authentication failed. Make sure your SSH key is added to your GitHub account " + "and loaded in ssh-agent, or omit --ssh to authenticate via HTTPS instead.")) + raise print(message) + +def resolve_auth_method(https, ssh): + """ + Map the --https/--ssh flags to lib50's auth_method ("https", "ssh", or None for lib50's default). + Warn and fall back to the default when both flags are given. + """ + if https and ssh: + cprint(_("--https and --ssh have no effect when used together"), "yellow") + return None + if https: + return "https" + if ssh: + return "ssh" + return None + if __name__ == "__main__": main() diff --git a/submit50/locale/vi/LC_MESSAGES/submit50.mo b/submit50/locale/vi/LC_MESSAGES/submit50.mo new file mode 100644 index 0000000..fa1a2cf Binary files /dev/null and b/submit50/locale/vi/LC_MESSAGES/submit50.mo differ diff --git a/submit50/locale/vi/LC_MESSAGES/submit50.po b/submit50/locale/vi/LC_MESSAGES/submit50.po new file mode 100644 index 0000000..79c3836 --- /dev/null +++ b/submit50/locale/vi/LC_MESSAGES/submit50.po @@ -0,0 +1,137 @@ +# Vietnamese translations for submit50. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the submit50 project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: submit50 3.2.2\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-09-04 23:47-0400\n" +"PO-Revision-Date: 2026-09-04 23:50-0400\n" +"Last-Translator: FULL NAME \n" +"Language: vi\n" +"Language-Team: vi \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: submit50/__main__.py:71 +msgid "" +"Could not connect to submit.cs50.io. Please visit our status page " +"https://cs50.statuspage.io for more information." +msgstr "" +"Không kết nối đến submit.cs50.io được. Vui lòng xem " +"https://cs50.statuspage.io để biết thêm thông tin." + +#: submit50/__main__.py:135 +msgid "Files that will be submitted:" +msgstr "Các tập tin sẽ nộp:" + +#: submit50/__main__.py:139 +msgid "No files in this directory are expected for submission." +msgstr "Không có tập tin nào trong thư mục này để nộp." + +#: submit50/__main__.py:143 +msgid "Files that won't be submitted:" +msgstr "Các tập tin sẽ không nộp:" + +#: submit50/__main__.py:156 +msgid "" +"Keeping in mind the course's policy on academic honesty, including its " +"restrictions on AI use, are you sure you want to submit these files " +"(yes/no)? " +msgstr "" +"Cân nhắc quy định về tính trung thực trong học thuật, bao gồm cả các hạn " +"chế về việc sử dụng AI, bạn có chắc chắn muốn nộp các tập tin này không " +"(có/không)? " + +#: submit50/__main__.py:174 submit50/__main__.py:194 +msgid "y|yes" +msgstr "c|co|có" + +#: submit50/__main__.py:189 +msgid "" +"You are submitting to a previous year's CS50x course. Your submission " +"will not be counted towards this year's course." +msgstr "" +"Bạn đang nộp bài cho khóa học CS50x của năm trước. Bài này sẽ không được " +"tính vào khóa học năm nay." + +#: submit50/__main__.py:190 +msgid "" +"If you are looking to submit to this year's course, please use the " +"following slug:" +msgstr "Nếu bạn muốn nộp bài cho khóa học năm nay, vui lòng sử dụng slug này:" + +#: submit50/__main__.py:194 +msgid "Do you want to continue with this submission (yes/no)? " +msgstr "Bạn có muốn tiếp tục nộp bài này không (có/không)? " + +#: submit50/__main__.py:195 +msgid "User aborted submission." +msgstr "Người dùng đã hủy nộp bài." + +#: submit50/__main__.py:207 +msgid "" +"Sorry, something's wrong, please try again. If the problem persists, " +"please visit our status page https://cs50.statuspage.io for more " +"information." +msgstr "" +"Rất tiếc, có gì xảy ra, vui lòng thử nộp lại. Nếu vấn đề này còn tiếp " +"nữa, vui lòng xem https://cs50.statuspage.io để biết thêm thông tin." + +#: submit50/__main__.py:212 +msgid "Submission cancelled." +msgstr "Đã hủy nộp bài." + +#: submit50/__main__.py:218 +msgid "logout of submit50" +msgstr "đăng xuất khỏi submit50" + +#: submit50/__main__.py:225 +msgid "failed to logout" +msgstr "không đăng xuất được" + +#: submit50/__main__.py:227 +msgid "logged out successfully" +msgstr "đăng xuất thành công" + +#: submit50/__main__.py:242 +msgid "" +"warning: displays usage warnings.\n" +"info: adds all commands run.\n" +"debug: adds the output of all commands run." +msgstr "" +"warning: hiển thị các cảnh báo về cách sử dụng.\n" +"info: thêm tất cả các lệnh đã chạy.\n" +"debug: thêm kết quả của tất cả các lệnh đã chạy." + +#: submit50/__main__.py:248 +msgid "force authentication via HTTPS" +msgstr "buộc xác thực qua HTTPS" + +#: submit50/__main__.py:251 +msgid "force authentication via SSH" +msgstr "buộc xác thực qua SSH" + +#: submit50/__main__.py:259 +msgid "prescribed identifier of work to submit" +msgstr "định danh được chỉ định của bài cần nộp" + +#: submit50/__main__.py:279 +msgid "" +"SSH authentication failed. Make sure your SSH key is added to your GitHub" +" account and loaded in ssh-agent, or omit --ssh to authenticate via HTTPS" +" instead." +msgstr "" +"Xác thực SSH thất bại. Hãy chắc chắn rằng khóa SSH của bạn đã được thêm " +"vào tài khoản GitHub và đã được nạp vào ssh-agent, hoặc bỏ --ssh để xác " +"thực qua HTTPS." + +#: submit50/__main__.py:291 +msgid "--https and --ssh have no effect when used together" +msgstr "--https và --ssh không có hiệu lực khi dùng cùng nhau" + diff --git a/tests/test_locale.py b/tests/test_locale.py new file mode 100644 index 0000000..e3292ad --- /dev/null +++ b/tests/test_locale.py @@ -0,0 +1,91 @@ +"""Consistency checks for the gettext catalogs under submit50/locale.""" +import ast +import gettext +import io +import pathlib +import re + +import pytest +from babel.messages.mofile import write_mo +from babel.messages.pofile import read_po + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SOURCE = ROOT / "submit50" / "__main__.py" +LOCALE_DIR = ROOT / "submit50" / "locale" +LOCALES = sorted(p.name for p in LOCALE_DIR.iterdir() if (p / "LC_MESSAGES" / "submit50.po").is_file()) + +# Catalogs known to be incomplete before the completeness check existed. +KNOWN_INCOMPLETE = {"es"} + + +def source_strings(): + """Every string literal passed to _() in __main__.py.""" + literals = set() + for node in ast.walk(ast.parse(SOURCE.read_text())): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + literals.add(node.args[0].value) + assert literals, "no _() literals found -- extraction is broken" + return literals + + +def load_po(locale): + with open(LOCALE_DIR / locale / "LC_MESSAGES" / "submit50.po", "rb") as f: + return read_po(f, locale=locale) + + +@pytest.mark.parametrize("locale", LOCALES) +def test_every_source_string_is_translated(locale, request): + if locale in KNOWN_INCOMPLETE: + request.applymarker(pytest.mark.xfail(reason=f"{locale} catalog is known to be incomplete", strict=True)) + catalog = load_po(locale) + translations = {m.id: m.string for m in catalog if m.id} + missing = sorted(s for s in source_strings() if s not in translations) + empty = sorted(s for s in source_strings() if s in translations and not translations[s]) + fuzzy = sorted(m.id for m in catalog if m.id and m.fuzzy) + assert not missing, f"{locale}: strings missing from catalog: {missing}" + assert not empty, f"{locale}: untranslated strings: {empty}" + assert not fuzzy, f"{locale}: fuzzy entries are skipped by compile_catalog: {fuzzy}" + + +@pytest.mark.parametrize("locale", LOCALES) +def test_prompt_translations_keep_trailing_space(locale): + """input() prompts end with a space in the source; a translation that drops it glues the cursor to the text.""" + for message in load_po(locale): + if message.id and message.string and message.id.endswith(" "): + assert message.string.endswith(" "), f"{locale}: translation of {message.id!r} lost its trailing space" + + +@pytest.mark.parametrize("locale", LOCALES) +def test_yes_regex_translation_is_valid(locale): + """`y|yes` is interpolated into a regex; the translation must compile and accept its own affirmative.""" + catalog = load_po(locale) + translated = catalog.get("y|yes") + if translated is None or not translated.string: + pytest.skip(f"{locale}: y|yes not translated") + pattern = re.compile(rf"^\s*(?:{translated.string})\s*$", re.I) + first_alternative = translated.string.split("|")[0] + assert pattern.match(first_alternative), f"{locale}: regex rejects its own first alternative" + assert not pattern.match("no"), f"{locale}: regex accepts 'no'" + + +@pytest.mark.parametrize("locale", LOCALES) +def test_committed_mo_matches_po(locale): + """A committed .mo must be the compiled form of the committed .po (`*.mo` is gitignored, so it drifts silently).""" + mo_path = LOCALE_DIR / locale / "LC_MESSAGES" / "submit50.mo" + if not mo_path.is_file(): + pytest.skip(f"{locale}: no .mo committed (CI compiles it at build time)") + buf = io.BytesIO() + write_mo(buf, load_po(locale)) + expected = gettext.GNUTranslations(io.BytesIO(buf.getvalue()))._catalog + with open(mo_path, "rb") as f: + actual = gettext.GNUTranslations(f)._catalog + expected.pop("", None) + actual.pop("", None) + assert actual == expected, f"{locale}: submit50.mo is stale -- run `python setup.py compile_catalog` and recommit" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..adf4e5c --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,114 @@ +"""Smoke tests for submit50's CLI wiring against lib50. + +These run in CI against both the newest lib50 and the minimum version declared in +setup.py, so a lib50 API drift (e.g. a missing ``auth_method`` kwarg) fails the +build instead of every student's submission. +""" +import inspect +import re +import subprocess +import sys + +import lib50 +import pytest + +import submit50.__main__ as cli + + +def test_lib50_push_accepts_auth_method(): + assert "auth_method" in inspect.signature(lib50.push).parameters + + +@pytest.mark.parametrize( + "https, ssh, expected", + [ + (False, False, None), + (True, False, "https"), + (False, True, "ssh"), + (True, True, None), + ], +) +def test_resolve_auth_method(https, ssh, expected, capsys): + assert cli.resolve_auth_method(https, ssh) == expected + out = capsys.readouterr().out + if https and ssh: + assert "--https and --ssh" in out + else: + assert out == "" + + +def _stub_checks(monkeypatch): + """Skip the network-dependent preflight checks.""" + monkeypatch.setattr(cli, "check_announcements", lambda: None) + monkeypatch.setattr(cli, "check_version", lambda: None) + monkeypatch.setattr(cli, "check_slug_year", lambda slug: None) + + +@pytest.mark.parametrize( + "flags, expected", + [ + ([], None), + (["--https"], "https"), + (["--ssh"], "ssh"), + (["--https", "--ssh"], None), + ], +) +def test_main_passes_auth_method_to_lib50(flags, expected, monkeypatch, capsys): + _stub_checks(monkeypatch) + calls = [] + + def fake_push(tool, slug, config_loader, **kwargs): + calls.append((tool, slug, kwargs)) + return "user", "deadbeef", "pushed" + + monkeypatch.setattr(lib50, "push", fake_push) + monkeypatch.setattr(sys, "argv", ["submit50", *flags, "cs50/problems/2026/x/hello"]) + + cli.main() + + assert len(calls) == 1 + tool, slug, kwargs = calls[0] + assert (tool, slug) == ("submit50", "cs50/problems/2026/x/hello") + assert kwargs["auth_method"] == expected + assert kwargs["prompt"] is cli.prompt + assert "pushed" in capsys.readouterr().out + + +def test_forced_ssh_failure_gets_actionable_error(monkeypatch): + _stub_checks(monkeypatch) + + def fail_push(*args, **kwargs): + raise lib50.ConnectionError # lib50 raises this bare when a forced SSH login fails + + monkeypatch.setattr(lib50, "push", fail_push) + monkeypatch.setattr(sys, "argv", ["submit50", "--ssh", "cs50/problems/2026/x/hello"]) + + with pytest.raises(cli.Error, match="SSH authentication failed"): + cli.main() + + +def test_unforced_connection_error_is_not_rewritten(monkeypatch): + _stub_checks(monkeypatch) + + def fail_push(*args, **kwargs): + raise lib50.ConnectionError + + monkeypatch.setattr(lib50, "push", fail_push) + monkeypatch.setattr(sys, "argv", ["submit50", "cs50/problems/2026/x/hello"]) + + with pytest.raises(lib50.ConnectionError): + cli.main() + + +def test_rstudio_skips_honesty_prompt(monkeypatch): + monkeypatch.setenv("RSTUDIO", "1") + monkeypatch.setattr("builtins.input", lambda *a: pytest.fail("input() must not be called under RSTUDIO")) + assert cli.prompt(True, ["hello.c"], []) is True + + +def test_version_output_shape(): + """The release workflow extracts the tag with `cut -d' ' -f2`; keep ` `.""" + out = subprocess.run( + [sys.executable, "-m", "submit50", "--version"], capture_output=True, text=True, check=True + ).stdout.strip() + assert re.fullmatch(r"submit50 \d+\.\d+\.\d+", out), out