في أول محاضرة من مقرر البرمجة أو تحليل البيانات تضيع نصف الساعة الأولى في تثبيت Python على أجهزة الطلاب: طالب على Windows ونسخة Python قديمة، وطالب على Mac ومكتبة لا تتثبت، وطالب جهازه ضعيف لا يحتمل pandas، والمعلم يركض بين الصفوف بدلاً من أن يشرح. والحل الذي تستخدمه الجامعات هو أن يكون مختبر البرمجة على السيرفر، فيفتح الطالب المتصفح ويجد بيئة Jupyter جاهزة بالمكتبات نفسها التي يستخدمها زملاؤه، وهذا ما يقدمه JupyterHub. وفي الأنظمة التي تحتاجها الجامعة ذكرنا مختبرات البرمجة بين الأنظمة التي تكمل منصة التعلم، والآن نشغلها فعلاً.
وفي هذا المقال سوف نتناول:
- كيف يعمل JupyterHub من الداخل، وأي طريقة تثبيت تناسب فصلاً واحداً، وأيها تناسب جامعة.
- مختبر على Docker فيه معلمة وثلاثة طلاب، وكم يستهلك كل طالب من الذاكرة فعلاً، وما الذي حدث عندما بدأت ثلاثة سيرفرات في الوقت نفسه.
- دورة واجب كاملة بـ nbgrader: إنشاء الواجب، وتوزيعه، وتسليمه، والتصحيح الآلي، وملف الدرجات.
- ثلاث نقاط أمان وجدناها في التجربة: حد الذاكرة الذي لا يطبق، ومجلد التسليم، وكود الطالب الذي يعمل بحساب المعلم.
كيف يعمل JupyterHub؟
Jupyter وحده برنامج لمستخدم واحد، يشغله الشخص على جهازه ويفتح دفاتره Notebooks في المتصفح. وJupyterHub يضع أمامه بوابة لعدة مستخدمين، فعندما يدخل الطالب يشغل له سيرفر Jupyter خاصاً به، ويوجه طلباته إليه. والشكل التالي يبين المكونات كما شغلناها، مع الذاكرة التي قسناها لكل منها:

