Skip to content
30 changes: 15 additions & 15 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,7 @@ under Unreleased and move into a version section at release time.

## Unreleased

### Fixed

- Enabling the `huggingface` hub no longer imports unused transformers model
modules. To find references to `hf_hub_download` and `snapshot_download`,
the hub tracker looked up the name on every loaded module, and transformers'
lazy modules import code on lookup. With transformers loaded this imported
about 140 extra modules, took about 0.15 s and logged about 430 "failed to
patch" debug lines. The tracker now reads each module's namespace instead.

## 0.3.0 - 2026-10-21
## 0.3.0 - 2026-10-24

### Added

Expand Down Expand Up @@ -48,10 +39,6 @@ under Unreleased and move into a version section at release time.
and environment configuration. `gguf` remains a compatibility alias; `all`
selects the integration once under its new name. Model format metadata
remains `gguf`.
- Automatic integrations no longer attach raw payloads, including GGUF prompts,
chat messages and replies. `attachments_enabled=True` enables uploads only for
attachments explicitly passed to `track_inference(attachments=[...])`.
Apps relying on automatic GGUF capture must supply attachments explicitly.
- `wildedge.init(dsn=...)` reuses the existing default client when the DSN
points to the same project, for example `dsn=os.environ["WILDEDGE_DSN"]`
under `wildedge run`. It used to create a second client.
Expand All @@ -76,6 +63,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 All @@ -85,7 +78,8 @@ under Unreleased and move into a version section at release time.
calls, the generation config (`temperature`, `top_p`, `top_k`, `max_tokens`,
`repeat_penalty`, penalties, `seed`, stop sequence count), and, for chat
calls, `prompt_type="chat"` and `turn_index` measured on the last user
message.
message. Prompts, chat messages and replies are never attached. As before,
attachments upload only when passed to `track_inference(attachments=[...])`.
- `import wildedge` no longer imports every installed ML framework and API
client. Each library is imported when its integration is activated or a
model from it is registered, so SDK import is faster and a broken unrelated
Expand Down Expand Up @@ -142,6 +136,12 @@ under Unreleased and move into a version section at release time.
queue, so events were duplicated and, depending on timing, later events
could be removed without being sent. This showed up when a process started
with events saved by an earlier run and flushed soon after.
- Enabling the `huggingface` hub no longer imports unused transformers model
modules. To find references to `hf_hub_download` and `snapshot_download`,
the hub tracker looked up the name on every loaded module, and transformers'
lazy modules import code on lookup. With transformers loaded this imported
about 140 extra modules, took about 0.15 s and logged about 430 "failed to
patch" debug lines. The tracker now reads each module's namespace instead.

## 0.2.2 - 2026-09-30

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
Loading
Loading