Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ under Unreleased and move into a version section at release time.

### Fixed

- Every error event now caps `error_message` at 500 characters, keeping the
start and the end of a longer message. Integrations used to cut it to the
first 200, which lost the cause in messages that end with it, such as
mlx-whisper audio errors that start with the ffmpeg banner. Manual
`track_error()` calls had no limit at all. `provider_error_code` is capped
at 64 characters for every event.
- The llama.cpp integration now tracks `Llama.create_chat_completion()` and
`Llama.create_completion()`, streaming or not. It used to patch only
`Llama.__call__`, so chat apps on llama-cpp-python reported model loads but
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
Releases follow a `release/<version>` branching flow:

1. Cut a `release/<version>` branch from `devel`.
2. Bump the version in `pyproject.toml`.
2. Bump the version in `pyproject.toml` and move the Unreleased entries in `CHANGELOG.md` into a `## <version> - <date>` section. The release notes are that section, so the release fails without it.
3. Open a pull request targeting `main`. CI will automatically run the full compatibility matrix and post a changelog preview comment.
4. Once merged, tag the commit as `v<version>`. The release workflow publishes to PyPI and creates a GitHub release.

Expand Down
42 changes: 23 additions & 19 deletions scripts/build_changelog_comment.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#!/usr/bin/env python3
"""Generate changelog text for release PRs and for tag-build release notes.

The notes are the version's section of CHANGELOG.md, so they list what changed
for users rather than every commit and merge since the previous tag.

The two callers differ only in wording: release-pr.yml renders a preview of
notes that do not exist yet, while release.yml (which sets TAG_NAME) renders
the notes themselves.
Expand All @@ -13,6 +16,8 @@
import subprocess
from pathlib import Path

CHANGELOG = Path("CHANGELOG.md")


def get_version() -> str:
# Regex instead of tomllib so the script also runs on Python 3.10.
Expand All @@ -24,9 +29,6 @@ def get_version() -> str:
return match.group(1)


RELEASE_COMMIT_RE = re.compile(r"^Release \d+\.\d+\.\d+$")


def get_previous_tag() -> str | None:
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^"],
Expand All @@ -37,26 +39,28 @@ def get_previous_tag() -> str | None:
return tag if result.returncode == 0 and tag else None


def get_commits_since(prev_tag: str | None) -> list[str]:
revision = f"{prev_tag}..HEAD" if prev_tag else "HEAD"
result = subprocess.run(
["git", "log", revision, "--pretty=format:%s"],
capture_output=True,
text=True,
check=True,
)
lines = [line for line in result.stdout.splitlines() if line.strip()]
return [line for line in lines if not RELEASE_COMMIT_RE.match(line)]
def changelog_section(text: str, version: str) -> str:
"""Body of the ``## <version>`` section, up to the next ``## `` heading."""
heading = re.compile(rf"^## \[?{re.escape(version)}\]?(?:\s|$)", re.MULTILINE)
match = heading.search(text)
if match is None:
raise SystemExit(f"CHANGELOG.md has no section for {version}")
line_end = text.find("\n", match.start())
start = len(text) if line_end == -1 else line_end + 1
end = re.compile(r"^## ", re.MULTILINE).search(text, start)
body = text[start : end.start() if end else len(text)].strip()
if not body:
raise SystemExit(f"CHANGELOG.md section for {version} is empty")
return body


