# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Shared rendering of dependency-update reports.
The markdown diff, held-back, and cooldown-bypass tables, the release-notes
sections, and the comparison URLs that every updater's PR body and terminal
output route through: `sync-uv-lock`, `sync-deps`, `sync-dep-sources`, the
three version-sync bumpers (`sync-tool-versions`, `sync-action-pins`,
`sync-workflow-pins`), and `audit --fix`.
The computations stay with their datasources ({mod}`repomatic.uv` for the lock,
{mod}`repomatic.version_sync` for GitHub/PyPI/npm); this module only renders
their results.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, datetime, timedelta
import arrow
from .github.pr_body import demote_markdown_headings, sanitize_markdown_mentions
from .github.releases import get_github_release_body
from .pypi import (
PYPI_PACKAGE_URL,
get_changelog_url as get_pypi_changelog_url,
get_release_dates as get_pypi_release_dates,
get_source_url as get_pypi_source_url,
)
from .tabular import render_markdown_table
from .version_sync import safe_version
RELEASE_NOTES_MAX_LENGTH = 2000
"""Maximum characters per package release body before truncation."""
# ---------------------------------------------------------------------------
# Shared primitives
# ---------------------------------------------------------------------------
[docs]
def link_name(name: str, name_urls: dict[str, str] | None) -> str:
"""Render a table's subject cell, linked when a URL is known for it.
:param name: Package, action or tool name.
:param name_urls: Mapping of names to their URL. Names absent from it (or
a `None` mapping) render as plain text.
:return: A markdown link, or the bare name.
"""
if name_urls and name in name_urls:
return f"[{name}]({name_urls[name]})"
return name
[docs]
def markdown_section(
heading: str,
note: str,
headers: tuple[str, ...],
rows: list[tuple[str, ...]],
) -> str:
"""Assemble a report section: heading, optional intro, and a table.
The one place the section layout is defined, so every updater's PR body
keeps the same shape. The alignment row is derived from *headers* rather
than written out, which is what stops a column from being added to one
and not the other.
:param heading: Full heading line, emoji included, without the `## `.
Omitted when empty, for a caller embedding the table under a title of
its own (the release PR's blocker banner sits inside a `[!CAUTION]`
blockquote that already says what it is).
:param note: Intro paragraph shown between heading and table. Omitted
when empty.
:param headers: Column titles.
:param rows: One tuple of pre-rendered cells per row, each as long as
*headers*.
:return: The rendered markdown, with no trailing newline.
"""
lines = [f"## {heading}", ""] if heading else []
if note:
lines += [note, ""]
lines.append(render_markdown_table(headers, rows, align=("left",) * len(headers)))
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Date parsing and formatting
# ---------------------------------------------------------------------------
[docs]
def parse_iso_datetime(value: str) -> datetime | None:
"""Parse an ISO 8601 / RFC 3339 timestamp into a timezone-aware datetime.
The package-wide parser for any timestamp an external service writes:
besides this module's own upload times, {mod}`repomatic.uv` reads lock
timestamps, {mod}`repomatic.cloudflare` token expiries and
{mod}`repomatic.github.job_timings` job clocks through it, so every
consumer tolerates the same shapes.
Uses arrow, so a nanosecond fractional second and a `Z` suffix (both of
which Python 3.10's stdlib `datetime.fromisoformat` rejects) parse cleanly;
sub-microsecond precision is truncated to fit `datetime`.
arrow also supplies the `.humanize()` relative-time phrasing used in the
sync report. whenever was the prior parser but has no humanizer; switch
back once one lands: https://github.com/ariebovenberg/whenever/discussions/277
:param value: An ISO 8601 / RFC 3339 instant, or empty.
:return: A timezone-aware {class}`~datetime.datetime`, or `None` when
*value* is empty or not a valid instant.
"""
if not value:
return None
try:
return arrow.get(value).datetime
except (ValueError, TypeError):
return None
# ---------------------------------------------------------------------------
# Diff table
# ---------------------------------------------------------------------------
[docs]
def pypi_name_urls(changes: list[tuple[str, str, str]]) -> dict[str, str]:
"""Map each changed package name to its PyPI project URL.
Convenience for {func}`format_diff_table`'s `name_urls` when the changes
come from a PyPI-resolved source (`sync-uv-lock`, `fix-vulnerable-deps`).
"""
return {name: PYPI_PACKAGE_URL.format(package=name) for name, _old, _new in changes}
# ---------------------------------------------------------------------------
# Held-back-by-cooldown table
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class HeldBackPackage:
"""A newer release withheld from the lock by the `exclude-newer` cooldown.
Built by {func}`repomatic.uv.compute_held_back_packages` for the `## Held
back by cooldown` report section: a package has already published a newer
version, but it is still inside the cooldown window, so `uv lock --upgrade`
keeps the older {attr}`locked_version`.
"""
name: str
"""Package name, as it appears on PyPI."""
locked_version: str
"""Version held in the lock: the newest release outside the cooldown."""
available_version: str
"""Newer version already on the index, still inside the cooldown window."""
released: str
"""Upload date of {attr}`available_version` (`YYYY-MM-DD`), or empty when
the lock records no upload time (a git or path source)."""
eligible: str
"""Date {attr}`available_version` leaves the cooldown and becomes lockable,
with a human-readable countdown (`2026-06-25 (in 4 days)`), or empty when
it cannot be computed."""
EXCLUDE_NEWER_HELD_BACK_NOTE = (
"Newer releases already published but withheld because they are still"
" inside the [`exclude-newer`](https://docs.astral.sh/uv/reference/"
"settings/#exclude-newer) cooldown window."
)
"""Intro paragraph for the `sync-uv-lock` held-back section.
The {mod}`repomatic.version_sync` updaters pass their own `minimum-release-age`
wording to {func}`format_held_back_table` instead.
"""
HELD_BACK_COLUMNS = ("Locked", "Available", "Released", "Eligible")
"""Held-back columns following the caller-supplied subject column.
Shared with {func}`repomatic.sync_ops.print_held_back_table`, so a run's
markdown PR body and its terminal table name the same columns in the same
order instead of drifting apart as two hand-kept literals.
"""
[docs]
def build_held_back(
name: str,
pinned: str,
available: str,
available_date: str,
min_age: timedelta,
today: date,
) -> HeldBackPackage:
"""Assemble a {class}`HeldBackPackage` row from raw selection data.
The formatting half of the version-sync held-back report:
{func}`repomatic.version_sync.select_held_back` picks the withheld
candidate, and this turns its raw version and upload date into the same
`released`/`eligible` strings
{func}`repomatic.uv.compute_held_back_packages` produces for uv, so
{func}`format_held_back_table` renders both identically. Unlike the uv
path, no second resolution is needed: the candidates are already in hand
from the datasource sweep.
:param name: Display name (package, action slug, or tool).
:param pinned: Version this run settled on (held in place by the cooldown).
:param available: The newer version still inside the cooldown window.
:param available_date: Upload date of *available* (`YYYY-MM-DD`), or empty.
:param min_age: The `minimum-release-age` cooldown width.
:param today: Reference date for the relative countdown.
:return: A populated {class}`HeldBackPackage`.
"""
released = format_released(available_date, today)
eligible = ""
upload_dt = parse_iso_datetime(available_date)
if upload_dt is not None:
eligible = format_eligible((upload_dt + min_age).date(), today)
return HeldBackPackage(name, pinned, available, released, eligible)
# ---------------------------------------------------------------------------
# Cooldown-bypass table
# ---------------------------------------------------------------------------
BYPASS_NEEDS_RELEASE = "needs release"
"""Expiry placeholder for a freeze holding an unreleased version.
A fixed-timestamp `exclude-newer-package` entry whose held version has no
upload time in the lock (a git, path, or otherwise unpublished source) can
never age past the rolling `exclude-newer` cutoff on its own: the freeze only
ends once the package ships a release the lock can adopt. The markdown report
renders the marker in italics to set it apart from real dates.
"""
[docs]
@dataclass(frozen=True)
class BypassForecast:
"""A cooldown-bypass freeze and the date it self-clears.
Built by {func}`repomatic.uv.compute_bypass_forecasts` (freezes still
active) and {func}`repomatic.uv.compute_pruned_forecasts` (freezes the run
just cleared) for the `## βοΈ Cooldown bypasses` report section: a
fixed-timestamp `exclude-newer-package` entry holds {attr}`name` at
{attr}`held_version` until that version ages past the `exclude-newer`
cutoff, at which point `sync-uv-lock` prunes the entry and the package
resumes normal cooldown resolution.
"""
name: str
"""Package name, as it appears on PyPI."""
held_version: str
"""Version the freeze holds in the lock."""
expires: str
"""Date the freeze expires and the entry is pruned, with a human-readable
countdown (`2026-07-08 (in 2 days)`, in the past for an already-cleared
freeze), {data}`BYPASS_NEEDS_RELEASE` when the held version has no upload
time in the lock, or empty when there is no rolling `exclude-newer` span
to forecast against."""
BYPASS_SECTION_NOTE = (
"Packages pulled in ahead of the cooldown by an [`exclude-newer-package`]"
"(https://docs.astral.sh/uv/reference/settings/#exclude-newer-package)"
" freeze. Each entry is cleared from `pyproject.toml` automatically once"
" its held version ages past the `exclude-newer` cutoff."
)
"""Intro paragraph for the `sync-uv-lock` cooldown-bypasses section."""
BYPASS_COLUMNS = ("Package", "Held at", "Held until")
"""Columns of the cooldown-bypass table.
Shared with {func}`repomatic.sync_ops.print_bypass_table` for the reason
{data}`HELD_BACK_COLUMNS` is.
"""
# ---------------------------------------------------------------------------
# GitHub release notes
# ---------------------------------------------------------------------------
def _versions_in_range(package: str, old: str, new: str) -> list[str]:
"""Return PyPI versions of *package* in the half-open range `(old, new]`.
Versions are sorted in ascending order. Falls back to `[new]` if no
intermediate versions are found or PyPI is unreachable.
"""
releases = get_pypi_release_dates(package)
if not releases:
return [new]
old_v = safe_version(old)
new_v = safe_version(new)
if old_v is None or new_v is None:
return [new]
intermediate = []
for version_str in releases:
v = safe_version(version_str)
if v is None:
continue
if old_v < v <= new_v:
intermediate.append((v, version_str))
if not intermediate:
return [new]
intermediate.sort()
return [s for _, s in intermediate]
[docs]
def fetch_release_notes(
changes: list[tuple[str, str, str]],
) -> dict[str, tuple[str, list[tuple[str, str]]]]:
"""Fetch release notes for all updated packages.
For each package with a new version, discovers the GitHub repository via
PyPI and fetches the release notes from GitHub Releases for all versions
in the range `(old, new]`. Falls back to a changelog link from PyPI
`project_urls` when no GitHub Release exists.
:param changes: List of `(name, old_version, new_version)` tuples.
:return: A dict mapping package names to `(repo_url, versions)` tuples
where `versions` is a list of `(tag, body)` pairs sorted ascending.
Only packages with at least one non-empty body are included. When a
changelog URL is used as fallback, `tag` is empty and `body`
contains a markdown link.
"""
notes: dict[str, tuple[str, list[tuple[str, str]]]] = {}
for name, old, new in changes:
if not new:
# Skip removed packages.
continue
repo_url = get_pypi_source_url(name)
if not repo_url:
logging.debug(f"No GitHub URL found for {name}.")
continue
# Discover all versions in the range (old, new].
versions_to_fetch = _versions_in_range(name, old, new) if old else [new]
fetched: list[tuple[str, str]] = []
for version in versions_to_fetch:
tag, body = get_github_release_body(repo_url, version)
if body:
fetched.append((tag, body))
if not fetched:
# Fallback: link to a changelog page from PyPI project_urls.
changelog_url = get_pypi_changelog_url(name)
if changelog_url:
fetched.append(("", f"[Changelog]({changelog_url})"))
logging.debug(f"Using PyPI changelog URL for {name}: {changelog_url}")
else:
logging.debug(f"No release body or changelog for {name} {new}.")
if fetched:
notes[name] = (repo_url, fetched)
return notes
[docs]
def build_comparison_urls(
changes: list[tuple[str, str, str]],
notes: dict[str, tuple[str, list[tuple[str, str]]]],
) -> dict[str, str]:
"""Build GitHub comparison URLs from version changes and release notes.
Uses the tag format discovered by {func}`fetch_release_notes` to construct
comparison URLs. Only packages with both old and new versions and a known
GitHub repository are included.
A package whose notes carry no tag at all is skipped. That happens when
{func}`fetch_release_notes` found no GitHub release for the range and fell
back to a changelog link, which is positive evidence that the tags this
URL would name do not exist: guessing a `v` prefix there yields a 404 in
the PR body.
:param changes: List of `(name, old_version, new_version)` tuples.
:param notes: Release notes dict as returned by {func}`fetch_release_notes`.
:return: Dict mapping package names to GitHub comparison URLs.
"""
urls: dict[str, str] = {}
for name, old, new in changes:
if not old or not new or name not in notes:
continue
repo_url, versions = notes[name]
# Determine tag prefix from the first discovered tag.
tags = [tag for tag, _ in versions if tag]
if not tags:
continue
prefix = "v" if tags[0].startswith("v") else ""
urls[name] = f"{repo_url}/compare/{prefix}{old}...{prefix}{new}"
return urls