في الشكل أعلاه لاحظ التالي:
- الوكيل Proxy: البرنامج الوحيد الذي يستقبل طلبات المتصفح، ويوجه
/hub/إلى المحور و/user/<name>/إلى سيرفر الطالب. - المصادقة Authenticator: تقرر من هو المستخدم، والافتراضية PAM أي حسابات Linux على السيرفر، وقائمة المصادقات فيها OAuth لربطه بمزود هوية مثل authentik الذي ربطنا به Moodle في دخول موحد إلى Moodle.
- المشغل Spawner: يقرر أين يعمل سيرفر الطالب، وهو أهم قرار في التصميم كله، والجدول التالي يبين الخيارات.
| الطريقة | أين يعمل سيرفر الطالب | حد الذاكرة لكل طالب | يناسب |
|---|---|---|---|
| LocalProcessSpawner (الافتراضي) | عملية Process بحساب Linux على السيرفر نفسه | لا يطبق | التجربة، أو فصل صغير تثق بطلابه |
| The Littlest JupyterHub | سيرفر واحد، وكل طالب في وحدة systemd | يطبق بالإعداد limits.memory | مقرر أو قسم حتى عشرات الطلاب على سيرفر واحد |
| DockerSpawner | Container لكل طالب | يطبقه Docker | سيرفر واحد مع عزل أقوى |
| Zero to JupyterHub على Kubernetes | Pod لكل طالب على عدة سيرفرات | يطبقه Kubernetes | جامعة بمئات الطلاب في الوقت نفسه |
وقد يتساءل البعض: لماذا نبدأ بالطريقة الأولى إذا كانت لا تطبق الحدود؟ والإجابة أنها الطريقة التي يفترضها دليل nbgrader لمعلم واحد ومقرر واحد، وأنها تجعل كل مكون ظاهراً، فنرى بأعيننا ما الذي تضيفه الطرق الأخرى، وسوف نقيس ذلك في قسم الأمان.
المتطلبات
- الذاكرة: في مختبرنا استخدم المحور 143MB والوكيل 80MB، وكل سيرفر طالب نحو 168MB وهو خامل قبل أن يشغل أي كود، فثلاثون طالباً في محاضرة واحدة يعنون نحو 5GB قبل أن يحمل أحدهم ملف بيانات، ثم يضاف ما تستهلكه pandas والبيانات نفسها، وهذا هو الرقم الذي تخطط عليه وليس عدد الطلاب المسجلين في المقرر.
- نطاق وشهادة HTTPS: مثل
https://lab.example.comعبر Reverse Proxy كما في تثبيت Caddy، والسبب أن كلمات مرور الطلاب ورموز الجلسة تمر عبره، ولاحظ أن الوكيل يحتاج دعم WebSocket لأن الدفتر يتصل بالنواة Kernel عبره. - حساب لكل طالب: سواء كان حساب Linux كما في المختبر، أو حساباً من مزود الهوية.
المختبر: JupyterHub وnbgrader على Docker
بنينا Docker Image فوق ال Image الرسمي quay.io/jupyterhub/jupyterhub:6.1.1، وأضفنا إليه JupyterLab وnbgrader ومكتبات البيانات، وأنشأنا حسابات Linux للمعلمة وثلاثة طلاب:
FROM quay.io/jupyterhub/jupyterhub:6.1.1
RUN python3 -m pip install --no-cache-dir jupyterlab==4.6.4 notebook==7.6.3 nbgrader==0.9.6 \
numpy==2.3.4 pandas==2.3.3 matplotlib==3.10.7
# One instructor and three students as Linux users (the lab uses PAM, the default authenticator)
RUN for u in t.sara s2026001 s2026002 s2026003; do useradd -m -s /bin/bash "$u" && echo "$u:Lab-Pass-2026!" | chpasswd; done \
&& mkdir -p /srv/nbgrader/exchange && chmod 777 /srv/nbgrader/exchange
COPY jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
COPY nbgrader_config.py /etc/jupyter/nbgrader_config.py
WORKDIR /srv/jupyterhub
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]ثم إعداد JupyterHub في jupyterhub_config.py:
c = get_config() # noqa
c.JupyterHub.bind_url = "http://0.0.0.0:8000"
c.Authenticator.allowed_users = {"t.sara", "s2026001", "s2026002", "s2026003"}
c.Authenticator.admin_users = {"t.sara"}
c.Spawner.default_url = "/lab"
c.Spawner.mem_limit = "512M"
# A service token for the lab script that starts and stops the students' servers
c.JupyterHub.services = [{"name": "lab-runner", "api_token": "lab-hub-api-token-4c8d"}]
c.JupyterHub.load_roles = [{"name": "lab-runner", "scopes": ["admin:users", "admin:servers"], "services": ["lab-runner"]}]
c.Spawner.http_timeout = 120
c.Spawner.start_timeout = 180وإعداد nbgrader المشترك في /etc/jupyter/nbgrader_config.py، وفيه اسم المقرر ومجلد المعلمة ومجلد التبادل الذي تمر عبره الواجبات بين المعلم والطلاب:
c = get_config() # noqa
c.CourseDirectory.course_id = "py101"
c.CourseDirectory.root = "/home/t.sara/py101"
c.Exchange.root = "/srv/nbgrader/exchange"name: edu3-jh
services:
hub:
image: edu3-jhub:6.1.1
container_name: edu3-jh-hub
ports: ["127.0.0.1:29280:8000"]
volumes: ["home:/home", "exchange:/srv/nbgrader/exchange"]
mem_limit: 2g
volumes:
home: { name: edu3-jh-home }
exchange: { name: edu3-jh-exchange }
networks:
default: { name: edu3-jh-net }docker build -t edu3-jhub:6.1.1 .
docker compose up -dفي الإعداد أعلاه لاحظ التالي:
- Python داخل ال Image: ال Image الرسمي مبني على Ubuntu 26.04.1، وكل حزم JupyterHub في بيئة افتراضية Virtual Environment في
/srv/venv، بينما الأمرpython3في جلسةsu - s2026001هو Python النظام الذي لا يعرف nbgrader، فأول أمر شغلناه بحساب المعلمة فشل بالرسالةModuleNotFoundError: No module named 'nbformat'. لذلك في الأوامر التالية من سطر الأوامر نضيف/srv/venv/binإلى PATH، وأما في الطرفية Terminal داخل JupyterLab فالبيئة صحيحة. - مجلدات الطلاب: أنشأ
useraddكل مجلد بالصلاحياتdrwxr-x---، فلا يرى الطالب مجلد زميله، وجربنا ذلك فكانت النتيجةPermission denied. وهذا من إعدادات النظام في الإصدارات الحديثة من Ubuntu، فتأكد منه في نظامك ولا تفترضه. - ال Volume
home: كل عمل الطلاب فيه، فهو أهم ما تنسخه احتياطياً. - الإصدارات: JupyterHub 6.1.1 وnbgrader 0.9.6 كانا أحدث الإصدارات عند التجربة.

