Spaces:
Running
Running
Commit ·
ebb4d1d
1
Parent(s): 2ad50f4
Show actionable Hugging Face persistence errors
Browse files- classroom_service.py +17 -5
- tests/test_classroom.py +15 -0
classroom_service.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Browser-side multi-file Python classroom for the Telearn demo Space."""
|
| 2 |
from __future__ import annotations
|
| 3 |
-
import asyncio, hmac, io, json, os, re, sqlite3, threading, time, zipfile
|
| 4 |
from pathlib import Path, PurePosixPath
|
| 5 |
from typing import Any
|
| 6 |
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
@@ -9,9 +9,9 @@ from pydantic import BaseModel
|
|
| 9 |
|
| 10 |
ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; PUBLISHED=ROOT/"projects"
|
| 11 |
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
|
| 12 |
-
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","")
|
| 13 |
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
|
| 14 |
-
router=APIRouter(); lock=threading.Lock(); students={}; teachers={}; live={}
|
| 15 |
|
| 16 |
class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
|
| 17 |
class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
|
|
@@ -86,6 +86,17 @@ def published_progress(display_name):
|
|
| 86 |
def check_password(p):
|
| 87 |
if not PASSWORD:raise HTTPException(503,"請先在 Space Secrets 設定 CLASSROOM_TEACHER_PASSWORD")
|
| 88 |
if not hmac.compare_digest(p,PASSWORD):raise HTTPException(403,"教師密碼不正確")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def finals(c,w):return [x for x in snapshot(c,w) if x.get("final_files")]
|
| 90 |
def make_zip(c,w):
|
| 91 |
b=io.BytesIO()
|
|
@@ -118,8 +129,9 @@ def create_class(req:CourseCreate):
|
|
| 118 |
from huggingface_hub import CommitOperationAdd,HfApi
|
| 119 |
payload=json.dumps([{"code":c,"name":n} for c,n in rows],ensure_ascii=False,indent=2)+"\n"
|
| 120 |
HfApi(token=TOKEN).create_commit(repo_id=REPO,repo_type="space",operations=[CommitOperationAdd(path_in_repo="classroom_data/classes.json",path_or_fileobj=payload.encode())],commit_message=f"Add classroom course {code}")
|
| 121 |
-
except Exception:
|
| 122 |
-
|
|
|
|
| 123 |
else:warning="課程已可使用,但尚未永久寫入 Space;請設定 HF_TOKEN。"
|
| 124 |
return {"ok":True,"class_code":code,"course_name":name,"already_exists":bool(existing),"warning":warning}
|
| 125 |
@router.delete("/api/classroom/students")
|
|
|
|
| 1 |
"""Browser-side multi-file Python classroom for the Telearn demo Space."""
|
| 2 |
from __future__ import annotations
|
| 3 |
+
import asyncio, hmac, io, json, logging, os, re, sqlite3, threading, time, zipfile
|
| 4 |
from pathlib import Path, PurePosixPath
|
| 5 |
from typing import Any
|
| 6 |
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
|
|
| 9 |
|
| 10 |
ROOT=Path(__file__).resolve().parent; STATIC=ROOT/"classroom_static"; DATA=ROOT/"classroom_data"; TEMPLATES=DATA/"templates"; PUBLISHED=ROOT/"projects"
|
| 11 |
RUNTIME=Path(os.getenv("CLASSROOM_DATA_DIR","/tmp/telearn_classroom")); DB=RUNTIME/"classroom.sqlite3"
|
| 12 |
+
PASSWORD=os.getenv("CLASSROOM_TEACHER_PASSWORD",""); REPO=os.getenv("CLASSROOM_DEMO_REPO",os.getenv("SPACE_ID","Telearn/Project_Demo")).strip(); DEMO_CLASS=os.getenv("CLASSROOM_DEMO_CLASS","2026Fall進階專題"); TOKEN=os.getenv("HF_TOKEN","").strip()
|
| 13 |
ALLOWED={".py",".json",".txt",".csv",".md",".toml",".yaml",".yml"}; ID_RE=re.compile(r"[^0-9A-Za-z_\-一-鿿]")
|
| 14 |
+
router=APIRouter(); lock=threading.Lock(); students={}; teachers={}; live={}; logger=logging.getLogger(__name__)
|
| 15 |
|
| 16 |
class TeacherRequest(BaseModel): class_code:str; week_id:str; password:str
|
| 17 |
class CourseCreate(BaseModel): class_code:str; course_name:str=""; password:str
|
|
|
|
| 86 |
def check_password(p):
|
| 87 |
if not PASSWORD:raise HTTPException(503,"請先在 Space Secrets 設定 CLASSROOM_TEACHER_PASSWORD")
|
| 88 |
if not hmac.compare_digest(p,PASSWORD):raise HTTPException(403,"教師密碼不正確")
|
| 89 |
+
def hf_failure_message(exc):
|
| 90 |
+
"""Return an actionable message without ever exposing a token."""
|
| 91 |
+
status=getattr(getattr(exc,"response",None),"status_code",None)
|
| 92 |
+
detail=getattr(exc,"server_message",None) or str(exc) or type(exc).__name__
|
| 93 |
+
detail=re.sub(r"hf_[A-Za-z0-9]+","hf_***",re.sub(r"\s+"," ",detail)).strip()[:220]
|
| 94 |
+
if status==401:reason="HF_TOKEN 未被 Space 正確載入、已失效或貼入值不正確"
|
| 95 |
+
elif status==403:reason=f"HF_TOKEN 已被載入,但對 {REPO} 的 Space 寫入驗證被拒絕"
|
| 96 |
+
elif status==404:reason=f"找不到目標 Space {REPO},請檢查 CLASSROOM_DEMO_REPO 或 SPACE_ID"
|
| 97 |
+
else:reason=f"連線到 Hugging Face 時發生 {type(exc).__name__}"
|
| 98 |
+
suffix=f"HTTP {status}:{detail}" if status else detail
|
| 99 |
+
return f"課程已可使用,但尚未永久寫入 Space。{reason}。詳情:{suffix}"
|
| 100 |
def finals(c,w):return [x for x in snapshot(c,w) if x.get("final_files")]
|
| 101 |
def make_zip(c,w):
|
| 102 |
b=io.BytesIO()
|
|
|
|
| 129 |
from huggingface_hub import CommitOperationAdd,HfApi
|
| 130 |
payload=json.dumps([{"code":c,"name":n} for c,n in rows],ensure_ascii=False,indent=2)+"\n"
|
| 131 |
HfApi(token=TOKEN).create_commit(repo_id=REPO,repo_type="space",operations=[CommitOperationAdd(path_in_repo="classroom_data/classes.json",path_or_fileobj=payload.encode())],commit_message=f"Add classroom course {code}")
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
logger.exception("Could not persist classroom course catalog to %s",REPO)
|
| 134 |
+
warning=hf_failure_message(exc)
|
| 135 |
else:warning="課程已可使用,但尚未永久寫入 Space;請設定 HF_TOKEN。"
|
| 136 |
return {"ok":True,"class_code":code,"course_name":name,"already_exists":bool(existing),"warning":warning}
|
| 137 |
@router.delete("/api/classroom/students")
|
tests/test_classroom.py
CHANGED
|
@@ -5,6 +5,7 @@ import tempfile
|
|
| 5 |
import unittest
|
| 6 |
import zipfile
|
| 7 |
from pathlib import Path
|
|
|
|
| 8 |
|
| 9 |
import classroom_service as classroom
|
| 10 |
|
|
@@ -115,6 +116,20 @@ class ClassroomServiceTests(unittest.TestCase):
|
|
| 115 |
course = next(item for item in classroom.catalog()["courses"] if item["class_code"] == "L6B")
|
| 116 |
self.assertEqual(course["course_name"], "週六進階班")
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
def test_published_final_can_be_loaded_after_runtime_restart(self):
|
| 119 |
old_published = classroom.PUBLISHED
|
| 120 |
try:
|
|
|
|
| 5 |
import unittest
|
| 6 |
import zipfile
|
| 7 |
from pathlib import Path
|
| 8 |
+
from types import SimpleNamespace
|
| 9 |
|
| 10 |
import classroom_service as classroom
|
| 11 |
|
|
|
|
| 116 |
course = next(item for item in classroom.catalog()["courses"] if item["class_code"] == "L6B")
|
| 117 |
self.assertEqual(course["course_name"], "週六進階班")
|
| 118 |
|
| 119 |
+
def test_hf_failure_message_distinguishes_authentication_and_permission(self):
|
| 120 |
+
unauthorized = RuntimeError("token hf_should_never_leak was rejected")
|
| 121 |
+
unauthorized.response = SimpleNamespace(status_code=401)
|
| 122 |
+
forbidden = RuntimeError("Forbidden")
|
| 123 |
+
forbidden.response = SimpleNamespace(status_code=403)
|
| 124 |
+
|
| 125 |
+
unauthorized_message = classroom.hf_failure_message(unauthorized)
|
| 126 |
+
forbidden_message = classroom.hf_failure_message(forbidden)
|
| 127 |
+
|
| 128 |
+
self.assertIn("HF_TOKEN 未被 Space 正確載入", unauthorized_message)
|
| 129 |
+
self.assertNotIn("hf_should_never_leak", unauthorized_message)
|
| 130 |
+
self.assertIn(classroom.REPO, forbidden_message)
|
| 131 |
+
self.assertIn("寫入驗證被拒絕", forbidden_message)
|
| 132 |
+
|
| 133 |
def test_published_final_can_be_loaded_after_runtime_restart(self):
|
| 134 |
old_published = classroom.PUBLISHED
|
| 135 |
try:
|