অধ্যায় 13

কনফিগারেশন আর সতর্কবার্তা — নিয়মগুলো একটি ফাইলে

প্রতিবার একই ফ্ল্যাগ টাইপ না করে pyproject.toml বা pytest.ini-তে প্রজেক্টের নিয়ম লিখে রাখা — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — আর pytest.warns, deprecated_call ও filterwarnings দিয়ে সতর্কবার্তা পরীক্ষা ও নিয়ন্ত্রণ।

55 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

shop প্রজেক্টটি এখন সেই চেহারা নিয়েছে যা বেশিরভাগ বাস্তব প্রজেক্টের থাকে: কোড src/-এ, টেস্ট tests/-এ।

text
shop/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       └── pricing.py
└── tests/
    └── test_pricing.py

একে ঠিকভাবে চালাতে আপনি শিখেছেন, আর «ঠিকভাবে» এখন একটি লম্বা লাইন হয়ে দাঁড়িয়েছে:

text
PYTHONPATH=src pytest -ra --strict-markers tests

একজন সহকর্মী রিপোজিটরিটি clone করে সেটাই টাইপ করেন যা যে কেউ করত:

text
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error

==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_pricing.py:1: in <module>
    from shop.pricing import line_total
E   ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================

কোডে কোনো ভুল নেই। ভুলটা হলো, টেস্ট চালানোর নিয়মটি প্রজেক্টের ভেতরে নেই — আছে আপনার মাথায় আর shell history-তে। CI চালাবে তৃতীয় আরেকটি রূপ, আর একই commit থেকে তিনজন মানুষ তিন রকম ফল দেখবেন।

ওই flag-গুলো আপনার ব্যক্তিগত পছন্দ নয়; সেগুলো প্রজেক্টের নিয়ম। এই অধ্যায় সেগুলোকে এমন একটি ফাইলে সরিয়ে নেয় যা pytest প্রতিবার পড়ে — তারপর সেই একই ফাইল দিয়ে দ্বিতীয়, আরও নীরব একটি সমস্যার মীমাংসা করে: যে warning-গুলো স্ক্রিন দিয়ে গড়িয়ে চলে যায়, কেউ কখনো পড়ে না।

এই অধ্যায় শেষে আপনি পারবেন

  • pytest-এর সেটিং pyproject.toml-এ (বা pytest.ini-তে) রাখতে, আর header দেখে নিশ্চিত হতে কোন ফাইলটি ব্যবহার হয়েছে
  • testpaths, addopts, pythonpath, minversion, markers আর xfail_strict ভেবেচিন্তে বেছে নিতে, আর কেন — তা বলতে
  • কোনো run চোখে চোখে দেখতে চাইলে log_cli দিয়ে live logging চালু করতে
  • pytest.warns আর pytest.deprecated_call দিয়ে assert করতে যে কোড warning দেয়, match আর রেকর্ড করা তালিকা ব্যবহার করে
  • config-এ filterwarnings দিয়ে, mark হিসেবে আর -W দিয়ে warning নিয়ন্ত্রণ করতে — আর CI-তে সেগুলোকে error বানাতে

আগে যা জানা লাগবে: মার্কার, skip আর xfail।


টেস্ট লেখার আগে

এই অধ্যায়ে অন্য সময়ের চেয়ে টেস্ট কম লেখা হবে, আর বেশি লেখা হবে টেস্ট কীভাবে চলে তার নিয়ম। তবে আগের ভাবনাটা একই: কী প্রতিশ্রুতি দেওয়া হচ্ছে তা ঠিক করুন, তারপর সেই প্রতিশ্রুতি যাচাই করার কেসগুলো পরিকল্পনা করুন।

কী প্রতিশ্রুতি দেওয়া হচ্ছে। এখানে দুই ধরনের প্রতিশ্রুতি আছে।

  • Suite কীভাবে চলে। যে কেউ শুধু pytest টাইপ করলে CI-এর মতোই একই টেস্ট, একই import path আর একই কড়াকড়ি পাবেন। বানান-ভুল marker run থামিয়ে দেয়। যে xfail পাস করতে শুরু করে, সেটি জানানো হয়, লুকানো হয় না।
  • Return value ছাড়াও কোড যা বলে। apply_discount(total, percent) একটি সংখ্যা ফেরত দেয়, আর percent > 50 হলে সাথে একটি UserWarning-ও দেয়, যেখানে বলা থাকে ছাড়টি "looks unusually large"। total_with_vat() এখনো কাজ করে, কিন্তু একটি DeprecationWarning দেয় যা line_total()-এর দিকে ইঙ্গিত করে। Warning-ও একরকম output; যখন সেটি চুক্তির অংশ, তখন output-এর মতোই তার টেস্ট হয়।

