لغات البرمجة عملياً الأدلة التقنية

Python للمطورين: التركيب Syntax والأدوات في يوم واحد

دليل لمن يبرمج بلغة أخرى ويريد أن يبدأ بـ Python 3.15 من أول يوم: التثبيت، وما يختلف في التركيب، والأدوات uv وruff وmypy وpytest على مشروع حقيقي بمخرجه الفعلي.

Python للمطورين: التركيب Syntax والأدوات في يوم واحد

لنفرض أنك مطور Backend تكتب بـ Java أو C# أو JavaScript منذ سنوات، وتعمل في شركة ناشئة تبيع القهوة والتمور عبر الإنترنت، وفي اجتماع الصباح قرر الفريق أن خدمة الأسعار والتقارير الجديدة سوف تكتب بـ Python، والسبب أن فريق البيانات يكتب بها أصلاً، ونماذج التوقع التي يريدون ربطها بالخدمة مكتوبة بها، ولا أحد يريد أن يترجم كود الحسابات من لغة إلى أخرى. والمطلوب منك أن تبدأ غداً، فأنت لا تحتاج دورة للمبتدئين تشرح لك ما هو المتغير وما هي الحلقة، وإنما تحتاج أن تعرف في يوم واحد كيف تثبت اللغة بالإصدار الصحيح، وما الذي يختلف في تركيبها عن اللغة التي تعرفها، وما هي الأدوات التي يستعملها مطورو Python فعلاً في مشاريعهم، وكيف يبدو مشروع مرتب لا يخجلك أمام زملائك في فريق البيانات.

وهذا المقال هو الأول في مرحلة لغة Python للمطورين من مسار لغات البرمجة عملياً، وكل مقالات المسار تأخذ نفس الشكل حتى تقارن بين اللغات: الأساسيات والأدوات، ثم الأنواع والذاكرة والأخطاء، ثم التزامن، ثم خدمة طلبات حقيقية لمتجر صغير بنفس المواصفات في كل لغة، ثم الأخطاء الشائعة وخطة القراءة. وإذا كنت لم تقرر بعد أي لغة تناسب مشروعك، فقد ناقشنا ذلك في مقال كيف تختار لغة البرمجة لمشروعك، أما هنا فالقرار اتخذ واللغة هي Python 3.15، وسوف نبني من أول يوم الوحدة shop التي تحسب أسعار الطلبات بالهللة وضريبة القيمة المضافة، وهي نفس الوحدة التي سوف تكبر في مقالات المرحلة حتى تصبح خدمة كاملة. وسوف نناقش في هذا المقال ما يلي:

  • من أين جاءت Python، ولماذا يهمك رقم الإصدار، وكيف تختار طريقة التثبيت المناسبة.
  • جولة سريعة في التركيب Syntax مقارنة بما تعرفه، مع ملف حقيقي ومخرجه سطراً بسطر.
  • كيف يشغل المفسر CPython ملفك من الداخل: الترجمة إلى Bytecode وملفات .pyc وحلقة التنفيذ.
  • أدوات المشروع Toolchain: uv لإدارة المشروع والحزم، وruff للفحص والتنسيق، وmypy لفحص الأنواع، وpytest للاختبارات.
  • تخطيط المشروع Project Layout، والبيئة الافتراضية Virtual Environment، ولماذا يرفض pip التثبيت على Python النظام.
  • زمن بدء التشغيل Startup Time، والاستيراد الكسول Lazy Imports الجديد في 3.15، ومشكلتان ظهرتا في المختبر.
  • أخطاء اليوم الأول، وقائمة تحقق، وتدريب عملي.

من أين جاءت Python؟ ولماذا يهمك رقم الإصدار Version؟

بدأ Guido van Rossum كتابة Python في بداية التسعينيات في معهد CWI في هولندا، كما يذكر تاريخ البرنامج في التوثيق الرسمي، وكان الهدف لغة سكربتات سهلة القراءة تقف بين لغة الصدفة Shell ولغة C، ولذلك جاءت بقرارين يميزانها إلى اليوم: المسافة البادئة Indentation جزء من التركيب بدل الأقواس، والكود يقرأ قريباً من الكلام العادي. وهذان القراران هما سبب انتشارها في البيانات والتعليم والسكربتات، وهما أيضاً سبب أول دقيقة من الارتباك عند كل مطور قادم من لغة فيها أقواس.

والتاريخ الذي يهمك عملياً هو قصة الانتقال من Python 2 إلى Python 3، فقد صدرت Python 3 في 2008 بتغييرات لا تتوافق مع الإصدار السابق، أهمها أن النصوص صارت Unicode افتراضياً وأن القسمة / صارت تعطي عدداً عشرياً، وبقي العالم يعمل بالإصدارين أكثر من عشر سنوات حتى أوقف الفريق دعم Python 2 في أول يناير 2020. وإذا وجدت في شركتك سكربتاً قديماً يكتب print "hello" بدون أقواس فهو من ذلك الزمن، ولن يعمل على أي إصدار حديث. وقد يتساءل البعض: لماذا أهتم بهذه القصة القديمة؟ والإجابة أن كثيراً من الإجابات القديمة في الإنترنت مكتوبة لـ Python 2، فإذا نسخت كوداً ولم يعمل فأول سؤال تسأله: هل هذا الكود من قبل 2020؟

ومنذ Python 3.9 يصدر إصدار رئيسي جديد كل سنة في شهر أكتوبر بحسب PEP 602، ويحصل كل إصدار على سنتين من إصلاحات الأخطاء ثم ثلاث سنوات من الإصلاحات الأمنية فقط، أي خمس سنوات في المجموع، وجدول كل الإصدارات وحالتها موجود في صفحة الإصدارات في دليل المطورين. وآخر إصدار مستقر وقت كتابة هذا المقال هو Python 3.15.2، وقد صدر الإصدار 3.15.0 في أكتوبر 2026 بحسب جدول PEP 790، ومعه تغييرات تهمك من أول يوم سوف نراها في المختبر، مثل الاستيراد الكسول بالكلمة lazy، وأن الترميز الافتراضي للملفات صار UTF-8 على كل الأنظمة، والتفاصيل كلها في صفحة ما الجديد في Python 3.15.

والقاعدة العملية لمشروع جديد: ابدأ بآخر إصدار مستقر إذا كانت مكتباتك تدعمه، أو بالإصدار الذي قبله إذا كانت مكتبة مهمة لم تلحق بعد، ولا تبدأ مشروعاً جديداً بإصدار دخل مرحلة الإصلاحات الأمنية فقط، والسبب أن ترقية الإصدار في مشروع حي أصعب بكثير من اختيار الإصدار الصحيح من البداية.

التثبيت Installation: من أين تأخذ Python؟

أول خطأ يقع فيه المطور القادم من لغة أخرى أنه يكتب python في الطرفية فيجد إصداراً ما، فيظن أن الأمر انتهى، والحقيقة أن على جهازك غالباً أكثر من Python واحدة، وكل واحدة لها غرض. وفيما يلي الطرق الشائعة وما يناسب كل واحدة منها:

  • Python النظام System Python: النسخة التي تأتي مع توزيعة Linux أو macOS، وهي ملك للنظام نفسه، فبعض أدوات التوزيعة مكتوبة بها وتعتمد على حزم معينة فيها، ولذلك لا تستعملها لمشاريعك ولا تثبت عليها حزماً، وسوف نرى في المختبر أن pip نفسه يرفض ذلك.
  • المثبت الرسمي من python.org: مناسب لجهاز التطوير على Windows وmacOS، ولكنك سوف تحتاج غالباً أكثر من إصدار مع الوقت.
  • مدير الإصدارات uv: يستطيع أن ينزل إصدارات Python جاهزة ويضعها جنباً إلى جنب بأمر واحد مثل uv python install 3.15، كما في دليل تثبيت Python في uv، وهو الطريقة التي ننصح بها على جهاز التطوير.
  • الـ Docker Image الرسمية python: هي الطريقة الأنسب للسيرفر وخط CI، وهي ما استعملناه في كل تجارب هذا المقال بالوسم python:3.15.2-slim-trixie، أي Python 3.15.2 على Debian 13 بنسخته المصغرة.

والآن سوف نتأكد من الإصدار داخل الـ Container، والمثال التالي يبين الأوامر الأولى من ملف run.sh في المختبر:

docker run --rm -v "$PWD:/lab" python:3.15.2-slim-trixie sh /lab/run.sh
python --version
python -c "import sys; print(sys.version); print(sys.implementation.name, sys.executable)"
python -c "import sys; print(sys._is_gil_enabled())"
pip --version

والمخرج سوف يكون كما يلي:

Python 3.15.2
3.15.2 (main, Oct 28 2026, 01:58:57) [GCC 14.2.0]
cpython /usr/local/bin/python
True
pip 26.2.2 from /usr/local/lib/python3.15/site-packages/pip (python 3.15)

في المخرج أعلاه لاحظ التالي:

  • السطر الثاني يقول إن هذا البناء ترجم بالمترجم GCC 14.2.0، فالـ Image الرسمية تبني Python من المصدر ولا تأخذها من حزم Debian، ولذلك تجدها في /usr/local وليس في /usr.
  • السطر الثالث يقول إن التطبيق Implementation هو cpython، أي التطبيق المرجعي المكتوب بلغة C، وهو ما يقصده الناس عندما يقولون Python. وتوجد تطبيقات أخرى مثل PyPy الذي يعتمد على JIT، ولكن كل ما في هذا المسار عن CPython.
  • السطر الرابع True يعني أن قفل المفسر العام GIL مفعل، وهذا هو البناء العادي، أما البناء الخالي من القفل Free-threaded فهو بناء منفصل سوف نقيسه في مقال التزامن.
  • السطر الأخير يبين أن pip موجود ومرتبط بهذه النسخة بالذات، وهذا مهم لأن الأمر pip على جهاز فيه عدة نسخ قد يثبت الحزمة في نسخة غير التي تشغل بها الكود، ولذلك يكتب المحترفون python -m pip عندما يستعملون pip مباشرة، حتى يكون واضحاً أي Python يقصدون.

جولة في التركيب Syntax: ما الذي يختلف عن لغتك؟

الشكل التالي يلخص ما يقابل الأشياء التي تعرفها في Java وC# وJavaScript، ثم سوف نشغل ملف tour.py الذي يمر على كل نقطة منها، ونقرأ مخرجه قسماً قسماً.

جدول من اثني عشر صفاً يقابل بين Java وC# وJavaScript من جهة وPython 3.15 من جهة: الأقواس مقابل المسافة البادئة، وnull مقابل None، وتعريف المتغير بنوع مقابل x = 1، والقسمة حيث 7 / 2 يعطي 3.5 و7 // 2 يعطي 3، وswitch مقابل match، والقوائم list وtuple، وdict، وملف pyproject.toml، وuv أو pip، وruff للفحص والتنسيق، وmypy لفحص الأنواع، وpytest للاختبارات
ما يقابل ما تعرفه في Python، وكل صف منها له قسم في المقال

والملف كاملاً كما يلي، وهو موجود في مجلد lab مع المقال:

# A quick tour of Python for a developer who already knows another language.
from dataclasses import dataclass

print("== 1. dynamic but strong types")
price = 9.90             # no declaration, the name points to a float object
print(type(price).__name__, price)
price = "9.90"           # the same name now points to a str
print(type(price).__name__, price)
try:
    print("1" + 1)
except TypeError as e:
    print("TypeError:", e)

