Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
7 changes: 4 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
50 changes: 41 additions & 9 deletions submit50/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"))
Comment thread
rongxin-liu marked this conversation as resolved.
parser.add_argument(
"-V", "--version",
action="version",
Expand All @@ -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()
Binary file added submit50/locale/vi/LC_MESSAGES/submit50.mo
Binary file not shown.
137 changes: 137 additions & 0 deletions submit50/locale/vi/LC_MESSAGES/submit50.po
Original file line number Diff line number Diff line change
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
"Language: vi\n"
"Language-Team: vi <LL@li.org>\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 ""
Comment thread
rongxin-liu marked this conversation as resolved.
Comment thread
rongxin-liu marked this conversation as resolved.
"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"

91 changes: 91 additions & 0 deletions tests/test_locale.py
Original file line number Diff line number Diff line change
@@ -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"
Loading