কী প্রস্তুত থাকতে হবে। pytest 9 সহ একটি virtual environment (pytest --version), টেস্ট থেকে import করা যায় এমন কোড (নিচের pythonpath সেটাই), আর প্রজেক্টের root-এ একটি config ফাইল। বাড়তি কোনো plugin লাগবে না।

warning-এর আচরণের পরিকল্পনা:

| কেস | ইনপুট | প্রত্যাশিত | | --- | --- | --- | | স্বাভাবিক পথ | apply_discount(200.0, 10) | 180.0, কোনো warning নেই | | সীমানা, এখনো নীরব | apply_discount(200.0, 50) | 100.0, কোনো warning নেই | | সীমানার ঠিক পরে | apply_discount(200.0, 60) | 80.0 আর একটি UserWarning, যাতে "unusually large" আছে | | deprecated নাম | total_with_vat(100.0, 1) | 115.0 আর একটি DeprecationWarning, যাতে line_total আছে | | কোনো library-র নিজস্ব deprecation | যা কিছু সেটি ঘটায় | আমাদের suite ফেল করায় না |

Configuration-এর ক্ষেত্রে «টেস্ট» হলো একটি run: root থেকে আর tests/-এর ভেতর থেকে — দুই জায়গাতেই শুধু pytest চালালে কেবল tests/ collect হতে হবে আর পাস করতে হবে, আর বানান-ভুল marker run থামিয়ে দিতে হবে।

কী টেস্ট করবেন না। পাইথনের warnings মডিউল কাজ করে কি না, বা pytest তার config পড়ে কি না — সেটি অন্য কারও কোড, যার টেস্ট আগেই হয়েছে। warning-এর পুরো লেখাও নয়: যে শব্দগুচ্ছ দেখে পাঠক পদক্ষেপ নেন ("unusually large"), শুধু সেটি মেলান, যাতে বাকিটা নতুন করে লিখলেও কিছু না ভাঙে। আর তৃতীয় পক্ষের library-র deprecation-ও নয় — সেগুলো filter করা হয়, আর সেটি configuration-এর সিদ্ধান্ত, টেস্ট নয়।

সীমানার সারিটিই মানুষ ভুলে যায়, কারণ warning না থাকলে কোনো শব্দ হয় না। অধ্যায়ের শেষে config-এর একটি লাইন এটিকে এমন একটি যাচাইয়ে পরিণত করবে যা আপনি বিনা খরচে পান।

একটি ফাইল, যা সবকিছুর আগে পড়া হয়

একটি টেস্টও collect করার আগে pytest তার সেটিং পড়ে নেয়। সাধারণত এর ঠিকানা প্রজেক্টে আগে থেকেই থাকা pyproject.toml, [tool.pytest.ini_options] নামের একটি table-এ:

toml
[project]
name = "shop"
version = "0.1.0"

[tool.pytest.ini_options]
pythonpath = ["src"]

pythonpath-এ থাকে কয়েকটি ডিরেক্টরি, প্রজেক্টের root-এর সাপেক্ষে, যেগুলো pytest টেস্ট import করার আগে sys.path-এ যোগ করে। যে কোডের টেস্ট হচ্ছে, সেটি সাধারণ:

python
def line_total(price, quantity):
    if quantity < 1:
        raise ValueError("quantity must be at least 1")
    return round(price * quantity * 1.15, 2)
text
$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items

tests/test_pricing.py ..                                                 [100%]

============================== 2 passed in 0.01s ===============================

header-টি পড়ুন। configfile: pyproject.toml হলো pytest-এর জানানো যে সে কোন ফাইল ব্যবহার করেছে। যখনই মনে হবে সেটিং উপেক্ষা করা হচ্ছে, আগে এই লাইনটি দেখুন।

import-টা টেস্ট ফাইলের ভেতরেই ঠিক করে দিলে কী সমস্যা? এটিই সবচেয়ে চোখে-পড়া ভুল পথ, আর এটি কাজও করে — মাঝে মাঝে:

python
import sys

sys.path.insert(0, "src")  # relative to wherever pytest was started

from shop.pricing import line_total


def test_three_items():
    assert line_total(15.0, 3) == 51.75
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E   ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

ওখানে "src" বর্তমান ডিরেক্টরির সাপেক্ষে, তাই আপনি কোথায় দাঁড়িয়ে আছেন তার উপর নির্ভর করে টেস্টটি পাস করে বা ভাঙে। config-এর pythonpath প্রজেক্টের root-এর সাপেক্ষে, যা নড়ে না। import-এর ব্যবস্থা প্রজেক্টের বিষয়, তাই সেটি একবারই প্রজেক্টের ফাইলে যায়, প্রতিটি টেস্ট মডিউলের মাথায় নয়।