ثلاثة طلاب يبدؤون في اللحظة نفسها
في الإعداد الأول لم يكن السطران الأخيران من jupyterhub_config.py موجودين، وطلبنا من JupyterHub تشغيل سيرفرات الطلاب الثلاثة معاً عبر الواجهة البرمجية، كما يحدث عندما يفتح الفصل كله الصفحة في بداية المحاضرة:
for u in s2026001 s2026002 s2026003; do
curl -s -o /dev/null -w "$u %{http_code}\n" -X POST -H 'Authorization: token lab-hub-api-token-4c8d' \
http://127.0.0.1:29280/hub/api/users/$u/server
doneقبل JupyterHub الطلبات الثلاثة بالرمز 202، ولكن لم يصبح أي سيرفر جاهزاً، وفي السجل رسائل تقترح الحل:
This may be a configuration issue specific to your chosen Spawner.
To fix: increase `Spawner.http_timeout` configuration
to a number of seconds that is enough for servers to become responsive.والقيمة الافتراضية لـ http_timeout هي 30 ثانية بحسب مرجع ال Spawner، وعلى سيرفر مشغول يحتاج سيرفر الطالب أكثر منها ليبدأ، فيعتبره JupyterHub فاشلاً ويوقفه. بعد رفع المهلة إلى 120 ثانية وتشغيل الطلاب واحداً بعد الآخر أصبحت السيرفرات جاهزة بعد 34 و31 و32 ثانية. والدرس: جرب على السيرفر الحقيقي سيناريو بداية المحاضرة، أي ثلاثين طالباً يدخلون في الدقيقة نفسها، وارفع المهلة بحسب ما تقيسه، كما شرحنا في اختبار الأحمال والضغط.
الواجبات والتصحيح الآلي مع nbgrader
nbgrader أداة لإنشاء الواجبات في دفاتر Jupyter وتصحيحها، ودليل إنشاء الواجبات يشرح الفكرة: المعلم يكتب دفتراً واحداً فيه الحل والاختبارات، والأداة تخرج منه نسخة الطالب دون الحل، ثم تصحح ما يسلمه الطالب بتشغيل الاختبارات عليه. والشكل التالي يبين الدورة كاملة كما نفذناها:

