Add homework 01 guarantees assignment
This commit is contained in:
+750
@@ -0,0 +1,750 @@
|
||||
"""Portable student CLI for registration and solution submission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unicodedata
|
||||
import uuid
|
||||
import zipfile
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
DEFAULT_CONFIG = Path(__file__).resolve().with_name("cli.yaml")
|
||||
TOTAL_REQUEST_TIMEOUT_SECONDS = 120.0
|
||||
SUCCESS_FIELDS = frozenset({"username", "password", "repo", "token"})
|
||||
ERROR_FIELDS = frozenset({"code", "message", "request_id"})
|
||||
REGISTER_ERROR_CODES = {
|
||||
400: frozenset({"INVALID_REQUEST", "STUDENT_NOT_FOUND"}),
|
||||
401: frozenset({"INVALID_REGISTRATION_TOKEN"}),
|
||||
409: frozenset({"STUDENT_ALREADY_REGISTERED", "EMAIL_ALREADY_REGISTERED"}),
|
||||
413: frozenset({"PAYLOAD_TOO_LARGE"}),
|
||||
415: frozenset({"UNSUPPORTED_MEDIA_TYPE"}),
|
||||
500: frozenset({"INTERNAL_ERROR"}),
|
||||
502: frozenset({"GITEA_OPERATION_FAILED"}),
|
||||
503: frozenset({"CONFIG_INVALID"}),
|
||||
}
|
||||
SUBMIT_ERROR_CODES = {
|
||||
400: frozenset({"INVALID_REQUEST", "INVALID_ARCHIVE"}),
|
||||
401: frozenset({"ACCESS_TOKEN_REQUIRED", "INVALID_ACCESS_TOKEN"}),
|
||||
403: frozenset({"SUBMISSION_DEADLINE_EXPIRED"}),
|
||||
404: frozenset({"ASSIGNMENT_NOT_FOUND"}),
|
||||
409: frozenset({"SUBMISSION_IN_PROGRESS", "SOLUTION_UNCHANGED"}),
|
||||
413: frozenset({"PAYLOAD_TOO_LARGE"}),
|
||||
415: frozenset({"UNSUPPORTED_MEDIA_TYPE"}),
|
||||
500: frozenset({"INTERNAL_ERROR"}),
|
||||
502: frozenset({"GITEA_OPERATION_FAILED"}),
|
||||
503: frozenset({"CONFIG_INVALID"}),
|
||||
}
|
||||
SUBMIT_SUCCESS_FIELDS = frozenset(
|
||||
{"submission_id", "accepted_at", "late_submission", "workflow_run"}
|
||||
)
|
||||
ASSIGNMENT_ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}\Z")
|
||||
_DRIVE_PATH_RE = re.compile(r"[A-Za-z]:")
|
||||
_UUID_RE = re.compile(
|
||||
r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-"
|
||||
r"[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\Z"
|
||||
)
|
||||
_RFC3339_RE = re.compile(
|
||||
r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})\Z"
|
||||
)
|
||||
_COPY_CHUNK_SIZE = 64 * 1024
|
||||
MAX_PATH_SEGMENT_UTF8_BYTES = 255
|
||||
MAX_SOLUTION_PATH_UTF8_BYTES = 1024
|
||||
|
||||
|
||||
class CLIError(RuntimeError):
|
||||
"""A safe command failure with its stable process exit code."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class BackendRejected(CLIError):
|
||||
"""A successfully validated backend error envelope."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int, backend_code: str) -> None:
|
||||
super().__init__(message, exit_code)
|
||||
self.backend_code = backend_code
|
||||
|
||||
|
||||
ClientFactory = Callable[[], httpx.Client]
|
||||
|
||||
|
||||
def normalize_server_url(value: str) -> str:
|
||||
"""Normalize an absolute root HTTP(S) backend URL without exposing user info."""
|
||||
if (
|
||||
not value
|
||||
or any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value)
|
||||
or "?" in value
|
||||
or "#" in value
|
||||
):
|
||||
raise CLIError("server URL is invalid", 2)
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise CLIError("server URL is invalid", 2) from exc
|
||||
if (
|
||||
parsed.scheme.lower() not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise CLIError("server URL is invalid", 2)
|
||||
host = parsed.hostname.lower()
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
netloc = host if port is None else f"{host}:{port}"
|
||||
normalized = urlunsplit((parsed.scheme.lower(), netloc, "", "", ""))
|
||||
try:
|
||||
httpx.URL(f"{normalized}/register")
|
||||
except httpx.InvalidURL as exc:
|
||||
raise CLIError("server URL is invalid", 2) from exc
|
||||
return normalized
|
||||
|
||||
|
||||
def _default_client() -> httpx.Client:
|
||||
return httpx.Client(
|
||||
timeout=httpx.Timeout(120.0, connect=10.0),
|
||||
follow_redirects=False,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
|
||||
def _post_registration_with_deadline(
|
||||
client_factory: ClientFactory,
|
||||
url: str,
|
||||
payload: dict[str, str],
|
||||
total_timeout_seconds: float,
|
||||
) -> httpx.Response:
|
||||
outcomes: queue.SimpleQueue[httpx.Response | BaseException] = queue.SimpleQueue()
|
||||
|
||||
def send() -> None:
|
||||
try:
|
||||
with client_factory() as client:
|
||||
outcomes.put(client.post(url, json=payload))
|
||||
except BaseException as exc:
|
||||
outcomes.put(exc)
|
||||
|
||||
worker = threading.Thread(target=send, name="distsys-cli-request", daemon=True)
|
||||
worker.start()
|
||||
worker.join(total_timeout_seconds)
|
||||
if worker.is_alive():
|
||||
raise CLIError("network request failed", 4)
|
||||
outcome = outcomes.get()
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
def _json_object(response: httpx.Response) -> dict[str, Any]:
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type != "application/json":
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError as exc:
|
||||
raise CLIError("backend returned an invalid response", 4) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
return value
|
||||
|
||||
|
||||
def _request_id(response: httpx.Response) -> str:
|
||||
request_id = response.headers.get("X-Request-ID")
|
||||
if not request_id:
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
return str(request_id)
|
||||
|
||||
|
||||
def _validate_success(response: httpx.Response) -> tuple[dict[str, str], str]:
|
||||
request_id = _request_id(response)
|
||||
if response.headers.get("Cache-Control") != "no-store":
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
body = _json_object(response)
|
||||
if set(body) != SUCCESS_FIELDS or any(
|
||||
not isinstance(body.get(field), str) or not body[field] for field in SUCCESS_FIELDS
|
||||
):
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
repository = str(body["repo"])
|
||||
try:
|
||||
parsed_repository = urlsplit(repository)
|
||||
except ValueError as exc:
|
||||
raise CLIError("backend returned an invalid response", 4) from exc
|
||||
if (
|
||||
any(char.isspace() for char in repository)
|
||||
or not parsed_repository.scheme
|
||||
or not parsed_repository.netloc
|
||||
):
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
return {field: str(body[field]) for field in SUCCESS_FIELDS}, request_id
|
||||
|
||||
|
||||
def _raise_backend_error(
|
||||
response: httpx.Response,
|
||||
allowed_errors: dict[int, frozenset[str]] = REGISTER_ERROR_CODES,
|
||||
) -> None:
|
||||
request_id = _request_id(response)
|
||||
body = _json_object(response)
|
||||
if (
|
||||
set(body) != ERROR_FIELDS
|
||||
or not all(isinstance(body.get(field), str) and body[field] for field in ERROR_FIELDS)
|
||||
or body["request_id"] != request_id
|
||||
):
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
allowed_codes = allowed_errors.get(response.status_code)
|
||||
if allowed_codes is None or body["code"] not in allowed_codes:
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
status_class = response.status_code // 100
|
||||
exit_code = 3 if status_class == 4 else 4
|
||||
raise BackendRejected(
|
||||
f"{body['message']} (request ID: {request_id})",
|
||||
exit_code,
|
||||
str(body["code"]),
|
||||
)
|
||||
|
||||
|
||||
class _StrictLoader(yaml.SafeLoader):
|
||||
"""YAML loader that rejects duplicate mapping keys."""
|
||||
|
||||
|
||||
def _strict_mapping(loader: _StrictLoader, node: yaml.MappingNode, deep: bool = False) -> Any:
|
||||
loader.flatten_mapping(node)
|
||||
result: dict[Any, Any] = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
try:
|
||||
duplicate = key in result
|
||||
except TypeError as exc:
|
||||
raise yaml.constructor.ConstructorError(
|
||||
None, None, "mapping key is not hashable", key_node.start_mark
|
||||
) from exc
|
||||
if duplicate:
|
||||
raise yaml.constructor.ConstructorError(
|
||||
None, None, "duplicate mapping key", key_node.start_mark
|
||||
)
|
||||
result[key] = loader.construct_object(value_node, deep=deep)
|
||||
return result
|
||||
|
||||
|
||||
_StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _strict_mapping)
|
||||
|
||||
|
||||
def _load_config(config_path: Path | None) -> tuple[str, str]:
|
||||
path = DEFAULT_CONFIG if config_path is None else config_path
|
||||
if config_path is not None and not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
try:
|
||||
documents = list(yaml.load_all(path.read_text(encoding="utf-8"), Loader=_StrictLoader))
|
||||
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
||||
raise CLIError("configuration is invalid", 2) from exc
|
||||
if len(documents) != 1 or not isinstance(documents[0], dict):
|
||||
raise CLIError("configuration is invalid", 2)
|
||||
value = documents[0]
|
||||
if set(value) - {"server_url", "access_token"} or "server_url" not in value:
|
||||
raise CLIError("configuration is invalid", 2)
|
||||
server_url = value.get("server_url")
|
||||
access_token = value.get("access_token")
|
||||
if not isinstance(server_url, str) or not isinstance(access_token, str) or not access_token:
|
||||
raise CLIError("configuration is invalid or has no access token", 2)
|
||||
return normalize_server_url(server_url), access_token
|
||||
|
||||
|
||||
def _normalized_segment(value: str) -> str:
|
||||
if (
|
||||
not value
|
||||
or value in {".", ".."}
|
||||
or "\\" in value
|
||||
or _DRIVE_PATH_RE.match(value)
|
||||
or any(unicodedata.category(character) == "Cc" for character in value)
|
||||
):
|
||||
raise CLIError("solution contains an unsupported path", 2)
|
||||
normalized = unicodedata.normalize("NFC", value)
|
||||
if (
|
||||
normalized in {"", ".", "..", ".git"}
|
||||
or len(normalized.encode("utf-8")) > MAX_PATH_SEGMENT_UTF8_BYTES
|
||||
):
|
||||
raise CLIError("solution contains an unsupported path", 2)
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_solution_path(path: tuple[str, ...]) -> None:
|
||||
if len("/".join(path).encode("utf-8")) > MAX_SOLUTION_PATH_UTF8_BYTES:
|
||||
raise CLIError("solution contains an unsupported path", 2)
|
||||
|
||||
|
||||
def _collect_solution_entries(
|
||||
source: Path,
|
||||
) -> list[tuple[Path, tuple[str, ...], bool, os.stat_result]]:
|
||||
entries: list[tuple[Path, tuple[str, ...], bool, os.stat_result]] = []
|
||||
normalized_paths: dict[tuple[str, ...], bool] = {}
|
||||
|
||||
def visit(directory: Path, relative: tuple[str, ...]) -> None:
|
||||
try:
|
||||
children = sorted(os.scandir(directory), key=lambda entry: entry.name)
|
||||
except OSError as exc:
|
||||
raise CLIError("solution directory cannot be read", 2) from exc
|
||||
for child in children:
|
||||
normalized = (*relative, _normalized_segment(child.name))
|
||||
_validate_solution_path(normalized)
|
||||
try:
|
||||
metadata = child.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise CLIError("solution entry cannot be read", 2) from exc
|
||||
mode = metadata.st_mode
|
||||
if stat.S_ISLNK(mode):
|
||||
raise CLIError("solution contains a symbolic link", 2)
|
||||
directory_entry = stat.S_ISDIR(mode)
|
||||
regular = stat.S_ISREG(mode)
|
||||
if not directory_entry and not regular:
|
||||
raise CLIError("solution contains a non-regular entry", 2)
|
||||
if regular and getattr(metadata, "st_nlink", 1) > 1:
|
||||
raise CLIError("solution contains a hard-linked file", 2)
|
||||
if normalized in normalized_paths:
|
||||
raise CLIError("solution contains duplicate normalized paths", 2)
|
||||
for length in range(1, len(normalized)):
|
||||
ancestor = normalized[:length]
|
||||
if ancestor in normalized_paths and not normalized_paths[ancestor]:
|
||||
raise CLIError("solution contains a file/directory conflict", 2)
|
||||
normalized_paths[normalized] = directory_entry
|
||||
entries.append((Path(child.path), normalized, directory_entry, metadata))
|
||||
if directory_entry:
|
||||
visit(Path(child.path), normalized)
|
||||
|
||||
visit(source, ())
|
||||
if not any(not directory for _, _, directory, _ in entries):
|
||||
raise CLIError("solution directory contains no regular files", 2)
|
||||
return entries
|
||||
|
||||
|
||||
def _zip_info(name: str, mode: int, *, directory: bool) -> zipfile.ZipInfo:
|
||||
info = zipfile.ZipInfo(name + ("/" if directory else ""))
|
||||
info.create_system = 3
|
||||
file_type = stat.S_IFDIR if directory else stat.S_IFREG
|
||||
info.external_attr = (file_type | mode) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
return info
|
||||
|
||||
|
||||
def _source_is_executable(mode: int, platform_name: str = os.name) -> bool:
|
||||
"""Return whether this platform exposes any executable bit for a source file."""
|
||||
return platform_name == "posix" and bool(mode & 0o111)
|
||||
|
||||
|
||||
def _resolve_solution_directory(solution_dir: Path) -> Path:
|
||||
try:
|
||||
source = solution_dir.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise CLIError("solution directory does not exist", 2) from exc
|
||||
if not source.is_dir():
|
||||
raise CLIError("solution path is not a directory", 2)
|
||||
return source
|
||||
|
||||
|
||||
def build_solution_archive(solution_dir: Path, destination: Path) -> None:
|
||||
"""Build one normalized, link-free archive below a synthetic solution root."""
|
||||
source = _resolve_solution_directory(solution_dir)
|
||||
entries = _collect_solution_entries(source)
|
||||
try:
|
||||
with zipfile.ZipFile(destination, "w", allowZip64=True) as archive:
|
||||
archive.writestr(_zip_info("solution", 0o755, directory=True), b"")
|
||||
for path, relative, directory, selected_metadata in entries:
|
||||
member_name = "solution/" + "/".join(relative)
|
||||
if directory:
|
||||
archive.writestr(_zip_info(member_name, 0o755, directory=True), b"")
|
||||
continue
|
||||
executable = _source_is_executable(selected_metadata.st_mode)
|
||||
permissions = 0o755 if executable else 0o644
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
current = os.fstat(descriptor)
|
||||
if getattr(current, "st_nlink", 1) > 1:
|
||||
raise CLIError("solution contains a hard-linked file", 2)
|
||||
identity_changed = os.name == "posix" and (
|
||||
current.st_dev != selected_metadata.st_dev
|
||||
or current.st_ino != selected_metadata.st_ino
|
||||
)
|
||||
metadata_changed = (
|
||||
current.st_size != selected_metadata.st_size
|
||||
or current.st_mtime_ns != selected_metadata.st_mtime_ns
|
||||
)
|
||||
if not stat.S_ISREG(current.st_mode) or identity_changed or metadata_changed:
|
||||
raise CLIError("solution changed while it was archived", 2)
|
||||
with os.fdopen(descriptor, "rb", closefd=True) as source_file:
|
||||
descriptor = -1
|
||||
info = _zip_info(member_name, permissions, directory=False)
|
||||
with archive.open(info, "w") as output:
|
||||
while chunk := source_file.read(_COPY_CHUNK_SIZE):
|
||||
output.write(chunk)
|
||||
final = os.fstat(source_file.fileno())
|
||||
if (
|
||||
final.st_size != current.st_size
|
||||
or final.st_mtime_ns != current.st_mtime_ns
|
||||
):
|
||||
raise CLIError("solution changed while it was archived", 2)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
except CLIError:
|
||||
raise
|
||||
except (OSError, ValueError, zipfile.BadZipFile) as exc:
|
||||
raise CLIError("solution archive could not be created", 1) from exc
|
||||
|
||||
|
||||
def _post_archive_with_deadline(
|
||||
client_factory: ClientFactory,
|
||||
url: str,
|
||||
archive_path: Path,
|
||||
access_token: str,
|
||||
total_timeout_seconds: float,
|
||||
) -> httpx.Response:
|
||||
outcomes: queue.SimpleQueue[httpx.Response | BaseException] = queue.SimpleQueue()
|
||||
# Windows does not permit unlinking a file held open by the request worker.
|
||||
# Detach the request body from the temporary path before enforcing the outer
|
||||
# wall-clock deadline so timeout cleanup remains immediate.
|
||||
windows_content = archive_path.read_bytes() if os.name == "nt" else None
|
||||
headers = {
|
||||
"Authorization": f"token {access_token}",
|
||||
"Content-Type": "application/zip",
|
||||
}
|
||||
|
||||
def send() -> None:
|
||||
try:
|
||||
with client_factory() as client:
|
||||
if windows_content is not None:
|
||||
outcomes.put(client.post(url, content=windows_content, headers=headers))
|
||||
else:
|
||||
with archive_path.open("rb") as content:
|
||||
outcomes.put(client.post(url, content=content, headers=headers))
|
||||
except BaseException as exc:
|
||||
outcomes.put(exc)
|
||||
finally:
|
||||
_remove_temporary_archive(archive_path)
|
||||
|
||||
worker = threading.Thread(target=send, name="distsys-cli-request", daemon=True)
|
||||
worker.start()
|
||||
worker.join(total_timeout_seconds)
|
||||
if worker.is_alive():
|
||||
raise CLIError("network request failed", 4)
|
||||
outcome = outcomes.get()
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
def _remove_temporary_archive(path: Path) -> None:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _validate_submit_success(response: httpx.Response) -> dict[str, str | bool]:
|
||||
_request_id(response)
|
||||
body = _json_object(response)
|
||||
if set(body) != SUBMIT_SUCCESS_FIELDS:
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
submission_id = body.get("submission_id")
|
||||
accepted_at = body.get("accepted_at")
|
||||
late = body.get("late_submission")
|
||||
workflow_run = body.get("workflow_run")
|
||||
if not isinstance(submission_id, str) or not submission_id:
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
if not isinstance(accepted_at, str) or not accepted_at:
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
if not isinstance(workflow_run, str) or not workflow_run:
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
if not isinstance(late, bool):
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
if _UUID_RE.fullmatch(submission_id) is None or _RFC3339_RE.fullmatch(accepted_at) is None:
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
try:
|
||||
uuid.UUID(submission_id)
|
||||
normalized_time = accepted_at[:-1] + "+00:00" if accepted_at[-1] in "Zz" else accepted_at
|
||||
parsed_time = datetime.fromisoformat(normalized_time)
|
||||
parsed_url = urlsplit(workflow_run)
|
||||
workflow_port = parsed_url.port
|
||||
parsed_http_url = httpx.URL(workflow_run)
|
||||
except (UnicodeError, ValueError, TypeError, httpx.InvalidURL) as exc:
|
||||
raise CLIError("backend returned an invalid response", 4) from exc
|
||||
if (
|
||||
parsed_time.tzinfo is None
|
||||
or accepted_at.endswith("-00:00")
|
||||
or parsed_url.scheme not in {"http", "https"}
|
||||
or not parsed_url.netloc
|
||||
or parsed_url.hostname is None
|
||||
or parsed_url.username is not None
|
||||
or parsed_url.password is not None
|
||||
or workflow_port == 0
|
||||
or not parsed_http_url.host
|
||||
or any(
|
||||
character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F
|
||||
for character in workflow_run
|
||||
)
|
||||
):
|
||||
raise CLIError("backend returned an invalid response", 4)
|
||||
return {
|
||||
"submission_id": submission_id,
|
||||
"accepted_at": accepted_at,
|
||||
"late_submission": late,
|
||||
"workflow_run": workflow_run,
|
||||
}
|
||||
|
||||
|
||||
def _format_accepted_at(accepted_at: str) -> str:
|
||||
normalized_time = accepted_at[:-1] + "+00:00" if accepted_at[-1] in "Zz" else accepted_at
|
||||
return datetime.fromisoformat(normalized_time).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def submit(
|
||||
assignment_id: str,
|
||||
solution_dir: Path,
|
||||
config_path: Path | None,
|
||||
*,
|
||||
client_factory: ClientFactory = _default_client,
|
||||
total_timeout_seconds: float = TOTAL_REQUEST_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if ASSIGNMENT_ID_RE.fullmatch(assignment_id) is None:
|
||||
raise CLIError("assignment ID is invalid", 2)
|
||||
server_url, access_token = _load_config(config_path)
|
||||
resolved_solution = _resolve_solution_directory(solution_dir)
|
||||
descriptor, archive_name = tempfile.mkstemp(prefix="distsys-solution-", suffix=".zip")
|
||||
os.close(descriptor)
|
||||
archive_path = Path(archive_name)
|
||||
try:
|
||||
try:
|
||||
archive_path.resolve().relative_to(resolved_solution)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise CLIError("temporary archive location overlaps the solution directory", 2)
|
||||
build_solution_archive(resolved_solution, archive_path)
|
||||
try:
|
||||
response = _post_archive_with_deadline(
|
||||
client_factory,
|
||||
f"{server_url}/submit/{assignment_id}",
|
||||
archive_path,
|
||||
access_token,
|
||||
total_timeout_seconds,
|
||||
)
|
||||
except (httpx.HTTPError, httpx.InvalidURL, OSError) as exc:
|
||||
raise CLIError("network request failed", 4) from exc
|
||||
if response.status_code != 201:
|
||||
try:
|
||||
_raise_backend_error(response, SUBMIT_ERROR_CODES)
|
||||
except BackendRejected as exc:
|
||||
if exc.backend_code == "SOLUTION_UNCHANGED":
|
||||
raise CLIError(f"{exc}; no grading run was created", 3) from None
|
||||
raise
|
||||
result = _validate_submit_success(response)
|
||||
finally:
|
||||
_remove_temporary_archive(archive_path)
|
||||
print(f"Submission ID: {result['submission_id']}")
|
||||
print(f"Accepted at: {_format_accepted_at(cast(str, result['accepted_at']))}")
|
||||
print(f"Late submission: {str(result['late_submission']).lower()}")
|
||||
print(f"Workflow run: {result['workflow_run']}")
|
||||
|
||||
|
||||
def _restrict_windows(path: Path) -> bool:
|
||||
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
try:
|
||||
identity = subprocess.run(
|
||||
["whoami"],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
principal = identity.stdout.strip()
|
||||
if identity.returncode != 0 or not principal:
|
||||
return False
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"icacls",
|
||||
str(path),
|
||||
"/inheritance:r",
|
||||
"/grant:r",
|
||||
f"{principal}:(F)",
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
return completed.returncode == 0
|
||||
|
||||
|
||||
def _write_config(destination: Path, server_url: str, access_token: str) -> bool:
|
||||
"""Synchronize a private sibling temporary file, then atomically replace."""
|
||||
payload = yaml.safe_dump(
|
||||
{"server_url": server_url, "access_token": access_token},
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
).encode("utf-8")
|
||||
descriptor = -1
|
||||
temporary: Path | None = None
|
||||
permission_warning = False
|
||||
try:
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{destination.name}.", dir=destination.parent
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
with os.fdopen(descriptor, "wb", closefd=True) as stream:
|
||||
descriptor = -1
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if os.name == "posix":
|
||||
temporary.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
if os.name == "nt" and not _restrict_windows(temporary):
|
||||
permission_warning = True
|
||||
os.replace(temporary, destination)
|
||||
temporary = None
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
if temporary is not None:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return permission_warning
|
||||
|
||||
|
||||
def _resolve_destination(value: Path | None) -> Path:
|
||||
destination = DEFAULT_CONFIG if value is None else value
|
||||
if value is not None and not destination.is_absolute():
|
||||
destination = Path.cwd() / destination
|
||||
try:
|
||||
destination = destination.resolve(strict=False)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise CLIError("configuration destination is not usable", 2) from exc
|
||||
if destination.exists() and destination.is_dir():
|
||||
raise CLIError("configuration destination is not a file", 2)
|
||||
return destination
|
||||
|
||||
|
||||
def _masked_email(email: str) -> str:
|
||||
local, separator, domain = email.rpartition("@")
|
||||
if not separator or not local or not domain:
|
||||
return "***"
|
||||
return f"{local[0]}***@{domain}"
|
||||
|
||||
|
||||
def register(
|
||||
server_url: str,
|
||||
config_path: Path | None,
|
||||
*,
|
||||
input_fn: Callable[[str], str] = input,
|
||||
password_fn: Callable[[str], str] = getpass.getpass,
|
||||
client_factory: ClientFactory = _default_client,
|
||||
total_timeout_seconds: float = TOTAL_REQUEST_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
normalized_url = normalize_server_url(server_url)
|
||||
destination = _resolve_destination(config_path)
|
||||
registration_token = password_fn("Registration token: ")
|
||||
name = input_fn("Full name: ")
|
||||
email = input_fn("Email: ")
|
||||
try:
|
||||
response = _post_registration_with_deadline(
|
||||
client_factory,
|
||||
f"{normalized_url}/register",
|
||||
{"name": name, "email": email, "reg_token": registration_token},
|
||||
total_timeout_seconds,
|
||||
)
|
||||
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
||||
raise CLIError("network request failed", 4) from exc
|
||||
if response.status_code != 201:
|
||||
_raise_backend_error(response)
|
||||
result, request_id = _validate_success(response)
|
||||
try:
|
||||
permission_warning = _write_config(destination, normalized_url, result["token"])
|
||||
except OSError as exc:
|
||||
raise CLIError(
|
||||
"Registration succeeded, but credentials could not be stored. "
|
||||
f"Contact course staff with request ID: {request_id}",
|
||||
1,
|
||||
) from exc
|
||||
if permission_warning:
|
||||
print(
|
||||
"warning: could not restrict configuration permissions to the current user",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"Username: {result['username']}")
|
||||
print(f"Temporary password: {result['password']}")
|
||||
print(f"Repository: {result['repo']}")
|
||||
print(f"Registration email: Check {_masked_email(email)} for your Gitea account details.")
|
||||
print("If it does not arrive within 5 minutes, check spam and contact course staff.")
|
||||
print("Action required: Sign in to Gitea and change the temporary password before submitting.")
|
||||
|
||||
|
||||
def parse_args(arguments: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=None)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
register_parser = subparsers.add_parser("register", help="create a student account")
|
||||
register_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
||||
register_parser.add_argument("server_url")
|
||||
submit_parser = subparsers.add_parser("submit", help="submit a solution for grading")
|
||||
submit_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
||||
submit_parser.add_argument("--assignment-id")
|
||||
submit_parser.add_argument("--solution-dir", type=Path)
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
|
||||
def _resolve_submit_options(
|
||||
assignment_id: str | None, solution_dir: Path | None
|
||||
) -> tuple[str, Path]:
|
||||
if assignment_id is None and solution_dir is None:
|
||||
working_directory = Path.cwd()
|
||||
default_solution = working_directory / "solution"
|
||||
if not default_solution.is_dir():
|
||||
raise CLIError("submit without options requires a solution subdirectory", 2)
|
||||
return working_directory.name, default_solution
|
||||
if assignment_id is None or solution_dir is None:
|
||||
raise CLIError("--assignment-id and --solution-dir must be provided together", 2)
|
||||
return assignment_id, solution_dir
|
||||
|
||||
|
||||
def main(arguments: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(arguments)
|
||||
try:
|
||||
if args.command == "register":
|
||||
register(args.server_url, args.config)
|
||||
elif args.command == "submit":
|
||||
assignment_id, solution_dir = _resolve_submit_options(
|
||||
args.assignment_id, args.solution_dir
|
||||
)
|
||||
submit(assignment_id, solution_dir, args.config)
|
||||
except CLIError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return exc.exit_code
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user