কোন ফাইল জেতে, আর root কোথায়

pytest কয়েকটি ফাইল মেনে নেয়। প্রতিটি ডিরেক্টরিতে সে এই ক্রমে খোঁজে: pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg। শুরু করে command line-এ দেওয়া path থেকে (বা বর্তমান ডিরেক্টরি থেকে), আর এগোয় উপরের দিকে; যে প্রথম ফাইলে সত্যিই একটি pytest section আছে, সেটিই জেতে। pytest table ছাড়া একটি pyproject.toml গোনায় ধরা হয় না।

সেই ডিরেক্টরিটিই হয় rootdir — header-এর rootdir: লাইন — আর config-এর relative path-গুলো তার সাপেক্ষে। তাই tests/-এর ভেতরে দাঁড়িয়েও root খুঁজে পাওয়া যায়:

text
$ cd tests && pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items

একই সেটিং pytest.ini-তে লিখতে হয় INI syntax-এ, একটি [pytest] section-এ, কোনো উদ্ধৃতিচিহ্ন বা বন্ধনী ছাড়া:

ini
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
    slow: takes more than a second; deselect with -m 'not slow'

একটি ফাইল বেছে নিন। দুটোই থাকলে pytest.ini পুরোপুরি জেতে — কিছুই মেশানো হয় না — আর header সেটা বলে দেয়। এখানে pyproject.toml-এর পাশে দুই লাইনের একটি pytest.ini (minversion = 9.0) যোগ করা হয়েছে:

text
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error

…আর ModuleNotFoundError ফিরে এসেছে, কারণ pythonpath ছিল সেই ফাইলে যেটি এখন উপেক্ষিত।

testpaths — কিছু না বললে কোথায় খুঁজবে

কেউ rounding নিয়ে পরীক্ষা করতে গিয়ে একটি খসড়া ফাইল রেখে গেছেন, notes/test_scratch.py। শুধু pytest চালালে root-এর নিচের সবকিছু collect হয়, তাই এটিও চলে:

