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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
### Fixed

- Show a changelog that opens with a list, a quote, or a table without a blank line above it. Closes [#368](https://github.com/ICTU/update-time/issues/368).
- Show a changelog heading that opens with non-breaking spaces without indenting it. Closes [#369](https://github.com/ICTU/update-time/issues/369).

## 0.0.38 - 2026-09-18

Expand Down
20 changes: 17 additions & 3 deletions src/update_time/io/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
import re
from itertools import pairwise
from logging import WARNING
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -140,18 +141,31 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR


class _ChangelogMarkdown(Markdown):
"""Markdown that shows raw HTML, renders shortcodes as emoji, and drops Rich's line break above its first block."""
"""Markdown that shows raw HTML, renders shortcodes as emoji, unindents headings, and drops a leading line break."""

elements: ClassVar = {**Markdown.elements, "html_block": _RawHtml}

def __init__(self, markup: str, *, hyperlinks: bool) -> None:
"""Parse the changes, replacing the shortcodes in every token the reader reads as text."""
"""Parse the changes, then replace their shortcodes and unindent their headings."""
super().__init__(markup, hyperlinks=hyperlinks)
for token in self.parsed:
self._replace_shortcodes(self.parsed)
self._unindent_headings(self.parsed)

@staticmethod
def _replace_shortcodes(tokens: list[Token]) -> None:
"""Replace the shortcodes written in prose or raw HTML with their emoji."""
for token in tokens:
for part in (token, *(token.children or ())):
if part.type in _READ_AS_TEXT:
part.content = Emoji.replace(part.content)

@staticmethod
def _unindent_headings(tokens: list[Token]) -> None:
"""Strip the non-breaking spaces opening a heading's title, since Markdown strips only the ordinary ones."""
for opening, title in pairwise(tokens):
if opening.type == "heading_open" and title.children and title.children[0].type == "text":
title.children[0].content = title.children[0].content.lstrip()

def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Render the changes, without the line break Rich writes above the list, quote, or table that opens them."""
for index, segment in enumerate(super().__rich_console__(console, options)):
Expand Down
33 changes: 33 additions & 0 deletions tests/update_time/io/test_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,39 @@ def test_changes_render_as_markdown(self):
self.assertIn("PyPI page: coverage 7.16.0", rendered)
self.assertNotIn("[coverage 7.16.0]", rendered)

@kills(
Mutation(
console_module._ChangelogMarkdown._unindent_headings,
"title.children[0].content.lstrip()",
"title.children[0].content",
"a heading opening with non-breaking spaces is indented by them",
),
Mutation(
console_module._ChangelogMarkdown._unindent_headings,
' and title.children[0].type == "text":',
":",
"a code span opening a heading loses the spaces the project wrote in it",
),
Mutation(
console_module._ChangelogMarkdown._unindent_headings,
"title.children[0].content.lstrip()",
'title.children[0].content.replace("\\xa0", "")',
"a heading loses the non-breaking spaces inside its title, running its words together",
),
)
def test_a_heading_drops_only_the_non_breaking_spaces_opening_its_text(self):
"""Test that a heading keeps its spaces, in code or out, except the non-breaking ones opening its title."""
cases = {
"text": ("###    🚀 Features", "🚀 Features"),
"link": ("#####     [View changes on GitHub](https://github.com)", "View changes"),
"code": ("### ` indented()`", " indented()"),
"inside": ("###  A B", "A\xa0B"),
}
for case, (markup, title) in cases.items():
with self.subTest(case=case):
rendered = self.rendered_changes(Changes(markup, markdown=True))
self.assertIn(f"{_BOX_SIDE} {title}", rendered)

@kills(
Mutation(
console_module,
Expand Down
2 changes: 2 additions & 0 deletions tools/prose-whitelist.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"held" stayed at 1.0 (e.g. min-release-age held back every newer release), "untracked" has no version:.
"held" stayed at 1.0 (e.g. minimumReleaseAge held back every newer release), "untracked" has no version:.
###    🚀 Features.
#####     [View changes on GitHub](https://github.com).
**In an `update-time` field**, for a reference in a file that can hold no comment.
- the sample log lines the sections below quote, from `log_samples`, each filling the placeholder it names.
A CVSS vector the `cvss` package refuses, since `AV:X` is not a value the metric can take.
Expand Down
2 changes: 1 addition & 1 deletion tools/vulture-whitelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
_.StartElementHandler # unused attribute (src/update_time/formats/xml.py:85)
_.CharacterDataHandler # unused attribute (src/update_time/formats/xml.py:86)
_.EndElementHandler # unused attribute (src/update_time/formats/xml.py:87)
_.create # unused method (src/update_time/io/console.py:120)
_.create # unused method (src/update_time/io/console.py:122)
tag_last_pushed # unused variable (src/update_time/sources/docker_hub.py:59)
download_url # unused variable (src/update_time/sources/github.py:90)
database_specific # unused variable (src/update_time/sources/osv.py:52)
Expand Down
Loading