p-e-w/heretic · error · TypeError

{cls.__name__}.settings must not be Optional; use a non-opti

Error message

{cls.__name__}.settings must not be Optional; use a non-optional pydantic.BaseModel subclass (e.g. `settings: Settings`).

What it means

A plugin's `settings` class attribute annotation must be a non-optional pydantic BaseModel subclass. Annotating it as `Optional[Settings]` is rejected because optional settings would break schema generation and validation. The error is raised by get_settings_model when it detects a Union containing NoneType in the annotation.

Source

Thrown at src/heretic/plugin.py:273

        def unwrap_settings_type(tp: Any) -> Any:
            """Unwrap `Annotated[T, ...]`."""
            while True:
                origin = get_origin(tp)
                if origin is Annotated:
                    tp = get_args(tp)[0]
                    continue
                return tp

        hints = get_type_hints(cls, include_extras=True)
        annotated = hints.get("settings")
        if annotated is None:
            return None

        model = unwrap_settings_type(annotated)
        origin = get_origin(model)
        if origin in (Union, types.UnionType) and type(None) in get_args(model):
            raise TypeError(
                f"{cls.__name__}.settings must not be Optional; "
                "use a non-optional pydantic.BaseModel subclass (e.g. `settings: Settings`)."
            )
        if not isinstance(model, type) or not issubclass(model, BaseModel):
            raise TypeError(
                f"{cls.__name__}.settings must be annotated with a pydantic.BaseModel subclass"
            )
        return model

    @classmethod
    def validate_settings(
        cls, raw_namespace: dict[str, Any] | None
    ) -> BaseModel | None:
        """
        Validates plugin settings for this plugin class.

        - If a settings model is present: returns an instance of that model.
        - Otherwise returns None.

View on GitHub (pinned to bedb94ef11)

Solutions

  1. Change the annotation to the plain model class, e.g. `settings: MySettings`.
  2. Give fields inside the pydantic model default values if some settings are optional.

Example fix

// before
class MyScorer(Plugin):
    settings: MySettings | None
// after
class MyScorer(Plugin):
    settings: MySettings
Defensive patterns

Strategy: validation

Validate before calling

import typing
from heretic.plugin import Plugin
ann = typing.get_type_hints(MyPlugin).get("settings")
assert ann is not None and not typing.get_origin(ann) is typing.Union, "settings must not be Optional"

Type guard

import types, typing
from pydantic import BaseModel
def is_valid_settings_annotation(cls) -> bool:
    ann = typing.get_type_hints(cls).get("settings")
    if ann is None: return False
    if typing.get_origin(ann) in (typing.Union, types.UnionType): return False
    return isinstance(ann, type) and issubclass(ann, BaseModel)

Try / catch

try:
    Plugin.get_settings_model(MyPlugin)
except TypeError as e:
    if "must not be Optional" in str(e):
        print("Remove Optional from settings annotation")
    else:
        raise

Prevention

When it happens

Trigger: Declaring `settings: Optional[MySettings]` or `settings: MySettings | None` on a Plugin subclass; get_settings_model is called by _get_scorer_settings_raw, plugin __init__, or validate_settings.

Common situations: Copy-pasting a settings annotation from a dataclass where Optional was common; making settings optional so defaults could be omitted instead of using a full settings model with defaults.

Related errors


AI-assisted analysis of p-e-w/heretic@bedb94ef11 (2026-08-29). Data as JSON: /api/errors/2c47727de3843f55. Report an issue: GitHub.