text
$ pytest -q --tb=no
F..                                                                      [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01s

testpaths বলে দেয় আসল suite কোথায় থাকে:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.01s

পুরো header-এ এখন testpaths: tests-ও দেখতে পাবেন। এটি একটি default, বেড়া নয়: শুধু তখনই খাটে যখন আপনি root থেকে কোনো path না দিয়ে চালান, তাই pytest notes এখনো খসড়া ফাইলটি চালায়। বড় রিপোজিটরিতে এটি collection-ও দ্রুত করে, কারণ pytest আর docs/, build/ বা virtual environment-এর ফোল্ডারে ঘুরে বেড়ায় না।

addopts আর markers — যে নিয়ম সবাই পায়

addopts হলো এমন লেখা যা pytest আপনার টাইপ করা সবকিছুর আগে বসিয়ে দেয়। শুরুর অংশের প্রজেক্টের দুটি নিয়ম এখানেই যায়:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"

-ra প্রতিটি টেস্টের জন্য একটি ছোট সারাংশ-লাইন ছাপে যেটি সোজাসুজি পাস করেনি — skipped, xfailed, xpassed, failed — তাই কোনো skip কখনো চোখ এড়াতে পারে না। --strict-markers রেজিস্টার-না-করা marker-কে error বানায়। নতুন একটি ফাইল, tests/test_discounts.py, যাতে একটি টেস্ট @pytest.mark.slow দিয়ে চিহ্নিত (আর একটি Windows ছাড়া অন্য জায়গায় skip হয়), আর marker-টি এখনো রেজিস্টার করা হয়নি:

text
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

flag-টির উদ্দেশ্যই এটা। এটি না থাকলে @pytest.mark.slwo-এর মতো একটি বানান-ভুল সারাংশে শুধু একটি PytestUnknownMarkWarning পায়, run সবুজই থাকে, আর -m "not slow" সেই টেস্টটি চালাতেই থাকে যেটি আপনি বাদ দিতে চেয়েছিলেন। marker রেজিস্টার করুন, প্রতিটির জন্য এক লাইন, এমন একটি বর্ণনাসহ যা pytest --markers দেখাবে:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
]
text
$ pytest -q
.s..                                                                     [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'

SKIPPED লাইনটি -ra-এর কাজ: এখন প্রতিটি run-এ একটি skip নিজেই তার কারণ জানায়।

addopts-এ যা থাকা উচিত নয়। ভুল পথটি হলো addopts = "-v -x --pdb"। ওগুলো আজকের debugging-এর জন্য আপনার অভ্যাস; শেয়ার করা ফাইলে রাখলে সেগুলো সবার উপর চাপে, CI-সহ — যেখানে --pdb এমন একটি কীবোর্ডের জন্য অনন্তকাল অপেক্ষা করে যার সামনে কেউ নেই। addopts-এ শুধু প্রজেক্টের নিয়ম রাখুন। আপনি যা টাইপ করেন তা এর পরে যোগ হয়, তাই pytest -x শুধু আপনার জন্য এখনো কাজ করে, আর -o এক run-এর জন্য যেকোনো সেটিং বদলে দেয়:

text
$ pytest -q -o addopts=""
.s..                                                                     [100%]
3 passed, 1 skipped in 0.01s

এবার কোনো skip লাইন নেই, কারণ ওই run-এর জন্য -ra বাদ পড়েছে।

minversion আর xfail_strict

কারও pytest ফাইলটির জন্য খুব পুরোনো হলে minversion সঙ্গে সঙ্গে run থামিয়ে দেয়। এমন একটি version চাইলে যা এখনো নেই, বার্তাটি দেখা যায়:

text
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'

শুরুতেই একটি স্পষ্ট error, পুরোনো একটি pytest সেটিং অর্ধেক বুঝে চলার চেয়ে ভালো।

xfail_strict গত অধ্যায়ের একটি খোলা প্রান্তের মীমাংসা করে: যে xfail পাস করতে শুরু করে। এখানে একটি বাগকে প্রত্যাশিত-ফেল হিসেবে চিহ্নিত করা হয়েছিল, আর তারপর কেউ সেটি ঠিক করে ফেলেছেন:

python
import pytest


def refund(amount):
    return round(amount * 0.9, 2)


@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
    assert refund(10.0) == 9.0
text
$ pytest -q
X                                                                        [100%]
1 xpassed in 0.01s

একটি সবুজ run, একটি X যা কেউ পড়ে না, আর একটি marker যা এখন মিথ্যা বলছে। config-এ xfail_strict = true থাকলে:

text
$ pytest -q
F                                                                        [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01s

সুখবরটি এখন আসে একটি failure হিসেবে, যা আপনাকে marker-টি সরাতে বলে — এ কারণেই এটি টেস্ট ধরে ধরে নয়, গোটা প্রজেক্টের জন্য সেট করা সার্থক। pytest 9.1 নতুন নাম strict_xfail-ও মেনে নেয়, আর একটিমাত্র strict = true-ও, যা একবারে সব কড়াকড়ির অপশন চালু করে (markers আর xfail-সহ); xfail_strict আগের মতোই কাজ করে।

Live log: log_cli

ডিফল্টভাবে pytest log record ধরে রাখে আর শুধু ফেল করা টেস্টের জন্য দেখায়। একটি পাস করা run-এর পুরো কাহিনি দেখতে চাইলে live logging চালু করুন:

toml
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"

তখন প্রতিটি টেস্টের INFO আর তার উপরের record চলার সময়েই একটি live log call শিরোনামের নিচে ছাপা হয়; নিচের পূর্ণাঙ্গ উদাহরণে সেটি দেখা যাবে। Live log বেশ কোলাহলপূর্ণ, তাই অনেক প্রজেক্ট ফাইলে শুধু log_cli_level রাখে, আর দরকার হলে pytest -o log_cli=true দিয়ে output চালু করে।

pytest 9-এর native table: [tool.pytest]

[tool.pytest.ini_options]-এর অদ্ভুত নামটির একটি কারণ আছে: এটি একটি TOML ফাইলে বসে থাকা INI সেটিং, তাই প্রতিটি মান লেখায় রূপান্তরিত হয়। pytest 9 একটি native table যোগ করেছে, [tool.pytest], যেখানে মানগুলো তাদের TOML ধরন রাখে — আসল list, আসল boolean:

toml
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true
text
$ pytest -q
.s..                                                                     [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s

addopts এখন একটি list। দুটি নিয়ম: একটি table বা অন্যটি ব্যবহার করুন, একই ফাইলে কখনো দুটো নয় (pytest শুরু করতেই রাজি হয় না), আর মনে রাখুন 9-এর আগের pytest [tool.pytest] একেবারেই পড়ে না। এই অধ্যায় [tool.pytest.ini_options] রাখছে, কারণ আপনি যত pytest দেখতে পারেন তার সবগুলোতে এটি কাজ করে; যে প্রজেক্ট শুধু pytest 9 চালায়, সেখানে native table-ই সুপারিশকৃত রূপ। pytest 9 একই native ধাঁচে [pytest] table-সহ একটি আলাদা pytest.toml-ও পড়ে।

Cache, সংক্ষেপে

একটি run-এর পর root-এ একটি .pytest_cache/ ফোল্ডার থাকে। সেটি মনে রাখে গতবার কোন টেস্টগুলো ফেল করেছিল, আর এটিই --lf (last failed) আর --ff (failed first)-কে চালায়। দুটি flag জেনে রাখার মতো। pytest --cache-clear run-এর আগে এটি খালি করে — কাজে লাগে যখন --lf বারবার এমন টেস্ট বাছে যা আর নেই। pytest -p no:cacheprovider cache plugin-টি পুরোপুরি বন্ধ করে দেয়, ফলে কিছুই লেখা হয় না, যা read-only checkout-এর জন্য মানানসই। মূল্য হলো, plugin-এর অপশনগুলোও তার সাথে উধাও হয়ে যায়:

text
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
  inifile: /home/you/mv/pytest.ini
  rootdir: /home/you/mv

Warning: run-এর একেবারে নিচের অংশ

warning হলো এমন একটি বার্তা যে কিছু একটা এখনো ভুল হয়নি। পাইথন সেটি ছাপে আর কাজ চালিয়ে যায়:

python
import warnings


def line_total(price, quantity):
    return round(price * quantity * 1.15, 2)


def total_with_vat(price, quantity):
    warnings.warn(
        "total_with_vat() is deprecated; use line_total()",
        DeprecationWarning,
        stacklevel=2,
    )
    return line_total(price, quantity)


def apply_discount(total, percent):
    if percent > 50:
        warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
    return round(total * (100 - percent) / 100, 2)
text
$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
  warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0

stacklevel=2 deprecation-কে caller-এর লাইনের দিকে নির্দেশ করায়, অর্থাৎ সেই লাইন যা কাউকে বদলাতে হবে। টেস্ট চলার সময় ওঠা warning-গুলো pytest ধরে ফেলে আর শেষে একটি অংশে জড়ো করে:

python
from pricing import total_with_vat


def test_old_name_still_works():
    assert total_with_vat(100.0, 1) == 115.0
text
$ pytest -q
.                                                                        [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
  /home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
    assert total_with_vat(100.0, 1) == 115.0

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01s

টেস্টটি পাস করেছে, আর শেষ লাইন বলছে 1 passed, 1 warning। বড় suite-এ এই অংশ বেড়ে কয়েক ডজন entry-তে দাঁড়ায়, সবাই এটি পেরিয়ে স্ক্রল করতে শিখে যায়, আর একদিন deprecated ফাংশনটি সরিয়ে ফেলা হয় আর «পাস করা» suite ভেঙে পড়ে। একটি warning নিয়ে আপনি দুটি কাজ করতে পারেন: যখন সেটি আচরণের অংশ, তখন সেটি প্রত্যাশা করা, আর যখন নয়, তখন সেটি প্রত্যাখ্যান করা।

pytest.warns — একটি warning প্রত্যাশা করা

যখন একটি warning চুক্তির অংশ — পরিকল্পনার «সীমানার ঠিক পরে» সারিটি — তখন এর টেস্ট সেভাবেই করুন যেভাবে pytest.raises দিয়ে exception-এর টেস্ট করেন:

python
import pytest

from pricing import apply_discount


def test_large_discount_warns():
    with pytest.warns(UserWarning, match="unusually large"):
        result = apply_discount(200.0, 60)
    assert result == 80.0


def test_small_discount_is_quiet():
    assert apply_discount(200.0, 10) == 180.0
text
$ pytest -q test_discount.py
..                                                                       [100%]
2 passed in 0.01s

block-টি পাস করে যখন এর ভেতরে অন্তত একটি UserWarning (বা তার subclass) ওঠে এবং তার বার্তা match-এর সাথে মেলে — match একটি regular expression, যা লেখার ভেতরে খোঁজা হয়, ঠিক pytest.raises-এর মতো। দুটো অর্ধেকই জোরালোভাবে ফেল করে। এখানে কোড 50-এ নীরব, আর দ্বিতীয় টেস্টটি ভুল শব্দ খুঁজছে:

python
import pytest

from pricing import apply_discount


def test_fifty_percent_warns():
    with pytest.warns(UserWarning):
        apply_discount(200.0, 50)


def test_wrong_message():
    with pytest.warns(UserWarning, match="too large"):
        apply_discount(200.0, 60)
text
$ pytest -q test_discount_bad.py
FF                                                                       [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________

    def test_fifty_percent_warns():
>       with pytest.warns(UserWarning):
E       Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E        Emitted warnings: [].

test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________

    def test_wrong_message():
>       with pytest.warns(UserWarning, match="too large"):
E       Failed: Regex pattern did not match any of the 1 warnings emitted.
E        Regex: 'too large'
E        Emitted warnings: [UserWarning('discount of 60% looks unusually large')].

test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
  /home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
    warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01s

দুটি রিপোর্টেই সেই warning-গুলোর তালিকা থাকে যেগুলো সত্যিই উঠেছিল, যা সাধারণত টেস্ট ঠিক করার জন্য যথেষ্ট। যে warning মেলেনি, সেটিও গিলে ফেলা হয় না; সেটি warnings summary-তে চলে যায়।

কেন সবসময় match দেবেন। শুধু pytest.warns(UserWarning) হলো দুর্বল রূপ: block-এর যেকোনো জায়গা থেকে যেকোনো UserWarning এটিকে সন্তুষ্ট করে — এমনকি কোনো library থেকে আসা একটিও, যেটি আপনি সম্পর্কহীন কারণে ডাকছেন। আপনার আসল warning-টি চলে যাওয়ার পরেও টেস্টটি পাস করে যেতে পারে। একটি মেলানো শব্দগুচ্ছ টেস্টটিকে এই warning-এর সাথে বেঁধে দেয়।

কতগুলো warning উঠল, বা সেগুলো কী বলল, তা যাচাই করতে as দিয়ে রেকর্ডটি রেখে দিন:

python
import pytest

from pricing import apply_discount


def test_each_large_discount_warns_once():
    with pytest.warns(UserWarning) as record:
        apply_discount(100.0, 60)
        apply_discount(100.0, 10)
        apply_discount(100.0, 75)

    assert len(record) == 2
    print(record[0].message)
    print(record[1].category.__name__, record[1].lineno)
text
$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01s

record-এ block-এর ভেতরে ধরা পড়া প্রতিটি warning-এর তালিকা থাকে — এখানে দুটি, কারণ 10%-এর call নীরব। প্রতিটি entry-তে আছে .message, .category, .filename আর .lineno।

pytest.deprecated_call — আপনার ব্যবহারকারীদের প্রতি একটি প্রতিশ্রুতি

একটি ফাংশন deprecate করা মানে একটি প্রতিশ্রুতি: «এটি এখনো কাজ করে, আর আপনাকে জানানো হয়েছে»। pytest.deprecated_call() দুটো অর্ধেকই যাচাই করে:

python
import pytest

from pricing import total_with_vat


def test_old_name_still_works():
    with pytest.deprecated_call():
        assert total_with_vat(100.0, 1) == 115.0
text
$ pytest -q test_old_api.py
.                                                                        [100%]
1 passed in 0.01s

এটি আসলে pytest.warns, যেখানে category-গুলো আগে থেকেই বসানো — DeprecationWarning, PendingDeprecationWarning আর FutureWarning — আর এটিও match নেয়। warnings summary-র entry-টি এখন নেই: warning-টি প্রত্যাশিত ছিল, তাই সেটি খরচ হয়ে গেছে।

filterwarnings — warning প্রত্যাখ্যান করা

প্রত্যাশিত warning-গুলো এখন টেস্ট। বাকিগুলো হয় কোলাহল, নয়তো আগাম সতর্কসংকেত, আর CI-তে যে অভ্যাসটি গ্রহণ করার মতো তা হলো: প্রতিটি অপ্রত্যাশিত warning একটি error। এক লাইন:

toml
[tool.pytest.ini_options]
filterwarnings = [
    "error",
]

এখানে একটি suite, যাতে একটি পরিষ্কার টেস্ট, একটি যা এখনো deprecated নামটি ডাকে, আর একটি যা এমন একটি library থেকে currency.rate() ডাকে যা আপনি বদলাতে পারেন না, আর সেটি নিজের একটি DeprecationWarning দেয়:

python
from currency import rate
from pricing import line_total, total_with_vat


def test_line_total():
    assert line_total(100.0, 1) == 115.0


def test_old_name():
    assert total_with_vat(100.0, 1) == 115.0


def test_rate():
    assert rate("USD") == 1.0
text
$ pytest -q --tb=short
.FF                                                                      [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
    assert total_with_vat(100.0, 1) == 115.0
           ^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
    warnings.warn(
E   DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
    assert rate("USD") == 1.0
           ^^^^^^^^^^^
currency.py:6: in rate
    warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E   DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01s

প্রতিটি warning এখন একটি exception হিসেবে ওঠে, সাথে যে লাইন সেটি ঘটিয়েছে সেখান পর্যন্ত traceback। ঢালাও নীরবতার বদলে প্রতিটি failure একটি সিদ্ধান্ত পায়।

ভুল পথটি হলো "ignore::DeprecationWarning": এক লাইন, সব সবুজ, আর library-রটার সাথে আপনার নিজের deprecation-ও লুকিয়ে গেল। সঠিক পথটি হলো প্রতিটি ব্যতিক্রমকে সরু করা। একটি filter-এর আকার action:message:category:module — পাইথনের নিজের -W অপশনের মতোই — যেখানে message warning-এর লেখার শুরুর সাথে মেলে, আর module হলো সেই মডিউল যার নামে warning-টি ধরা হয়:

toml
[tool.pytest.ini_options]
filterwarnings = [
    "error",
    "ignore::DeprecationWarning:currency",
]

ক্রম গুরুত্বপূর্ণ: পরের লাইন অগ্রাধিকার পায়, তাই error যায় প্রথমে আর ব্যতিক্রমগুলো তার পরে। library-র warning এখন উপেক্ষা করা হয় শুধু তখনই যখন সেটি currency থেকে আসে। একটিমাত্র টেস্টের জন্য একই filter একটি mark হিসেবেও লাগানো যায় — এখানে test_old_name-এ, ফাইলের বাকিটা অপরিবর্তিত:

python
import pytest

from pricing import total_with_vat


@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
    assert total_with_vat(100.0, 1) == 115.0
text
$ pytest -q
...                                                                      [100%]
3 passed in 0.01s

ওই mark শুধু warning-টিকে অনুমতি দেয়। যদি আপনার বক্তব্য হয় «এটিকে warning দিতেই হবে», তাহলে pytest.deprecated_call() বেশি জোরালো পছন্দ, কারণ warning উধাও হলে সেটি ফেলও করে। যে warning আপনি সহ্য করেন তার জন্য mark, আর যে warning-এর প্রতিশ্রুতি দেন তার জন্য warns।

একই filter এক run-এর জন্য -W দিয়েও দেওয়া যায়, যা কোনো নীতি লিখে ফেলার আগে পরখ করার জন্য সুবিধাজনক। আগের test_old_api.py-তে, কোনো config ছাড়াই:

text
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F                                                                        [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01s

উৎসগুলো একমত না হলে সবচেয়ে নির্দিষ্টটি জেতে: টেস্টের উপরের mark -W-কে হারায়, আর -W config ফাইলকে হারায়। তিন-টেস্টের suite-এ -W config-এর currency-র জন্য দেওয়া ignore-কে অগ্রাহ্য করে, কিন্তু চিহ্নিত টেস্টটি তার ছাড় ধরে রাখে:

text
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F                                                                      [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01s

error আপনাকে আরও একটি জিনিস এনে দেয়। পরিকল্পনার সীমানার সারিটির দিকে আবার তাকান, «50-এ কোনো warning নেই»। config-এ error থাকলে apply_discount(200.0, 50)-এর একটি সাধারণ টেস্ট ইতিমধ্যেই এটি যাচাই করে: ওই call কখনো warning দিলে সেটি একটি exception হতো আর টেস্টটি ফেল করত। নীরব কেসগুলোর জন্য বিশেষ কোনো কোড লাগে না।


একটি পূর্ণাঙ্গ উদাহরণ

সব নিয়ম এক জায়গায় নিয়ে shop প্রজেক্ট। pyproject.toml:

toml
[project]
name = "shop"
version = "0.1.0"

[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
    "error",
]
log_cli_level = "INFO"

src/shop/pricing.py:

python
import logging
import warnings

log = logging.getLogger(__name__)


def line_total(price, quantity):
    if quantity < 1:
        raise ValueError("quantity must be at least 1")
    return round(price * quantity * 1.15, 2)


def total_with_vat(price, quantity):
    warnings.warn(
        "total_with_vat() is deprecated; use line_total()",
        DeprecationWarning,
        stacklevel=2,
    )
    return line_total(price, quantity)


def apply_discount(total, percent):
    if percent > 50:
        warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
    log.info("discount %d%% on %.2f", percent, total)
    return round(total * (100 - percent) / 100, 2)
text
$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0

tests/test_pricing.py — অধ্যায়ের শুরুর পরিকল্পনা, সারি ধরে ধরে:

python
import pytest

from shop.pricing import apply_discount, line_total, total_with_vat


def test_line_total():
    assert line_total(100.0, 1) == 115.0


def test_old_name_is_deprecated():
    with pytest.deprecated_call(match="use line_total"):
        assert total_with_vat(100.0, 1) == 115.0


def test_large_discount_warns():
    with pytest.warns(UserWarning, match="unusually large") as record:
        assert apply_discount(200.0, 60) == 80.0
    assert len(record) == 1


def test_fifty_percent_is_quiet():
    assert apply_discount(200.0, 50) == 100.0


@pytest.mark.slow
def test_many_lines():
    total = sum(line_total(1.0, 1) for _ in range(10_000))
    assert round(total, 2) == 11500.0


@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
    assert line_total(15.0, 0) == 0.0
text
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items

tests/test_pricing.py .....x                                             [100%]

=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected

tests/test_pricing.py::test_line_total PASSED                            [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED                [ 40%]
tests/test_pricing.py::test_large_discount_warns 
-------------------------------- live log call ---------------------------------
INFO     shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED                                                                   [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet 
-------------------------------- live log call ---------------------------------
INFO     shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED                                                                   [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]

=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================

তিনটি জিনিস লক্ষ করার মতো।

প্রথমত, দুটি কমান্ডের কোনোটিতেই প্রজেক্টের কোনো নিয়ম নেই। নিয়মগুলো — টেস্ট কোথায়, কী import করা যায়, কড়া marker, কড়া xfail, warning মানে error — সবই ফাইলে, তাই যে সহকর্মী শুধু pytest টাইপ করেন তিনি হুবহু প্রথম run-টিই পান, আর CI-ও তাই পায়।

দ্বিতীয়ত, কোনো warnings summary নেই। কোড যে দুটি warning দেয়, দুটোই প্রত্যাশিত, তাই warns আর deprecated_call সেগুলো খরচ করে ফেলেছে; অন্য যেকোনো warning কোনো একটি টেস্ট ফেল করাত। একটি পরিষ্কার run এখন সত্যিই পরিষ্কার।

তৃতীয়ত, test_fifty_percent_is_quiet-এ warning-সংক্রান্ত কোনো কোড নেই, তবু এটি সীমানা পাহারা দেয়: error-এর অধীনে 50%-এ একটি warning উঠলে এটি ফেল করত।


যখন ভাঙে

ModuleNotFoundError: No module named 'shop' during collection টেস্ট থেকে কোডটি import করা যাচ্ছে না। config-এ pythonpath = ["src"] যোগ করুন, আর header-এর configfile: লাইনটি দেখে নিশ্চিত হোন যে পড়া হচ্ছে সেই ফাইলটিই।

configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) দুটি config ফাইল, আর আপনি যেটি সম্পাদনা করেছেন সেটি হেরেছে। কিছুই মেশানো হয় না। একটি মুছে ফেলুন।

'slow' not found in markers configuration option --strict-markers (বা strict = true) চালু আছে, আর marker-টি রেজিস্টার করা নেই বা বানান ভুল। markers-এর নিচে রেজিস্টার করুন, বা বানান ঠিক করুন।

PytestConfigWarning: Unknown config option: testpath একটি সেটিংয়ের বানান ভুল — testpaths-এর জায়গায় testpath। pytest শুধু warning দেয়, তাই সেটিংটি নীরবে কিছুই করে না। strict_config = true (বা strict = true) থাকলে এটি হয়ে যায় ERROR: Unknown config option: testpath আর run থেমে যায়, আপনি ঠিক সেটাই চান।

ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml-এ দুটো table-ই আছে। সবকিছু একটিতে সরিয়ে নিন।

TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' native [tool.pytest] table-এ addopts একটি list: addopts = ["-ra", "--strict-markers"]। string রূপটি [tool.pytest.ini_options]-এর জন্য।

ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' ইনস্টল করা pytest প্রজেক্টের প্রয়োজনের চেয়ে পুরোনো। virtual environment-এর ভেতরে এটি আপগ্রেড করুন।

Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. block-এর ভেতরে ওই category-র কিছুই ওঠেনি। নিচের Emitted warnings: পড়ুন — প্রায়ই দেখা যায় কোড অন্য category দিয়ে warning দেয়, বা এই ইনপুটে দেয়ই না।

Failed: Regex pattern did not match any of the 1 warnings emitted. category ঠিক, লেখা ভুল। match একটি regular expression, তাই (, . আর ? escape করতে হয় — বা লেখাটিকে re.escape()-এ মুড়ে দিন।

AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? একটি filterwarnings লাইনের category-তে টাইপো। pytest শুরু করতেই রাজি হয় না, আর ঠিক উপরে সেই লাইনটি ছাপে যেটি সে parse করতে পারেনি।