شرح pyproject.toml: ملف واحد لإعدادات مشاريع بايثون

حزمة علمية يبدأ ملف setup.py فيها بسطر import numpy لجلب مسارات ترويسات الترجمة. في بيئة نظيفة ينهار التثبيت قبل أن تعرف pip أصلاً أن numpy مطلوبة: السكربت الذي يُفترض أن يُعلن التبعيات يحتاج إلى تثبيتها ليعمل. هذه الحلقة المفرغة، لا كثرة الملفات وحدها، هي ما استدعى ملفاً يُقرأ ولا يُنفَّذ. pyproject.toml هو ذلك الملف: بيانات TOML ثابتة تقرؤها pip وuv وPoetry وأدوات الفحص دون تشغيل سطر واحد من شيفرتك، وقد ابتلعت في طريقها setup.py وsetup.cfg وملفات إعدادها المتفرقة.
إن كان المشروع جديداً فابدأ بهذا الملف فوراً؛ لا فائدة من إنشاء setup.py ثم الترحيل عنه بعد شهر. في مشروع قائم لا يلزمك حذف setup.py يوم الترحيل: انقل البيانات الوصفية إلى [project] وإعدادات الأدوات إلى [tool]، وأبقِ السكربت إن كان يبني امتدادات مترجمة، فsetuptools يقبل الاثنين معاً ويصير setup.py تفصيلاً في البناء لا واجهةً للمشروع. ولمن يريد الصورة الأوسع للعدّة المحيطة بالملف، راجع أدوات بايثون الحديثة: دليل uv وRuff وpyproject.toml.
أربعة معايير بنَت الملف طبقة فوق طبقة
لم يُبنَ هذا الملف دفعة واحدة ولا تحت تصميم واحد؛ كل جدول فيه أثر معيار مستقل عالج مشكلة مختلفة. من هنا يأتي تنافر الجداول الذي يربك القادمين إليه.
استحدث PEP 518 الملف نفسه وجدول [build-system] ومفتاح requires، وتاريخ إنشائه 10 مايو 2016. أما مفتاح build-backend والواجهة الموحّدة التي فتحت الباب لواجهات غير setuptools فجاءا مع PEP 517، وهو أسبق زمنياً إذ أُنشئ في 30 سبتمبر 2015. جدول [project] عرّفه PEP 621 المُنشأ في 22 يونيو 2020. وآخر الطبقات [dependency-groups]، عرّفه PEP 735 الذي أُنشئ في 20 نوفمبر 2023 وقُبل في 10 أكتوبر 2024.
ثلاثة جداول، وثلاثة قرّاء مختلفين
اسأل عن كل جدول سؤالاً واحداً: من يقرؤه؟ فهي ليست أقساماً تنظيمية بل عناوين لجهات مختلفة، لكل جهة ما يعنيها وحده.
جدول [build-system] تقرؤه الواجهة الأمامية قبل أن يُنشأ أي شيء، لتعرف ما الذي تثبّته داخل بيئة البناء المعزولة وأي واجهة خلفية ستستدعي. و[project] جمهوره الواجهة الخلفية: منه تولّد البيانات الوصفية التي ستُقرأ لاحقاً من الحزمة بعد تثبيتها، ومن حقوله القياسية name وversion وdescription وreadme وrequires-python وlicense وlicense-files وauthors وdependencies وoptional-dependencies وdynamic. المواصفة هي التي تحدد هذه الحقول، فما ليس منها مكانه [tool] لا هنا. أما [tool] نفسه فلا يقرؤه نظام التغليف إطلاقاً؛ كل أداة تحجز مساحتها الخاصة مثل [tool.ruff] و[tool.uv] و[tool.hatch]، والتغليف يمرّ فوقها دون أن ينظر.
حقل name إلزامي، ويُمنع صراحةً إدراجه في dynamic؛ الواجهة الخلفية ملزمة برفع خطأ إن فعلتَ. [dependency-groups] جدول رابع لا علاقة له بـ[project]: ملف لا يحوي سواه يبقى ملفاً صحيحاً، ومشروع غير منشور يستعمل pyproject.toml بلا [project] ولا [build-system].
أي واجهة بناء خلفية تناسب مشروعك
الواجهة الخلفية تأخذ شجرة مصدرك وتخرج منها توزيعة مصدرية وعجلة. والاختيار بين الواجهات ليس مسألة ذوق: بعضها لا يبني امتدادات مترجمة، وهذا قيد يحسم الأمر قبل أي تفضيل شخصي.
| الواجهة | build-backend | متى تختارها |
|---|---|---|
| setuptools | setuptools.build_meta | مشروع قائم أو امتدادات C؛ المعيار الفعلي |
| hatchling | hatchling.build | بايثون خالص مع تحكّم أوسع في اختيار الملفات ومصدر الإصدار |
| flit-core | flit_core.buildapi | حزمة بايثون خالصة بلا أي خطوة بناء |
| pdm-backend | pdm.backend | سير عمل PDM أو حاجة إلى خطّاف بناء مخصص |
| uv_build | uv_build | مشاريع uv ببايثون خالص |
| maturin | maturin | امتدادات مكتوبة بلغة Rust |
توثيق flit يقولها مباشرة: إن احتاجت حزمتك خطوة بناء فلن تستطيع استخدام flit، ويحيلك إلى setuptools بوصفه المعيار الفعلي. وuv_build مثله في الحدّ: يدعم حالياً شيفرة بايثون الخالصة فقط، وتوثيق uv نفسه يوصي بـ hatchling لمن يحتاج امتدادات. أما hatchling فلا يبني امتدادات C أصلياً، بل عبر خطّافات بناء تنتج ملفات .so أو .dll تُدرَج بعدها عبر خيار artifacts. وmaturin يبني حزم بايثون من صناديق Rust عبر ارتباطات PyO3 وCFFI وUniFFI. وفي pdm-backend مفتاح run-setuptools قيمته الافتراضية false، وهو مسار دعم امتدادات C فيه.
دليل PyPA نفسه لا يرشّح واجهة بعينها بوصفها الأفضل. وأرقام الحد الأدنى داخل requires ليست ثابتة؛ رقم منسوخ من مقال قديم يفتح الباب لواجهة أقدم مما تحتاجه شيفرتك فعلاً.
ملف كامل بتخطيط src وما يعنيه كل سطر
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-cli-tool"
description = "A small example CLI tool"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Tqni Team", email = "[email protected]" }]
dependencies = [
"httpx>=0.27",
"rich>=13",
]
dynamic = ["version"]
[project.scripts]
my-cli-tool = "my_cli_tool.cli:main"
[project.entry-points.pytest11]
my-cli-tool = "my_cli_tool.plugin"
[dependency-groups]
test = ["pytest>8", "coverage"]
typing = ["mypy"]
dev = [{ include-group = "test" }, { include-group = "typing" }]
[tool.hatch.version]
path = "src/my_cli_tool/__about__.py"
[tool.ruff]
line-length = 100
جدول [project.scripts] ينشئ عند التثبيت أمراً تنفيذياً يستدعي الدالة المحددة، وهو في جوهره اختصار لمجموعة نقاط دخول اسمها console_scripts. ويقابله [project.gui-scripts] لمجموعة gui_scripts، والفرق بينهما يظهر على ويندوز تحديداً: الأول يُربط بوحدة تحكّم تدعم الإدخال والإخراج القياسي، والثاني يعمل بلا وحدة تحكّم. ولهذا السبب يُمنع أن تكتب [project.entry-points.console_scripts] أو [project.entry-points.gui_scripts] مباشرة؛ PEP 621 يوجب على واجهة البناء رفع خطأ لالتباسهما مع الاختصارين.
نقاط الدخول بمجموعات مخصّصة هي آلية الإضافات في بايثون. تُخزَّن عند التثبيت في ملف entry_points.txt داخل مجلد .dist-info؛ pytest مثلاً يبحث عن مجموعة pytest11 ليكتشف إضافاته، وأمر pytest --trace-config يخبرك إن كانت إضافتك قد رُئيت فعلاً. وصيغة مرجع الكائن واحدة في كل الحالات: importable.module أو importable.module:object.attr.
لماذا لا يجد بايثون حزمتك بعد التثبيت
العَرَض واحد دائماً: pip install -e . يمرّ بلا خطأ، ثم يرفع أول استيراد استثناءً بأن الوحدة غير موجودة. والمتّهم الأول هو الاكتشاف التلقائي للحزم. في setuptools لا يعمل الاكتشاف التلقائي إلا إذا لم تضبط packages ولا py_modules، وقد يتعطّل بوجود ext_modules. وفي التخطيط المسطّح يرفض setuptools البناء إذا وجد أكثر من حزمة عليا واحدة، ورسالة الخطأ تبدأ بـ Multiple top-level packages discovered in a flat-layout.
الأخطر من الرفض الصريح استبعادٌ صامت: يستبعد setuptools تلقائياً في التخطيط المسطّح مجلدات منها tests وdocs وexamples وscripts وtools وbuild وdist وvenv. لن ترى تحذيراً واحداً، وقد لا يكون هذا ما تريده. الإصلاح المعياري أن تصرّح بما تريد:
[tool.setuptools.packages.find]
where = ["src"]
include = ["my_cli_tool*"]
namespaces = false
مفتاح namespaces هنا يتحكم في معاملة المجلدات الخالية من __init__.py بوصفها حزم فضاء أسماء ضمنية؛ وإطفاؤه يمنع مجلد بيانات أو أصول عابراً من أن يُلتقط حزمةً ويُشحن داخل العجلة.
وحجج تخطيط src الرسمية عملية لا جمالية: يستلزم تثبيت المشروع فعلاً لتشغيل شيفرته، ويمنع الاستخدام العرضي لنسخة التطوير لأن بايثون يضع مجلد العمل أولاً في sys.path، بينما التخطيط المسطّح يدسّ ملفات مثل README.md وtox.ini وnoxfile.py في مسار الاستيراد. وانتبه: include-package-data قيمته الافتراضية true عند إعداد setuptools عبر pyproject.toml، خلافاً للافتراضي التاريخي في setup.py.
في hatchling القصة مختلفة: ترتيب البحث الافتراضي للعجلة هو <NAME>/__init__.py ثم src/<NAME>/__init__.py ثم <NAME>.py ثم <NAMESPACE>/<NAME>/__init__.py. فإن اختلف اسم مجلد الحزمة عن اسم المشروع، فالحل تحديد packages في [tool.hatch.build.targets.wheel]، لا اللجوء إلى bypass-selection.
الحقل الديناميكي: من أين يأتي رقم الإصدار
إدراج حقل في dynamic يعني رسالة واحدة للأدوات: هذا الحقل ستحسبه الواجهة الخلفية، فلا تبحث عنه هنا.
في hatchling تكتب dynamic = ["version"] مع [tool.hatch.version] ومفتاح path يشير إلى src/my_cli_tool/__about__.py؛ المصدر الافتراضي يبحث في الملف عن __version__ أو VERSION بتعبير نمطي، وأمر hatch version minor يزيد الرقم نيابة عنك. وفي setuptools:
[project]
dynamic = ["version", "readme"]
[tool.setuptools.dynamic]
version = {attr = "my_cli_tool.__version__"}
readme = {file = ["README.md"]}
توجيه attr أدقّ مما يبدو: يقرأ القيمة أولاً بفحص شجرة البناء المجرّدة، فإن فشل لجأ إلى استيراد الوحدة فعلياً، وهو مسار هشّ لأن الحزمة غير مثبّتة بعد. ولهذا تنصح الوثائق بأن تكون القيمة حرفية بسيطة. أما flit فيشتقّ الإصدار من __version__ والوصف من نص توثيق الوحدة عند إدراجهما في dynamic.
هنا قيدان يسقط فيهما كثيرون. الأول: إذا جعلت optional-dependencies ديناميكياً في setuptools فيجب أن تكون كل المجموعات ديناميكية، ولا يصحّ خلط ثابت بديناميكي، كما يجب أن تلتزم ملفات التبعيات بـ PEP 508 دون صيغ pip الخاصة مثل -r و-e و--index-url. والثاني: تعريف الحقل ثابتاً وإدراجه في dynamic معاً خطأ في PEP 621. لكن المواصفة الحيّة الحالية أضافت استثناءً دقيقاً: الحقل الذي قيمته قائمة أو جدول من مدخلات اعتباطية يجوز فيه الجمع، وحينها تملك الواجهة الخلفية أن تُلحق مدخلات فقط، ولا يجوز لها حذف ما كتبته أنت أو إعادة ترتيبه أو تعديله.
تبعيات التطوير مكانها dependency-groups لا الإضافات
الفرق في الجمهور، لا في الصيغة. حقل optional-dependencies بيانات وصفية منشورة يراها مستخدم حزمتك على PyPI، ولا يمكن تثبيت إضافة منه دون تثبيت الحزمة نفسها وكل تبعياتها. أما [dependency-groups] فيوجب PEP 735 على واجهات البناء ألّا تُدرجه في التوزيعات المبنية إطلاقاً، فلا يظهر أثر له في بيانات الحزمة. ولهذا تصلح المجموعات لمشروع لا يُنشر، بل ولمشروع لا واجهة بناء له.
تُركَّب المجموعات بعضها فوق بعض عبر include-group كما في المثال السابق. ولا وجود لمجموعة افتراضية في المواصفة نفسها؛ كل مجموعة تُطلب بالاسم. يثبّت pip مجموعة عبر خيار --group، ويفترض pyproject.toml في المجلد الحالي إن لم تعطه مساراً. أما uv فيعامل مجموعة dev معاملة خاصة: uv add --dev ينشئها، وتُزامَن افتراضياً، وله --group و--only-group و--no-default-groups، ويمكن ضبط الافتراضي عبر default-groups في [tool.uv].
يبقى سؤال requirements.txt. حقل dependencies يعلن مدى إصدارات مرناً لحزمة يعاد استخدامها في بيئات مختلفة، بينما requirements.txt وملفات القفل مثل uv.lock تصف بيئة بعينها لتكرارها بدقة. وهذا يحكم صيغة ما تكتبه: تثبيت إصدار واحد بـ== داخل dependencies يفرض قيدك على كل من يثبّت حزمتك، لا على بيئتك أنت. القفل مكانه ملف القفل، وحقل dependencies مكانه المدى.
ما الذي يحتاجه ملفك فعلاً
لا يحتاج كل مشروع كل جدول، والفرق ليس أسلوبياً: هو ما يحدد إن كنت تحتاج واجهة بناء أصلاً.
| نوع المشروع | ما يلزمك | ما تتركه |
|---|---|---|
| خدمة أو تطبيق داخلي لا يُنشر | [dependency-groups] و[tool] | [build-system] و[project] |
| مكتبة تُنشر على PyPI | الجداول الأربعة | لا شيء |
| مكتبة بامتدادات مترجمة | ما سبق مع واجهة تدعم البناء | flit-core وuv_build |
والحالة الأولى هي ما يفاجئ الأكثرية: المشروع الداخلي لا يحتاج اسماً ولا إصداراً ولا واجهة بناء.
ما الذي يفعله pip وuv فعلاً بالملف
يدعم pip البناء عبر هذا الملف منذ الإصدار 19.0 الصادر في 22 يناير 2019، والتثبيت القابل للتحرير عبره منذ 21.3. فإذا وجد [build-system] بلا build-backend سقط إلى setuptools.build_meta:__legacy__. وsetuptools من جهته يدعم جدول [project] منذ 61.0.0، وصار setup.py معه اختيارياً. أما uv فيشترط وجود الملف لتحديد جذر المشروع: بلا هذا الملف لا يرى مشروعاً يديره، وما يكتبه تحت [tool.uv] ليس بيانات تغليف قياسية فلن تقرأه أداة أخرى. وPoetry انضم إلى [project] القياسي في 2.0.0 الصادر في 5 يناير 2025، بعد سنوات في [tool.poetry].
ثم إن pip install -e . ليس سلوكاً واحداً. عرّف PEP 660 خطّافات اختيارية للتثبيت القابل للتحرير وترك للواجهة الخلفية حرية اختيار الآلية: ملف .pth يشير إلى مجلد المصدر، أو وحدات وسيطة، أو شجرة روابط رمزية. نفّذ setuptools هذه الخطّافات في الإصدار 64.0.0 الصادر في 11 أغسطس 2022، وله وضع --config-settings editable_mode=strict لا تظهر فيه الملفات الجديدة تلقائياً. عملياً هذا يعني أن تعديل الشيفرة يسري فوراً، بينما إضافة تبعية أو نقطة دخول أو تغيير بيانات وصفية يستلزم إعادة التثبيت القابل للتحرير؛ وأن الآلية العاملة تحته اختيار تملكه الواجهة الخلفية، فلا تبنِ عليها أداة تفترض ملف .pth بعينه.
تحقق من الملف قبل أن يتحوّل إلى إصدار فاشل
validate-pyproject pyproject.toml
python -m build
tar tzf dist/*.tar.gz
unzip -l dist/*.whl
أداة validate-pyproject تتحقق من الملف اعتماداً على مخططات JSON، وتغطي فحوص PEP 517 و518 و621 و639 و735، وتُثبَّت بـ pipx install 'validate-pyproject[all]' أو تُشغَّل عبر خطّاف pre-commit في المستودع.
أمر python -m build بسلوكه الافتراضي يبني التوزيعة المصدرية، ثم يفكّها في مجلد مؤقت، ثم يبني العجلة من المستخرَج. فإن نقص ملف من التوزيعة المصدرية فشل بناء العجلة ونبّهك مبكراً؛ ولهذا فتمرير --wheel وحده يتخطّى هذا الفحص بالذات. وهو يعزل البناء في بيئة مؤقتة لا يُثبَّت فيها إلا ما أعلنته في requires، وهذا اختبار مجاني لصحة ذلك الحقل.
سرد محتوى الأرشيفين هو ما يؤكد لك بعينك أن مجلد الحزمة موجود فعلاً وأن tests/ ليست داخل العجلة. ولا تخلط بين هذا وبين twine check dist/*؛ وظيفته الموثقة فحص عرض الوصف الطويل على PyPI فقط، لا التحقق البنيوي من الملف.
زلات تتكرر في هذا الملف تحديداً
- وضع تبعيات التطوير في
optional-dependenciesفتُنشر على PyPI أمام مستخدمي الحزمة. - كتابة
version = "1.0"وإدراج"version"فيdynamicمعاً. - الاعتماد على الاكتشاف التلقائي في تخطيط مسطّح ثم اكتشاف أن
tests/استُثنيت بصمت أو أن البناء رفض المتابعة. - كتابة
license = {text = "MIT"}أو الاعتماد على مصنّفاتLicense ::: هجرها PEP 639 المقبول في أغسطس 2024 لصالح تعبير SPDX نصياً معlicense-files، وأضاف setuptools الدعم في 77.0.0 وهجر الجدول القديم في الإصدار نفسه، والصيغة تقبل تعابير مركّبة مثلMIT AND (Apache-2.0 OR BSD-2-Clause). - وضع إعداد أداة داخل
[project]: ما ليس حقلاً معيارياً مكانه[tool]حصراً. - تمرير
--wheelوحده إلىpython -m buildتوفيراً للوقت، ثم اكتشاف بعد النشر أن التوزيعة المصدرية ينقصها ملف كان الفحص التلقائي سيكشفه. - توقّع أن تعديل الملف يثبّت الحزم تلقائياً؛ أتبِعه دائماً بـ
pip install -e .أوuv sync. - كتابة
dependenciesكائناً بدل مصفوفة سلاسل، وهي زلة القادمين من صيغة Poetry القديمة.
من هذا الملف إلى بقية العدّة
- أداة uv لإدارة حزم بايثون: دليل عملي للانتقال من pip وvenv لاستخدام الملف في مشروع يومي فعلي.
- أدوات بايثون الحديثة: دليل uv وRuff وpyproject.toml كخريطة مختصرة للعدّة كاملة.
- نشر أول حزمة بايثون على PyPI خطوة بخطوة حين يصبح الملف جاهزاً وتريد رفع المشروع.