print("== 2. integers have no fixed size, / and // differ")
print(2 ** 100)
print(7 / 2, 7 // 2, -7 // 2, 7 % 3)

print("== 3. strings, f-strings, slicing")
name = "تمر سكري"
qty = 3
print(f"{name} x {qty} = {9.90 * qty:.2f} SAR")
print(len(name), name[0], name[-1], name[::-1])

print("== 4. collections: list, tuple, dict, set")
cart = [("coffee", 2), ("dates", 1)]
stock = {"coffee": 10, "dates": 0}
tags = {"new", "sale", "new"}
print(cart, stock, tags)
for i, (item, n) in enumerate(cart, start=1):
    print(i, item, n, "in stock" if stock[item] >= n else "out of stock")

print("== 5. comprehensions")
squares = [n * n for n in range(6) if n % 2 == 0]
by_item = {item: n for item, n in cart}
print(squares, by_item)

print("== 6. functions: defaults, keyword args, *args, **kwargs")
def total(*amounts, vat=0.15, **extra):
    s = sum(amounts)
    return round(s * (1 + vat), 2), extra
print(total(10, 20))
print(total(10, 20, vat=0, note="export"))

print("== 7. classes and dataclasses")
@dataclass
class Product:
    name: str
    price_halalas: int
    def label(self) -> str:
        return f"{self.name}: {self.price_halalas / 100:.2f}"
p = Product("قهوة", 4500)
print(p, p.label(), p == Product("قهوة", 4500))

print("== 8. match statement")
def describe(msg):
    match msg:
        case {"type": "order", "items": [first, *rest]}:
            return f"order: first={first} and {len(rest)} more"
        case {"type": "ping"}:
            return "pong"
        case _:
            return "unknown"
print(describe({"type": "order", "items": ["a", "b", "c"]}))
print(describe({"type": "ping"}))

print("== 9. generators and with")
def countdown(n):
    while n > 0:
        yield n
        n -= 1
print(list(countdown(3)))
with open("/tmp/note.txt", "w") as f:
    f.write("السلام عليكم")
with open("/tmp/note.txt") as f:
    print(f.read(), f.closed)
print("closed after with:", f.closed)

نشغل الملف داخل نفس الـ Container بالأمر python /lab/tour.py، والمخرج كاملاً كما يلي:

== 1. dynamic but strong types
float 9.9
str 9.90
TypeError: can only concatenate str (not "int") to str
== 2. integers have no fixed size, / and // differ
1267650600228229401496703205376
3.5 3 -4 1
== 3. strings, f-strings, slicing
تمر سكري x 3 = 29.70 SAR
8 ت ي يركس رمت
== 4. collections: list, tuple, dict, set
[('coffee', 2), ('dates', 1)] {'coffee': 10, 'dates': 0} {'sale', 'new'}
1 coffee 2 in stock
2 dates 1 out of stock
== 5. comprehensions
[0, 4, 16] {'coffee': 2, 'dates': 1}
== 6. functions: defaults, keyword args, *args, **kwargs
(34.5, {})
(30, {'note': 'export'})
== 7. classes and dataclasses
Product(name='قهوة', price_halalas=4500) قهوة: 45.00 True
== 8. match statement
order: first=a and 2 more
pong
== 9. generators and with
[3, 2, 1]
السلام عليكم False
closed after with: True

الأسماء والكائنات Names and Objects: النوع للقيمة وليس للمتغير

في القسم الأول عرفنا price دون أن نكتب نوعاً، ثم أسندنا إليه نصاً في السطر التالي ولم يعترض أحد، وهذا ما يسمى الأنواع الديناميكية Dynamic Typing. والطريقة الصحيحة لفهمها أن المتغير في Python ليس صندوقاً له نوع ثابت كما في Java، وإنما اسم Name يشير إلى كائن Object في الذاكرة، والنوع صفة للكائن نفسه، فالاسم price أشار أولاً إلى كائن من نوع float ثم أشار إلى كائن من نوع str. ولاحظ في نفس القسم أن "1" + 1 أعطى خطأ TypeError ولم يعط "11" كما تفعل JavaScript، فاللغة ديناميكية ولكنها قوية النوع Strongly Typed، أي أنها لا تحول الأنواع من تلقاء نفسها في العمليات، وهذا الفرق وحده يمنع صنفاً كاملاً من الأخطاء التي يعرفها مطورو JavaScript جيداً. وقد شرحنا الفرق بين هذه الأنظمة بالتفصيل في مقال أنماط البرمجة وأنظمة الأنواع، أما في Python بالتحديد فسوف نعود إلى الكائنات والمراجع في المقال الثاني من المرحلة.

ولاحظ أيضاً أن السطر الأول من المخرج طبع 9.9 وليس 9.90، فالعدد العشري لا يحفظ عدد الخانات التي كتبتها، وهذه أول إشارة إلى أننا لن نحفظ الأسعار كأعداد عشرية، وإنما كأعداد صحيحة بالهللة كما سوف نرى بعد قليل.

الأعداد Integers والقسمة: لا يوجد Overflow

القسم الثاني طبع 2 ** 100 كاملاً بثلاثين خانة، والسبب أن int في Python ليس 32 أو 64 بت، وإنما عدد صحيح بحجم غير محدود يكبر في الذاكرة كلما احتاج، ولذلك لا يوجد في Python ما يسمى Integer Overflow للأعداد الصحيحة، وهذا مريح في الحسابات المالية، ولكنه يعني أيضاً أن كل عدد كائن كامل في الذاكرة وليس قيمة خام، وهذا من أسباب بطء الحلقات الحسابية الكبيرة في Python مقارنة بـ C أو Java.

والسطر الذي يليه هو أكثر سطر يخدع المطورين القادمين من Java وC#: 7 / 2 أعطى 3.5 وليس 3، فالعامل / في Python 3 يعطي دائماً عدداً عشرياً، والقسمة الصحيحة لها عامل خاص هو //. ولاحظ أن -7 // 2 أعطى -4 وليس -3، لأن // في Python يقرب نحو سالب ما لا نهاية Floor Division، بينما Java وC يقربان نحو الصفر، وهذا الفرق الصغير يظهر عندما تحسب صفحات أو فترات زمنية بأعداد سالبة، فإذا نقلت معادلة من Java إلى Python فراجع كل قسمة فيها.

النصوص Strings والـ f-strings والتقطيع Slicing

النصوص في Python 3 كلها Unicode، ولذلك طبع len(name) الرقم 8 لكلمة «تمر سكري»، أي عدد الحروف مع المسافة وليس عدد البايتات، وأعطى name[0] الحرف الأول وname[-1] الحرف الأخير، فالفهرس السالب يعد من النهاية. والتعبير name[::-1] هو التقطيع Slicing بخطوة سالبة، أي النص مقلوباً، وظهر في المخرج مقلوباً حرفاً بحرف. أما الـ f-string في السطر f"{name} x {qty} = {9.90 * qty:.2f} SAR" فهي الطريقة الحديثة لدمج القيم في النصوص، وتشبه Template Literals في JavaScript، والجزء :.2f يحدد التنسيق بخانتين عشريتين، ولذلك ظهر 29.70 رغم أن الحساب نفسه تم بعدد عشري.

المجموعات الأربع Collections: list، tuple، dict، set

القسم الرابع يبين المجموعات الأربع التي سوف تستعملها كل يوم: القائمة list تقابل ArrayList أو المصفوفة في JavaScript، والـ tuple قائمة لا تتغير بعد إنشائها وتستعمل كثيراً لإرجاع أكثر من قيمة من دالة، والقاموس dict يقابل HashMap ويحفظ ترتيب الإدخال منذ Python 3.7، والمجموعة set لا تقبل التكرار، ولذلك ظهرت {'sale', 'new'} بعنصرين رغم أننا كتبنا "new" مرتين، ولاحظ أن ترتيبها في المخرج ليس ترتيب الكتابة، فالمجموعة لا تعد بأي ترتيب.

والحلقة for في Python لا تشبه حلقة C ذات العداد، فهي تمر على عناصر أي شيء قابل للتكرار Iterable مباشرة، وإذا احتجت الرقم مع العنصر فاستعمل enumerate كما في المثال، ولاحظ أننا فككنا العنصر في نفس السطر for i, (item, n) in ...، وهذا التفكيك Unpacking من أكثر ما يجعل كود Python قصيراً. ولاحظ أخيراً الشرط المختصر "in stock" if ... else "out of stock"، فهو يقابل العامل الثلاثي ? : في لغتك ولكن بترتيب الكلام.

الـ Comprehensions: حلقة وشرط في سطر واحد

السطر [n * n for n in range(6) if n % 2 == 0] يبني قائمة من مربعات الأعداد الزوجية، وأعطى [0, 4, 16]، وهذا الشكل يسمى List Comprehension، ويقابل map وfilter في JavaScript أو Streams في Java، ومعه شكل للقاموس كما في by_item، وشكل للمجموعة. والقاعدة العملية أن تستعمله عندما يقرأ كجملة واحدة، فإذا احتاج أكثر من شرط وحلقتين متداخلتين فالحلقة العادية أوضح لمن يقرأ الكود بعدك.

الدوال Functions والمعاملات Arguments

الدالة total في القسم السادس تجمع أكثر أشكال المعاملات شيوعاً: *amounts تجمع كل المعاملات الموضعية في tuple، وvat=0.15 معامل له قيمة افتراضية ولا يمرر إلا بالاسم لأنه جاء بعد *amounts، و**extra يجمع أي معاملات بالاسم لم تعرف في قاموس، ولذلك ظهر {'note': 'export'} في الاستدعاء الثاني. والتمرير بالاسم Keyword Arguments من أهم عادات Python، فالاستدعاء total(10, 20, vat=0) يقرأ بوضوح بدون أن تفتح تعريف الدالة.

ولاحظ شيئاً صغيراً في المخرج: الاستدعاء الأول أعطى 34.5 عدداً عشرياً، والثاني أعطى 30 عدداً صحيحاً، والسبب أن vat=0 عدد صحيح فصار الحساب كله بأعداد صحيحة، وهذا مثال صغير على أن نوع الناتج في Python يتبع أنواع المدخلات وليس تعريف الدالة، وهو بالضبط ما تساعدك عليه تلميحات الأنواع Type Hints مع mypy كما سوف نرى في قسم الأدوات.

الكلاسات Classes والـ dataclass

الكلاس Product عرفناه بالمزخرف Decorator المسمى @dataclass، فكتب Python لنا الدالة __init__ التي تأخذ الحقلين، والدالة __repr__ التي طبعت Product(name='قهوة', price_halalas=4500)، والدالة __eq__ التي جعلت مقارنة كائنين بنفس القيم تعطي True. والدوال ذات الشرطتين من الجهتين تسمى Dunder Methods، وهي الطريقة التي تتكامل بها كلاساتك مع اللغة، فالدالة __eq__ هي ما ينفذ عندما تكتب ==، و__len__ هي ما ينفذ عند len(). ولاحظ أن الدالة label تأخذ self صراحة كأول معامل، فلا يوجد this ضمني كما في Java، ولا توجد كلمات private وpublic، وإنما عرف بين المطورين أن الاسم الذي يبدأ بشرطة سفلية _name للاستعمال الداخلي.

ولاحظ في نفس الكلاس أننا حفظنا السعر بالهللة 4500 كعدد صحيح، ولم نكتب 45.00، والسبب ما رأيناه في القسم الأول: العدد العشري Float لا يمثل الكسور العشرية بدقة، وقد شرحنا ذلك بالأرقام في مقال الأرقام العشرية Floating Point من الداخل، والقاعدة في كل مقالات هذا المسار أن المال عدد صحيح بأصغر وحدة.

الأمر match: مطابقة الأنماط Pattern Matching

الأمر match دخل في Python 3.10، ويبدو من بعيد مثل switch، ولكنه أقوى بكثير لأنه يطابق شكل البيانات وليس قيمة واحدة، ففي المثال طابقت الحالة الأولى أي قاموس فيه "type": "order" وقائمة عناصر فيها عنصر واحد على الأقل، وفككت العنصر الأول في first والباقي في rest، ولذلك طبع order: first=a and 2 more. والحالة case _ هي الحالة الافتراضية. وهو مفيد جداً عند قراءة رسائل JSON من API أو طابور رسائل، حيث يختلف شكل الرسالة بحسب نوعها.

المولدات Generators والأمر with

الدالة countdown فيها yield بدل return، وهذا يجعلها مولداً Generator، أي دالة تعطي قيمة ثم تتوقف حتى يطلب منها القيمة التالية، ولذلك تستطيع أن تمر على ملف من عشرة ملايين سطر سطراً بسطر دون أن تحمله كله في الذاكرة. أما with فهو ما يقابل try-with-resources في Java أو using في C#، فهو يضمن إغلاق الملف عند الخروج من الكتلة حتى لو حدث خطأ، ولاحظ في المخرج أن f.closed كانت False داخل الكتلة ثم صارت True بعدها. ولاحظ أيضاً أننا فتحنا ملفاً وكتبنا فيه نصاً عربياً دون أن نحدد الترميز، وهذا آمن في Python 3.15 لأن الترميز الافتراضي صار UTF-8 على كل الأنظمة، أما في الإصدارات السابقة على Windows فكان يأخذ ترميز النظام، وكان النص العربي يفسد أو يعطي خطأ، ولذلك إذا كان مشروعك يعمل على إصدار أقدم فاكتب encoding="utf-8" دائماً.

كيف يشغل CPython ملفك؟ الترجمة إلى Bytecode

يقول الكثيرون إن Python لغة مفسرة Interpreted، وهذا صحيح جزئياً فقط، فالمفسر CPython لا ينفذ النص سطراً بسطر، وإنما يترجم الملف كاملاً أولاً إلى تعليمات وسيطة اسمها Bytecode، ثم ينفذها في حلقة تنفيذ Eval Loop مكتوبة بلغة C. وقد شرحنا الفرق بين المترجم Compiler والمفسر Interpreter والـ JIT بالتفصيل في مقال المترجم والمفسر وJIT، أما هنا فسوف نرى ما يحدث في Python بالتحديد. والشكل التالي يبين المراحل الثلاث:

ثلاثة صناديق من اليمين إلى اليسار: الكود المصدري prices.py فيه دالة total، ثم الترجمة إلى Bytecode وفيها التعليمات LOAD_GLOBAL وLOAD_FAST_BORROW وCALL وLOAD_SMALL_INT وBINARY_OP وRETURN_VALUE وتحفظ في __pycache__، ثم حلقة التنفيذ مع رسم لمعالج وترس، وتحتها بطاقتان: أول تشغيل بدون ملف pyc أخذ 2031.5 ms، والتشغيل الثاني 578.2 ms ومتوسط 21 تشغيلاً 489.9 ms
الملف يترجم مرة إلى Bytecode، ويحفظ في .pyc، ثم تنفذه حلقة التنفيذ

ونستطيع أن نرى الـ Bytecode بأنفسنا بالوحدة dis من المكتبة القياسية، فالمثال التالي يترجم دالة صغيرة ويطبع تعليماتها:

import dis
def total(prices, vat):
    return sum(prices) * (1 + vat)
dis.dis(total)

والمخرج من Python 3.15.2 سوف يكون كما يلي:

  3           RESUME                   0

  4           LOAD_GLOBAL              1 (sum + NULL)
              LOAD_FAST_BORROW         0 (prices)
              CALL                     1
              LOAD_SMALL_INT           1
              LOAD_FAST_BORROW         1 (vat)
              BINARY_OP                0 (+)
              BINARY_OP                5 (*)
              RETURN_VALUE

في المخرج أعلاه لاحظ التالي:

  • الحلقة تعمل على مكدس Stack: LOAD_GLOBAL يبحث عن الاسم sum في المتغيرات العامة ثم الدوال المدمجة، وLOAD_FAST_BORROW يضع المعامل prices على المكدس، ثم CALL 1 يستدعي الدالة بمعامل واحد.
  • التعليمة BINARY_OP لا تعرف نوع القيمتين مسبقاً، فهي تسأل الكائن نفسه وقت التشغيل كيف يجمع أو يضرب، وهذا هو ثمن الأنواع الديناميكية، وهو أيضاً ما تحاول المفسرات الحديثة تقليله بالتخصيص: فمنذ Python 3.11 يراقب المفسر الأنواع التي تمر فعلاً على كل تعليمة، فإذا رأى أن BINARY_OP يجمع أعداداً صحيحة في كل مرة استبدلها بنسخة متخصصة أسرع.
  • أسماء التعليمات تتغير بين الإصدارات، فالتعليمتان LOAD_FAST_BORROW وLOAD_SMALL_INT لم تكونا موجودتين قبل سنوات، ولذلك لا تبن أي أداة على تفاصيل الـ Bytecode، فهي ليست جزءاً من وعود اللغة.

وعندما تستورد وحدة Module لأول مرة يحفظ CPython الـ Bytecode الخاص بها في ملف داخل المجلد __pycache__ اسمه مثل prices.cpython-315.pyc، والجزء 315 يعني أن الملف خاص بالإصدار 3.15، وفي المرة التالية يقارن تاريخ الملف المصدري بما في ملف .pyc، فإذا لم يتغير حمل الـ Bytecode مباشرة وتجاوز الترجمة. وهذا تفصيل يبدو أكاديمياً حتى ترى أثره في الـ Container، وقد رأيناه بالأرقام في قسم زمن البدء أدناه. وأما المترجم JIT التجريبي الذي وصفه PEP 744 فقد تحسن كثيراً في 3.15 بحسب صفحة ما الجديد، ولكنه ما زال غير مفعل افتراضياً، فلا تبن قرارك على وجوده الآن.

أدوات المشروع Toolchain: من يفعل ماذا؟

إذا سألت عشرة مطورين Python عن أدواتهم قبل خمس سنوات لسمعت عشر إجابات: pip مع virtualenv، أو pipenv، أو Poetry، أو conda، ومعها flake8 وisort وblack وpylint، وكل أداة لها ملف إعداد. وهذا التشتت كان أكبر شكوى للقادمين من npm أو Cargo، وقد شرحنا أصل المشكلة ومقارنة مديري الحزم في مقال مديرو الحزم Package Managers. أما اليوم فقد استقرت أغلب المشاريع الجديدة على مجموعة صغيرة، والشكل التالي يبينها مع المشروع الذي سوف نبنيه:

شجرة مشروع shop فيها pyproject.toml للوصف والاعتماديات وuv.lock للإصدارات المثبتة و.python-version و.venv الذي لا يرفع إلى Git وsrc/shop/__init__.py وprices.py وtests/test_prices.py، وبجانبها أربع بطاقات: uv 0.13.6 ينشئ المشروع والبيئة ويكتب uv.lock، وruff 0.17.3 وجد 7 ملاحظات منها 3 imports غير مستعملة، وmypy 2.4.1 وجد أن parse_qty ترجع str بدل int، وpytest 9.2.0 نجحت فيه الاختبارات الثلاثة ولم ير الخطأ
أربع أدوات، وكل واحدة تمسك نوعاً مختلفاً من الأخطاء في نفس الملف
  • uv: مدير المشروع والحزم والإصدارات، مكتوب بلغة Rust من شركة Astral، ويجمع في أداة واحدة ما كان يحتاج pip وvenv وpip-tools وpyenv، وتوثيقه في موقع uv.
  • pip: مثبت الحزم الرسمي الذي يأتي مع Python، وما زال المرجع لكل شيء، وuv نفسه يتبع نفس المعايير، فإذا وجدت مشروعاً قديماً فيه requirements.txt فهو يعمل مع الاثنين.
  • ruff: الفاحص Linter والمنسق Formatter، من نفس الشركة، ويقوم بعمل flake8 وisort وblack معاً وبسرعة كبيرة، والتوثيق في موقع ruff.
  • mypy: فاحص الأنواع Type Checker الذي يقرأ تلميحات الأنواع ويبحث عن التناقضات قبل التشغيل، والتوثيق في موقع mypy.
  • pytest: إطار الاختبارات الأكثر استعمالاً، يكتب فيه الاختبار كدالة عادية مع assert، والتوثيق في موقع pytest.

وقد يتساءل البعض: لماذا لا نكتفي بـ pip الذي يأتي مع اللغة؟ والإجابة أن pip يثبت الحزم فقط، ولا يدير البيئة الافتراضية ولا يكتب ملف قفل Lock File بالإصدارات الدقيقة لكل الاعتماديات غير المباشرة، وهذا الملف هو ما يجعل الكود يعمل على جهاز زميلك وفي CI بنفس الإصدارات التي اختبرتها، ولذلك كنت تحتاج أداة إضافية مع pip في كل مشروع جاد، وuv يقدم كل ذلك في أمر واحد.

إنشاء المشروع Project بالأمر uv init

الآن سوف نقوم بإنشاء المشروع داخل الـ Container، والأوامر من ملف run-tools.sh في المختبر كما يلي:

pip install --root-user-action=ignore -q uv==0.13.6
uv --version
uv init --package shop
cd shop
find . -path ./.git -prune -o -type f -print | sort
cat pyproject.toml

والمخرج سوف يكون كما يلي:

uv 0.13.6 (x86_64-unknown-linux-gnu)
Initialized project `shop` at `/tmp/shop`
./.python-version
./README.md
./pyproject.toml
./src/shop/__init__.py
[project]
name = "shop"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.15"
dependencies = []

[project.scripts]
shop = "shop:main"

[build-system]
requires = ["uv_build>=0.13.6,<0.14.0"]
build-backend = "uv_build"

في المخرج أعلاه لاحظ التالي:

  • الخيار --package جعل uv ينشئ مشروعاً قابلاً للتثبيت كحزمة، بالتخطيط المسمى src layout، أي أن الكود داخل src/shop وليس في جذر المشروع، وبدون هذا الخيار ينشئ uv تطبيقاً بسيطاً بملف main.py في الجذر، وأنواع المشاريع موضحة في صفحة تخطيط المشاريع في uv.
  • الملف pyproject.toml هو ما يقابل package.json أو pom.xml، وقسم [project] فيه معيار عام عرفه PEP 621، فأي أداة حديثة تقرؤه وليس uv فقط.
  • السطر requires-python = ">=3.15" أخذه uv من الإصدار الذي وجده، وهو يمنع تثبيت الحزمة على إصدار أقدم، فإذا كنت تكتب مكتبة يستعملها آخرون فخفضه إلى أقدم إصدار تختبر عليه فعلاً.
  • قسم [project.scripts] يعرف أمراً اسمه shop يستدعي الدالة main، فبعد تثبيت الحزمة يصبح shop أمراً في الطرفية.
  • الملف .python-version فيه رقم الإصدار 3.15، وuv يستعمله ليختار أو ينزل Python المناسبة لهذا المشروع بالذات.

ولماذا الكود داخل src؟ والسبب أن Python تضع مجلد التشغيل الحالي في مسار البحث عن الوحدات، فإذا كان المجلد shop في جذر المشروع فسوف تستورد الاختبارات الكود من المجلد مباشرة حتى لو كانت الحزمة المثبتة مختلفة أو ناقصة، وقد تنجح الاختبارات عندك ثم تفشل الحزمة عند غيرك لأن ملفاً لم يدخل فيها، أما مع src layout فالاختبارات لا ترى الكود إلا بعد تثبيته، والنقاش كاملاً في دليل التحزيم الرسمي.

الاعتماديات Dependencies وملف القفل uv.lock

بعد ذلك نضيف مكتبة httpx للطلبات، ثم أدوات التطوير في مجموعة منفصلة:

uv add httpx
uv add --dev ruff pytest mypy
cat pyproject.toml
ls -a
grep -c '^\[\[package\]\]' uv.lock

والمخرج المهم من الأمر الأول كما يلي:

Using CPython 3.15.2 interpreter at: /usr/local/bin/python3.15
Creating virtual environment at: .venv
Resolved 7 packages in 3.75s
Prepared 7 packages in 2.51s
Installed 7 packages in 54ms
 + anyio==4.15.1
 + certifi==2026.10.23
 + h11==0.16.0
 + httpcore==1.0.9
 + httpx==0.28.1
 + idna==3.20
 + shop==0.1.0 (from file:///tmp/shop)

وبعد الأمر الثاني صار ملف المشروع كما يلي:

[project]
name = "shop"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.15"
dependencies = [
    "httpx>=0.28.1",
]

[project.scripts]
shop = "shop:main"

[build-system]
requires = ["uv_build>=0.13.6,<0.14.0"]
build-backend = "uv_build"

[dependency-groups]
dev = [
    "mypy>=2.4.1",
    "pytest>=9.2.0",
    "ruff>=0.17.3",
]

في المخرج أعلاه لاحظ التالي:

  • أول أمر uv add أنشأ البيئة الافتراضية .venv تلقائياً، فلا تحتاج أن تكتب python -m venv ثم source .venv/bin/activate كما في الطريقة القديمة.
  • طلبنا مكتبة واحدة فثبت uv سبع حزم، فالمكتبة httpx تعتمد على anyio وhttpcore وغيرها، وهذه هي الاعتماديات غير المباشرة Transitive Dependencies، وهي التي تسبب أغلب مشاكل «يعمل عندي ولا يعمل عندك».
  • الملف pyproject.toml فيه حد أدنى فقط httpx>=0.28.1، أما الإصدار الدقيق لكل حزمة من العشرين مع الهاش Hash الخاص بها فهو في uv.lock، وقد عد الأمر grep فيه 20 حزمة. ولذلك ترفع الملفين إلى Git، ولا ترفع .venv الذي بلغ حجمه 95 ميجابايت حسب الأمر du -sh .venv في آخر المختبر.
  • أدوات التطوير في قسم [dependency-groups] وليس في dependencies، فهي لا تثبت مع الحزمة عند المستخدمين ولا في الـ Image الخاصة بالإنتاج.
  • تنزيل أدوات التطوير أخذ دقيقة كاملة في المختبر بسبب حجم mypy وruff، أما التثبيت نفسه فأخذ 265ms، فالبطء كان في الشبكة وليس في uv، والمرة التالية تأتي الحزم من ذاكرة uv المؤقتة Cache.
📘
بين 25 و30 ديسمبر 2022 رفع شخص إلى PyPI حزمة اسمها torchtriton، وهو نفس اسم حزمة كان مشروع PyTorch يوزعها من مستودعه الخاص للنسخ الليلية، ولأن pip يفضل PyPI عند تشابه الأسماء ثبت كل من نزل PyTorch-nightly عبر pip في تلك الأيام الحزمة الخبيثة التي تجمع معلومات من الجهاز وترسلها، وقد نشر فريق PyTorch تفاصيل الحادثة وطلب من الجميع حذفها. والدرس لمشروعك: اعرف من أين تأتي كل حزمة، وثبت الإصدارات والهاش في ملف القفل، وراجع أي تغيير فيه في طلب الدمج كما تراجع الكود.

الفحص Linting بالأداة ruff

الآن نضع في المشروع الملف prices.py، وهو أول نسخة من دوال الأسعار التي سوف نستعملها في كل المرحلة، وفيه عن قصد بعض الأخطاء التي يكتبها المطور القادم من لغة أخرى:

import os, sys
from decimal import Decimal


def line_total(price_halalas: int, qty: int) -> int:
    return price_halalas * qty


def vat_halalas(subtotal: int, rate_percent: int = 15) -> int:
    # half up, in integer halalas: 4455 / 1000 = 4.455 -> 446
    return (subtotal * rate_percent + 50) // 100


def order_total(lines: list[tuple[int, int]]) -> int:
    subtotal = sum(line_total(p, q) for p, q in lines)
    return subtotal + vat_halalas(subtotal)


def format_sar(halalas: int) -> str:
    return f"{halalas / 100:.2f} SAR"


def parse_qty(text: str) -> int:
    if text == None:
        return 0
    return text

والدالة vat_halalas تحسب الضريبة 15% بالأعداد الصحيحة مع التقريب لأعلى عند النصف Round Half Up: ثلاثة أكياس تمر بسعر 9.90 ريال تعطي 2970 هللة، والضريبة 445.5 هللة تقرب إلى 446، فيكون المجموع 3416 هللة أي 34.16 ريال. والحيلة أننا نضيف 50 قبل القسمة الصحيحة على 100، فما كان نصفاً أو أكثر يعبر إلى الهللة التالية، وهذا يعمل لأن المبالغ هنا موجبة دائماً. نشغل ruff بإعداده الافتراضي ثم بمجموعة قواعد أوسع:

uv run ruff check
uv run ruff check --select E,F,I,UP,B

والمخرج من الأمر الأول مختصراً كما يلي:

EXE002 The file is executable but no shebang is present
--> src/shop/prices.py:1:1

I001 [*] Import block is un-sorted or un-formatted
 --> src/shop/prices.py:1:1

F401 [*] `os` imported but unused
 --> src/shop/prices.py:1:8

F401 [*] `sys` imported but unused
 --> src/shop/prices.py:1:12

F401 [*] `decimal.Decimal` imported but unused
 --> src/shop/prices.py:2:21

EXE002 The file is executable but no shebang is present
--> tests/test_prices.py:1:1

I001 [*] Import block is un-sorted or un-formatted
 --> tests/test_prices.py:1:1

Found 7 errors.
[*] 5 fixable with `--fix`.

ومن الأمر الثاني ظهرت ملاحظتان لم تظهرا في الأول:

E401 [*] Multiple imports on one line
 --> src/shop/prices.py:1:1

E711 Comparison to `None` should be `cond is None`
  --> src/shop/prices.py:24:16
   |
23 | def parse_qty(text: str) -> int:
24 |     if text == None:
   |                ^^^^
help: Replace with `cond is None`
note: This is an unsafe fix and may change runtime behavior

Found 7 errors.
[*] 6 fixable with `--fix` (1 unsafe fix available with `--fix --unsafe-fixes`).

في المخرج أعلاه لاحظ التالي:

  • كل ملاحظة لها رمز، والحرف الأول يدل على مصدر القاعدة: F من Pyflakes للأخطاء المنطقية مثل الاستيراد غير المستعمل، وE من pycodestyle لأسلوب الكتابة، وI من isort لترتيب الاستيراد، وB من flake8-bugbear للأنماط التي تسبب أخطاء، وUP لتحديث الصيغ القديمة، والقائمة الكاملة وطريقة اختيارها في صفحة إعداد ruff.
  • العلامة [*] تعني أن ruff يستطيع الإصلاح تلقائياً بالخيار --fix، أما E711 فإصلاحه «غير آمن» لأن == وis قد يختلفان في كلاس عرف __eq__ بطريقة خاصة، ولذلك لا يطبقه إلا إذا طلبت ذلك صراحة.
  • المقارنة مع None تكون دائماً بـ is، والسبب أن None كائن واحد فقط في البرنامج كله، وis تسأل: هل هو نفس الكائن؟ بينما == تستدعي دالة المقارنة في الكلاس، وقد تكون مكتوبة بطريقة لا تتوقعها.

أما EXE002 فهي مشكلة واجهناها فعلاً ولم نخطط لها: الملفات جاءت من مجلد على Windows مربوط داخل الـ Container، وكل ملف يظهر من هذا الربط بصلاحية التنفيذ، فاعتبرها ruff ملفات تنفيذية بدون سطر #! في أولها. والحل أن تنسخ الملفات داخل الـ Container أو تزيل صلاحية التنفيذ بالأمر chmod -x، وإذا كان فريقك يعمل على Windows فأضف EXE002 إلى قائمة ignore في إعداد ruff بدل أن تطارد هذه الملاحظة في كل طلب دمج. ولاحظ أن قاعدتي I001 وEXE002 ظهرتا في الإعداد الافتراضي لهذا الإصدار بينما لم تظهر E711 إلا عندما اخترنا المجموعة E كاملة، ولذلك لا تعتمد على الإعداد الافتراضي، واكتب قائمة القواعد صراحة في قسم [tool.ruff.lint] من pyproject.toml حتى لا يتغير ما يفحصه CI عند ترقية ruff.

وأما التنسيق فالأمر uv run ruff format --diff src/shop/prices.py أعطى 1 file already formatted، أي أن الملف مكتوب بالتنسيق القياسي أصلاً، والمنسق في ruff يتبع أسلوب black الذي صار شبه معيار، فلا تضيع وقت الفريق في نقاش المسافات والأقواس، واترك المنسق يقرر كما يفعل Prettier في JavaScript.

فحص الأنواع Type Checking بالأداة mypy

لاحظت أن الدوال في prices.py فيها تلميحات أنواع Type Hints مثل price_halalas: int و-> int، وهذه التلميحات لا يفحصها CPython عند التشغيل إطلاقاً، فهي معلومات للأدوات والمحررات فقط، ومن يفحصها هو mypy:

uv run mypy --version
uv run mypy src

والمخرج سوف يكون كما يلي:

mypy 2.4.1 (compiled: yes)
src/shop/prices.py:26: error: Incompatible return value type (got "str", expected "int")  [return-value]
Found 1 error in 1 file (checked 2 source files)

وجد mypy أن parse_qty وعدت بإرجاع int ولكنها ترجع text وهو str، وهذا الخطأ الحقيقي في الملف، فالدالة كتبها مطور قادم من JavaScript تعود أن النص "3" يتحول إلى رقم من تلقاء نفسه. ولاحظ أن ruff لم يره، وأن Python نفسها لن تراه حتى يستدعي أحد الدالة ويحاول أن يضرب الناتج في سعر، فيحصل على تكرار النص بدل حاصل الضرب، لأن "3" * 2 في Python تعطي "33". وقد رأينا نفس الفكرة في مقال TypeScript للمطورين: الأنواع تفحص قبل التشغيل ثم تختفي، وما يدخل من خارج البرنامج يحتاج تحققاً وقت التشغيل، وسوف نعود إلى ذلك في مقال الأنواع والذاكرة من هذه المرحلة.

الاختبارات Testing بالأداة pytest

الاختبارات في الملف tests/test_prices.py كتبت بالطريقة المعتادة في pytest، أي دوال عادية اسمها يبدأ بـ test_ وفيها assert:

from shop.prices import order_total, vat_halalas, format_sar


def test_vat_rounds_half_up():
    assert vat_halalas(2970) == 446


def test_total_three_bags_of_dates():
    assert order_total([(990, 3)]) == 3416
    assert format_sar(3416) == "34.16 SAR"


def test_empty_order():
    assert order_total([]) == 0

نشغلها بالأمر uv run pytest -q، والمخرج كما يلي:

...                                                                      [100%]
3 passed in 0.06s

نجحت الاختبارات الثلاثة، ولم ير أي منها خطأ parse_qty لأنه لا يوجد اختبار لها أصلاً، وهذه هي الفكرة التي يبينها الشكل السابق: كل أداة تمسك نوعاً مختلفاً من الأخطاء، فـ ruff رأى الاستيراد غير المستعمل والمقارنة الخاطئة مع None، وmypy رأى تناقض الأنواع، وpytest تأكد أن الحساب نفسه صحيح بالأرقام، ولا تغني أداة منها عن الأخرى. ولاحظ أن الاختبار استورد shop.prices مباشرة، وهذا ممكن لأن uv ثبت الحزمة نفسها داخل البيئة بوضع قابل للتعديل Editable، فكل تعديل في src يظهر فوراً دون إعادة التثبيت، والأمر uv run python -c "import shop; ..." في المختبر طبع /tmp/shop/src/shop/__init__.py و/tmp/shop/.venv ليؤكد ذلك. وقد شرحنا أنواع الاختبارات ومتى تكتب كل نوع في مقال أنواع الاختبارات.

وأخيراً يعرض الأمر uv tree --depth 1 شجرة الاعتماديات المباشرة:

shop v0.1.0
├── httpx v0.28.1
├── mypy v2.4.1 (group: dev)
├── pytest v9.2.0 (group: dev)
└── ruff v0.17.3 (group: dev)

البيئة الافتراضية Virtual Environment: لماذا يرفض pip التثبيت؟

البيئة الافتراضية مجلد فيه نسخة خاصة من المجلد site-packages مع رابط إلى مفسر Python، فكل مشروع يثبت فيها إصداراته دون أن يتأثر غيره، وهي ما يقابل node_modules في JavaScript مع فرق مهم: في JavaScript يبحث Node.js عن المجلد تلقائياً بجانب الكود، أما في Python فالمفسر يحتاج أن يعرف أي بيئة يستعمل، ولذلك كانت الطريقة القديمة أن تفعل البيئة بالأمر source .venv/bin/activate، والطريقة الحالية أن تشغل كل شيء عبر uv run الذي يختار البيئة الصحيحة بنفسه.

عمودان: على اليمين Python النظام مع رسم سيرفر والمسار /usr/lib/python3/dist-packages الذي يملكه apt والأمر pip3 install httpx مرفوضاً بالخطأ externally-managed-environment، وعلى اليسار بيئة لكل مشروع .venv مع رسم ملفين والمسار shop/.venv/lib/python3.15/site-packages والأمر uv add httpx ناجحاً وsys.prefix يشير إلى /tmp/shop/.venv
الحزم على Python النظام ملك لمدير حزم التوزيعة، وحزم مشروعك مكانها بيئته الخاصة

وماذا يحدث إذا تجاهلت كل ذلك وثبت الحزم مباشرة على Python النظام كما يفعل كثير من الشروحات القديمة؟ قمنا بالتجربة على Debian 13 نفسه بدون الـ Image الرسمية، أي بـ Python التي تأتي من apt:

apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv
python3 --version
pip3 --version
pip3 install httpx
ls /usr/lib/python3*/EXTERNALLY-MANAGED
python3 -m venv /opt/venv
/opt/venv/bin/pip install -q httpx && /opt/venv/bin/python -c "import httpx; print('httpx', httpx.__version__, 'from', httpx.__file__)"

والمخرج سوف يكون كما يلي:

Python 3.13.5
pip 25.1.1 from /usr/lib/python3/dist-packages/pip (python 3.13)
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

    If you wish to install a non-Debian-packaged Python package,
    create a virtual environment using python3 -m venv path/to/venv.
    Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
    sure you have python3-full installed.

    If you wish to install a non-Debian packaged Python application,
    it may be easiest to use pipx install xyz, which will manage a
    virtual environment for you. Make sure you have pipx installed.

    See /usr/share/doc/python3.13/README.venv for more information.

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.
/usr/lib/python3.13/EXTERNALLY-MANAGED
httpx 0.28.1 from /opt/venv/lib/python3.13/site-packages/httpx/__init__.py

لاحظ أولاً أن Python التي تأتي مع Debian 13 هي 3.13.5 وليست 3.15، فالتوزيعة تثبت إصداراً واحداً طوال عمرها ولا ترقيه إلى الإصدار الرئيسي التالي، وهذا سبب آخر لكي لا تبني مشروعك عليها. ثم رفض pip3 install httpx التثبيت بالخطأ externally-managed-environment، واقترحت الرسالة ثلاثة طرق: حزمة من apt باسم python3-xyz، أو بيئة افتراضية، أو الأداة pipx للتطبيقات. وفي السطرين الأخيرين أنشأنا بيئة بالأمر python3 -m venv /opt/venv فنجح التثبيت داخلها، وطبع المسار /opt/venv/lib/python3.13/site-packages ليؤكد أن الحزمة دخلت البيئة وليس النظام.

والخطأ هنا ليس عائقاً يجب تجاوزه، وإنما حماية وضعها PEP 668: التوزيعة تضع الملف EXTERNALLY-MANAGED بجانب مكتبة Python النظام، فيقرؤه pip ويرفض الكتابة فوق حزم يديرها apt، والسبب أن أدوات النظام نفسها مكتوبة بـ Python وتعتمد على إصدارات معينة، فإذا رقيت مكتبة منها بـ pip فقد يتوقف أمر من أوامر النظام دون أن تعرف لماذا. ولذلك لا تقم باستعمال الخيار --break-system-packages الذي تقترحه رسالة الخطأ إلا في Container ترميه بعد قليل، والحل الأنسب هو بيئة افتراضية لكل مشروع كما فعلنا في السطر الأخير، أو أداة مثل uv tool install للأدوات التي تريدها في الطرفية.

زمن البدء Startup Time وملفات .pyc داخل Docker

عندما بدأنا قياس زمن تشغيل سكربت صغير داخل الـ Container ظهرت أرقام غريبة: استيراد asyncio وحده أخذ أكثر من ثانيتين في القياس الأول، وسكربت فيه خمسة imports أخذ أكثر من ثانية، وهذا لا يشبه ما تعرفه عن Python على جهازك. وبعد البحث وجدنا سببين، وكلاهما درس يستحق أن تعرفه قبل أن تبني أول Image لخدمة Python.

السبب الأول أن الـ Image الرسمية تحذف كل ملفات .pyc من المكتبة القياسية عند بنائها لتقليل الحجم، وهذا مكتوب صراحة في ملف Dockerfile الخاص بها في الأمر find /usr/local ... -name '*.pyc' ... -exec rm -rf. وبالتالي أول تشغيل في كل Container جديد يترجم كل وحدة يستوردها من البداية، ثم يكتب ملفات .pyc داخل الـ Container، وعندما يحذف الـ Container تحذف معه. وقد عددنا ملفات .pyc في المكتبة القياسية قبل التجربة فكانت 0، وبعدها صارت 117.

والسبب الثاني أننا شغلنا السكربت من المجلد المربوط من Windows، وPython تبحث عن كل وحدة تستوردها في مجلد السكربت أولاً، وكل عملية بحث في هذا الربط بطيئة لأنها تعبر من Linux إلى نظام ملفات Windows، فصار كل import يدفع ثمن عمليات بحث فاشلة على قرص بعيد. والحل كان أن ننسخ السكربتات إلى /tmp/app داخل الـ Container قبل القياس، وهذا هو ملف القياس النهائي:

find /usr/local/lib/python3.15 -name "*.pyc" | wc -l
mkdir -p /tmp/app && cp /lab/eager.py /lab/lazy.py /tmp/app/ && cd /tmp/app
python - <<'PY'
import subprocess, time, statistics
def once(args):
    s = time.perf_counter(); subprocess.run(args, capture_output=True); return (time.perf_counter() - s) * 1000
print(f"first run, no .pyc yet: eager.py {once(['python', 'eager.py']):7.1f} ms")
print(f"second run, .pyc cached: eager.py {once(['python', 'eager.py']):7.1f} ms")
def bench(args, n=21):
    return statistics.median(once(args) for _ in range(n))
for label, args in [("python -c pass", ["python", "-c", "pass"]),
                    ("eager.py", ["python", "eager.py"]),
                    ("lazy.py", ["python", "lazy.py"]),
                    ("lazy.py json", ["python", "lazy.py", "json"])]:
    print(f"{label:16s} median of 21: {bench(args):6.1f} ms")
PY
find /usr/local/lib/python3.15 -name "*.pyc" | wc -l

والملف eager.py يستورد خمس وحدات من المكتبة القياسية بالطريقة العادية ولا يستعمل أياً منها إلا إذا طلبت ذلك في سطر الأوامر:

import sys
import asyncio
import email.message
import http.client
import decimal
import json

if len(sys.argv) > 1 and sys.argv[1] == "json":
    print(json.dumps({"ok": True}))
else:
    print("fast path: nothing used")

أما lazy.py فهو نفس الملف تماماً مع الكلمة الجديدة lazy قبل كل import من الخمسة، أي lazy import asyncio وهكذا، وهي ميزة الاستيراد الكسول Lazy Imports التي عرفها PEP 810 ودخلت في Python 3.15: الوحدة لا تحمل عند سطر الاستيراد، وإنما عند أول استعمال للاسم. شغلنا القياس مرتين في Container جديد كل مرة، والمخرج من التشغيل الأول كما يلي:

0
first run, no .pyc yet: eager.py  2031.5 ms
second run, .pyc cached: eager.py   578.2 ms
python -c pass   median of 21:   66.1 ms
eager.py         median of 21:  489.9 ms
lazy.py          median of 21:   63.0 ms
lazy.py json     median of 21:  108.8 ms
117

ومن التشغيل الثاني:

first run, no .pyc yet: eager.py  2119.1 ms
second run, .pyc cached: eager.py   335.1 ms
python -c pass   median of 21:   55.7 ms
eager.py         median of 21:  466.7 ms
lazy.py          median of 21:   57.6 ms
lazy.py json     median of 21:   92.1 ms
أعمدة أفقية لزمن التشغيل: بدون كود python -c pass بين 56 و66 ms، والملف eager.py مع خمسة imports بين 467 و490 ms، والملف lazy.py مع lazy import بين 58 و63 ms، والملف lazy.py مع استعمال json فقط بين 92 و109 ms، وتحتها الكود قبل وبعد إضافة الكلمة lazy قبل كل import
نفس الملف، والفرق كلمة lazy قبل كل import

في المخرج أعلاه لاحظ التالي:

  • أول تشغيل في Container جديد أخذ أكثر من ثانيتين في المرتين، والثاني مباشرة بعده أخذ بين 335 و578ms، والفرق كله ترجمة المكتبة القياسية إلى Bytecode. وهذا يعني أن كل خدمة Python تبدأ في Container جديد تدفع هذا الثمن في أول طلب، ولذلك إذا كان زمن البدء يهمك، كما في Serverless أو التوسع التلقائي، فأضف إلى الـ Dockerfile الخاص بك خطوة python -m compileall لكودك ولمكتباتك، وسوف نقيس أثر ذلك على الخدمة الحقيقية في المقال الرابع من المرحلة.
  • Python بدون أي كود أخذت بين 56 و66ms، وهذا أعلى مما تراه على جهاز عادي، والسبب أن كل تشغيل هنا ينشئ عملية Process جديدة داخل Container على Docker Desktop، والأرقام كلها للمقارنة بين الحالات وليست سرعة تتوقعها على سيرفرك.
  • الملف الكسول في المسار السريع أخذ نفس زمن Python الفارغة تقريباً، أي أن الخمسة imports لم تكلف شيئاً لأنها لم تحمل أصلاً، بينما دفع الملف العادي ثمنها كاملاً رغم أنه لم يستعمل أياً منها.
  • عندما طلبنا json حملت تلك الوحدة وحدها عند استعمالها، فارتفع الزمن إلى 92 إلى 109ms، وبقيت الوحدات الأربع الأخرى بدون تحميل.

وقد يتساءل البعض: لماذا لا تكون كل الاستيرادات كسولة تلقائياً؟ والإجابة أن بعض الوحدات تفعل شيئاً عند استيرادها، مثل تسجيل Plugin أو تعديل إعداد عام، فإذا تأخر تحميلها تغير سلوك البرنامج، ولذلك جعلها PEP 810 اختيارية صريحة في كل سطر. والمكان الطبيعي لها هو أدوات سطر الأوامر CLI التي لها أوامر فرعية كثيرة، فالأمر --help لا يجب أن ينتظر تحميل مكتبة الرسوم البيانية. أما إذا أردت أن تعرف أي وحدة تأخذ الوقت عند البدء في مشروعك، فالخيار -X importtime المذكور في صفحة خيارات سطر الأوامر يطبع زمن كل import، وقد أظهر في ملفنا أن asyncio وحدها أخذت 194ms من الوقت الكلي بما فيها ما تستورده هي من logging وconcurrent.futures.

ماذا لو What If: نسيت البيئة وشغلت الكود بمفسر آخر؟

رأينا أن uv يثبت الحزم داخل .venv، فماذا يحدث إذا شغلت الاختبارات بالأمر python -m pytest مباشرة دون uv run؟ في الـ Container الذي استعملناه لن يجد Python النظام الحزمة pytest أصلاً لأنها مثبتة داخل البيئة فقط، وسوف تحصل على No module named pytest، وهذا خطأ واضح وسهل. أما الحالة الخطرة فهي على جهاز التطوير الذي ثبت عليه أحدهم pytest عاماً قبل سنتين، فسوف يعمل الأمر بإصدار قديم من pytest وبدون مكتبات مشروعك، ويفشل اختبار يستورد httpx برسالة لا علاقة لها بالكود الذي غيرته. والقاعدة التي تريحك من كل ذلك: كل أمر في المشروع يبدأ بـ uv run، في الطرفية وفي CI وفي ملف Makefile، وإذا أردت أن تتأكد أي مفسر يعمل فاطبع sys.prefix كما فعلنا في المختبر، فإذا لم ينته بـ .venv فأنت خارج بيئة المشروع.

أخطاء شائعة Common Mistakes في اليوم الأول

  • خلط المسافات Spaces مع الـ Tab في المسافة البادئة: Python 3 ترفض الخلط في نفس الكتلة، والحل أن تترك المنسق ruff يقرر، وأن يكون محررك مضبوطاً على أربع مسافات.
  • تثبيت الحزم بـ sudo pip install: رأينا أن التوزيعات الحديثة ترفض ذلك، وإذا نجح في توزيعة قديمة فقد يفسد أدوات النظام.
  • رفع .venv إلى Git: البيئة مرتبطة بالمسار ونظام التشغيل ولا تعمل على جهاز آخر، وما يرفع هو pyproject.toml وuv.lock فقط.
  • الاعتماد على / للقسمة الصحيحة كما في Java، أو على // مع الأعداد السالبة كأنها تقرب نحو الصفر.
  • حفظ المال كعدد عشري Float: استعمل عدداً صحيحاً بالهللة، أو decimal.Decimal إذا احتجت كسوراً عشرية دقيقة.
  • كتابة تلميحات الأنواع ثم عدم تشغيل mypy: التلميحات بدون فاحص مجرد تعليقات، وقد تكون خاطئة دون أن يعرف أحد.
  • نسخ كود من إجابة قديمة مكتوبة لـ Python 2، مثل print بدون أقواس أو dict.has_key.

وفي آخر مقال من هذه المرحلة سوف نمر على أخطاء أعمق يقع فيها المطورون القادمون من لغات أخرى، مثل القيم الافتراضية القابلة للتغيير والمتغيرات في الدوال المتداخلة، كل واحد منها مع تشغيل خاطئ حقيقي وإصلاحه.

قائمة تحقق Checklist لمشروع Python جديد

  • هل اخترت إصداراً مدعوماً من جدول الإصدارات، وكتبته في .python-version وفي requires-python؟
  • هل المشروع بتخطيط src layout، والاختبارات في مجلد tests منفصل؟
  • هل pyproject.toml وuv.lock في Git، و.venv و__pycache__ في .gitignore؟
  • هل أدوات التطوير في [dependency-groups] وليست في dependencies؟
  • هل قواعد ruff مكتوبة صراحة في [tool.ruff.lint]، ويعمل ruff check وruff format --check في CI؟
  • هل يعمل mypy في CI ويوقف الدمج عند الخطأ؟
  • هل كل أمر في التوثيق وفي CI يبدأ بـ uv run؟
  • هل تبدأ خدمتك داخل Container مع ملفات .pyc مترجمة مسبقاً إذا كان زمن البدء يهمك؟
  • هل الأسعار والمبالغ أعداد صحيحة بأصغر وحدة؟

تدريب عملي

  • شغل uv run ruff check --fix على المشروع، ثم أصلح parse_qty لتحول النص إلى عدد بـ int(text)، وأضف لها اختباراً يمرر "3" واختباراً يمرر "abc"، ولاحظ ما الذي يحدث في الحالة الثانية، فهذا موضوع المقال التالي عن الأخطاء.
  • أضف الكلمة lazy إلى استيراد مكتبة كبيرة في سكربت تستعمله كل يوم، وقس الزمن قبل وبعد بالخيار -X importtime.
  • غير requires-python إلى ">=3.12" وشغل uv run --python 3.12 pytest، ولاحظ هل تعمل الأمثلة التي تستعمل ميزات 3.15.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • Python 3.15.2 هي الإصدار المستقر الحالي، وكل إصدار يصدر في أكتوبر ويدعم خمس سنوات، فاختر إصداراً مدعوماً واكتبه في المشروع صراحة.
  • الأنواع في Python ديناميكية وقوية: الاسم يشير إلى كائن والنوع للكائن، و"1" + 1 خطأ وليس "11"، و/ تعطي عدداً عشرياً دائماً.
  • CPython يترجم الكود إلى Bytecode ويحفظه في .pyc، والـ Image الرسمية تأتي بدون هذه الملفات، فأول تشغيل في Container جديد أخذ أكثر من ثانيتين بدل نصف ثانية.
  • المجموعة الحديثة للأدوات هي uv للمشروع والحزم، وruff للفحص والتنسيق، وmypy للأنواع، وpytest للاختبارات، وكل واحدة أمسكت في المختبر خطأ لم تره الأخرى.
  • لا تثبت الحزم على Python النظام، فالتوزيعات الحديثة ترفض ذلك بحسب PEP 668، واستعمل بيئة لكل مشروع.
  • الاستيراد الكسول في 3.15 جعل المسار السريع لسكربت فيه خمسة imports بنفس زمن Python الفارغة تقريباً.

وفي مرحلة لغة Python للمطورين تكمل المقالات التالية ما بدأناه هنا، من الأنواع والذاكرة إلى التزامن ثم خدمة الطلبات الكاملة:

دليلPython من الداخل: الأنواع Types والذاكرة والاستثناءاتما هو المتغير فعلاً في Python، ومن يحرر الذاكرة، وكيف تعبر اللغة عن الخطأ، بقياسات حقيقية على Python 3.15 من حجم مليون سجل إلى Container قتلته النواة دون رسالة.دليلمديرو الحزم Package Managers: npm و pip و Maven و Cargoنجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على Python 3.15.2 في الـ Image الرسمية python:3.15.2-slim-trixie مع uv 0.13.6 وruff 0.17.3 وmypy 2.4.1 وpytest 9.2.0، وعلى Python النظام في debian:trixie-slim.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.