mirror of
https://github.com/tykeal/homeassistant-rental-control.git
synced 2026-11-06 05:17:23 +00:00
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:
1 parent
0653b1904c
commit
617bee6227
14 files changed
+713
-4
No files matched your search
@@ -85,10 +85,15 @@ autolabeler:
|
||||
- label: "build"
|
||||
title:
|
||||
- '/^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
|
||||
template: |
|
||||
header: |+
|
||||
[](https://github.com/$OWNER/$REPOSITORY/releases/v$RESOLVED_VERSION)
|
||||
|
||||
template: |
|
||||
$CHANGES
|
||||
|
||||
## Links
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 3
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# Harden the runner used by this workflow
|
||||
# yamllint disable-line rule:line-length
|
||||
@@ -31,5 +31,31 @@ jobs:
|
||||
with:
|
||||
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
|
||||
- uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
|
||||
with:
|
||||
config-name: 'file:.github/release-drafter-generated.yml'
|
||||
@@ -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
|
||||
@@ -24,3 +24,5 @@ Thumbs.db
|
||||
*.swp
|
||||
.idea/
|
||||
.worktrees/
|
||||
# Written by the release-drafter workflow from .github/release-drafter.yml
|
||||
.github/release-drafter-generated.yml
|
||||
@@ -260,6 +260,15 @@ from the repository root using `uv`:
|
||||
- All tests must pass before pushing
|
||||
- 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
|
||||
|
||||
1. Make changes to code
|
||||
|
||||
@@ -923,3 +923,24 @@ All commits must pass [pre-commit](https://pre-commit.com/) checks:
|
||||
pre-commit install
|
||||
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
|
||||
```
|
||||
@@ -41,3 +41,9 @@ path = ".aislop/baseline.json"
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "2026 Andrew Grimberg <tykeal@bardicgrove.org>"
|
||||
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"
|
||||
@@ -66,6 +66,12 @@ packages = ["custom_components"]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"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]
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Maintainer scripts for the repository."""
|
||||
Executable
+244
@@ -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())
|
||||
@@ -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"
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.14.2"
|
||||
|
||||
[[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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "envs"
|
||||
version = "1.4"
|
||||
@@ -1187,6 +1227,10 @@ test = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "homeassistant" },
|
||||
{ name = "reno" },
|
||||
]
|
||||
releasenotes = [
|
||||
{ name = "reno" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -1203,7 +1247,11 @@ requires-dist = [
|
||||
provides-extras = ["dev", "test"]
|
||||
|
||||
[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]]
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pillow"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "requests"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
|
||||
Reference in new issue
Block a user