نشر أول حزمة بايثون على PyPI في 2026 خطوة بخطوة

حين تنفّذ pip install requests فأنت تسحب حزمة رفعها غيرك إلى فهرس PyPI؛ النشر هو الطريق المعاكس: تحزّم مكتبتك وترفعها، فيصبح بوسع أي مطوّر جلبها بالأمر نفسه. والطريق كله مجاني، لا يتطلب حتى وسيلة دفع، لكن ما كان صحيحاً أيام setup.py لم يعد كذلك: ترخيص بتعبير SPDX بدل المصنّفات، نشر موثوق بلا أسرار دائمة، وقاعدة جديدة أُعلنت في 22 يوليو 2026 تمنع تأجيل رفع أحد ملفّي التوزيعة طويلاً. المسار هنا كامل: من حجز الاسم إلى التعامل مع إصدار معطوب بعد فوات الأوان.
اسم الحزمة أولاً: التطبيع يجعل my_pkg وMy.Pkg اسماً واحداً
الاسم أول قرار لا رجعة فيه، فاحسمه قبل الشيفرة نفسها. يطبّق PyPI تطبيعاً معرّفاً في PEP 503: كل سلسلة من - و_ و. تُستبدل بشرطة واحدة، وتُخفض الأحرف كلها إلى الصغيرة:
re.sub(r"[-_.]+", "-", name).lower()
النتيجة أن My.Pkg وmy-pkg وmy_pkg اسم واحد على الفهرس، فلا تراهن على الشرطة السفلية لتمييز حزمتك. الأحرف المقبولة عملياً هي اللاتينية والأرقام و- و_ و. فقط.
قد يُرفض الاسم أو يتعذر عليك حجزه لأربعة أسباب موثقة: تعارض مع وحدة في المكتبة القياسية، أو تشابه مربك مع مشروع قائم — ولا يوجد معيار تشابه رقمي منشور، فالقرار حالة بحالة — أو حظر إداري صريح يطال أسماء تخدع المستخدم كالأخطاء الإملائية الشائعة، أو حجز سابق من مستخدم آخر بلا أي إصدارات. ولأن بحث الموقع لا يُظهر الأسماء المحجوزة بلا إصدارات، لا تكتفِ به: جرّب python -m pip index versions <name>، والأدق فتح pypi.org/project/<name>/ مباشرة في المتصفح. أما استرداد اسم مهجور فمسار طويل عبر PEP 541: 6 أسابيع من محاولات التواصل مع المالك، و12 شهراً على الأقل بلا إصدارات ولا نشاط، ثم قرار من مشرفي PyPI.
تخطيط src وترخيص SPDX في pyproject.toml
يعتمد الدليل الرسمي تخطيط src/: كود الحزمة في مجلد منفصل عن ملفات الإعداد والاختبارات، فلا يختلط ما يُبنى بما لا يُبنى:
project/
├── LICENSE
├── pyproject.toml
├── README.md
├── src/
│ └── my_pkg/
│ └── __init__.py
└── tests/
ملف __init__.py هو ما يحوّل المجلد إلى حزمة قابلة للاستيراد ولو بقي فارغاً. ويعرض PyPI محتوى الـ README كاملاً في صفحة حزمتك، ويستنتج صيغة عرضه من الامتداد نفسه؛ README.md يعني تنسيق Markdown بنكهة GitHub. أما الإعداد المركزي فيجتمع في pyproject.toml:
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
[project]
name = "my-pkg"
version = "0.0.1"
authors = [
{ name = "Your Name", email = "[email protected]" },
]
description = "Small utilities for Arabic text handling"
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
license-files = ["LICEN[CS]E*"]
[project.urls]
Homepage = "https://github.com/USERNAME/my-pkg"
Issues = "https://github.com/USERNAME/my-pkg/issues"
انتبه لسطر الترخيص تحديداً، فهو ما تغيّر منذ اعتماد PEP 639 نهائياً: مصنّفات License :: OSI Approved ... مهجورة رسمياً، والبديل تعبير SPDX نصي مثل license = "MIT" مع license-files. الجمع بين المصنّف القديم والتعبير الجديد قد يرفع خطأ بناء، وصيغة الجدول license = {file=...} مهجورة بدورها. اختر backend واحداً والتزم به؛ hatchling خيار افتراضي جيد. ولتفصيل بقية الحقول راجع شرح pyproject.toml: ملف واحد لإعدادات مشاريع بايثون.
رقم الإصدار: قواعد PEP 440 ومصدر وحيد للرقم
أرقام الإصدارات على PyPI تتبع مخطط PEP 440: [N!]N(.N)*[{a|b|rc}N][.postN][.devN]، والترتيب فيه محدد بدقة: 1.0.dev1 < 1.0a1 < 1.0 < 1.0.post1. والنسخ المحلية مثل 1.0+build1 محظورة نصاً على الفهرس العام، فلا تحاول رفعها.
المشكلة العملية الأشهر هي ازدواج الرقم: مرة في pyproject.toml ومرة في __version__ داخل الكود، ثم يُنسى أحدهما عند الترقية. اجعل للرقم مصدراً واحداً لا غير: وسم في نظام إدارة النسخ، أو رقم ثابت في pyproject.toml وحده، أو ملف بايثون يقرؤه الـ backend عبر dynamic = ["version"] — وتفصيل هذا الإعداد في مقال pyproject.toml المربوط أعلاه. وأياً كان المصدر الذي اخترته، أضف اختباراً يطابق my_pkg.__version__ مع importlib.metadata.version("my-pkg") حتى لا يفترق الرقمان يوماً دون أن تنتبه.
python -m build ينتج ملفين، وقاعدة 14 يوماً تمنع تأجيل أحدهما
من جذر المشروع نفّذ:
python -m build
ينتج هذا ملفين داخل dist/، والفرق بينهما أكبر مما يبدو. الأرشيف المصدري sdist بامتداد .tar.gz يضم المصدر الخام مع PKG-INFO، وعادة الاختبارات والتوثيق أيضاً. أما العجلة wheel بامتداد .whl فتحتوي بالضبط ما سيُنسخ إلى جهاز المستخدم عند التثبيت، بلا اختبارات ولا توثيق، لذا تثبيتها أسرع لأنها لا تحتاج خطوة بناء. حين يجد pip العجلة استعملها، وإلا بنى واحدة من الـ sdist — ولهذا يُرفع الاثنان. والوسم py3-none-any في اسم العجلة يعني أنها بايثون صرفة: تعمل على أي تطبيق بايثون 3، بلا اعتماد على واجهة ثنائية ولا على منصة بعينها.
هنا فخ موثق لمستخدمي setuptools: ملف MANIFEST.in يتحكم بمحتوى الـ sdist فقط، فإن أضفت فيه ملفات بيانات ظهرت في الأرشيف المصدري وغابت عن العجلة، ويحصل المستخدم على نصف حزمة. العجلة تحتاج include-package-data أو package-data، علماً أن include-package-data صار مفعّلاً افتراضياً منذ setuptools 61 مع pyproject.toml. والتوصية الرسمية: ضع البيانات داخل مجلد الحزمة واقرأها بـ importlib.resources لا بمسارات __file__.
وثمة سبب جديد لرفع الملفين معاً في عملية واحدة: أعلن PyPI في 22 يوليو 2026 أنه بات يرفض إضافة ملفات جديدة لأي إصدار مضى على نشره أكثر من 14 يوماً. خطة «أرفع الـ sdist اليوم والعجلة حين أتفرغ» لم تعد مجدية. والبديل الأحدث للبناء هو uv build، وتفاصيله في أداة uv لإدارة حزم بايثون: دليل عملي للانتقال من pip وvenv.
اسم المستخدم __token__ حرفياً: ما تبقّى من طرق المصادقة
الرفع بكلمة المرور انتهى: منذ 1 يونيو 2023 مُنع لأي حساب مفعّل عليه التحقق بخطوتين، ومنذ 1 يناير 2024 صار التحقق بخطوتين إلزامياً على كل حسابات PyPI، والبريد الموثّق شرط للرفع أصلاً. عملياً لم يبقَ أمامك من جهازك سوى الـ API token: أنشئه من إعدادات حسابك وقيّده بمشروع واحد ما أمكن. عند الرفع يكون اسم المستخدم حرفياً __token__، وكلمة المرور هي الرمز الذي يبدأ بـ pypi-. احفظه في ~/.pypirc أو في متغيّرَي البيئة TWINE_USERNAME وTWINE_PASSWORD، ولا تضعه أبداً في الكود أو في commit.
وحين يفشل الرفع، الرقم يدلّك. أسباب 403 الموثقة: رمز غير صالح أو ملغى، أو رمز نُسخ ومعه حرف زائد كسطر جديد، أو اسم مستخدم غير __token__، أو رمز TestPyPI مستخدم على PyPI والعكس. وأسباب 400: اسم الملف مرفوع سابقاً، أو استُخدم ثم حُذف، أو المحتوى نفسه مرفوع باسم آخر.
بروفة على TestPyPI ثم تثبيت تجريبي بفهرسين
افحص التوزيعات قبل رفعها لتطمئن إلى أن وصفك الطويل سيُعرض سليماً على صفحة الحزمة:
twine check dist/*
ثم جرّب على TestPyPI:
twine upload --repository testpypi dist/*
TestPyPI قاعدة بيانات منفصلة كلياً: حساب مستقل ورمز مستقل، والاسم قد يكون محجوزاً في أحد الفهرسين ومتاحاً في الآخر، والحسابات والحزم هناك تخضع لتنظيف دوري وقد تُحذف — فلا تعتمد عليه مرجعاً دائماً. ولتجربة التثبيت تحتاج فهرسين معاً، لأن اعتماديات حزمتك غالباً غير موجودة على TestPyPI:
python3 -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ your-package
حين يمرّ كل شيء، ارفع إلى الفهرس الحقيقي:
twine upload dist/*
والبديل بأداة uv هو uv publish بعد uv build؛ وإن كنت تتبنى المنظومة الحديثة من أولها إلى آخرها فستجد الصورة الكاملة في أدوات بايثون الحديثة: دليل uv وRuff وpyproject.toml.
Trusted Publishing: 4 حقول على PyPI بدل سرّ دائم في CI
النشر الموثوق يعفيك من حمل سر طويل العمر. الآلية: يطلب ملف الـ workflow في GitHub Actions رمز OIDC ويقدّمه إلى PyPI، فيردّ PyPI برمز رفع قصير العمر يُستهلك في لحظته ثم يبطل. لا يبقى token دائم قد يُسرّب أو يُنسى صالحاً حتى تُبطله بيدك. إعداده على PyPI أربعة حقول: مالك المستودع واسمه واسم ملف الـ workflow إلزامية، وحقل environment اختياري لكنه موصى به بشدة لأنه يتيح اشتراط موافقة يدوية قبل النشر. والمزودون المدعومون أربعة: GitHub Actions وGitLab CI/CD وGoogle Cloud وActiveState.
jobs:
pypi-publish:
name: upload release to PyPI
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
صلاحية id-token: write إلزامية، ويوصى بتثبيت الأكشن على وسم إصدار محدد لا على فرع. ومنذ الإصدار v1.11.0 يولّد هذا الأكشن شهادات PEP 740 ويرفعها تلقائياً مع كل نشر موثوق، بلا أي إعداد إضافي، فيستطيع من يثبّت حزمتك التحقق من أنها بُنيت فعلاً في مستودعك. وميزة pending publisher تتيح تهيئة كل هذا قبل وجود المشروع على PyPI أصلاً، ويُنشأ المشروع عند أول نشر ناجح. القاعدة: للنشر المتكرر من مستودع GitHub اختر Trusted Publishing من أول حزمة؛ وللرفع اليدوي العابر من جهازك يكفي token مقيّد بمشروع.
نشرتَ إصداراً معطوباً: الحذف يحجب اسم الملف للأبد
ستقع يوماً في هذا الموقف، والحسم فيه مسبقاً يوفّر عليك خسارة لا تُرد. الحذف على PyPI نهائي بلا استثناء: اسم الملف لا يُعاد استخدامه أبداً، حتى لو حذفت المشروع كله وأنشأته من جديد، فرقم النسخة المحذوفة محجوب للأبد. أما السحب المعرّف في PEP 592 فيُبقي الملف موسوماً بأنه مسحوب: يتجاهله pip في الاختيار التلقائي، لكنه يثبّته مع تحذير لمن ثبّته صراحة بـ == أو === أو عبر ملف قفل — فلا تنكسر بيئات الإنتاج القائمة — والسحب قابل للتراجع متى شئت.
| الخيار | متى يناسبك | أثره على المستخدمين | كلفته |
|---|---|---|---|
| رفع رقم جديد | الحل الافتراضي لأي علة: أصلح وارفع 1.2.4 | يصل الإصلاح للجميع بترقية عادية | لا شيء يُفقد |
| السحب yank | إصدار مؤذٍ لا تريد أن يختاره أحد تلقائياً | يختفي من الاختيار التلقائي ويبقى للتثبيت الصريح مع تحذير | قابل للتراجع، والملف باقٍ |
| الحذف | سرّ مسرّب أو محتوى لا يجوز بقاؤه متاحاً | يختفي الملف نهائياً وينكسر من يعتمد عليه | الاسم والرقم محجوبان للأبد |
بهذا يكتمل المسار: اسم مطبَّع متحقق منه، تخطيط src/ مع ترخيص SPDX، رقم واحد من مصدر واحد، بناء يرفع sdist والعجلة معاً خلال نافذة الـ 14 يوماً، بروفة على TestPyPI، ثم نشر برمز مقيّد أو عبر Trusted Publishing. وإن انكسر شيء بعد النشر، فالرقم الجديد أولاً، والسحب ثانياً، والحذف لا تلجأ إليه إلا لما لا يجوز بقاؤه.