Skip to content

Classes

ExcludeExtrasMixin

Mixin that excludes model_extra keys from serialization by default.

Models using extra="allow" silently collect unrecognised fields in model_extra. This mixin overrides model_dump and model_dump_json so those extra keys are excluded unless the caller explicitly passes include. This prevents accidental exposure of sensitive values (e.g. tokens, secrets) during serialization.

Source code in src/hassette/config/classes.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
class ExcludeExtrasMixin:
    """Mixin that excludes ``model_extra`` keys from serialization by default.

    Models using ``extra="allow"`` silently collect unrecognised fields in
    ``model_extra``.  This mixin overrides ``model_dump`` and
    ``model_dump_json`` so those extra keys are excluded unless the caller
    explicitly passes ``include``.  This prevents accidental exposure of
    sensitive values (e.g. tokens, secrets) during serialization.
    """

    def get_extra_keys(self) -> set[str]:
        extras = getattr(self, "model_extra", None)
        return set(extras) if extras else set()

    @staticmethod
    def merge_exclude(exclude: Any | None, extra_keys: set[str]) -> Any:
        """Merge extra keys into an existing ``exclude`` value.

        Handles the three shapes Pydantic accepts for *exclude*:
        - ``None``  → return a new ``set`` of extra keys
        - ``dict``  → copy and mark each extra key as ``True`` (fully excluded)
        - ``set`` (or other iterable) → union with extra keys
        """
        if exclude is None:
            return set(extra_keys)
        if isinstance(exclude, dict):
            return {**exclude, **{k: True for k in extra_keys}}
        return set(exclude) | extra_keys

    def model_dump(self, *, exclude: Any | None = None, **kwargs: Any) -> dict[str, Any]:
        """Serialize declared fields only; extra fields are excluded for privacy."""
        extra_keys = self.get_extra_keys()
        # Skip auto-exclude when caller explicitly specifies `include` — respect their selection
        if extra_keys and kwargs.get("include") is None:
            exclude = self.merge_exclude(exclude, extra_keys)
        return super().model_dump(exclude=exclude, **kwargs)  # pyright: ignore[reportAttributeAccessIssue]

    def model_dump_json(self, *, exclude: Any | None = None, **kwargs: Any) -> str:
        """Serialize declared fields only; extra fields are excluded for privacy."""
        extra_keys = self.get_extra_keys()
        if extra_keys and kwargs.get("include") is None:
            exclude = self.merge_exclude(exclude, extra_keys)
        return super().model_dump_json(exclude=exclude, **kwargs)  # pyright: ignore[reportAttributeAccessIssue]

merge_exclude(exclude: Any | None, extra_keys: set[str]) -> Any staticmethod

Merge extra keys into an existing exclude value.

Handles the three shapes Pydantic accepts for exclude: - None → return a new set of extra keys - dict → copy and mark each extra key as True (fully excluded) - set (or other iterable) → union with extra keys

Source code in src/hassette/config/classes.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@staticmethod
def merge_exclude(exclude: Any | None, extra_keys: set[str]) -> Any:
    """Merge extra keys into an existing ``exclude`` value.

    Handles the three shapes Pydantic accepts for *exclude*:
    - ``None``  → return a new ``set`` of extra keys
    - ``dict``  → copy and mark each extra key as ``True`` (fully excluded)
    - ``set`` (or other iterable) → union with extra keys
    """
    if exclude is None:
        return set(extra_keys)
    if isinstance(exclude, dict):
        return {**exclude, **{k: True for k in extra_keys}}
    return set(exclude) | extra_keys

model_dump(*, exclude: Any | None = None, **kwargs: Any) -> dict[str, Any]

Serialize declared fields only; extra fields are excluded for privacy.

Source code in src/hassette/config/classes.py
104
105
106
107
108
109
110
def model_dump(self, *, exclude: Any | None = None, **kwargs: Any) -> dict[str, Any]:
    """Serialize declared fields only; extra fields are excluded for privacy."""
    extra_keys = self.get_extra_keys()
    # Skip auto-exclude when caller explicitly specifies `include` — respect their selection
    if extra_keys and kwargs.get("include") is None:
        exclude = self.merge_exclude(exclude, extra_keys)
    return super().model_dump(exclude=exclude, **kwargs)  # pyright: ignore[reportAttributeAccessIssue]

