pathlib وإعدادات JSON وYAML ومتغيرات البيئة
مسارات تعمل من أي مجلد عبر pathlib، وإعدادات JSON وYAML المتداخلة، وملفات .env ومتغيرات البيئة — مع بناء محمل إعدادات يتوقف عند بدء التشغيل إذا كانت هناك قيمة خاطئة.
كيف تريد أن تتعلم؟
المشكلة التي نقوم بحلها
في مشروع لبناء روبوت دعم فني (support-bot)، يوجد سكريبت صغير في المسار scripts/show_config.py يقرأ ملف الإعدادات config/app.json ويعرض اسم النموذج ودرجة الحرارة (temperature). اعتاد كاتب الكود الوقوف في جذر المشروع وتشغيل الأمر python scripts/show_config.py، فيعمل البرنامج بنجاح. وفي أحد الأيام، دخل زميل له إلى مجلد scripts/ وشغل السكريبت نفسه، ففوجئ برسالة خطأ: FileNotFoundError — رغم أن الملف موجود في نفس مكانه تماماً ولم يتغير!
وخلال الأسبوع نفسه، حدثت مشكلتان أخريان: كان الخادم مضبوطاً بالقيمة APP_DEBUG=false، ومع ذلك أظهرت سجلات النظام (logs) أن وضع تصحيح الأخطاء (debug mode) يعمل بكامل طاقته! وقام شخص آخر بكتابة temperature: 0 في ملف الإعدادات لضمان تقديم النموذج لنفس الإجابة في كل مرة — لكن البرنامج استمر في العمل بالقيمة 0.7. والغريب أنه لم تظهر أي رسالة خطأ في الحالتين!
لا تعود أي من هذه المشكلات الثلاث إلى خلل في منطق الكود نفسه، بل تكمن المشكلة في طريقة قراءة البرنامج للعالم الخارجي — نظام الملفات، وملفات الإعدادات، ومتغيرات البيئة. في هذا الفصل، سنجعل قراءة هذه المدخلات موثوقة تماماً: إدارة المسارات عبر pathlib، وقراءة الإعدادات المتداخلة بصيغتي JSON وYAML، وإدارة الأسرار عبر ملفات .env — وكل ذلك مع التحقق الصارم من صحة البيانات، حتى يتوقف البرنامج فوراً عند وجود إعدادات خاطئة بدلاً من أن يستمر في العمل بصمت وبسلوك غير متوقع.
بنهاية هذا الفصل ستكون قادراً على
- بناء مسارات تعمل على أي نظام تشغيل باستخدام
pathlib.Path، واستخدام المعامل/بدلاً من دمج النصوص عبرos.path - تحديد مواقع الملفات استناداً إلى موقع السكريبت نفسه، بغض النظر عن المجلد الذي تقف فيه داخل الطرفية
- قراءة ملفات إعدادات JSON وYAML المتداخلة، ومعرفة الطرق التي تغير بها دالة
yaml.safe_loadالأنواع بهدوء - قراءة القيم من متغيرات البيئة وملفات
.env، وتحويلها إلى الأنواع الصحيحة والتحقق من صحتها - كتابة محمل إعدادات (config loader) يتوقف برسالة واضحة عند فقدان أو خطأ أي قيمة برمجية
المتطلبات المسبقة: pyproject.toml وملفات القفل وهيكلية مجلد src/ — وتحديداً أمري uv add وuv run.
pathlib: المسار كائن برمجي وليس مجرد نص
في أكواد بايثون القديمة، كان المسار مجرد نص عادي، يُدمج باستخدام + "/" + أو os.path.join. بينما تتعامل فئة Path من مكتبة pathlib مع المسار ككائن برمجي غني يمكنك الاستعلام عن كل جزء من أجزائه بسهولة:
from pathlib import Path
config_file = Path("config") / "models" / "chat.yaml"
print(config_file.as_posix())
print(config_file.name)
print(config_file.stem)
print(config_file.suffix)
print(config_file.parent.as_posix())
print(config_file.with_suffix(".json").as_posix())
print(config_file.parts)config/models/chat.yaml
chat.yaml
chat
.yaml
config/models
config/models/chat.json
('config', 'models', 'chat.yaml')هنا لا يُعبر الرمز / عن عملية قسمة — فعند استخدامه مع كائن Path، فإنه يدمج أجزاء المسار ويختار الفاصل المناسب لنظام التشغيل تلقائياً. تمت طباعة المخرجات باستخدام .as_posix() لتبدو موحدة على كل الأجهزة؛ فالطباعة العادية print(config_file) ستظهر config\models\chat.yaml في ويندوز وconfig/models/chat.yaml في macOS ولينكس.
تظهر المقارنة بين دمج النصوص واستخدام كائنات المسار الفارق جلياً:
from pathlib import Path
base = "config"
print(base + "/" + "app.json")
print(Path(base) / "app.json")
print(repr(Path(base) / "app.json"))في نظام ويندوز يُطبع التالي:
config/app.json
config\app.json
WindowsPath('config/app.json')السطر الأول نص تم تجميعه يدوياً. يقبل ويندوز عادةً الفاصل /، ولكن بمجرد تمرير هذه المسارات لأدوات أخرى أو مقارنتها كنصوص، فإن خلط نوعين من الفواصل يسبب أخطاء معقدة. تبني فئة Path المسار وفق قواعد نظام التشغيل الفعلي. وعلى macOS أو لينكس يطبع السطر الثاني config/app.json والسطر الثالث PosixPath('config/app.json').
لا يعمل المعامل/إلا إذا كان أحد الطرفين على الأقل كائناً من نوعPath. في التعبير"config" / "app.json"كلا الطرفين نصوص عادية، لذا يعامله بايثون كعملية قسمة ويرمي خطأTypeError. اجعل الطرف الأيسر كائنPathوستعمل بقية الأجزاء كنصوص بصورة طبيعية.
إليك بعض الدوال المفيدة في العمل اليومي، والتي سنستخدمها في الأمثلة القادمة:
path.exists()، وpath.is_file()، وpath.is_dir()— للتحقق من وجود المسار وتحديد نوعهpath.read_text(encoding="utf-8")وpath.write_text(text, encoding="utf-8")— لقراءة أو كتابة الملف بالكامل دون الحاجة لعبارةopenpath.mkdir(parents=True, exist_ok=True)— لإنشاء المجلد والمجلدات الأبوية دون إطلاق خطأ إذا كان المجلد موجوداً مسبقاًfolder.glob("*.txt")— للبحث عن كل الملفات المطابقة لنمط معين داخل المجلد؛ وتتيحrglobالبحث في المجلدات الفرعية أيضاًpath.resolve()— لتحويل المسار النسبي إلى مسار مطلق كامل
احرص على كتابة encoding="utf-8" في كل مرة. في ويندوز، لا يكون الترميز الافتراضي لبايثون غالباً UTF-8، لذا فإن ملف التعليمات (prompt) الذي يحتوي على نصوص عربية أو رموز تعبيرية (emojis) سيُقرأ بنجاح على الماك، بينما سيظهر مشوهاً أو يرمي خطأ UnicodeDecodeError على ويندوز.
نسبي بالنسبة لمن؟ (The Working Directory Trap)
السكريبت المذكور في بداية الفصل كُتب بهذا الشكل:
from pathlib import Path
config_path = Path("config/app.json")
print(config_path.read_text(encoding="utf-8"))عند التشغيل من جذر المشروع، يطبع أمر python scripts/show_config.py الإعدادات بنجاح. لكن ادخل إلى مجلد scripts/ وشغّل python show_config.py:
Traceback (most recent call last):
File "show_config.py", line 4, in <module>
print(config_path.read_text(encoding="utf-8"))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "...\Lib\pathlib.py", line 1028, in read_text
with self.open(mode='r', encoding=encoding, errors=errors) as f:
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "...\Lib\pathlib.py", line 1014, in open
return io.open(self, mode, buffering, encoding, errors, newline)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'config\\app.json'(في جهازك سيعرض تتبع الخطأ المسارات كاملة؛ تم اختصارها هنا). يُبحث عن المسار النسبي دائماً انطلاقاً من مجلد العمل الحالي (Current Working Directory) — وهو المجلد الذي تقف فيه داخل الطرفية لحظة التشغيل — وليس المجلد الذي يوجد فيه ملف السكريبت! ولا يوجد مجلد config/app.json داخل scripts/، ولذا لم يتم العثور على الملف.
في بيئات الإنتاج يحدث هذا بكثرة: فأنت لا تدري دائماً من أي مجلد ستقوم المهام المجدولة (cron jobs) أو مدير الخدمات أو الحاويات (containers) بتشغيل برنامجك. والحل الجذري هو ربط المسار بموقع ملف السكريبت نفسه. يحمل المتغير __file__ داخل كل وحدة مسار ذلك الملف:
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
config_path = PROJECT_ROOT / "config" / "app.json"
print(config_path.read_text(encoding="utf-8")){"model": "chat-small", "temperature": 0.2}الآن سيطبع نفس السطر سواء قمت بتشغيله من جذر المشروع، أو من داخل مجلد scripts/، أو من أي مكان آخر تماماً باستخدام مسار مطلق. فلنحلل الخطوات: Path(__file__) هو ملف show_config.py؛ وتجعله .resolve() مساراً مطلقاً؛ ويمثل .parent الأول مجلد scripts/؛ بينما يمثل .parent الثاني جذر المشروع.
لا تستغنِ أبداً عن استدعاء.resolve(). ففي بعض الحالات قد يكون__file__مساراً نسبياً، وحينها سيعتمد.parent.parentعلى مجلد العمل الحالي مجدداً — فتقع في نفس الفخ الذي تحاول تجنبه.
JSON: الإعدادات المتداخلة وفخ الصفر
تكون إعدادات مشاريع الذكاء الاصطناعي دائماً متداخلة تقريباً — إعدادات التطبيق، وإعدادات النموذج، وإعدادات الاسترجاع في مجموعات منفصلة. تحول دالة json.loads النص بصيغة JSON إلى قواميس متداخلة (لقراءة ملف: json.loads(path.read_text(encoding="utf-8"))):
import json
raw = """
{
"app": {"name": "support-bot", "debug": false},
"llm": {"model": "chat-small", "temperature": 0, "max_tokens": 512},
"retrieval": {"top_k": 4}
}
"""
config = json.loads(raw)
print(config["llm"]["model"])
print(config["retrieval"]["top_k"])
print(type(config["app"]["debug"]))
temperature = config["llm"].get("temperature") or 0.7
print(temperature)
temperature = config["llm"].get("temperature", 0.7)
print(temperature)chat-small
4
<class 'bool'>
0.7
0تتحول قيمة false في JSON إلى False المنطقية في بايثون، وتبقى الأرقام أرقاماً — يحتفظ JSON بالأنواع الأصلية. لكن انظر إلى آخر سطرين: نص الإعدادات يحدد صراحة "temperature": 0، ومع ذلك أعاد الأسلوب الأول القيمة 0.7!
تعبير x or default يعني بالبايثون: "أعد x إذا كانت قيمتها تعتبر صحيحة (truthy)، وإلا فأعد القيمة الافتراضية default". وفي بايثون، تعتبر القيم 0 و0.0 و"" و[] و{} كلها قيماً خادعة (falsy). ولذلك يعجز المعامل or عن التمييز بين "غياب القيمة" وبين "وجود القيمة وكانت تساوي صفراً". درجة حرارة 0 تجعل النموذج يقدم الإجابة الأكثر ترجيحاً وتطابقاً في كل مرة، وهو ما تريده في الاختبارات والتقييمات؛ لكن المعامل or حولها بهدوء إلى 0.7، فتفاجأ بتغير الإجابات من تشغيل لآخر دون أن تدري السبب!
استخدم دائماً.get(key, default)لتحديد القيم الافتراضية — فهي لا تطبق القيمة الافتراضية إلا إذا كان المفتاح غير موجود في القاموس فعلاً. بينما لا يصلح استخدامorإلا إذا كنت ترغب حقاً في تجاهل الصفر والقيم الفارغة.
YAML: سهل للبشر، ولكنه يخمن الأنواع
تفضل العديد من الفرق كتابة الإعدادات بصيغة YAML، لأنها تدعم التعليقات وتتطلب عدداً أقل من الأقواس وعلامات التنصيص. لا يحتوي بايثون على قارئ YAML مدمج؛ لذا أضف المكتبة إلى المشروع:
uv add pyyamlاسم الحزمة هو pyyaml، لكنك تستوردها في الكود كـ import yaml. لنقرأ الآن ملف إعدادات:
import yaml
raw = """
llm:
model: chat-small
temperature: 0.2
features:
streaming: yes
cache: off
python_version: 3.10
api_version: "3.10"
"""
config = yaml.safe_load(raw)
print(config["llm"])
print(config["features"])
print(config["python_version"], type(config["python_version"]).__name__)
print(config["api_version"], type(config["api_version"]).__name__){'model': 'chat-small', 'temperature': 0.2}
{'streaming': True, 'cache': False}
3.1 float
3.10 strعلى خلاف JSON، لا يلزمك YAML بتحديد الأنواع بدقة — بل يخمن المحلل النوع تلقائياً. وفي معظم الأحيان يكون التخمين صحيحاً، لكنه في موضعين يقع في خطأ صامت: فالقيم yes وno وon وoff تتحول إلى قيم منطقية (booleans) — الملف يحتوي على cache: off وحصلت في بايثون على False. والقيمة 3.10 قُرئت كرقم عشري قيمته 3.1؛ وهذا كرقم إصدار شيء مختلف تماماً! بينما القيمة المحاطة بعلامات تنصيص "3.10" بقيت نصاً سليماً.
القاعدة واضحة وبسيطة: أي قيمة تبدو كرقم أو كإجابة بنعم/لا ولكنها في الحقيقة نص — مثل أرقام الإصدارات، والمعرفات، والرموز البريدية — يجب وضعها بين علامتي تنصيص في ملفات YAML.
واحرص دائماً على استخدام yaml.safe_load. فالطريقة القديمة yaml.load(text) التي تراها في الشروحات القديمة تفشل الآن بخطأ صريح: TypeError: load() missing 1 required positional argument: 'Loader'. وهذا التغيير مقصود؛ فبعض المحللات القديمة كانت تبني كائنات بايثون عشوائية بناءً على تعليمات داخل ملف YAML، مما يعني أن قراءة ملف غير موثوق به قد تؤدي لتشغيل أكواد برمجية خبيثة. تبني دالة safe_load الأنواع البسيطة والآمنة فقط — القواميس، والقوائم، والنصوص، والأرقام، والقيم المنطقية، وNone.
متغيرات البيئة: كل شيء نص
مفاتيح الواجهات البرمجية، وعناوين قواعد البيانات، واسم النموذج المستخدم — كل هذه قيم تختلف من جهاز لآخر أو يجب أن تظل سرية للغاية، ومكانها الطبيعي هو متغيرات البيئة (environment variables)، وليس داخل الكود أو ملفات الإعدادات المحفوظة في المستودع. يقرأها بايثون عبر os.environ أو os.getenv. للتوضيح، قمنا بتعيين متغيرين داخل الكود نفسه؛ بينما في العمل الحقيقي تأتي هذه القيم من الطرفية أو الخادم أو الحاوية:
import os
os.environ["APP_DEBUG"] = "false"
os.environ["APP_PORT"] = "8000"
debug = os.getenv("APP_DEBUG")
print(repr(debug), bool(debug))
if debug:
print("debug mode is ON")
port = os.getenv("APP_PORT")
print(port + "1")
print(int(port) + 1)
print(os.getenv("LLM_API_KEY"))
print(os.getenv("LLM_TIMEOUT", "30"))'false' True
debug mode is ON
80001
8001
None
30متغيرات البيئة هي نصوص دائماً. القيمة "false" هي نص غير فارغ مكوّن من خمسة أحرف، ولذا تعامل معها الشرط if debug: كقيمة صحيحة — وهذا تحديداً هو ما جعل خادم الإنتاج في قصة البداية يعمل في وضع تصحيح الأخطاء رغم ضبطه على "false"! وبالطريقة نفسها، فإن "8000" + "1" هي عملية دمج نصوص وليست عملية جمع حسابية. لا توجد رسائل خطأ، ولكن البرنامج يعمل بطريقة خاطئة تماماً.
لذلك، يجب تحويل كل متغير بيئة إلى نوعه الصحيح بمجرد قراءته. بالنسبة للأرقام، تكفي دوال int() أو float() — فهما يرميان خطأ ValueError تلقائياً عند وجود مدخلات غير صالحة. أما للقيم المنطقية، فلا يوفر بايثون دالة تحويل جاهزة (لأن bool("false") تعيد True)، لذا تكتب دالتك الخاصة:
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"not a boolean: {value!r}")
for raw in ["false", "True", " yes ", "0"]:
print(repr(raw), "->", parse_bool(raw))
parse_bool("flase")'false' -> False
'True' -> True
' yes ' -> True
'0' -> False
Traceback (most recent call last):
File "main.py", line 13, in <module>
parse_bool("flase")
File "main.py", line 7, in parse_bool
raise ValueError(f"not a boolean: {value!r}")
ValueError: not a boolean: 'flase'السطر الأخير يوضح القيمة الحقيقية لهذه الدالة: فالكلمة الخاطئة إملائياً "flase" لا تتحول بهدوء إلى True أو False — بل يتوقف البرنامج عند بدء التشغيل ويوضح بالضبط القيمة التي تعذر عليه فهمها.
تذكر أيضاً الفارق بين os.environ["NAME"] وos.getenv("NAME"). الأولى ترمي خطأ KeyError: 'LLM_API_KEY' عند غياب المتغير؛ بينما تعيد الثانية القيمة None بصمت، والتي قد تظهر بعد دقائق على هيئة خطأ 401 Unauthorized من استدعاء خارجي للواجهة البرمجية — وحينها يكون اكتشاف السبب أصعب بكثير. تحقق دائماً من القيم الإلزامية في بداية تشغيل البرنامج.
ملف .env: ضبط متغيرات البيئة على جهازك المحلي
ليس من العملي ضبط عشرة متغيرات بيئة يدوياً في كل مرة تفتح فيها الطرفية. والحل المعتمد هو استخدام ملف .env في جذر المشروع وحزمة python-dotenv، التي تقرأ هذا الملف وتضع قيمه داخل os.environ:
uv add python-dotenvهنا أيضاً يختلف اسم الحزمة عن اسم الاستيراد: الحزمة هي python-dotenv، والاستيراد هو dotenv. محتوى ملف .env:
# Local secrets. Never commit this file.
LLM_API_KEY=sk-test-placeholder-1234
LLM_MODEL=chat-small
APP_DEBUG=true(القيمة sk-test-placeholder-1234 هي مجرد مثال وهمي؛ إياك ووضع مفاتيح حقيقية في أي وثيقة أو لقطة شاشة أو التزام في Git). كود القراءة، مع بناء المسار انطلاقاً من موقع الملف:
import os
from pathlib import Path
from dotenv import load_dotenv
env_file = Path(__file__).resolve().parent / ".env"
found = load_dotenv(env_file)
print(found)
print(os.getenv("LLM_MODEL"))
print(os.getenv("APP_DEBUG"))
print(os.getenv("LLM_API_KEY")[:7] + "...")تشغيل البرنامج بـ uv run main.py:
True
chat-small
true
sk-test...والآن نشغل البرنامج نفسه، مع تعيين LLM_MODEL مسبقاً في سطر الأوامر (على macOS/لينكس أو Git Bash):
LLM_MODEL=chat-large uv run main.pyTrue
chat-large
true
sk-test...دالة load_dotenv لا تستبدل المتغيرات المضبوطة مسبقاً. وهذا سلوك مقصود وبالغ الأهمية: فملف .env مجرد وسيلة مريحة لجهازك المحلي، بينما المتغيرات الفعلية المضبوطة على الخادم أو في الحاوية لها الأولوية القصوى دائماً. (الاستدعاء load_dotenv(path, override=True) يعكس هذا الترتيب، لكن نادراً ما تحتاج لذلك في التطبيقات العملية).
لاحظ أيضاً أن load_dotenv لا تطلق أي خطأ إذا كان الملف غير موجود — بل تكتفي بإرجاع False. لذا لا تفترض أبداً أن "ملف .env قد قُرئ"؛ بل تحقق من القيم الفردية التي تحتاجها صراحة كما سنرى في المثال التالي.
قاعدتان أساسيتان تتبعهما كل المستودعات البرمجية المحترفة:
- لا يُحفظ ملف
.envفي Git أبداً. يوضع في.gitignore. بمجرد دخول المفتاح في تاريخ Git يظل هناك حتى لو حذفت الملف؛ والتصرف الآمن الوحيد حينها هو إبطال المفتاح واستخراج غيره. - يُحفظ ملف
.env.exampleفي المستودع دائماً. يحتوي على نفس أسماء المتغيرات مع قيم فارغة أو توضيحية. ينسخه العضو الجديد إلى.envفيعرف بدقة ما هي المتغيرات المطلوبة لتشغيل المشروع.
مثال عملي كامل
لنجمع الآن كل هذه المفاهيم معاً: محمل إعدادات (config loader) متكامل لروبوت الدعم الفني. أُنشئ المشروع عبر uv init --app --python 3.12 support-bot، ثم أضفنا الحزم uv add pyyaml python-dotenv. هيكلية المجلدات:
support-bot/
├── .env
├── .env.example
├── config/
│ └── app.yaml
├── prompts/
│ ├── summarise.txt
│ └── system.txt
├── pyproject.toml
├── settings.py
└── uv.lockملف config/app.yaml — محفوظ في المستودع، ولا يحتوي على أي أسرار:
app:
name: support-bot
debug: false
llm:
model: chat-small
temperature: 0.2
max_tokens: 512
retrieval:
top_k: 4ملف .env — غير محفوظ في المستودع:
# Local values for this machine. Never commit this file.
LLM_API_KEY=sk-test-placeholder-1234
APP_DEBUG=trueملف .env.example — محفوظ في المستودع:
# Copy to .env and fill in. This file is committed; .env is not.
LLM_API_KEY=
APP_DEBUG=falseيحتوي ملف prompts/system.txt على سطر واحد: You are a support assistant. Answer only from the provided context.، ويحتوي prompts/summarise.txt على: Summarise the ticket below in two sentences.
وملف settings.py:
import os
import sys
from pathlib import Path
import yaml
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent
CONFIG_FILE = PROJECT_ROOT / "config" / "app.yaml"
PROMPTS_DIR = PROJECT_ROOT / "prompts"
class ConfigError(Exception):
"""Configuration is missing or invalid."""
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ConfigError(f"not a boolean: {value!r}")
def load_yaml(path: Path) -> dict:
if not path.is_file():
raise ConfigError(f"config file not found: {path.relative_to(PROJECT_ROOT).as_posix()}")
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ConfigError(f"{path.name} must contain a mapping at the top level")
return data
def load_prompts(folder: Path) -> dict[str, str]:
return {path.stem: path.read_text(encoding="utf-8").strip() for path in sorted(folder.glob("*.txt"))}
def require_env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise ConfigError(f"missing required environment variable: {name}")
return value
def load_settings() -> dict:
load_dotenv(PROJECT_ROOT / ".env")
config = load_yaml(CONFIG_FILE)
temperature = float(os.getenv("LLM_TEMPERATURE", config["llm"]["temperature"]))
if not 0 <= temperature <= 2:
raise ConfigError(f"LLM_TEMPERATURE must be between 0 and 2, got {temperature}")
debug = config["app"]["debug"]
if "APP_DEBUG" in os.environ:
debug = parse_bool(os.environ["APP_DEBUG"])
return {
"app_name": config["app"]["name"],
"debug": debug,
"model": os.getenv("LLM_MODEL", config["llm"]["model"]),
"temperature": temperature,
"top_k": int(config["retrieval"]["top_k"]),
"api_key": require_env("LLM_API_KEY"),
"prompts": load_prompts(PROMPTS_DIR),
}
def mask(secret: str) -> str:
return secret[:3] + "*" * 8
if __name__ == "__main__":
try:
settings = load_settings()
except ConfigError as exc:
print(f"config error: {exc}")
sys.exit(1)
for key in ["app_name", "debug", "model", "temperature", "top_k"]:
print(f"{key:<12} {settings[key]}")
print(f"{'api_key':<12} {mask(settings['api_key'])}")
print(f"{'prompts':<12} {', '.join(settings['prompts'])}")أربع حالات تشغيل تجريبية — التشغيل العادي، ثم مع تعديل قيمتين من سطر الأوامر، ثم بعد إبعاد ملف .env، وأخيراً مع تمرير درجة حرارة مستحيلة:
uv run settings.py
LLM_TEMPERATURE=0 LLM_MODEL=chat-large uv run settings.py
uv run settings.py
LLM_TEMPERATURE=5 uv run settings.pyapp_name support-bot
debug True
model chat-small
temperature 0.2
top_k 4
api_key sk-********
prompts summarise, system
app_name support-bot
debug True
model chat-large
temperature 0.0
top_k 4
api_key sk-********
prompts summarise, system
config error: missing required environment variable: LLM_API_KEY
config error: LLM_TEMPERATURE must be between 0 and 2, got 5.0(قبل التشغيل الثالث تمت إعادة تسمية ملف .env مؤقتاً لمحاكاة غيابه، وأُعيد قبل التشغيل الرابع). ينتهي التشغيلان الأخيران برمز الخروج 1، بينما ينتهي الأولان برمز 0، مما يتيح لسكريبتات النشر وخوادم CI اكتشاف أخطاء الإعدادات فوراً.
القرارات الهندسية وراء هذا المحمل:
كل المسارات تنطلق من PROJECT_ROOT. يُحسب التعبير Path(__file__).resolve().parent لمرة واحدة وتُبنى عليه سائر المسارات. ومهما كان المجلد الذي بدأ منه تشغيل البرنامج، فإنه سيقرأ نفس الملفات دائماً.
ترتيب أسبقية صريح ومحدد. القيم الافتراضية في YAML أولاً، وتعلوها قيم ملف .env، وتعلو الجميع متغيرات البيئة الحقيقية القادمة من سطر الأوامر أو الخادم. في التشغيل الثاني تغير اسم النموذج ودرجة الحرارة دون تعديل سطر واحد في أي ملف — وهذه هي الطريقة الاحترافية لتغيير الإعدادات في بيئات الإنتاج.
قيمة temperature 0.0 تظل سالمة. يرجع الاستدعاء os.getenv("LLM_TEMPERATURE", ...) إلى قيمة YAML فقط إذا كان المتغير غائباً تماماً — دون استخدام or في أي مكان. وكذلك يفحص debug وجود "APP_DEBUG" in os.environ بدلاً من الاعتماد على القيمة المنطقية للنص.
تحويل الأنواع وفحص النطاقات في مكان موحد. توجد عمليات float() وint() وparse_bool() وفحص النطاق 0 <= temperature <= 2 داخل دالة load_settings. وتستطيع بقية أجزاء البرنامج افتراض أن settings["temperature"] رقم صحيح ومعتمد.
استثناء مخصص ConfigError. تظهر كل مشكلات الإعدادات كنوع خطأ واحد موحد، وتحوله كتلة __main__ إلى رسالة واضحة في سطر واحد تبين القيمة المطلوب تصحيحها بدلاً من عرض تتبع خطأ طويل.
حجب الأسرار عند الطباعة. تظهر دالة mask أول ثلاثة أحرف فقط لتعرف نوع المفتاح المستخدم، دون أن يتسرب المفتاح إلى السجلات. طول القناع ثابت أيضاً بحيث لا يمكن استنتاج أي معلومة من طول المفتاح الأصلي.
قراءة ملفات التعليمات عبر glob. أضف أي ملف جديد بصيغة .txt في مجلد prompts/ وسيظهر تلقائياً في settings["prompts"] دون تعديل الكود. واستُخدمت sorted() لأن ترتيب الملفات العائد من glob يعتمد على نظام التشغيل.
هناك نقطة ضعف واحدة: تعيد دالة load_settings قاموساً عادياً، لذا فإن أي خطأ إملائي مثل settings["tempreature"] لن يُكتشف إلا كخطأ KeyError أثناء التشغيل، كما تتطلب كل خاصية جديدة كتابة كود التحويل والتحقق منها يدوياً. سنتعرف لاحقاً في الدورة على أداة تعلن عن هذه الإعدادات بشكل تلقائي واحترافي؛ لكن المبادئ الأساسية المكتسبة هنا — التحميل في مكان واحد، والتحويل للأنواع الصحيحة، والتوقف عند بدء التشغيل — تظل هي الأساس المتين دائماً.
عند حدوث مشاكل شائعة
FileNotFoundError: [Errno 2] No such file or directory: 'config\\app.json' المسار نسبي وتم تشغيل البرنامج من مجلد آخر. ابنِ المسار انطلاقاً من Path(__file__).resolve().parent. ولمعرفة مجلد العمل الحالي الذي بدأ منه البحث، اطبع Path.cwd().
TypeError: unsupported operand type(s) for /: 'str' and 'str' المعامل "config" / "app.json" يحوي نصوصاً في كلا الطرفين. اجعل الطرف الأيسر كائناً من نوع Path: Path("config") / "app.json".
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 36 (char 35) يحتوي ملف JSON على فاصلة زائدة بعد العنصر الأخير (مثل {"top_k": 4,})، أو مفتاح محاط بعلامات تنصيص مفردة. يقبل بايثون ذلك في قواميسه، لكن مواصفات JSON القياسية تمنعه. يوضح رقم العمود في رسالة الخطأ موضع الخلل بدقة.
TypeError: load() missing 1 required positional argument: 'Loader' كود قديم يستدعي yaml.load(text). استبدله بـ yaml.safe_load(text).
KeyError: 'LLM_API_KEY' قرأ الكود المتغير عبر os.environ["LLM_API_KEY"] والمتغير غير مضبوط. تأكد من وجود ملف .env في جذر المشروع، وأن load_dotenv مرر إليها المسار الصحيح، وأن الاسم مكتوب بنفس الطريقة. لعرض رسالة واضحة للمتغيرات الناقصة، استخدم دالة مثل require_env الموضحة أعلاه.
ModuleNotFoundError: No module named 'dotenv' لم تتم إضافة حزمة python-dotenv للمشروع، أو تم تشغيل البرنامج بدون uv run فلم تُستخدم بيئة المشروع. نفّذ uv add python-dotenv وشغّل البرنامج بواسطة uv run.
الخطوة ٤ من ٦ — التوقع
تحقّق من فهمك
The server has APP_DEBUG=false set (shown here by setting it inside the code). What prints?
import os
os.environ["APP_DEBUG"] = "false"
debug = os.getenv("APP_DEBUG")
if debug:
print("debug on")
else:
print("debug off")- A`debug on`
- B`debug off`
- C`False`
- D`KeyError: 'APP_DEBUG'`
For an evaluation run, the config sets temperature to 0 so the model answers the same way every time. But the answers differ on every run, and there is no error. Where is the bug?
config = {"llm": {"model": "chat-small", "temperature": 0}}
temperature = config["llm"].get("temperature") or 0.7
print(f"calling {config['llm']['model']} at temperature {temperature}")- A`0` is falsy, so `or` discards it and takes `0.7`; it should be `config["llm"].get("temperature", 0.7)`
- B`.get("temperature")` does not work on nested dictionaries; it must be `config["temperature"]`
- CUsing single quotes inside the f-string changes the value of `temperature`
- DThe `0` coming from JSON is really the string `"0"`, so the comparison fails
This code lives in scripts/show_config.py, and the config is at config/app.json in the project root. It works from the root and gives FileNotFoundError from scripts/. What is the best fix?
from pathlib import Path
config_path = Path("config/app.json")
print(config_path.read_text(encoding="utf-8"))- AWrite the path as `"../config/app.json"`
- BKeep a copy of `config/app.json` inside the `scripts/` folder as well
- C`Path(__file__).resolve().parent.parent / "config" / "app.json"` — build the path from the script's own location
- DTell everyone the script must only be run from the project root
اللعب جزء من Pro
+30 XP بانتظارك
العب هذا الفصل مع Pro
القراءة مجانية، والفصل كله متاح. أول 5 فصول من كل دورة مجانية متاحة للعب مجانًا. ابتداءً من الفصل 6، الإجابة عن الأسئلة وكسب نقاط XP وتنمية سلسلتك جزء من Pro.
افتح اللعب مع Proدورك الآن
اكتب محمل إعدادات يدمج إعدادات خاصة ببيئة معينة فوق إعدادات أساسية عامة. أنشئ مشروعاً بالأمر uv init --app --python 3.12 config-lab، ثم نفّذ uv add python-dotenv، ثم أنشئ الملفات التالية:
config/base.json:{"llm": {"model": "chat-small", "temperature": 0.2}, "retrieval": {"top_k": 4}}config/staging.json:{"llm": {"temperature": 0}, "retrieval": {"top_k": 8}}.env: سطر واحد،APP_ENV=staging
اكتب كود ملف main.py بحيث:
- تُبنى كل المسارات انطلاقاً من
Path(__file__).resolve().parent، ليعطي البرنامج نفس النتيجة من أي مجلد - تُقرأ قيمة
APP_ENVمن متغيرات البيئة أو من.env، مع القيمة الافتراضيةdev؛ وبالنسبة لأي بيئة لا يوجد لها ملف (مثلconfig/prod.json) يطبع البرنامجconfig error: no config/prod.jsonويتوقف مع رمز الخروج1 - يُدمج ملفا الإعدادات دمجاً عميقاً (deep merge): بحيث تُدمج القواميس المتداخلة مفتاحاً بمفتاح بدلاً من استبدال القاموس بالكامل، فيبقى المفتاح
modelالقادم منbase.jsonحتى لو لم يكن موجوداً فيstaging.json - تظل قيمة
temperatureالتي تساوي0صفراً كما هي — دون استخدامor
عند تشغيل uv run main.py من جذر المشروع، يجب أن تظهر هذه المخرجات تحديداً:
env staging
model chat-small
temperature 0
top_k 8بعد ذلك اختبر أمرين: أولاً، شغّل APP_ENV=prod uv run main.py وتأكد من توقف البرنامج برسالة واضحة. ثانياً، جرب الدمج السطحي المباشر {**base, **overlay} بدلاً من الدمج العميق واطبع config["llm"] — أين ذهب المفتاح model، ولماذا لم يُظهر سطر الدمج نفسه أي رسالة خطأ؟
الخطوة ٦ من ٦
التحدي — اختبار الفصل
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
العب مع Pro