13 وقفة مع ضبط صحة المنطق
سبب إنشاء الإجراءات
تجزئة المهام التفصيلية وإعطاء كل مهمة اسمها ثم استخراجها وجعلها في دالة= يسهل قراءة النص. وهذا الذي فعلناه في الأمرين:
- دالة البحث:
search_algorithm - دالة التقسيم والترقيم:
paginate
حيث استطعنا الإشارة إليها بإجمال في ثنايا الدالة الخادمة لمسار البحث:
def search_page_paginated(request: Request):
# ... details before
results = search_algorithm(query, zen_of_python)
paging = paginate(results_count, page, per_page)
# ... details afterوسبب اختيارنا لهما ليس مجرَّد التسهيل، فهناك من تفاصيل نفس الإجراء ما كان يُمكن استخراجه لكننا لم نفعل، وذلك في مثل:
# Parse input: read and convert query parameters to proper types
query = request.query_params.get("q", "")
page = int(request.query_params.get("page", 1))
per_page = int(request.query_params.get("per_page", 5))ومثل:
# Slice results to only include the current page
idx_start = (page - 1) * per_page
idx_end = idx_start + per_page
results_sliced = results[idx_start:idx_end]فقد اكتفينا بعنونتهما بتعليق يشرح الوظيفة بإيجاز. وكذلك فإنه يفيدنا في تقسيم النص لأجزاء منطقيَّة بحسب مرحلة المعالجة. فالقراءة والتحويل لأنواع المعطيات الصحيحة، من الطبيعي أن يكون في رأس الإجراء.
وقد ذكرنا في المقدمة البايثونية للبرمجة باللغة العربية -عند حديثنا عن الدالة-، أن من أسباب إنشاء الإجراء (استخراجه):
- التكرار: إذا وجدت أنك تكرر نفس القطعة البرمجية مرارًا
- التعقيد: إذا كانت العملية تحتاج لكد الذهن أو لمعرفة لا تتوفر عند الجميع
- القابلية للتركيب: إذا كانت القطعة ككل ذات وظيفة واضحة ومحددة، ورأيت أنها تنسجم مع غيرها من القطع إذا وضعت لها اسمًا
وعليه يتبين فيما سبق:
- في دالة البحث (
search_algorithm) نرى أن الخوارزمية قابلة للتحسين؛ مع بقاء الشكل الخارجي (المدخلات والمخرجات). - في دالة التقسيم والترقيم (
paginate) فإننا نلاحظ وظيفة يتم تكرارها في صفحات كثيرة غير صفحة البحث هذه. - وأن هاتين الدالتين لا تعتمد واحدة منهما على الأخرى، لكن يمكن تركيب التقسيم والترقيم بعد البحث كما فعلنا.
اختبار صحَّة الإجراء
- ثم إن المعقَّد مما تصعب صياغته صحيحًا؛ لذا وجَبَ اختباره.
- وكذلك المكرر لا لصعوبته في نفسه ولكن لانتشار أثره في ثنايا الإجراءات الأخرى.
- ثم قد نختبر نتيجة المركَّب إن كان في ذلك تغطية لحالات يصعب تغطيتها مجزءًا.
الطريقة الأولى: اختبار التوثيق
سنخرج دالة التصفح ونجلعها في ملف utils.py اختصار (utilities) بمعنى المرافق، ثم نقوم باستيرادها:
from utils import paginateيسمى النص الذي يكون في رأس الدالة بعد وصفها البرمجي، التوثيق (Documentation) ويختصر بكلمة docs؛ وهو وصف باللغة الإنجليزية، وهو يلخص عمل الدالة:
def paginate(total: int, page: int, per_page: int):
"""
Returns a dictionary of pagination info.
"""لكنه ليس كافيًا لوحده ولذلك نحتاج إلى Args لوصف المدخلات نفسها، وكذلك Returns لوصف المخرجات:
def paginate(total: int, page: int, per_page: int):
"""
Returns a dictionary of pagination info.
Args:
total (int): Total number of items.
page (int): Current page (1-based).
per_page (int): Items per page.
Returns:
dict: {page, per_page, total_count, total_pages, has_next, has_previous, next_page, previous_page}وليكون التوثيق حيًّا؛ فإن بايثون أضافت إمكانية تضمين أمثلة حقيقية؛ فنكتب الاستدعاء بعد ثلاثة أسهم <<<، ثم نكتب النتيجة المتوقعة في السطر الذي يليها مباشرة. وذلك تحت قسم الأمثلة: Examples على النحو التالي:
def paginate(total: int, page: int, per_page: int):
"""
Returns a dictionary of pagination info.
Args:
total (int): Total number of items.
page (int): Current page (1-based).
per_page (int): Items per page.
Returns:
dict: {page, per_page, total_count, total_pages, has_next, has_previous, next_page, previous_page}
Examples:
>>> paginate(23, 1, 5)
{'page': 1, 'per_page': 5, 'total_count': 23, 'total_pages': 5, 'has_next': True, 'has_previous': False, 'next_page': 2, 'previous_page': None}
# Single page edge case
>>> paginate(4, 1, 10)
{'page': 1, 'per_page': 10, 'total_count': 4, 'total_pages': 1, 'has_next': False, 'has_previous': False, 'next_page': None, 'previous_page': None}
# Zero total items (empty result set)
>>> paginate(0, 1, 10)
{'page': 1, 'per_page': 10, 'total_count': 0, 'total_pages': 0, 'has_next': False, 'has_previous': False, 'next_page': None, 'previous_page': None}
"""
total_pages = math.ceil(total / per_page)
has_next = page < total_pages
has_previous = page > 1
next_page = page + 1 if has_next else None
previous_page = page - 1 if has_previous else None
return {
"page": page,
"per_page": per_page,
"total_count": total,
"total_pages": total_pages,
"has_next": has_next,
"has_previous": has_previous,
"next_page": next_page,
"previous_page": previous_page,
}
if __name__ == "__main__":
import doctest
doctest.testmod()لاحظ في آخر الملف قطعة if __name__ ومعناها أن ما فيها مشروطٌ بتنفيذ الوحدة ابتداءً لا باستيرداها. والنص الذي فيه هو اختبار لصحة التوثيق الذي في هذه الوحدة (doctest.testmod()) ولذلك فإنها تعمل فقط إذا نفذناها من سطر الأوامر هكذا:
python utils.pyفإذا لم يخرج لنا التنفيذ بأخطاء، عرفنا أن الأمثلة المكتوبة مطابقة للواقع.
وطريقة اختبار التوثيق قد تنفع فيما هو بسيط، لكننا قد نحتاج لخصائص بايثون الكاملة لاختبارات أكثر شمولاً، ولذلك سننظر في الطريقة الثانية..
الطريقة الثانية: اختبار الوحدة
وأشهر الحزم -في بايثون- التي تمكِِّن من اختبار الوحدات (Unit Testing): pytest، سنثبته:
uv add pytestنستخرج الدالة كذلك إلى ملف utils.py حتى لا يختلط بباقي النص البرمجي.
ننظر أولاً في وصف الدالة الأولى -بغض النظر عن التفاصيل- لأن الوظيفة إنما تُعرَف بحصر جميع المخرجات الناتجة عن جميع الاحتمالا للمدخلات:
def search_algorithm(query: str, documents: list[str]) -> list[str]:- المدخلات: نص بحث ومجموعة مستندات
- المخرجات: قائمة مرشَّحة من تلك المستندات
والاختبار هو سبرُ المدخلات الممكنة، والمخرجات المتوقعة التي تقابلها، بعد أن نجمع الحالات لألا نكرر الحالة. كل ما عليك فعله هو كتابة ملف باسم utils_test.py أو test_utils.py ثم كتابة اختبار دالة البحث بالشرطة السفلية كذلك test_search_algorithm:
def test_search_algorithm():
docs = [
"The grass is green", # 0
"The sky is blue and the grass is green", # 1
"The ocean is blue", # 2
]
test_cases = [
# existing words
((docs, "green"), [docs[0], docs[1]]),
((docs, "blue"), [docs[1], docs[2]]),
# existing words with different case
((docs, "The"), docs),
((docs, "the"), docs),
# non-existing words
((docs, "yellow"), []),
# empty query
((docs, ""), docs),
]
for (docs, query), expected in test_cases:
got = search_algorithm(query, docs)
assert got == expected- أولاً
docsهي قائمة بسيطة تمثِّل البيانات التي يتم البحث فيها. - ثانيًا:
test_casesفيه الزوج الأوَّل,(docs, "green"))مثلاً، هو المدخلات، ويتبعها المخرجات[docs[0], docs[1]]). - ثالثًا: يتم استخراج المعطيات في كل مرة من هذا الجدول وتمريرها للدالة التي هي محل الاختبار:
search_algorithmثم نقارن النتيجة (got) بالمتوقَّع (expected) بعبارة التوكيدassertالتي تخرج بخطأ عند عدم المطابقة.
ثم نشغل الاختبارات:
pytest .فلسفة التطوير بالإجمال قبل التفصيل
بعد اكتساب خبرة في البرمجة، تتكون لدينا صورة أكبر مجرَّدة عن التفاصيل الدقيقة؛ مما يساعد كثيرًا في تحديد ما يتم كتابته أولاً؛ فنبدأ من الكبير إلى الصغير / من الإجمال إلى التفصيل. فيتبع المبرمج هذا الترتيب في كتابة النص البرمجي:
- يكتب حد الدالة (Function Signature) أولاً؛ وهو: الاسم والمعطيات والمخرجات ووصفها (توثيقها)؛ دون أن يملأ تفاصيله (وهذا مهم)
- يكتب حالات الاختبار (Test Cases) ثانيًا؛ وهي: استدعاءات بمدخلات مفترضة ومخرجات متوقعة. كما فعلنا في مثال خوارزمية البحث في:
test_cases
وهو بين هذا وذاك ينتقل ويعود ويشكِّل الوحدة من الخارج. وإذا فرغ من ذلك؛ يبدأ بتشغيل الاختبار:
pytest .وسيكون كل شيء أحمر، وكل حالة غير مطابقة؛ وذلك لأنه لم يكتب أي تفصيلٍ حتى الآن. لكن ظهر له كل حالة يجب عليه تغطيتها، فلديه قائمة مهام (Todo list) مؤتمتة.
- يشرع بعد ذلك بملئ التفاصيل (Implementation). وهو يعود بعد كل تعديلٍ ليرى ما تم من القائمة، وما انتقض بعد تمام، إلى أن يتم جميع المهام؛ وبالتالي يكون قد غطى جميع الحالات التي افترضها ابتداءً.
وقد لا يتأتى ذلك دائمًا -أقصد البداءة بالإجمال- مثلاً عند استكشاف آليات جديدة لم نضبط تفاصيلها بعد.