def build_comment(
tag_name: str,
commits: list[str],
notes: str,
prev_tag: str | None,
*,
preview: bool = True,
) -> str:
items = "\n".join(f"- {c}" for c in commits) if commits else "- No changes."
range_str = f"{prev_tag}...{tag_name}" if prev_tag else tag_name
heading = "Changelog preview for" if preview else "Changelog for"
blurb = (
Expand All @@ -67,22 +71,22 @@ def build_comment(
return (
f"## {heading} `{tag_name}`\n\n"
f"{blurb}"
f"**What's Changed**\n\n{items}\n\n"
f"{notes}\n\n"
f"**Full Changelog**: {range_str}\n"
)


def main() -> None:
output = os.environ.get("OUTPUT", "/tmp/changelog-preview.md")
# release.yml sets TAG_NAME on tag builds; release-pr.yml never does, so its
# release.yml sets TAG_NAME on tag builds and release-pr.yml never does. Its
# absence is what distinguishes a PR preview from the real release notes.
tag_name = os.environ.get("TAG_NAME")
preview = not tag_name
tag_name = tag_name or f"v{get_version()}"

notes = changelog_section(CHANGELOG.read_text(), tag_name.removeprefix("v"))
prev_tag = get_previous_tag()
commits = get_commits_since(prev_tag)
Path(output).write_text(build_comment(tag_name, commits, prev_tag, preview=preview))
Path(output).write_text(build_comment(tag_name, notes, prev_tag, preview=preview))


if __name__ == "__main__":
Expand Down
99 changes: 70 additions & 29 deletions tests/test_build_changelog_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@
import importlib.util
from pathlib import Path

import pytest

SCRIPT = Path(__file__).parent.parent / "scripts" / "build_changelog_comment.py"

COMMITS = ["Add a thing (#1)", "Fix another thing (#2)"]
CHANGELOG = """# Changelog

Intro text.

## Unreleased

- Not released yet.

## 1.2.3 - 2026-10-21

### Added

- A thing.

### Fixed

- Another thing.

## 1.2.2 - 2026-09-30

### Fixed

- An older thing.
"""


def load_script():
Expand All @@ -15,58 +40,74 @@ def load_script():
return module


def test_notes_are_the_version_section_of_the_changelog():
module = load_script()
notes = module.changelog_section(CHANGELOG, "1.2.3")

assert notes == "### Added\n\n- A thing.\n\n### Fixed\n\n- Another thing."


def test_last_section_runs_to_the_end_of_the_file():
module = load_script()
assert (
module.changelog_section(CHANGELOG, "1.2.2") == "### Fixed\n\n- An older thing."
)


def test_bracketed_version_headings_are_found():
module = load_script()
text = "## [2.0.0] - 2026-11-01\n\n- New.\n"
assert module.changelog_section(text, "2.0.0") == "- New."


def test_version_prefix_does_not_match_a_longer_version():
module = load_script()
with pytest.raises(SystemExit, match=r"no section for 1\.2$"):
module.changelog_section(CHANGELOG, "1.2")


def test_missing_or_empty_section_fails_the_build():
module = load_script()
with pytest.raises(SystemExit, match=r"no section for 9\.9\.9"):
module.changelog_section(CHANGELOG, "9.9.9")
with pytest.raises(SystemExit, match="is empty"):
module.changelog_section("## 1.0.0\n\n## 0.9.0\n\n- Old.\n", "1.0.0")


def test_preview_wording_on_pull_requests():
module = load_script()
text = module.build_comment("v1.2.3", COMMITS, "v1.2.2", preview=True)
text = module.build_comment("v1.2.3", "- A thing.", "v1.2.2", preview=True)

assert text.startswith("## Changelog preview for `v1.2.3`")
assert "> Preview of the release notes" in text
assert "- A thing." in text
assert "**Full Changelog**: v1.2.2...v1.2.3" in text
for commit in COMMITS:
assert f"- {commit}" in text


def test_release_notes_drop_the_preview_wording():
"""Tag builds render the notes themselves, not a preview of them."""
module = load_script()
text = module.build_comment("v1.2.3", COMMITS, "v1.2.2", preview=False)
text = module.build_comment("v1.2.3", "- A thing.", "v1.2.2", preview=False)

assert text.startswith("## Changelog for `v1.2.3`")
assert "preview" not in text.lower()
assert "**Full Changelog**: v1.2.2...v1.2.3" in text
for commit in COMMITS:
assert f"- {commit}" in text


def test_first_release_falls_back_to_the_tag_alone():
module = load_script()
text = module.build_comment("v0.1.0", COMMITS, None, preview=False)
text = module.build_comment("v0.1.0", "- First.", None, preview=False)

assert "**Full Changelog**: v0.1.0" in text


def test_empty_commit_range_is_reported():
module = load_script()
text = module.build_comment("v1.2.3", [], "v1.2.2", preview=False)

assert "- No changes." in text


def test_release_commits_are_filtered_from_the_log(monkeypatch):
def test_get_version_reads_pyproject_without_tomllib():
module = load_script()
log = "Release 1.2.3\nAdd a thing (#1)\n\nBump version to 1.2.3\n"
monkeypatch.setattr(
module.subprocess,
"run",
lambda *a, **k: type("R", (), {"stdout": log, "returncode": 0})(),
)

assert module.get_commits_since("v1.2.2") == [
"Add a thing (#1)",
"Bump version to 1.2.3",
]
assert module.get_version().count(".") >= 1


def test_get_version_reads_pyproject_without_tomllib():
def test_current_version_has_a_changelog_section():
"""Catches a release whose CHANGELOG.md entries were left under Unreleased."""
module = load_script()
assert module.get_version().count(".") >= 1
changelog = (SCRIPT.parent.parent / "CHANGELOG.md").read_text()
assert module.changelog_section(changelog, module.get_version())
28 changes: 28 additions & 0 deletions tests/test_event_serialization.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from wildedge import constants
from wildedge.events import common
from wildedge.events.error import ErrorCode, ErrorEvent
from wildedge.events.feedback import FeedbackEvent, FeedbackType
Expand Down Expand Up @@ -77,6 +78,33 @@ def test_feedback_event_enum_and_string_forms():
assert string_event.to_dict()["feedback"]["feedback_type"] == "reject"


def test_error_event_caps_message_and_provider_code():
event = ErrorEvent(
model_id="m1",
error_code=ErrorCode.UNKNOWN,
error_message="x" * 5000,
provider_error_code="c" * 500,
).to_dict()["error"]
assert len(event["error_message"]) == constants.ERROR_MSG_MAX_LEN
assert len(event["provider_error_code"]) == constants.PROVIDER_ERROR_CODE_MAX_LEN


def test_long_error_message_keeps_start_and_end():
message = "ffmpeg banner " * 100 + "Error opening input file audio.wav"
sent = ErrorEvent(
model_id="m1", error_code=ErrorCode.UNKNOWN, error_message=message
).to_dict()["error"]["error_message"]
assert len(sent) == constants.ERROR_MSG_MAX_LEN
assert sent.startswith("ffmpeg banner")
assert sent.endswith("Error opening input file audio.wav")
assert " … " in sent


def test_short_error_message_is_unchanged():
event = ErrorEvent(model_id="m1", error_code="UNKNOWN", error_message="boom")
assert event.to_dict()["error"]["error_message"] == "boom"


def test_span_event_to_dict_includes_required_fields():
event = SpanEvent(
kind="tool",
Expand Down
2 changes: 1 addition & 1 deletion wildedge/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
IDLE_POLL_INTERVAL = 5.0

# Error message truncation
ERROR_MSG_MAX_LEN = 200
ERROR_MSG_MAX_LEN = 500
PROVIDER_ERROR_CODE_MAX_LEN = 64

# Device ID persistence
Expand Down
7 changes: 2 additions & 5 deletions wildedge/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import time
from typing import TYPE_CHECKING, Any

from wildedge import constants
from wildedge.timing import elapsed_ms

if TYPE_CHECKING:
Expand Down Expand Up @@ -87,7 +86,7 @@ def wrapper(*args, **kwargs):
if self.capture_errors:
self.handle.track_error(
error_code="UNKNOWN",
error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN],
error_message=str(exc),
**self._correlation,
)
raise
Expand All @@ -106,9 +105,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
if self.capture_errors:
self.handle.track_error(
error_code="UNKNOWN",
error_message=str(exc_val)[: constants.ERROR_MSG_MAX_LEN]
if exc_val
else None,
error_message=str(exc_val) if exc_val else None,
**self._correlation,
)
else:
Expand Down
34 changes: 32 additions & 2 deletions wildedge/events/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from enum import Enum
from typing import Any

from wildedge import constants
from wildedge.events.common import add_optional_fields


Expand All @@ -19,6 +20,29 @@ class ErrorCode(str, Enum):
UNKNOWN = "UNKNOWN"


ELLIPSIS = " … "


def truncate(value: str | None, limit: int) -> str | None:
return None if value is None else str(value)[:limit]


def truncate_message(value: str | None, limit: int) -> str | None:
"""Keep the start and the end of a long message.

Errors put their detail at either end. mlx-whisper prints the ffmpeg banner
first and the failure last, and Hugging Face Hub starts with a request id.
"""
if value is None:
return None
value = str(value)
if len(value) <= limit:
return value
keep = limit - len(ELLIPSIS)
head = keep - keep // 2
return value[:head] + ELLIPSIS + value[len(value) - keep // 2 :]


@dataclass
class ErrorEvent:
model_id: str
Expand Down Expand Up @@ -49,11 +73,17 @@ def to_dict(self) -> dict:
error_data = add_optional_fields(
{"error_code": code},
{
"error_message": self.error_message,
# Capped here, so every error event gets the same limit,
# from an integration or a manual track_error() call.
"error_message": truncate_message(
self.error_message, constants.ERROR_MSG_MAX_LEN
),
"stack_trace_hash": self.stack_trace_hash,
"related_event_id": self.related_event_id,
"http_status": self.http_status,
"provider_error_code": self.provider_error_code,
"provider_error_code": truncate(
self.provider_error_code, constants.PROVIDER_ERROR_CODE_MAX_LEN
),
"duration_ms": self.duration_ms,
},
)
Expand Down
Loading
Loading