2025, Oct 05 03:31

Generic TypeVar के साथ pattern matching: mypy में exhaustive जाँच क्यों विफल होती है और क्या करें

mypy में BaseModel-bound TypeVar के साथ pattern matching exhaustive क्यों नहीं मानी जाती, और समाधान: सीमित union, assert_never या type: ignore कब अपनाएँ.

जब किसी जेनेरिक क्लास को ऐसे TypeVar से पैरामीटराइज़ किया जाता है, जो BaseModel से व्युत्पन्न किसी अमूर्त बेस पर bound हो, तो mypy उस bounded बेस पर किए गए pattern matching को पूर्ण (exhaustive) नहीं मानता। नतीजे में ऐसा भ्रम पैदा होता है कि वाइल्डकार्ड शाखा अप्राप्य है, जबकि वास्तव में mypy TypeVar को जस‑का‑तस रखता है और उसे उस फ़ंक्शन में भेजने से रोकता है, जो Never अपेक्षित करता है।

समस्या को पुनरुत्पादित करना

नीचे दिया गया न्यूनतम उदाहरण त्रुटि दिखाता है, क्योंकि match में जाँच की जा रही वस्तु एक अकेला TypeVar है, न कि ज्ञात उपवर्गों का कोई ठोस (concrete) union। BaseModel हटाने पर भी परिणाम नहीं बदलता।

from typing import Generic, NoReturn, TypeVar
from pydantic import BaseModel
from abc import ABC
def fail_exhaustive(x: NoReturn) -> NoReturn:
    raise AssertionError("Exhaustiveness check failed: unhandled case")
class CtxBase(BaseModel, ABC):
    pass
class DashCtx(CtxBase):
    pass
class DefaultCtx(CtxBase):
    pass
TCtx = TypeVar("TCtx", bound=CtxBase)
class ScopedAction(Generic[TCtx]):
    def __init__(self, ctx: TCtx) -> None:
        self.ctx = ctx
    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return f"Applying action in {type(self.ctx)}"
            case DefaultCtx():
                return f"Applying action in {type(self.ctx)}"
            case _:
                fail_exhaustive(self.ctx)

mypy यह बताता है: “Argument 1 to ‘fail_exhaustive’ has incompatible type ‘TCtx’; expected ‘Never’”.

ऐसा क्यों होता है

यह Python के structural pattern matching के साथ mypy की एक ज्ञात सीमा है। match/case में टाइप संकुचन तब काम करता है जब जाँची जा रही अभिव्यक्ति कोई ठोस union हो। हमारे उदाहरण में self.ctx का प्रकार TCtx है—यह CtxBase पर bound एक अकेला TypeVar है, न कि DashCtx | DefaultCtx। mypy के पास यह साबित करने का कोई आधार नहीं है कि सभी उपवर्ग गिन लिए गए हैं, इसलिए वाइल्डकार्ड शाखा पहुँच योग्य बनी रहती है। दूसरे शब्दों में, कहीं और कोई वैकल्पिक उपवर्ग, जैसे काल्पनिक OtherContext, मौजूद हो सकता है और यहाँ आ सकता है। क्योंकि लाइब्रेरी कोड की जाँच अज्ञात उपभोक्ताओं पर निर्भर नहीं कर सकती, mypy _ वाले केस को अभी भी TCtx ही मानता है और उसे ऐसे फ़ंक्शन में भेजने से मना करता है जिसे Never चाहिए। सील्ड क्लासेस होतीं तो ऐसा तर्क संभव होता, पर Python की टाइपिंग में वे आज उपलब्ध नहीं हैं।

व्यावहारिक समाधान या वैकल्पिक उपाय

उपाय इस पर निर्भर है कि आपको सच‑मुच exhaustiveness चाहिए या आप generic API बनाए रखना चाहते हैं।

अगर आप match/case के साथ exhaustiveness जाँच चाहते हैं, तो जाँच की जा रही अभिव्यक्ति को ठोस प्रकारों के सीमित union में बदलें। उस union के लिए एक type alias बनाने से mypy हर केस को संकुचित कर पाता है और यह निष्कर्ष निकालता है कि वाइल्डकार्ड अप्राप्य है।

from typing import Union
AllowedCtx = DashCtx | DefaultCtx
class ScopedAction:
    def __init__(self, ctx: AllowedCtx) -> None:
        self.ctx = ctx
    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return f"Applying action in {type(self.ctx)}"
            case DefaultCtx():
                return f"Applying action in {type(self.ctx)}"
            case _:
                fail_exhaustive(self.ctx)

अगर आपको generic रूप बनाए रखना है, तो मान लें कि खुली वंशानुक्रम (open hierarchy) पर mypy exhaustiveness साबित नहीं कर सकता। ऐसे में या तो वाइल्डकार्ड शाखा को बिल्कुल न रखें, या फिर टाइपिंग चेतावनी को उसी जगह शांत कर दें।

class ScopedAction(Generic[TCtx]):
    def __init__(self, ctx: TCtx) -> None:
        self.ctx = ctx
    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return "Dashboard"
            case DefaultCtx():
                return "Default"
            case _:
                fail_exhaustive(self.ctx)  # type: ignore[arg-type]

Python 3.11+ में इसी उद्देश्य के लिए typing.assert_never मौजूद है, लेकिन यह तब ही कारगर है जब इनपुट ज्ञात प्रकारों का union हो, न कि कोई TypeVar।

from typing import assert_never
class ScopedAction:
    def __init__(self, ctx: AllowedCtx) -> None:
        self.ctx = ctx
    def run(self) -> str:
        match self.ctx:
            case DashCtx():
                return "Dashboard"
            case DefaultCtx():
                return "Default"
            case _:
                assert_never(self.ctx)

यह क्यों मायने रखता है

Pattern matching अक्सर लंबी isinstance कड़ियों से ज्यादा पठनीय लगता है, लेकिन स्थिर exhaustiveness केवल बंद समुच्चयों पर काम करती है। लाइब्रेरी डिज़ाइन में वंश-वृक्ष को खुला मानना बुनियादी बात है: नए उपवर्ग कहीं भी उभर सकते हैं। किसी बेस क्लास से bound TypeVar पर निर्भर रहने का मतलब है कि टाइप चेकर यह मान कर नहीं चल सकता कि आपने सारे केस कवर कर लिए हैं, और वाइल्डकार्ड वास्तव में पहुँच योग्य रहता है। यह सीमा समझना आपको अभिव्यक्तिपूर्णता (open hierarchies) और सख्त गारंटी (closed unions) के बीच सही चुनाव करने में मदद करता है।

निष्कर्ष

यदि आप चाहते हैं कि mypy पुष्टि करे कि match/case पूरी तरह exhaustive है, तो इनपुट को DashCtx | DefaultCtx जैसे किसी ठोस union के रूप में मॉडल करें और अंतिम शाखा में assert_never या Never स्वीकार करने वाले किसी सहायक का उपयोग करें। अगर आपको किसी बेस क्लास पर generic API चाहिए, तो exhaustiveness जाँच की अपेक्षा न करें; या तो वाइल्डकार्ड शाखा हटा दें, या जहाँ जरूरी हो वहाँ वही खास चेतावनी शांत कर दें। सील्ड क्लासेस इस खाई को भर सकतीं, पर Python की टाइपिंग प्रणाली में वे अभी उपलब्ध नहीं हैं।

यह लेख StackOverflow के प्रश्न (लेखक: Nitsugua) और Parsa Parvizi के उत्तर पर आधारित है।