model_dump_json(*, exclude: Any | None = None, **kwargs: Any) -> str

Serialize declared fields only; extra fields are excluded for privacy.

Source code in src/hassette/config/classes.py
112
113
114
115
116
117
def model_dump_json(self, *, exclude: Any | None = None, **kwargs: Any) -> str:
    """Serialize declared fields only; extra fields are excluded for privacy."""
    extra_keys = self.get_extra_keys()
    if extra_keys and kwargs.get("include") is None:
        exclude = self.merge_exclude(exclude, extra_keys)
    return super().model_dump_json(exclude=exclude, **kwargs)  # pyright: ignore[reportAttributeAccessIssue]

AppManifest

Bases: ExcludeExtrasMixin, BaseModel

Manifest for a Hassette app.

Source code in src/hassette/config/classes.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
class AppManifest(ExcludeExtrasMixin, BaseModel):
    """Manifest for a Hassette app."""

    model_config = ConfigDict(
        extra="allow", coerce_numbers_to_str=True, validate_assignment=True, use_attribute_docstrings=True
    )

    app_key: str = Field(default=...)
    """Reflects the key for this app in hassette.toml"""

    enabled: bool = Field(default=True)
    """Whether the app is enabled or not, will default to True if not set. Does not consider @only_app decorator."""

    filename: str = Field(default=..., examples=["my_app.py"], validation_alias=AliasChoices("filename", "file_name"))
    """Filename of the app, will be looked for in app_path"""

    class_name: str = Field(
        default=..., examples=["MyApp"], validation_alias=AliasChoices("class_name", "class", "module", "module_name")
    )
    """Class name of the app"""

    display_name: str = Field(default=..., examples=["My App"])
    """Display name of the app, will use class_name if not set"""

    app_dir: Path = Field(..., examples=["./apps"])
    """Path to the app directory, relative to current working directory or absolute"""

    app_config: dict[str, Any] | list[dict[str, Any]] = Field(
        default_factory=dict, validation_alias=AliasChoices("config", "app_config"), validate_default=True
    )
    """Instance configuration for the app"""

    auto_loaded: bool = Field(default=False)
    """Whether the app was auto-detected or manually configured"""

    full_path: Path
    """Fully resolved path to the app file"""

    def __repr__(self) -> str:
        return f"<AppManifest {self.display_name} ({self.class_name}) - enabled={self.enabled} file={self.filename}>"

    @model_validator(mode="before")
    @classmethod
    def validate_app_manifest(cls, values: dict[str, Any]) -> dict[str, Any]:
        """Validate the app configuration."""
        required_keys = ["filename", "class_name", "app_dir"]
        missing_keys = [key for key in required_keys if key not in values]
        if missing_keys:
            raise ValueError(f"App configuration is missing required keys: {', '.join(missing_keys)}")

        values["app_dir"] = app_dir = Path(values["app_dir"]).resolve()

        values["display_name"] = values.get("display_name") or values.get("class_name")

        if app_dir.is_file():
            LOGGER.warning("App directory %s is a file, using the parent directory as app_dir", app_dir)
            values["filename"] = app_dir.name
            values["app_dir"] = app_dir.parent

        return values

    @field_validator("app_config", mode="before")
    @classmethod
    def validate_app_config(cls, v: Any, validation_info: ValidationInfo) -> Any:
        """Set instance name if not set in config."""

        if not v:
            return v

        if isinstance(v, dict):
            v = [v]

        class_name = validation_info.data.get("class_name", "UnknownApp")

        for idx, item in enumerate(v):
            if "instance_name" not in item or not item["instance_name"]:
                item["instance_name"] = f"{class_name}.{idx}"

        return v

    def validate_model_extra(self) -> None:
        if not self.model_extra:
            return

        keys = list(self.model_extra.keys())
        msg = (
            f"{type(self).__name__} - {self.display_name} - Instance configuration values should be"
            " set under the `config` field:\n"
            f"  {keys}\n"
            "This will ensure proper validation and handling of custom configurations."
        )

        if not self.app_config:
            self.app_config = deepcopy(self.model_extra)
        elif isinstance(self.app_config, dict) and not set(self.app_config).intersection(set(keys)):
            self.app_config.update(deepcopy(self.model_extra))
        else:
            if isinstance(self.app_config, list):
                msg += "\nNote: Unable to merge extra fields into list-based config."
            elif isinstance(self.app_config, dict):
                msg += "\nNote: Unable to merge extra fields into existing config due to intersecting keys."

            msg += "\nExtra fields will be ignored. Please update your configuration."

        warn(msg, stacklevel=5)

    def model_post_init(self, context: Any) -> None:
        self.validate_model_extra()

        # if we don't have app_config then we don't have any apps with config
        # which means we have, at most, one app
        # so we can just set the default instance name
        if not self.app_config:
            self.app_config = [{"instance_name": f"{self.class_name}.0"}]

