Feat(release): Add reno release note snippets

The draft release body is rebuilt from .github/release-drafter.yml on
every push to main, so hand-written upgrade guidance was wiped by the
next merge and operator-facing changes had nowhere to live.

Adopt reno (dev dependency, in its own 'releasenotes' group so CI can
install it without Home Assistant). Notes live in releasenotes/notes/
and are written in Markdown. scripts/render_release_notes.py uses
reno's loader to select the notes added since the latest release tag
and renders them as Markdown, because 'reno report' only emits RST.

The release-drafter workflow now checks out full history, renders the
notes into a generated copy of the config and loads it with the file:
scheme. The downloads badge moves from 'template' to 'header' so the
notes land between the badge and the changelog categories; with no
notes the body is byte-for-byte what it was before.

A new Release Notes PR check runs 'reno lint' when releasenotes/
changes and fails a 'breaking-change' pull request that adds no note.
The first note covers the HA 2026.11 calendar trigger offset change
and Marketplace readiness for v3.10.0.

Closes #819

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
This commit is contained in:
Andrew GrimbergandCopilot committed 2026-11-02 08:49:43 -08:00
1 parent 0653b1904c
commit 617bee6227
14 files changed
+713 -4

No files matched your search

+6 -1
View File
@@ -85,10 +85,15 @@ autolabeler:
- label: "build" - label: "build"
title: title:
- '/^build(\([^)]+\))?:/i' - '/^build(\([^)]+\))?:/i'
# The release body is header + template. The release-drafter workflow
# appends the reno notes from releasenotes/notes/ to the header (see
# scripts/render_release_notes.py), placing them between the downloads
# badge and $CHANGES. '|+' keeps the blank line that separates them.
# yamllint disable rule:line-length # yamllint disable rule:line-length
template: | header: |+
[![Downloads for this release](https://img.shields.io/github/downloads/$OWNER/$REPOSITORY/v$RESOLVED_VERSION/total.svg)](https://github.com/$OWNER/$REPOSITORY/releases/v$RESOLVED_VERSION) [![Downloads for this release](https://img.shields.io/github/downloads/$OWNER/$REPOSITORY/v$RESOLVED_VERSION/total.svg)](https://github.com/$OWNER/$REPOSITORY/releases/v$RESOLVED_VERSION)
template: |
$CHANGES $CHANGES
## Links ## Links
+27 -1
View File
@@ -23,7 +23,7 @@ jobs:
contents: write contents: write
pull-requests: read pull-requests: read
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
timeout-minutes: 3 timeout-minutes: 10
steps: steps:
# Harden the runner used by this workflow # Harden the runner used by this workflow
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
@@ -31,5 +31,31 @@ jobs:
with: with:
egress-policy: 'audit' egress-policy: 'audit'
# Full history and tags let reno find the notes added since the
# latest release tag.
# yamllint disable-line rule:line-length
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
# No cache: this job holds contents: write.
# yamllint disable-line rule:line-length
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
python-version: '3.14'
enable-cache: false
# The draft body is rebuilt on every push, so release notes live in
# releasenotes/notes/ and are rendered into the config each time.
- name: 'Render release notes into the Release Drafter config'
run: |
uv run --frozen --only-group releasenotes \
python scripts/render_release_notes.py \
--release-drafter-config .github/release-drafter.yml \
--output .github/release-drafter-generated.yml
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
- uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0 - uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
with:
config-name: 'file:.github/release-drafter-generated.yml'
+85
View File
@@ -0,0 +1,85 @@
---
# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
# SPDX-License-Identifier: Apache-2.0
name: 'Release Notes'
# yamllint disable-line rule:truthy
on:
# 'labeled' and 'unlabeled' re-evaluate the breaking-change rule when
# the autolabeler (or a maintainer) changes the labels.
pull_request:
types:
- opened
- reopened
- synchronize
- labeled
- unlabeled
branches:
- main
permissions: {}
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
# Queue rather than cancel, as in autolabeler.yaml: the autolabeler
# labels a new pull request moments after it opens, and a cancelled
# run beside a passing one marks the head SHA as not successful.
cancel-in-progress: false
queue: max
jobs:
release-notes:
name: 'Release Notes'
runs-on: 'ubuntu-latest'
permissions:
contents: read
timeout-minutes: 10
steps:
# Harden the runner used by this workflow
# yamllint disable-line rule:line-length
- uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2
with:
egress-policy: 'audit'
# reno lint scans the git history, so fetch all of it.
# yamllint disable-line rule:line-length
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: 'Find release note changes'
id: changes
run: |
# HEAD is the pull request merge commit; its first parent is
# the tip of the base branch.
added=$(git diff --name-only --diff-filter=A HEAD^1 HEAD \
-- 'releasenotes/notes/*.yaml')
changed=$(git diff --name-only HEAD^1 HEAD -- releasenotes/)
echo "Added notes: ${added:-none}"
{
echo "added=$([ -n "$added" ] && echo true || echo false)"
echo "changed=$([ -n "$changed" ] && echo true || echo false)"
} >> "$GITHUB_OUTPUT"
- name: 'Require a release note for breaking changes'
if: >-
contains(github.event.pull_request.labels.*.name, 'breaking-change')
&& steps.changes.outputs.added != 'true'
run: |
echo "::error::Pull requests labelled 'breaking-change' must" \
"add a release note: run 'uv run reno new <slug>' and fill" \
"in the upgrade section."
exit 1
# yamllint disable-line rule:line-length
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
if: steps.changes.outputs.changed == 'true'
with:
python-version: '3.14'
enable-cache: false
- name: 'Lint release notes'
if: steps.changes.outputs.changed == 'true'
run: uv run --frozen --only-group releasenotes reno lint
+2
View File
@@ -24,3 +24,5 @@ Thumbs.db
*.swp *.swp
.idea/ .idea/
.worktrees/ .worktrees/
# Written by the release-drafter workflow from .github/release-drafter.yml
.github/release-drafter-generated.yml
+9
View File
@@ -260,6 +260,15 @@ from the repository root using `uv`:
- All tests must pass before pushing - All tests must pass before pushing
- New features should include appropriate test coverage - New features should include appropriate test coverage
## Release Notes
Describe user-visible changes with a [reno](https://docs.openstack.org/reno/latest/)
note in the same pull request: run `uv run reno new <slug>`, fill in
the sections that apply in Markdown, delete the rest, and check it with
`uv run reno lint`. A note is **required** for a breaking change (the
`breaking-change` label); CI fails without one. Upgrade actions belong
in `upgrade`.
## Development Workflow Summary ## Development Workflow Summary
1. Make changes to code 1. Make changes to code
+21
View File
@@ -923,3 +923,24 @@ All commits must pass [pre-commit](https://pre-commit.com/) checks:
pre-commit install pre-commit install
pre-commit run --all-files pre-commit run --all-files
``` ```
### Release Notes
Release notes live in `releasenotes/notes/` as
[reno](https://docs.openstack.org/reno/latest/) snippets, written in
Markdown. Add one in the same pull request as the change it describes:
```bash
uv run reno new <slug>
uv run reno lint
```
Fill in the sections that apply (`upgrade`, `features`, `fixes`,
`deprecations` or `security`) and delete the rest. A pull request
labelled `breaking-change` must add a note. The draft GitHub release
shows every note added since the last release tag, above the list of
pull requests. To preview it:
```bash
uv run python scripts/render_release_notes.py
```
+6
View File
@@ -41,3 +41,9 @@ path = ".aislop/baseline.json"
precedence = "aggregate" precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Andrew Grimberg <tykeal@bardicgrove.org>" SPDX-FileCopyrightText = "2026 Andrew Grimberg <tykeal@bardicgrove.org>"
SPDX-License-Identifier = "Apache-2.0" SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = "releasenotes/notes/*.yaml"
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Andrew Grimberg <tykeal@bardicgrove.org>"
SPDX-License-Identifier = "Apache-2.0"
+6
View File
@@ -66,6 +66,12 @@ packages = ["custom_components"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"homeassistant>=2026.10.0", "homeassistant>=2026.10.0",
{include-group = "releasenotes"},
]
# reno manages releasenotes/notes/; kept in its own group so CI can
# install it without Home Assistant (uv run --only-group releasenotes).
releasenotes = [
"reno>=4.1.0",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]
+48
View File
@@ -0,0 +1,48 @@
---
# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
# SPDX-License-Identifier: Apache-2.0
# reno configuration. Notes are written in Markdown, not reStructuredText,
# because scripts/render_release_notes.py renders them into the GitHub
# release body. See the "Release notes" section of README.md.
default_branch: main
# Release tags are vX.Y.Z; any vX.Y.ZrcN tags fold into the final release.
collapse_pre_releases: true
# Main is the only release branch, so scan its full history.
stop_at_branch_base: false
# Section order is the order of the rendered release notes. The optional
# 'prelude' section is built into reno and always renders first.
sections:
- [upgrade, Upgrade Notes]
- [features, New Features]
- [fixes, Bug Fixes]
- [deprecations, Deprecation Notes]
- [security, Security Issues]
# Release Drafter, not reno, decides the next version number.
semver_major: []
semver_minor: []
semver_patch: []
# Template used by 'reno new'. Keep only the sections that apply.
template: |
---
# Write each item in Markdown. Delete every section you do not use,
# including this comment.
# prelude: |
# Optional release summary, rendered above all sections.
upgrade:
- |
Action an operator must take when upgrading, such as a changed
option or a minimum Home Assistant version.
features:
- |
A new capability, described from the user's point of view.
fixes:
- |
A user-visible bug that is now fixed.
deprecations:
- |
Something that still works but will be removed later, and what to
use instead.
security:
- |
A security issue that is now fixed.
@@ -0,0 +1,19 @@
---
upgrade:
- |
From Home Assistant 2026.11, `calendar.event_started` and
`calendar.event_ended` triggers no longer default to
`offset_type: before`; the sign of the offset now sets the direction.
Check any YAML automation on a Rental Control calendar that uses an
offset without `offset_type`. Triggers created in the UI are not
affected. See
[Calendar Triggers in Home Assistant 2026.11](
https://github.com/tykeal/homeassistant-rental-control#calendar-triggers-in-home-assistant-202611)
in the README.
features:
- |
Rental Control is ready for the Home Assistant Marketplace in Home
Assistant 2026.11 and later. The integration ships its own brand
images and declares its integration categories, so it installs from
**Settings** > **Marketplace** with no restart. On earlier Home
Assistant releases, install it through HACS as before.
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
# SPDX-License-Identifier: Apache-2.0
"""Maintainer scripts for the repository."""
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
# SPDX-License-Identifier: Apache-2.0
"""Render reno release notes as Markdown for the GitHub release body.
``reno report`` only emits reStructuredText, so this script uses reno's
Python API to find the notes for a release and renders them as Markdown.
By default it renders the unreleased notes: every note added since the
most recent release tag, plus staged or modified notes in the working
copy. ``--version`` renders the notes of an existing release tag instead.
With ``--release-drafter-config`` it writes a copy of that Release
Drafter configuration with the rendered notes appended to its
``header`` key, so they sit between the downloads badge and the
changelog categories.
"""
from __future__ import annotations
import argparse
from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import field
from pathlib import Path
import subprocess
import sys
import textwrap
from typing import Any
from typing import Protocol
import yaml
RELNOTES_DIR = "releasenotes"
WORKING_COPY = "*working-copy*"
class NoteLoader(Protocol):
"""The subset of ``reno.loader.Loader`` this script relies on."""
@property
def versions(self) -> list[str]:
"""Return the versions with notes, newest first."""
def __getitem__(self, version: str) -> list[tuple[str, str | None]]:
"""Return ``(filename, sha)`` pairs for the notes of a version."""
def parse_note_file(self, filename: str, sha: str | None) -> dict[str, Any]:
"""Return the parsed sections of one note file."""
@dataclass
class RenderedNotes:
"""Prelude paragraphs and section items gathered from note files."""
prelude: list[str] = field(default_factory=list)
sections: dict[str, list[str]] = field(default_factory=dict)
def select_versions(
versions: Sequence[str], tags: set[str], version: str | None
) -> list[str]:
"""Pick the reno versions whose notes belong in the output.
Without ``version`` this is every leading entry that is not a release
tag: the working copy and reno's ``<latest-tag>-<N>`` development
version. With ``version`` it is that version alone, if it has notes.
"""
if version is not None:
return [version] if version in versions else []
selected = []
for candidate in versions:
if candidate in tags:
break
selected.append(candidate)
return selected
def collect_notes(
loader: NoteLoader,
versions: Sequence[str],
section_names: Sequence[str],
prelude_name: str,
) -> RenderedNotes:
"""Gather the prelude and section items from the selected versions."""
notes = RenderedNotes()
for version in versions:
for filename, sha in loader[version]:
content = loader.parse_note_file(filename, sha)
prelude = content.get(prelude_name)
if isinstance(prelude, str) and prelude.strip():
notes.prelude.append(prelude)
for name in section_names:
items = content.get(name) or []
if isinstance(items, str):
items = [items]
notes.sections.setdefault(name, []).extend(
item for item in items if isinstance(item, str) and item.strip()
)
return notes
def format_item(text: str) -> str:
"""Render one note item as a Markdown bullet.
Continuation lines are indented by two spaces so that paragraphs,
nested lists and fenced code blocks stay inside the bullet.
"""
lines = textwrap.dedent(text).strip().splitlines()
rest = [f" {line}" if line.strip() else "" for line in lines[1:]]
return "\n".join([f"- {lines[0]}", *rest])
def render_markdown(
notes: RenderedNotes, sections: Sequence[tuple[str, str, int]]
) -> str:
"""Render collected notes as Markdown, or ``""`` when there are none.
``sections`` holds ``(name, title, level)`` in output order; level 1
sections become ``###`` headings and deeper levels nest below them.
"""
blocks = [textwrap.dedent(text).strip() for text in notes.prelude]
for name, title, level in sections:
items = notes.sections.get(name)
if not items:
continue
blocks.append(f"{'#' * (2 + level)} {title}")
blocks.append("\n".join(format_item(item) for item in items))
return "\n\n".join(blocks)
def _literal_str(dumper: yaml.SafeDumper, data: str) -> yaml.ScalarNode:
"""Dump multi-line strings as YAML literal blocks for readability."""
style = "|" if "\n" in data else None
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
class _ConfigDumper(yaml.SafeDumper):
"""YAML dumper that keeps multi-line strings readable."""
_ConfigDumper.add_representer(str, _literal_str)
def build_drafter_config(base_config: str, notes_markdown: str) -> str:
"""Return the Release Drafter config with notes appended to ``header``.
The base config is returned unchanged when there are no notes.
"""
if not notes_markdown:
return base_config
config = yaml.safe_load(base_config)
header = config.get("header") or ""
if header and not header.endswith("\n\n"):
header = header.rstrip("\n") + "\n\n"
config["header"] = f"{header}{notes_markdown.rstrip()}\n\n"
dumped: str = yaml.dump(
config,
Dumper=_ConfigDumper,
sort_keys=False,
allow_unicode=True,
width=1_000_000,
)
return (
"---\n# Generated by scripts/render_release_notes.py; do not edit.\n" + dumped
)
def git_tags(repo_root: Path) -> set[str]:
"""Return the names of every tag in the repository."""
result = subprocess.run(
["git", "tag", "--list"],
cwd=repo_root,
check=True,
capture_output=True,
text=True,
)
return set(result.stdout.split())
def load_release_notes(repo_root: Path, version: str | None) -> str:
"""Scan the repository with reno and render the selected notes."""
from reno import config as reno_config
from reno import loader as reno_loader
conf = reno_config.Config(str(repo_root), RELNOTES_DIR)
sections = [(s.name, s.title, s.section_level) for s in conf.sections]
tags = git_tags(repo_root)
with reno_loader.Loader(conf) as ldr:
versions = select_versions(ldr.versions, tags, version)
notes = collect_notes(
ldr, versions, [s[0] for s in sections], conf.prelude_section_name
)
return render_markdown(notes, sections)
def parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--repo-root",
type=Path,
default=Path("."),
help="repository to scan (default: current directory)",
)
parser.add_argument(
"--version",
help="render the notes of this release tag instead of unreleased notes",
)
parser.add_argument(
"--release-drafter-config",
type=Path,
help="write this Release Drafter config with the notes in its header",
)
parser.add_argument(
"--output",
type=Path,
help="file to write instead of standard output",
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
"""Run the renderer; return the process exit status."""
args = parse_args(argv)
if args.version is not None and args.version not in git_tags(args.repo_root):
print(f"error: {args.version!r} is not a git tag", file=sys.stderr)
return 2
output = load_release_notes(args.repo_root, args.version)
if args.release_drafter_config is not None:
output = build_drafter_config(
args.release_drafter_config.read_text(encoding="utf-8"), output
)
elif output:
output += "\n"
if args.output is not None:
args.output.write_text(output, encoding="utf-8")
else:
sys.stdout.write(output)
return 0
if __name__ == "__main__":
sys.exit(main())
+151
View File
@@ -0,0 +1,151 @@
# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
# SPDX-License-Identifier: Apache-2.0
"""Tests for the reno-to-Markdown release note renderer (issue #819)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
import yaml
from scripts import render_release_notes as rrn
SECTIONS = [
("upgrade", "Upgrade Notes", 1),
("features", "New Features", 1),
("fixes", "Bug Fixes", 1),
]
SECTION_NAMES = [name for name, _, _ in SECTIONS]
REPO_ROOT = Path(__file__).resolve().parents[2]
class FakeLoader:
"""In-memory stand-in for ``reno.loader.Loader``."""
def __init__(self, notes: dict[str, dict[str, dict[str, Any]]]) -> None:
"""Store ``{version: {filename: parsed content}}``."""
self._notes = notes
@property
def versions(self) -> list[str]:
"""Return versions newest first, as reno does."""
return list(self._notes)
def __getitem__(self, version: str) -> list[tuple[str, str | None]]:
"""Return the note files of one version."""
return [(name, "sha") for name in self._notes[version]]
def parse_note_file(self, filename: str, sha: str | None) -> dict[str, Any]:
"""Return the parsed content of one note file."""
for files in self._notes.values():
if filename in files:
return files[filename]
raise KeyError(filename)
@pytest.mark.parametrize(
("version", "expected"),
[
(None, ["*working-copy*", "v3.9.0-2"]),
("v3.9.0", ["v3.9.0"]),
("v3.7.0", []),
],
)
def test_select_versions(version: str | None, expected: list[str]) -> None:
"""Unreleased notes stop at the newest tag; a named tag stands alone."""
versions = ["*working-copy*", "v3.9.0-2", "v3.9.0", "v3.8.0"]
tags = {"v3.9.0", "v3.8.0", "v3.7.0"}
assert rrn.select_versions(versions, tags, version) == expected
def test_select_versions_head_on_tag_has_no_unreleased_notes() -> None:
"""Right after a release, nothing is unreleased."""
assert rrn.select_versions(["v3.9.0", "v3.8.0"], {"v3.9.0", "v3.8.0"}, None) == []
def test_render_orders_sections_and_formats_multiline_items() -> None:
"""Sections follow config order; continuation lines stay in the bullet."""
loader = FakeLoader(
{
"v3.9.0-1": {
"a.yaml": {
"fixes": ["Fixed a thing.\n"],
"upgrade": [
"First line\nsecond line.\n\n```yaml\nkey: value\n```\n"
],
},
"b.yaml": {
"prelude": "A summary\nparagraph.\n",
"fixes": "A single string item.\n",
},
}
}
)
notes = rrn.collect_notes(loader, ["v3.9.0-1"], SECTION_NAMES, "prelude")
assert rrn.render_markdown(notes, SECTIONS) == (
"A summary\nparagraph.\n\n"
"### Upgrade Notes\n\n"
"- First line\n second line.\n\n ```yaml\n key: value\n ```\n\n"
"### Bug Fixes\n\n"
"- Fixed a thing.\n- A single string item."
)
def test_render_without_notes_is_empty() -> None:
"""Empty or blank items produce no output at all."""
loader = FakeLoader({"v3.9.0-1": {"a.yaml": {"fixes": [" \n"]}}})
notes = rrn.collect_notes(loader, ["v3.9.0-1"], SECTION_NAMES, "prelude")
assert rrn.render_markdown(notes, SECTIONS) == ""
def test_drafter_config_unchanged_without_notes() -> None:
"""With no notes the committed config is used as is."""
base = "---\nheader: |+\n badge\n\ntemplate: |\n $CHANGES\n"
assert rrn.build_drafter_config(base, "") == base
def test_drafter_config_places_notes_between_badge_and_changes() -> None:
"""Notes follow the badge in the header; everything else is preserved."""
base_text = (REPO_ROOT / ".github" / "release-drafter.yml").read_text()
base = yaml.safe_load(base_text)
generated = yaml.safe_load(
rrn.build_drafter_config(base_text, "### Bug Fixes\n\n- Fixed.")
)
body = generated["header"] + generated["template"]
assert body.startswith(base["header"] + "### Bug Fixes\n\n- Fixed.\n\n$CHANGES")
assert {k: v for k, v in generated.items() if k != "header"} == {
k: v for k, v in base.items() if k != "header"
}
def test_main_rejects_unknown_version(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""An explicit version must be an existing tag."""
monkeypatch.setattr(rrn, "git_tags", lambda _root: {"v3.9.0"})
assert rrn.main(["--version", "v9.9.9"]) == 2
assert "not a git tag" in capsys.readouterr().err
def test_main_writes_drafter_config(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The CLI merges rendered notes into the given config file."""
monkeypatch.setattr(rrn, "load_release_notes", lambda _root, _v: "- Note.")
base = tmp_path / "base.yml"
base.write_text("header: |\n badge\ntemplate: $CHANGES\n")
out = tmp_path / "out.yml"
assert rrn.main(["--release-drafter-config", str(base), "--output", str(out)]) == 0
assert yaml.safe_load(out.read_text())["header"] == "badge\n\n- Note.\n\n"
Generated
+86 -2
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 5
requires-python = ">=3.14.2" requires-python = ">=3.14.2"
[[package]] [[package]]
@@ -869,6 +869,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/01/894b2f6954d12c5c527232906d67272b59f0f12f386cd2d394d5e5eac7fc/dbus_fast-4.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:698fe83128150dd49aaa7deb5493523309f0a9749c3c075730a6981851607455", size = 1627660, upload-time = "2026-04-02T04:50:36.5Z" }, { url = "https://files.pythonhosted.org/packages/9e/01/894b2f6954d12c5c527232906d67272b59f0f12f386cd2d394d5e5eac7fc/dbus_fast-4.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:698fe83128150dd49aaa7deb5493523309f0a9749c3c075730a6981851607455", size = 1627660, upload-time = "2026-04-02T04:50:36.5Z" },
] ]
[[package]]
name = "dulwich"
version = "1.2.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/92/e47acfff672d6219d1bba1c210f902c1ad4a58d8c68eb89fe0b786f00a68/dulwich-1.2.20.tar.gz", hash = "sha256:28e1f48098d2cb157e722588466070cbefdfe8093c2a141c568b01362296c0f3", size = 1443547, upload-time = "2026-10-26T21:42:03.075Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/54/81b54b6ea48d347e8d408ba6f522680dd3a51181eb07c8381891c1f6bea9/dulwich-1.2.20-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:cf092fc46beb13b589d81c8b555d462f4848f79aa3ac40497e312d156749aa0e", size = 1591799, upload-time = "2026-10-26T21:41:17.862Z" },
{ url = "https://files.pythonhosted.org/packages/ef/3d/8915d557e3433af5731479c3fb676133148f3a522c320edba6e55748bc72/dulwich-1.2.20-cp314-cp314-android_24_x86_64.whl", hash = "sha256:8153780c99af33bc3c9fc46fa8381bc792d89f5b85ed90e751a7c55216d5e9c2", size = 1647161, upload-time = "2026-10-26T21:41:19.357Z" },
{ url = "https://files.pythonhosted.org/packages/a4/65/033a59758369bdf86db793373994366d8cc019652d29a96a52861b272687/dulwich-1.2.20-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c28de206253ab36de2cf64cb416a250e104f1a460476ebd6d838a112a2abc314", size = 1443388, upload-time = "2026-10-26T21:41:20.688Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e1/f136f9171727a6d9b380dbefd1f673a426cf876ee35881e3ad041aeb7b25/dulwich-1.2.20-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b281c75d6a7d2cde5206af349564e20124511895c2766a89c901a4addb4aea80", size = 1424821, upload-time = "2026-10-26T21:41:22.078Z" },
{ url = "https://files.pythonhosted.org/packages/8c/30/09492157eac271c4d3cb7101b9b2e7870699c0603ca2b2550d39a72b6cf3/dulwich-1.2.20-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3d8092beaafe801d0d4221ae5173fed0d2d67c65f290679f456ff606a78fe51", size = 1559645, upload-time = "2026-10-26T21:41:23.448Z" },
{ url = "https://files.pythonhosted.org/packages/56/86/6e22635bdd9a361c5c274449dbf1322023ed6b622d652a6f64de37c150a6/dulwich-1.2.20-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e983fa6a8aeb5026e8b7c7086f3b60604895d218a36d56792065d6288a3839c3", size = 1535342, upload-time = "2026-10-26T21:41:24.819Z" },
{ url = "https://files.pythonhosted.org/packages/69/60/0844bdfdc5578c13f0f6ece06c3c0ebb71f4f7bd4d9b46dc9f09f0864c92/dulwich-1.2.20-cp314-cp314-win32.whl", hash = "sha256:c35060cd74cf4947cfe79469521c9b5ec1e05aed638a9e9d116266349d397a70", size = 1173400, upload-time = "2026-10-26T21:41:26.579Z" },
{ url = "https://files.pythonhosted.org/packages/24/da/0a6409210ccc3557d092a25d88995d924f94680a329a0552b476543efc21/dulwich-1.2.20-cp314-cp314-win_amd64.whl", hash = "sha256:51bc52582d5fd95315ffbd75667b12784b4e1845396a1919abe0cf84e496b5b0", size = 1130390, upload-time = "2026-10-26T21:41:28.033Z" },
{ url = "https://files.pythonhosted.org/packages/d3/63/b7d71d6ead942cdf4d01e9e1273cd01c47dc05553713e1092a4431a8c26a/dulwich-1.2.20-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:da70bf6a8bf0e693c09217b4764fcca3d067edb5bcc192c5fe3aa24ad311de08", size = 1504581, upload-time = "2026-10-26T21:41:30.034Z" },
{ url = "https://files.pythonhosted.org/packages/57/1e/67b571aab9895fc2c82d7b85354302d28842584974e5230edb553ed44c47/dulwich-1.2.20-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:16675b9502ce7fb30def56359aef8f1df450037646c1b5dd2f03976982425bab", size = 1422252, upload-time = "2026-10-26T21:41:31.824Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7d/2f17e2f3e80da9726a26893956e91fc6f0c8b9cca075b5dded1ebd44cf0b/dulwich-1.2.20-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:bf60e8590bd2fa466d9412617d96d61e16b3cc02b5bd5b8059297977086ee82a", size = 1556477, upload-time = "2026-10-26T21:41:33.473Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/e9859aa442a15900fff7a273ecfb490e0e990ae2d0d69621dba2eac94fee/dulwich-1.2.20-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:774e5c4e7856ca1e39b5684603a4de53f1a5d8baf70eacad7d432ac5bb1e1d95", size = 1535383, upload-time = "2026-10-26T21:41:34.899Z" },
{ url = "https://files.pythonhosted.org/packages/2c/47/c1dd1d8545f2a7cba77b69e9c1f9b7fd62e299b0ab2f7e80bfc027ed6393/dulwich-1.2.20-cp314-cp314t-win32.whl", hash = "sha256:f90a99b1f1198c2757abea1ed8e359014b2931f6046892efaed4677021a5060c", size = 1113568, upload-time = "2026-10-26T21:41:36.366Z" },
{ url = "https://files.pythonhosted.org/packages/21/1a/89e4557e99af883e1add3b91c86390b0d84813cf17d6d763287a6a51dab8/dulwich-1.2.20-cp314-cp314t-win_amd64.whl", hash = "sha256:f78c50152186374124f5e9ae397148b4302028e93b0bf2fc17862f8906650ac1", size = 1130923, upload-time = "2026-10-26T21:41:37.803Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f9/6a76551f6df6ded25be23df7feb5f9b9ba9c716968888ebeb962470833a0/dulwich-1.2.20-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:9e74e9c49dd2f0c206b029e474cefefdcfa07f1162f47353d4bac529668ad6a5", size = 1593369, upload-time = "2026-10-26T21:41:39.535Z" },
{ url = "https://files.pythonhosted.org/packages/da/4e/bd3f0b4f2e60c11c65d944a295d9d3b99d56a228ee13d01e0bb49ad6cedf/dulwich-1.2.20-cp315-cp315-android_24_x86_64.whl", hash = "sha256:83ba7f16f5eb6589ac1393a84578a38704a7c67fce73ce3c8eb16c4f02830153", size = 1648522, upload-time = "2026-10-26T21:41:40.993Z" },
{ url = "https://files.pythonhosted.org/packages/87/43/57434f696f893ae5f247c79e3f555f4cfe9dee9b3e038e3fdd036116d3b3/dulwich-1.2.20-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:4bb6d707cd8cc197df9c147058608c47ae2f4f428f7473a97f66e36bb57883b8", size = 1444916, upload-time = "2026-10-26T21:41:42.459Z" },
{ url = "https://files.pythonhosted.org/packages/fc/05/35f29728e0e0bcc3470fb51cd346565a1f7f6d0ec2bf30c33d7d4f182c8e/dulwich-1.2.20-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:fa74d4436dce73230832a6a95aeccc5c64672b2d201eb53a09aa0f2a7e2b86d3", size = 1425723, upload-time = "2026-10-26T21:41:44.152Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/40f1e9ca06b500928c75c6bbdeb52adb8d9e44f2afe81e3a22a9b19579d1/dulwich-1.2.20-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ac11231f391b6b9cf7bd1d689c36dc5827ea8426833cc51d9f3ee40c8efe9c86", size = 1560093, upload-time = "2026-10-26T21:41:45.583Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cd/23dd00b59286defb2ffeb54dabf4e0613e9366766092eb66a875da5ddf2e/dulwich-1.2.20-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:827d56649028ab1ca34d9d2bdec6dc5c6f108caf2074880a647474c342d37ca2", size = 1536152, upload-time = "2026-10-26T21:41:47.835Z" },
{ url = "https://files.pythonhosted.org/packages/93/dd/15d11f187435ffc5e45223f2fe04ed8c55df57904f40ba98920ea9381212/dulwich-1.2.20-cp315-cp315-win32.whl", hash = "sha256:72335dd97d552927aa3460ee3199953b28d12fa8c110e9fe383dc63f9c919cec", size = 1173853, upload-time = "2026-10-26T21:41:49.212Z" },
{ url = "https://files.pythonhosted.org/packages/77/e5/b3be3f8f8bcbd53b75cf4f9baf082b518b65699d0e1350b7abccb0ae7db4/dulwich-1.2.20-cp315-cp315-win_amd64.whl", hash = "sha256:56c4798acf8dc27bdea670fa8211d69e22c82663a21b10221c5133635a81fd92", size = 1130602, upload-time = "2026-10-26T21:41:50.555Z" },
{ url = "https://files.pythonhosted.org/packages/92/bd/15c18c882db82809e9e7c1ce6b275892cd89d24bead8fe158ccf9457f2e1/dulwich-1.2.20-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:dd1e931220d1c5b2c584d1e3b79f4eb06f46ed70bc48a7f927d79f4907a079cc", size = 1505493, upload-time = "2026-10-26T21:41:52.195Z" },
{ url = "https://files.pythonhosted.org/packages/55/fe/3fc67d5987f796091b17ecbbbc8abcd515b445e7d3533881a4cada92dbd6/dulwich-1.2.20-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b147b8066804160cdf71927826cfac19322a16b2816de979d20f36a1624af3c7", size = 1481699, upload-time = "2026-10-26T21:41:53.759Z" },
{ url = "https://files.pythonhosted.org/packages/93/88/b5259292509fcad7cbeabe70baf78f9dbee654a9ebf6716d50386215107c/dulwich-1.2.20-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:a68464a882d478f15835494cdb1b7f0bdc2c7aa4d1d4f96606467dc251841da9", size = 1557880, upload-time = "2026-10-26T21:41:55.223Z" },
{ url = "https://files.pythonhosted.org/packages/b8/26/c5373c21176fd5cb108ab94473efd5162e42471ba63516d9dc791a6ee7d7/dulwich-1.2.20-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:deeebe420db3387c38c3c18ed996a17a9a0b322fe72eaaa9e3fbb46a7e36cacb", size = 1536154, upload-time = "2026-10-26T21:41:56.871Z" },
{ url = "https://files.pythonhosted.org/packages/10/ea/2b70d9272ce8edd6e87e927e50bf7a66902530f648b57d3522898ab8eeea/dulwich-1.2.20-cp315-cp315t-win32.whl", hash = "sha256:4b03cc7be57a0ee37d1a53d09cbd99f9c8581fa04f94b3365e00498a32216b2c", size = 1113845, upload-time = "2026-10-26T21:41:58.521Z" },
{ url = "https://files.pythonhosted.org/packages/3e/1a/7ee4452d14e2b05f14f292c1725a092067f6d0485b3b0afce45805e2b676/dulwich-1.2.20-cp315-cp315t-win_amd64.whl", hash = "sha256:28cb103caffc60369b54159e41d2799d69d3107ac78b79bceaedc407977ec47d", size = 1131638, upload-time = "2026-10-26T21:42:00.005Z" },
{ url = "https://files.pythonhosted.org/packages/08/c8/217841f8ae98909dd2045cfd9e9060791ae1dc9c0d2239672ae318b28796/dulwich-1.2.20-py3-none-any.whl", hash = "sha256:ab521391c3475e7c507f14aeacad0cdf26e97ba5050d8010a1ff34e417c9a842", size = 758582, upload-time = "2026-10-26T21:42:01.525Z" },
]
[[package]] [[package]]
name = "envs" name = "envs"
version = "1.4" version = "1.4"
@@ -1187,6 +1227,10 @@ test = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "homeassistant" }, { name = "homeassistant" },
{ name = "reno" },
]
releasenotes = [
{ name = "reno" },
] ]
[package.metadata] [package.metadata]
@@ -1203,7 +1247,11 @@ requires-dist = [
provides-extras = ["dev", "test"] provides-extras = ["dev", "test"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "homeassistant", specifier = ">=2026.10.0" }] dev = [
{ name = "homeassistant", specifier = ">=2026.10.0" },
{ name = "reno", specifier = ">=4.1.0" },
]
releasenotes = [{ name = "reno", specifier = ">=4.1.0" }]
[[package]] [[package]]
name = "httpcore" name = "httpcore"
@@ -1692,6 +1740,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
] ]
[[package]]
name = "pbr"
version = "7.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/8d/ce438c28c7958e33184e8ac851ea2225b47a41e5e9e708fa3bddba631135/pbr-7.1.3.tar.gz", hash = "sha256:9a4a85b84e906337708009af0b5f5cdabeeb72d4dc213c9e97974da54fd9acc5", size = 140100, upload-time = "2026-10-07T10:38:15.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/a2/79a926b7ab54b247c3419bfa00cfbeb78ad495d21c6d787c978c6261623d/pbr-7.1.3-py2.py3-none-any.whl", hash = "sha256:6583e878a1d97cb135fdc509811f31b9235905cde8d4dacd3dbadf9efc45d745", size = 136717, upload-time = "2026-10-07T10:38:14.069Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.3.0" version = "12.3.0"
@@ -2491,6 +2551,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" },
] ]
[[package]]
name = "reno"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dulwich" },
{ name = "packaging" },
{ name = "pbr" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/a0/a2526ccefaf41717f7387fb5c068a094af0f5853822eb43c792e338d9e0b/reno-4.1.0.tar.gz", hash = "sha256:f992f1fdbd16215ec9de47af08131d53a2830c9e78439eb563ce8d6a7f625370", size = 86657, upload-time = "2024-03-04T07:40:35.552Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/4f/e933aae43bfaea4f6646009379036bec42d78ee2231ab9a983317c3a1c4b/reno-4.1.0-py3-none-any.whl", hash = "sha256:9b6a2cb768ffb7f7c74bbd76822acff70840a1219f45bcec5080dbc108df4f96", size = 72126, upload-time = "2024-03-04T07:40:33.517Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.34.2" version = "2.34.2"
@@ -2580,6 +2655,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/b6/48339c109bab6f54ff608800773b9425464c6cbf7fd3f2ba01294d78be3d/sentence_stream-1.3.0-py3-none-any.whl", hash = "sha256:7448d131315b85eefdf238e5edd9caa62899acf609145d5e0e10c09812eb8a1d", size = 8707, upload-time = "2026-01-08T16:25:05.918Z" }, { url = "https://files.pythonhosted.org/packages/48/b6/48339c109bab6f54ff608800773b9425464c6cbf7fd3f2ba01294d78be3d/sentence_stream-1.3.0-py3-none-any.whl", hash = "sha256:7448d131315b85eefdf238e5edd9caa62899acf609145d5e0e10c09812eb8a1d", size = 8707, upload-time = "2026-01-08T16:25:05.918Z" },
] ]
[[package]]
name = "setuptools"
version = "84.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"