الخطوة 1: دفتر المصدر
كل خلية في دفتر المصدر لها بيانات nbgrader في الحقل metadata، تقول هل هي خلية حل يكتب فيها الطالب، أم خلية اختبار مقفلة لها درجة. وفي الواجهة تضعها من شريط Create Assignment على يمين JupyterLab، ونحن أنشأنا الدفتر بسكربت حتى يكون قابلاً للتكرار:
# Writes the instructor's source notebook py101/source/hw1/hw1.ipynb with nbgrader cell metadata:
# two answer cells (solution) and two locked test cells worth 2 and 3 points, the second with hidden tests.
import nbformat as nbf, os
def md(text, gid):
c = nbf.v4.new_markdown_cell(text)
c.metadata["nbgrader"] = {"grade": False, "grade_id": gid, "locked": True, "schema_version": 3, "solution": False, "task": False}
return c
def code(src, gid, solution=False, points=None):
c = nbf.v4.new_code_cell(src)
c.metadata["nbgrader"] = {"grade": points is not None, "grade_id": gid, "locked": not solution,
"schema_version": 3, "solution": solution, "task": False}
if points is not None:
c.metadata["nbgrader"]["points"] = points
return c
nb = nbf.v4.new_notebook()
nb.cells = [
md("# الواجب الأول: درجات الطلاب\nاكتب الدالتين التاليتين، ثم شغل خلايا الاختبار للتأكد من إجابتك قبل التسليم.", "intro"),
md("**السؤال 1:** اكتب الدالة `average(grades)` التي ترجع متوسط الدرجات، وتتجاهل القيم `None` (طالب غائب).", "q1"),
code("def average(grades):\n ### BEGIN SOLUTION\n marks = [g for g in grades if g is not None]\n return sum(marks) / len(marks)\n ### END SOLUTION", "average", solution=True),
code("assert average([80, 90, 100]) == 90\nassert average([70, None, 90]) == 80", "test_average", points=2),
md("**السؤال 2:** اكتب الدالة `letter(score)` التي ترجع «ممتاز» من 90 فما فوق، و«جيد جداً» من 80، و«جيد» من 70، و«راسب» لما دون ذلك.", "q2"),
code("def letter(score):\n ### BEGIN SOLUTION\n if score >= 90:\n return \"ممتاز\"\n if score >= 80:\n return \"جيد جداً\"\n if score >= 70:\n return \"جيد\"\n return \"راسب\"\n ### END SOLUTION", "letter", solution=True),
code("assert letter(95) == \"ممتاز\"\nassert letter(85) == \"جيد جداً\"\n### BEGIN HIDDEN TESTS\nassert letter(90) == \"ممتاز\"\nassert letter(69.5) == \"راسب\"\n### END HIDDEN TESTS", "test_letter", points=3),
]
nb.metadata["kernelspec"] = {"name": "python3", "display_name": "Python 3", "language": "python"}
os.makedirs("py101/source/hw1", exist_ok=True)
nbf.write(nb, "py101/source/hw1/hw1.ipynb")
print("wrote py101/source/hw1/hw1.ipynb with", len(nb.cells), "cells")في السكربت أعلاه لاحظ التالي:
- ما بين
### BEGIN SOLUTIONو### END SOLUTIONهو الحل، ويحذفه nbgrader من نسخة الطالب ويضع مكانهraise NotImplementedError(). - ما بين
### BEGIN HIDDEN TESTSو### END HIDDEN TESTSاختبارات لا يراها الطالب، وتعود عند التصحيح، وهي هنا تختبر الحدود: الدرجة 90 بالضبط، والدرجة 69.5.
الخطوتان 2 و3: إنشاء نسخة الطالب وتوزيعها
su - t.sara -c 'export PATH=/srv/venv/bin:$PATH; python3 /tmp/make_assignment.py && cd py101 && nbgrader generate_assignment hw1 --create && nbgrader release_assignment hw1'wrote py101/source/hw1/hw1.ipynb with 7 cells
[GenerateAssignmentApp | INFO] Updating/creating assignment 'hw1': {}
[GenerateAssignmentApp | INFO] Converting notebook /home/t.sara/py101/source/./hw1/hw1.ipynb
[GenerateAssignmentApp | INFO] Making directory /home/t.sara/py101/release/./hw1
[GenerateAssignmentApp | INFO] Writing 3795 bytes to /home/t.sara/py101/release/hw1/hw1.ipynb
[GenerateAssignmentApp | INFO] Setting destination file permissions to 644
[ReleaseAssignmentApp | INFO] Source: /home/t.sara/py101/release/./hw1
[ReleaseAssignmentApp | INFO] Destination: /srv/nbgrader/exchange/py101/outbound/hw1
[ReleaseAssignmentApp | INFO] Released as: py101 hw1وهذه خلايا الكود في النسخة التي وصلت إلى مجلد التبادل، أي ما يراه الطالب:
def average(grades):
# YOUR CODE HERE
raise NotImplementedError()
---
assert average([80, 90, 100]) == 90
assert average([70, None, 90]) == 80
---
def letter(score):
# YOUR CODE HERE
raise NotImplementedError()
---
assert letter(95) == "ممتاز"
assert letter(85) == "جيد جداً"الحل اختفى، والاختبارات المخفية اختفت، وبقيت الاختبارات الظاهرة ليجرب بها الطالب إجابته قبل التسليم.
الخطوتان 4 و5: الطلاب يجلبون ويسلمون
من JupyterLab يجد الطالب الواجب في قائمة Nbgrader ثم Assignment List، ويضغط Fetch ثم Submit، والأمران المقابلان في سطر الأوامر هما nbgrader fetch_assignment hw1 --course py101 وnbgrader submit hw1 --course py101. وجعلنا كل طالب يكتب إجابة مختلفة:
s2026001: إجابة صحيحة.s2026002: متوسط يقسم على عدد الدرجات مع الغائبين، ودرجة «ممتاز» تبدأ بعد 90 وليس من 90، أي خطأ في الحدود لا تكشفه الاختبارات الظاهرة.s2026003: إجابة صحيحة، ومعها سطر يكتب في ملف اسم المستخدم الذي يشغل الكود والمجلد الذي يعمل فيه، وسوف نعرف سببه بعد قليل.
== s2026001
[FetchAssignmentApp | INFO] Destination: /home/s2026001/hw1
[FetchAssignmentApp | INFO] Fetched as: py101 hw1
answered correct
[SubmitApp | INFO] Source: /home/s2026001/hw1
[SubmitApp | INFO] Destination: /srv/nbgrader/exchange/py101/inbound/s2026001+hw1+2026-10-22 01:07:40.749315 UTC+jQrwWzFOeYso
[SubmitApp | INFO] Submitted as: py101 hw1 2026-10-22 01:07:40.749315 UTCوالصورة التالية للدفتر في JupyterLab بحساب الطالب بعد أن كتب إجابته:

لاحظ في الصورة أن JupyterLab يعرض نص الخلايا العربي من اليسار، فتظهر النقطة والقوس في أول السطر، وهذا مزعج في واجب عربي طويل، ومن الحلول التي تستحق التجربة أن تضع نص السؤال داخل وسم HTML يحدد اتجاه الكتابة <div dir="rtl"> في خلية Markdown، ثم تتأكد من شكله عند الطالب قبل توزيع الواجب.
الخطوات 6 إلى 8: الجمع والتصحيح والدرجات
su - t.sara -c 'export PATH=/srv/venv/bin:$PATH; cd py101 && nbgrader collect hw1 && nbgrader autograde hw1 && nbgrader export && cat grades.csv'[CollectApp | INFO] Processing 3 submissions of 'hw1' for course 'py101'
[CollectApp | INFO] Collecting submission: s2026001 hw1
[CollectApp | INFO] Collecting submission: s2026002 hw1
[CollectApp | INFO] Collecting submission: s2026003 hw1
[AutogradeApp | WARNING] Attribute 'checksum' for cell test_letter has changed! (should be: b7b536840c3f16c0c88debf8a7403fee, got: 9ee79326aa2c469f7e1f55fbe5375f19)
[AutogradeApp | INFO] Autograding /home/t.sara/py101/autograded/s2026001/hw1/hw1.ipynb
...
[AutogradeApp | INFO] Overwriting files with master versions from the source directory
...
[ExportApp | INFO] Exporting grades to grades.csv
assignment,duedate,timestamp,student_id,last_name,first_name,email,raw_score,late_submission_penalty,score,max_score
hw1,,2026-10-22 01:07:40.749315,s2026001,,,,5.0,0.0,5.0,5.0
hw1,,2026-10-22 01:08:21.700744,s2026002,,,,0.0,0.0,0.0,5.0
hw1,,2026-10-22 01:08:59.915875,s2026003,,,,5.0,0.0,5.0,5.0في المخرج أعلاه لاحظ التالي:
- تحذير checksum: ظهر لكل طالب على خلية
test_letter، والسبب أن نسخة الطالب لا تحتوي الاختبارات المخفية، فبصمة الخلية تختلف عن بصمة المصدر، ثم يعيد nbgrader الخلايا المقفلة من نسخة المصدر كما في السطر «Overwriting files with master versions»، فالاختبارات المخفية تعمل والطالب لا يستطيع أن يغير خلايا الاختبار ليجتاز الواجب. - درجة الطالب الثاني صفر: عند سؤال قاعدة الدرجات Gradebook عن كل خلية كانت النتيجة
test_average 0.0 من 2.0وtest_letter 0.0 من 3.0. والسؤال الثاني هو المهم، فالاختباران الظاهران نجحا عند الطالب قبل التسليم، والاختبار المخفيletter(90)هو الذي كشف خطأ الحدود، وهذا بالضبط سبب وجود الاختبارات المخفية. ولاحظ أن الخلية كلها تأخذ صفراً إذا فشل فيها سطر واحد، فاجعل كل خلية اختبار تختبر فكرة واحدة إذا أردت درجات جزئية. - الأعمدة الفارغة: الاسم والبريد فارغان في الملف، لأن الطلاب أضيفوا إلى قاعدة الدرجات بأسماء المستخدمين فقط، فأضف الطلاب قبل الواجب الأول بالأمر
nbgrader db student importمن ملف CSV حتى يخرج ملف الدرجات جاهزاً للرفع إلى منصة التعلم.
والصورة التالية لواجهة التصحيح formgrader بحساب المعلمة، وفيها الواجب وعدد التسليمات:

فتحناها مباشرة على المسار /user/t.sara/formgrader في تبويب مستقل، والسبب أن دليل nbgrader مع JupyterHub ينبه إلى أن JupyterHub منذ الإصدار 4.1 يمنع عرض الصفحات داخل إطار iframe لأسباب أمنية، وواجهة التصحيح تعرض داخل إطار في JupyterLab فتظهر فارغة، والحلول التي يذكرها الدليل: فتحها في تبويب جديد، أو تفعيل النطاقات الفرعية Subdomains في JupyterHub.
ثلاث نقاط أمان قبل أول مقرر حقيقي
كود الطالب يعمل بحساب المعلم
هذا هو سبب السطر الإضافي في إجابة الطالب الثالث، فقد كتب في الملف /tmp/who-ran-my-code.txt اسم المستخدم الذي شغل كوده، وبعد التصحيح كان محتوى الملف كما يلي:
-rw-rw-r-- 1 t.sara t.sara 49 Oct 22 01:11 /tmp/who-ran-my-code.txt
t.sara /home/t.sara/py101/autograded/s2026003/hw1أي أن الأمر autograde شغل كود الطالب بحساب المعلمة t.sara وداخل مجلدها، وهذا يعني أن طالباً يكتب في إجابته سطراً يقرأ ملف gradebook.db ويغير درجته، أو يقرأ إجابات زملائه من مجلد submitted، أو يحذف ملفات المعلم، سوف ينجح كوده لحظة التصحيح. والأسئلة الشائعة في nbgrader تقول صراحة إن الأداة لا تحمي من الكود غير الآمن بشكل مباشر، وتحيل إلى التصحيح داخل Docker Container عبر نواة معزولة. لذلك لا تقم بتشغيل autograde بحساب المعلم على سيرفر فيه بيانات مهمة، والأنسب أن يكون التصحيح بحساب مخصص لا يملك إلا مجلد المقرر، أو داخل Container لكل تصحيح.
حد الذاكرة الذي لا يطبق
في الإعداد كتبنا c.Spawner.mem_limit = "512M"، فهل يمنع ذلك طالباً من استهلاك ذاكرة السيرفر كلها؟ جربنا من حساب الطالب الأول:
su - s2026001 -c 'p=$(pgrep -u s2026001 -f singleuser | head -1); tr "\0" "\n" < /proc/$p/environ | grep -E "^MEM_LIMIT"
/srv/venv/bin/python3 -c "
b = bytearray(900 * 1024 * 1024)
import resource; print(\"allocated 900 MB, peak RSS\", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024, \"MB\")"'MEM_LIMIT=536870912
allocated 900 MB, peak RSS 910 MBالحد وصل إلى سيرفر الطالب كمتغير بيئة Environment Variable فقط، والعملية استهلكت 910MB دون أي اعتراض. ومرجع ال Spawner يقول ذلك صراحة: الحد يحتاج Spawner يطبقه، والمشغل الافتراضي LocalProcessSpawner لا يطبقه. فإذا كان السيرفر مشتركاً بين ثلاثين طالباً، فطالب واحد بحلقة تحمل ملف بيانات كبيراً يستطيع أن يوقف المحاضرة للجميع، والحل أن تستخدم طريقة تطبق الحدود فعلاً من الجدول في أول المقال، مثل limits.memory في The Littlest JupyterHub أو حدود Kubernetes.
مجلد التسليم
مجلد التبادل /srv/nbgrader/exchange مفتوح للكتابة للجميع، والمجلد الذي تصل إليه التسليمات له الصلاحيات drwx-ws-wx، أي يكتب فيه الطالب ولا يستطيع عرض محتواه:
ls: cannot open directory '/srv/nbgrader/exchange/py101/inbound': Permission deniedولكن لاحظ أن مجلد كل تسليم داخله بالصلاحيات drwxrwxrwx، وجربنا من حساب الطالب الثاني قراءة ملف تسليم الطالب الأول بمساره الكامل فنجحت القراءة. والذي يحمي التسليم هنا هو أن اسم المجلد فيه الوقت بأجزاء المليون من الثانية ونص عشوائي من 12 حرفاً مثل jQrwWzFOeYso، فلا يمكن تخمينه عملياً، ولكنه ليس حماية بالصلاحيات. والقاعدة: اجمع التسليمات بالأمر collect بعد الموعد مباشرة، ولا تجعل المجلد على مساحة مشتركة يصل إليها من لا علاقة له بالمقرر، وفي الأنظمة الكبيرة استخدم خدمة تبادل بديلة من إضافات nbgrader.
النسخ الاحتياطي والترقية
- ما الذي تنسخه؟ مجلدات الطلاب في
/home، ومجلد المقرر عند المعلم وفيهgradebook.dbوكل التسليمات والتصحيحات، وملفي الإعداد، وقاعدة بيانات JupyterHubjupyterhub.sqliteفي مجلد عمله. ثم طبق ما في النسخ الاحتياطي بقاعدة 3-2-1. - الترقية Upgrade: لا ترق JupyterHub أو JupyterLab أو nbgrader في منتصف الفصل الدراسي، والسبب أن واجهات nbgrader في JupyterLab إضافات Extensions مبنية لإصدار محدد من JupyterLab، فإذا تقدم أحدهما على الآخر قد تتعطل قائمة الواجبات عند كل الطلاب في يوم التسليم. فرق بين الفصول، وجرب دورة واجب كاملة كالتي في هذا المقال قبل أن تفتح السيرفر للطلاب.
مشكلات شائعة وحلولها
| ما تراه | السبب | الحل |
|---|---|---|
| السيرفر لا يبدأ، وفي السجل «increase Spawner.http_timeout» | السيرفر لم يرد خلال 30 ثانية | ارفع c.Spawner.http_timeout وأضف ذاكرة ومعالجاً للسيرفر |
ModuleNotFoundError: No module named 'nbformat' في سطر الأوامر | Python النظام وليس بيئة JupyterHub | أضف /srv/venv/bin إلى PATH أو استخدم الطرفية داخل JupyterLab |
| واجهة formgrader فارغة داخل JupyterLab | JupyterHub 4.1 وما بعده يمنع الإطارات iframe | افتحها في تبويب مستقل أو فعل النطاقات الفرعية |
| تحذير checksum عند autograde | الاختبارات المخفية غير موجودة في نسخة الطالب | متوقع، ويعيد nbgrader الخلايا المقفلة من المصدر |
| طالب واحد يبطئ السيرفر للجميع | LocalProcessSpawner لا يطبق حد الذاكرة | The Littlest JupyterHub أو DockerSpawner أو Kubernetes |
| الاسم والبريد فارغان في grades.csv | الطلاب غير مضافين إلى قاعدة الدرجات | nbgrader db student import قبل أول واجب |
تنظيف المختبر
docker compose down -v
docker image rm edu3-jhub:6.1.1تدريب عملي
- أضف إلى الواجب سؤالاً ثالثاً يقرأ ملف CSV بالمكتبة pandas ويحسب متوسط كل مادة، مع اختبار مخفي لملف فيه قيمة فارغة.
- أنشئ حساب Linux اسمه
graderلا يملك إلا مجلد المقرر، وشغلautogradeبه، ثم أعد تسليم الطالب الثالث وانظر من أصبح يشغل الكود.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- JupyterHub يعطي كل طالب سيرفر Jupyter خاصاً به في المتصفح، وكل سيرفر استهلك في تجربتنا نحو 168MB وهو خامل، فخطط على عدد الطلاب في المحاضرة نفسها.
- المشغل Spawner هو القرار الأهم: الافتراضي لا يطبق حد الذاكرة، وقد استهلك طالب 910MB رغم حد 512MB، فاختر The Littlest JupyterHub أو Docker أو Kubernetes لأي مقرر حقيقي.
- nbgrader يغطي دورة الواجب كاملة، والاختبارات المخفية هي التي كشفت خطأ الحدود الذي اجتاز الاختبارات الظاهرة.
- التصحيح الآلي يشغل كود الطالب بحساب المعلم وداخل مجلده، فاعزل التصحيح في حساب مخصص أو Container قبل أول مقرر.
- جرب بداية المحاضرة قبل أن تبدأ، فقد فشلت ثلاثة سيرفرات بدأت معاً بالمهلة الافتراضية 30 ثانية.
سجل التحديثات
- أكتوبر 2026: كتابة المقال وتجربته على JupyterHub 6.1.1 وJupyterLab 4.6.4 وnbgrader 0.9.6.