app_key: str = Field(default=...) class-attribute instance-attribute

Reflects the key for this app in hassette.toml

enabled: bool = Field(default=True) class-attribute instance-attribute

Whether the app is enabled or not, will default to True if not set. Does not consider @only_app decorator.

filename: str = Field(default=..., examples=['my_app.py'], validation_alias=(AliasChoices('filename', 'file_name'))) class-attribute instance-attribute

Filename of the app, will be looked for in app_path

class_name: str = Field(default=..., examples=['MyApp'], validation_alias=(AliasChoices('class_name', 'class', 'module', 'module_name'))) class-attribute instance-attribute

Class name of the app

display_name: str = Field(default=..., examples=['My App']) class-attribute instance-attribute

Display name of the app, will use class_name if not set

app_dir: Path = Field(..., examples=['./apps']) class-attribute instance-attribute

Path to the app directory, relative to current working directory or absolute

app_config: dict[str, Any] | list[dict[str, Any]] = Field(default_factory=dict, validation_alias=(AliasChoices('config', 'app_config')), validate_default=True) class-attribute instance-attribute

Instance configuration for the app

auto_loaded: bool = Field(default=False) class-attribute instance-attribute

Whether the app was auto-detected or manually configured

full_path: Path instance-attribute

Fully resolved path to the app file

validate_app_manifest(values: dict[str, Any]) -> dict[str, Any] classmethod

Validate the app configuration.

Source code in src/hassette/config/classes.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
@model_validator(mode="before")
@classmethod
def validate_app_manifest(cls, values: dict[str, Any]) -> dict[str, Any]:
    """Validate the app configuration."""
    required_keys = ["filename", "class_name", "app_dir"]
    missing_keys = [key for key in required_keys if key not in values]
    if missing_keys:
        raise ValueError(f"App configuration is missing required keys: {', '.join(missing_keys)}")

    values["app_dir"] = app_dir = Path(values["app_dir"]).resolve()

    values["display_name"] = values.get("display_name") or values.get("class_name")

    if app_dir.is_file():
        LOGGER.warning("App directory %s is a file, using the parent directory as app_dir", app_dir)
        values["filename"] = app_dir.name
        values["app_dir"] = app_dir.parent

    return values

validate_app_config(v: Any, validation_info: ValidationInfo) -> Any classmethod

Set instance name if not set in config.

Source code in src/hassette/config/classes.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
@field_validator("app_config", mode="before")
@classmethod
def validate_app_config(cls, v: Any, validation_info: ValidationInfo) -> Any:
    """Set instance name if not set in config."""

    if not v:
        return v

    if isinstance(v, dict):
        v = [v]

    class_name = validation_info.data.get("class_name", "UnknownApp")

    for idx, item in enumerate(v):
        if "instance_name" not in item or not item["instance_name"]:
            item["instance_name"] = f"{class_name}.{idx}"

    return v