pathlib, JSON/YAML কনফিগ আর এনভায়রনমেন্ট ভেরিয়েবল
pathlib দিয়ে যেকোনো ফোল্ডার থেকে কাজ করা পাথ, নেস্টেড JSON ও YAML কনফিগ, .env আর এনভায়রনমেন্ট ভেরিয়েবল — আর এমন একটি কনফিগ লোডার, যা ভুল মান পেলে শুরুতেই থামে।
কীভাবে শিখতে চান?
যে সমস্যাটা আমরা সমাধান করছি
একটি সাপোর্ট-বটের প্রজেক্টে scripts/show_config.py নামে ছোট একটি স্ক্রিপ্ট আছে, যা config/app.json পড়ে মডেলের নাম আর টেম্পারেচার দেখায়। লেখক সবসময় প্রজেক্টের মূল ফোল্ডারে দাঁড়িয়ে python scripts/show_config.py চালান, আর সেটি কাজ করে। একদিন একজন সহকর্মী scripts/ ফোল্ডারে ঢুকে একই স্ক্রিপ্ট চালালেন, আর পেলেন FileNotFoundError — ফাইলটি যেখানে ছিল সেখানেই আছে।
একই সপ্তাহে আরও দুটি ঘটনা। সার্ভারে APP_DEBUG=false সেট করা, তবু লগে দেখা গেল ডিবাগ মোড চালু। আর কেউ কনফিগে temperature: 0 লিখলেন যাতে মডেল প্রতিবার একই উত্তর দেয় — কিন্তু প্রোগ্রাম চালাল 0.7 দিয়ে। কোনোটিতেই কোনো ত্রুটি আসেনি।
তিনটি সমস্যাই কোডের যুক্তিতে নয়; সমস্যা হলো প্রোগ্রাম বাইরের জগৎ — ফাইল সিস্টেম, কনফিগ ফাইল, এনভায়রনমেন্ট ভেরিয়েবল — কীভাবে পড়ছে। এই অধ্যায়ে আমরা সেই পড়াটা নির্ভরযোগ্য করব: pathlib দিয়ে পাথ, JSON আর YAML দিয়ে নেস্টেড কনফিগ, আর .env দিয়ে গোপন মান — প্রতিটির সাথে যাচাই, যাতে ভুল কনফিগ চুপচাপ চলে না গিয়ে শুরুতেই থেমে যায়।
এই অধ্যায় শেষে আপনি পারবেন
pathlib.Pathদিয়ে যেকোনো অপারেটিং সিস্টেমে কাজ করা পাথ বানাতে, আরos.path-এর স্ট্রিং জোড়া দেওয়ার বদলে/ব্যবহার করতে- টার্মিনাল কোথায় দাঁড়িয়ে আছে তার উপর নির্ভর না করে, স্ক্রিপ্টের নিজের অবস্থান থেকে ফাইল খুঁজে পেতে
- নেস্টেড JSON আর YAML কনফিগ পড়তে, আর
yaml.safe_load-এর চুপচাপ ধরন বদলানোর ফাঁদগুলো চিনতে - এনভায়রনমেন্ট ভেরিয়েবল আর
.envফাইল থেকে মান পড়ে সঠিক ধরনে রূপান্তর ও যাচাই করতে - একটি কনফিগ লোডার লিখতে, যা ভুল বা অনুপস্থিত মান পেলে পরিষ্কার বার্তা দিয়ে থামে
আগে যা জানা লাগবে: pyproject.toml, লকফাইল আর src/ লেআউট — বিশেষ করে uv add আর uv run।
pathlib: পাথ একটি স্ট্রিং নয়, একটি বস্তু
পুরনো পাইথন কোডে পাথ মানে ছিল স্ট্রিং, আর সেগুলো জোড়া হতো + "/" + বা os.path.join দিয়ে। pathlib মডিউলের Path পাথকে একটি বস্তু হিসেবে দেখে, যার অংশগুলো আলাদা করে পাওয়া যায়:
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 দেখাবে, macOS আর লিনাক্সে config/models/chat.yaml।
স্ট্রিং দিয়ে জোড়া দিলে এই পার্থক্যটি চোখে পড়ে:
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")—openছাড়াই পুরো ফাইল পড়া বা লেখাpath.mkdir(parents=True, exist_ok=True)— ফোল্ডার বানানো, মাঝের ফোল্ডারসহ, আগে থাকলেও ত্রুটি ছাড়াfolder.glob("*.txt")— একটি ফোল্ডারের ভেতরে প্যাটার্ন মেলানো সব ফাইল;rglobসাব-ফোল্ডারেও খোঁজেpath.resolve()— আপেক্ষিক পাথকে সম্পূর্ণ (absolute) পাথে পরিণত করা
encoding="utf-8" প্রতিবার লিখুন। উইন্ডোজে পাইথনের পূর্বনির্ধারিত এনকোডিং অনেক সময় UTF-8 নয়, তাই বাংলা লেখা বা ইমোজি থাকা একটি প্রম্পট ফাইল ম্যাকে ঠিকঠাক পড়া যায় অথচ উইন্ডোজে ভুল অক্ষর বা UnicodeDecodeError দেয়।
আপেক্ষিক পাথ কীসের সাপেক্ষে?
অধ্যায়ের শুরুর স্ক্রিপ্টটি এই রকম:
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) থেকে খোঁজা হয় — টার্মিনাল যেখানে দাঁড়িয়ে আছে সেখান থেকে — স্ক্রিপ্ট ফাইলটি যেখানে আছে সেখান থেকে নয়। scripts/ ফোল্ডারে config/app.json নেই, তাই ফাইলটি পাওয়া যায়নি।
প্রোডাকশনে এটি আরও বেশি ঘটে: একটি ক্রন জব, একটি সার্ভিস ম্যানেজার বা একটি কন্টেইনার আপনার প্রোগ্রাম কোন ফোল্ডার থেকে চালাবে তা আপনি সবসময় জানেন না। সমাধান হলো পাথটিকে স্ক্রিপ্টের নিজের অবস্থানের সাথে বাঁধা। প্রতিটি মডিউলের ভেতরে __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
0JSON-এর false পাইথনে False হয়, সংখ্যা সংখ্যাই থাকে — JSON ধরন বজায় রাখে। কিন্তু শেষ দুটি লাইন দেখুন। কনফিগে পরিষ্কার লেখা "temperature": 0, অথচ প্রথম পদ্ধতিটি দিল 0.7।
x or default মানে «x সত্যবাচক (truthy) হলে x, নইলে 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 strJSON-এর মতো YAML-এ মানের ধরন লিখে দিতে হয় না — পার্সার নিজে আন্দাজ করে। বেশিরভাগ সময় আন্দাজটি ঠিক, কিন্তু দুটি জায়গায় চুপচাপ ভুল হয়। yes, no, on, off বুলিয়ানে পরিণত হয় — 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 ফাইলের ভেতরের নির্দেশে যেকোনো পাইথন অবজেক্ট বানাতে পারে, তাই অবিশ্বস্ত একটি YAML ফাইল পড়া মানে অন্য কারও কোড চালানো। safe_load কেবল সাধারণ ধরন বানায় — ডিকশনারি, লিস্ট, স্ট্রিং, সংখ্যা, বুলিয়ান, None।
এনভায়রনমেন্ট ভেরিয়েবল: সবকিছুই স্ট্রিং
এপিআই কী, ডেটাবেসের ঠিকানা, কোন মডেল ব্যবহার হবে — যেসব মান মেশিন থেকে মেশিনে বদলায় বা যা গোপন রাখতে হয়, সেগুলো কোডে বা কমিট করা কনফিগে নয়, এনভায়রনমেন্ট ভেরিয়েবলে থাকে। পাইথনে সেগুলো পড়া যায় 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 একটি কাল্পনিক মান; কোনো আসল কী কখনো কোনো ডকুমেন্ট, স্ক্রিনশট বা কমিটে দেবেন না।) এটি পড়ার কোড, আবারও স্ক্রিপ্টের অবস্থান থেকে পাথ বানিয়ে:
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/লিনাক্স বা গিট ব্যাশে):
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কখনো কমিট হয় না। এটি.gitignore-এ থাকে। একবার গিট হিস্ট্রিতে একটি কী চলে গেলে ফাইল মুছে দিলেও সেটি হিস্ট্রিতে থেকে যায়; একমাত্র নিরাপদ পথ হলো কীটি বাতিল করে নতুন কী বানানো।.env.exampleকমিট হয়। এতে একই ভেরিয়েবলের নাম থাকে, মান খালি বা নিরীহ। নতুন সহকর্মী সেটি কপি করে.envবানান, আর জানেন কোন কোন মান লাগবে।
একটা সম্পূর্ণ উদাহরণ
এবার সব একসাথে: সাপোর্ট-বটের জন্য একটি কনফিগ লোডার। প্রজেক্টটি 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.lockconfig/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=falseprompts/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 দিয়ে। prompts/ ফোল্ডারে নতুন একটি .txt রাখলেই সেটি settings["prompts"]-এ চলে আসে, কোড না বদলে। sorted() দেওয়া হয়েছে কারণ glob কোন ক্রমে ফাইল দেবে তা অপারেটিং সিস্টেমের উপর নির্ভর করে।
একটি সীমাবদ্ধতা: load_settings একটি সাধারণ ডিকশনারি ফেরত দেয়, তাই settings["tempreature"]-এর মতো বানান ভুল কেবল চলার সময় KeyError হয়ে ধরা পড়ে, আর প্রতিটি নতুন সেটিংয়ের জন্য রূপান্তর-যাচাই হাতে লিখতে হয়। এই কাজটি ঘোষণামূলকভাবে করার একটি টুল কোর্সে সামনে আসছে; এখানে যে নীতিগুলো শিখলেন — এক জায়গায় লোড, সঠিক ধরন, শুরুতেই থামা — সেগুলোই তখনো থাকবে।
কিছু ভাঙা অবস্থা ও তার সমাধান
FileNotFoundError: [Errno 2] No such file or directory: 'config\\app.json' পাথটি আপেক্ষিক, আর প্রোগ্রাম অন্য ফোল্ডার থেকে চালানো হয়েছে। পাথটি Path(__file__).resolve().parent থেকে বানান। কোন ফোল্ডার থেকে খোঁজা হচ্ছে জানতে print(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 দিয়ে চালান।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
সার্ভারে APP_DEBUG=false সেট করা আছে (এখানে কোডের ভেতরে সেট করে দেখানো হলো)। কী ছাপা হবে?
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'`
মূল্যায়নের জন্য কনফিগে temperature 0 রাখা হয়েছে, যাতে মডেল প্রতিবার একই উত্তর দেয়। কিন্তু উত্তরগুলো প্রতিবার আলাদা আসছে, আর কোনো ত্রুটি নেই। ভুলটি কোথায়?
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` মিথ্যাবাচক, তাই `or` সেটিকে বাদ দিয়ে `0.7` নেয়; লিখতে হবে `config["llm"].get("temperature", 0.7)`
- B`.get("temperature")` নেস্টেড ডিকশনারিতে কাজ করে না; `config["temperature"]` লিখতে হবে
- Cf-string-এর ভেতরে একক কোটেশন ব্যবহার করায় `temperature`-এর মান বদলে যায়
- DJSON থেকে আসা `0` আসলে স্ট্রিং `"0"`, তাই তুলনা ব্যর্থ হয়
এই কোড scripts/show_config.py-এ আছে, আর কনফিগটি আছে প্রজেক্টের মূলে, config/app.json-এ। মূল থেকে চালালে কাজ করে, scripts/ থেকে চালালে FileNotFoundError। সবচেয়ে ভালো সমাধান কোনটি?
from pathlib import Path
config_path = Path("config/app.json")
print(config_path.read_text(encoding="utf-8"))- Aপাথটি `"../config/app.json"` লিখে দেওয়া
- B`config/app.json` ফাইলটি `scripts/` ফোল্ডারেও একটি কপি করে রাখা
- C`Path(__file__).resolve().parent.parent / "config" / "app.json"` — স্ক্রিপ্টের নিজের অবস্থান থেকে পাথ বানানো
- Dসবাইকে বলে দেওয়া যে স্ক্রিপ্টটি কেবল প্রজেক্টের মূল থেকে চালাতে হবে
প্লে প্রো-এর অংশ
+30 XP অপেক্ষায়
প্রো দিয়ে এই অধ্যায়টি খেলুন
পড়া ফ্রি, পুরো অধ্যায়টিই খোলা। প্রতিটি ফ্রি কোর্সের প্রথম 5টি অধ্যায় ফ্রিতে খেলা যায়। অধ্যায় 6 থেকে প্রশ্নের উত্তর দেওয়া, XP পাওয়া আর স্ট্রিক বাড়ানো প্রো-এর অংশ।
প্রো দিয়ে প্লে চালু করুননিজে করুন
একটি কনফিগ লোডার লিখুন, যা একটি সাধারণ কনফিগের উপরে পরিবেশ-নির্দিষ্ট আরেকটি কনফিগ বসায়। 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দিয়ে থামে- দুই কনফিগ গভীরভাবে মেলানো হয়: নেস্টেড ডিকশনারি পুরোটা বদলে না গিয়ে কী ধরে ধরে মেলে, তাই
staging.json-এmodelনা থাকলেওbase.json-এরmodelটিকে থাকে temperature0হলে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} ব্যবহার করে print(config["llm"]) ছেপে দেখুন — model কোথায় গেল, আর মেলানোর লাইনটি নিজে কেন কোনো ত্রুটি দিল না?
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
প্রো দিয়ে খেলুন