markrender 1.1.1__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- markrender-1.2.0/CHANGELOG.md +46 -0
- {markrender-1.1.1/markrender.egg-info → markrender-1.2.0}/PKG-INFO +14 -2
- {markrender-1.1.1 → markrender-1.2.0}/README.md +13 -1
- {markrender-1.1.1 → markrender-1.2.0}/markrender/_version.py +2 -2
- {markrender-1.1.1 → markrender-1.2.0}/markrender/formatters.py +57 -25
- {markrender-1.1.1 → markrender-1.2.0}/markrender/parser.py +16 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender/renderer.py +18 -5
- {markrender-1.1.1 → markrender-1.2.0/markrender.egg-info}/PKG-INFO +14 -2
- {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/SOURCES.txt +2 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/scm_file_list.json +3 -0
- markrender-1.2.0/tests/test_changelog.py +150 -0
- {markrender-1.1.1 → markrender-1.2.0}/tests/test_formatters.py +15 -0
- {markrender-1.1.1 → markrender-1.2.0}/tests/test_parser.py +18 -0
- {markrender-1.1.1 → markrender-1.2.0}/tests/test_renderer.py +59 -0
- {markrender-1.1.1 → markrender-1.2.0}/tests/test_versioning.py +29 -0
- {markrender-1.1.1 → markrender-1.2.0}/.gitignore +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/LICENSE +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/MANIFEST.in +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/check_truecolor.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/basic_usage.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/complete_showcase.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/definition_lists.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/footnotes.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/highlights.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/link_highlighting.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/mermaid_diagrams.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/new_features_demo.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/progress_bars.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/streaming_demo.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/theme_showcase.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/examples/v1_0_7_showcase.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender/__init__.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender/__main__.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender/cli.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender/colors.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender/config.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender/themes.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/dependency_links.txt +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/entry_points.txt +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/requires.txt +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/markrender.egg-info/top_level.txt +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/pyproject.toml +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/requirements.txt +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/setup.cfg +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/setup.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/test.md +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/tests/test_colors.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/tests/test_dim_mode.py +0 -0
- {markrender-1.1.1 → markrender-1.2.0}/tests/test_regressions.py +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to MarkRender are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and the project uses [Semantic Versioning](https://semver.org/). Releases are
|
|
7
|
+
cut from conventional commits, and new entries are added by the publish
|
|
8
|
+
workflow when a release goes out.
|
|
9
|
+
|
|
10
|
+
## [1.1.1] - 2026-10-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Dim mode now renders the whole response in one uniform muted gray, including paragraphs, lists, tables, quotes, and code blocks.
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- Clarified the CLI and Python usage examples.
|
|
19
|
+
|
|
20
|
+
## [1.1.0] - 2026-10-30
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Dim mode for streaming responses, enabled per render call.
|
|
25
|
+
- Theme previews in the README and a `preview-themes` command.
|
|
26
|
+
- `--width`, `--force-color`, and `--version` CLI flags.
|
|
27
|
+
- TOML config file support with warnings for unknown keys.
|
|
28
|
+
- Dynamic code block line-number widths.
|
|
29
|
+
- Better handling of split stream chunks and buffer boundaries.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Rewrote the README around CLI and Python usage instead of feature marketing.
|
|
34
|
+
- Dim mode moved from the constructor to a `render()` argument.
|
|
35
|
+
- Themes are now deep-copied so rendering cannot mutate shared theme state.
|
|
36
|
+
- Improved the parser regexes for bold + italic, inline code, links, and blockquotes.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- Blockquotes falling through to other block handlers.
|
|
41
|
+
- Dictionary themes loaded from TOML files failing to render.
|
|
42
|
+
- Table overflow when content is wider than the terminal.
|
|
43
|
+
- Encoding fallback when the output stream is not UTF-8.
|
|
44
|
+
- Config files now warn about unknown keys instead of failing silently.
|
|
45
|
+
|
|
46
|
+
Releases before 1.1.0 were published before this changelog existed.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: markrender
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Professional terminal markdown renderer for streaming LLM responses
|
|
5
5
|
Author: Praneeth Gandodi
|
|
6
6
|
License: MIT
|
|
@@ -422,6 +422,11 @@ Hello :wave: Let's :rocket: go!
|
|
|
422
422
|
- **Note-Taking Apps** - Terminal-based markdown note viewers
|
|
423
423
|
- **Log Viewers** - Render structured logs with markdown
|
|
424
424
|
|
|
425
|
+
## Changelog
|
|
426
|
+
|
|
427
|
+
Release notes come from conventional commit messages, so the history stays in
|
|
428
|
+
one place. See [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
|
|
429
|
+
|
|
425
430
|
## Development
|
|
426
431
|
|
|
427
432
|
### Setup Development Environment
|
|
@@ -458,12 +463,19 @@ no version number to edit by hand.
|
|
|
458
463
|
2. Choose `auto` (recommended) or an explicit `patch`, `minor`, or `major` bump.
|
|
459
464
|
3. The workflow runs the tests, creates the next `vX.Y.Z` tag, builds the
|
|
460
465
|
sdist and wheel, publishes to PyPI, and creates a GitHub release.
|
|
466
|
+
4. Release notes are generated from the commits since the last tag, and the
|
|
467
|
+
same entry is added to [CHANGELOG.md](CHANGELOG.md) on `main`.
|
|
461
468
|
|
|
462
469
|
With `auto`, commit messages since the last tag decide the bump:
|
|
463
470
|
|
|
464
471
|
- `feat!:` or a `BREAKING CHANGE:` footer → major
|
|
465
472
|
- `feat:` → minor
|
|
466
|
-
-
|
|
473
|
+
- `fix:`, `perf:`, `refactor:`, or `revert:` → patch
|
|
474
|
+
- `docs:`, `test:`, `chore:`, `ci:`, `build:`, and `style:` don't trigger a
|
|
475
|
+
release on their own
|
|
476
|
+
|
|
477
|
+
Write commit subjects in the `type(scope): summary` form so the changelog and
|
|
478
|
+
version bump stay accurate.
|
|
467
479
|
|
|
468
480
|
Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in
|
|
469
481
|
the repository. Configure it once:
|
|
@@ -390,6 +390,11 @@ Hello :wave: Let's :rocket: go!
|
|
|
390
390
|
- **Note-Taking Apps** - Terminal-based markdown note viewers
|
|
391
391
|
- **Log Viewers** - Render structured logs with markdown
|
|
392
392
|
|
|
393
|
+
## Changelog
|
|
394
|
+
|
|
395
|
+
Release notes come from conventional commit messages, so the history stays in
|
|
396
|
+
one place. See [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
|
|
397
|
+
|
|
393
398
|
## Development
|
|
394
399
|
|
|
395
400
|
### Setup Development Environment
|
|
@@ -426,12 +431,19 @@ no version number to edit by hand.
|
|
|
426
431
|
2. Choose `auto` (recommended) or an explicit `patch`, `minor`, or `major` bump.
|
|
427
432
|
3. The workflow runs the tests, creates the next `vX.Y.Z` tag, builds the
|
|
428
433
|
sdist and wheel, publishes to PyPI, and creates a GitHub release.
|
|
434
|
+
4. Release notes are generated from the commits since the last tag, and the
|
|
435
|
+
same entry is added to [CHANGELOG.md](CHANGELOG.md) on `main`.
|
|
429
436
|
|
|
430
437
|
With `auto`, commit messages since the last tag decide the bump:
|
|
431
438
|
|
|
432
439
|
- `feat!:` or a `BREAKING CHANGE:` footer → major
|
|
433
440
|
- `feat:` → minor
|
|
434
|
-
-
|
|
441
|
+
- `fix:`, `perf:`, `refactor:`, or `revert:` → patch
|
|
442
|
+
- `docs:`, `test:`, `chore:`, `ci:`, `build:`, and `style:` don't trigger a
|
|
443
|
+
release on their own
|
|
444
|
+
|
|
445
|
+
Write commit subjects in the `type(scope): summary` form so the changelog and
|
|
446
|
+
version bump stay accurate.
|
|
435
447
|
|
|
436
448
|
Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in
|
|
437
449
|
the repository. Configure it once:
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
21
|
+
__version__ = version = '1.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 2, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -3,7 +3,6 @@ Output formatters for markdown elements
|
|
|
3
3
|
Handles rendering of headings, code, tables, lists, and more
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
import re
|
|
7
6
|
from pygments import highlight
|
|
8
7
|
from pygments.lexers import get_lexer_by_name, TextLexer
|
|
9
8
|
from pygments.formatters import Terminal256Formatter
|
|
@@ -12,7 +11,7 @@ try:
|
|
|
12
11
|
except ImportError:
|
|
13
12
|
emoji_lib = None
|
|
14
13
|
|
|
15
|
-
from .colors import colorize, Colors, get_terminal_width
|
|
14
|
+
from .colors import colorize, strip_ansi, Colors, get_terminal_width
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
class MarkdownFormatter:
|
|
@@ -135,7 +134,31 @@ class MarkdownFormatter:
|
|
|
135
134
|
"""
|
|
136
135
|
return self._colorize(f'`{text}`', self.inline_code_color)
|
|
137
136
|
|
|
138
|
-
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _visible_width(text):
|
|
139
|
+
"""Return the printable width of text, ignoring ANSI escapes."""
|
|
140
|
+
return len(strip_ansi(str(text)))
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _fit_table_widths(widths, available):
|
|
144
|
+
"""Keep short columns intact and share the loss across long ones."""
|
|
145
|
+
widths = list(widths)
|
|
146
|
+
if sum(widths) <= available:
|
|
147
|
+
return widths
|
|
148
|
+
|
|
149
|
+
fitted = [0] * len(widths)
|
|
150
|
+
remaining = available
|
|
151
|
+
remaining_cols = len(widths)
|
|
152
|
+
|
|
153
|
+
for index in sorted(range(len(widths)), key=widths.__getitem__):
|
|
154
|
+
share = max(1, remaining // remaining_cols)
|
|
155
|
+
fitted[index] = min(widths[index], share)
|
|
156
|
+
remaining -= fitted[index]
|
|
157
|
+
remaining_cols -= 1
|
|
158
|
+
|
|
159
|
+
return fitted
|
|
160
|
+
|
|
161
|
+
def format_table(self, rows, alignments=None):
|
|
139
162
|
if not rows:
|
|
140
163
|
return ''
|
|
141
164
|
|
|
@@ -149,21 +172,15 @@ class MarkdownFormatter:
|
|
|
149
172
|
col_widths = [0] * max_cols
|
|
150
173
|
for row in normalized_rows:
|
|
151
174
|
for i, cell in enumerate(row):
|
|
152
|
-
|
|
153
|
-
col_widths[i] = max(col_widths[i], len(clean_cell))
|
|
175
|
+
col_widths[i] = max(col_widths[i], self._visible_width(cell))
|
|
154
176
|
|
|
155
177
|
total_border_chars = max_cols * 3 + 1
|
|
156
|
-
|
|
157
|
-
|
|
178
|
+
available = self.width - 2 - total_border_chars
|
|
179
|
+
available = max(available, max_cols)
|
|
180
|
+
col_widths = self._fit_table_widths(col_widths, available)
|
|
158
181
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
widths_sum = sum(col_widths)
|
|
162
|
-
if available < max_cols:
|
|
163
|
-
col_widths = [max(1, available // max_cols)] * max_cols
|
|
164
|
-
else:
|
|
165
|
-
for i in range(max_cols):
|
|
166
|
-
col_widths[i] = max(1, col_widths[i] * available // widths_sum)
|
|
182
|
+
alignments = list(alignments or [])
|
|
183
|
+
alignments += ['left'] * (max_cols - len(alignments))
|
|
167
184
|
|
|
168
185
|
border_color = self.theme['table_border']
|
|
169
186
|
header_color = self.theme.get('table_header')
|
|
@@ -172,18 +189,33 @@ class MarkdownFormatter:
|
|
|
172
189
|
for row_idx, row in enumerate(normalized_rows):
|
|
173
190
|
padded = []
|
|
174
191
|
for i, cell in enumerate(row):
|
|
175
|
-
clean_cell =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
clean_cell = strip_ansi(str(cell))
|
|
193
|
+
width = col_widths[i]
|
|
194
|
+
|
|
195
|
+
if self._visible_width(clean_cell) > width:
|
|
196
|
+
if width > 1:
|
|
197
|
+
cell_str = clean_cell[:width - 1] + '…'
|
|
198
|
+
else:
|
|
199
|
+
cell_str = '…'
|
|
180
200
|
else:
|
|
181
201
|
cell_str = str(cell)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
202
|
+
|
|
203
|
+
padding = width - self._visible_width(cell_str)
|
|
204
|
+
alignment = alignments[i]
|
|
205
|
+
if alignment == 'right':
|
|
206
|
+
cell_str = ' ' * padding + cell_str
|
|
207
|
+
elif alignment == 'center':
|
|
208
|
+
left = padding // 2
|
|
209
|
+
cell_str = (
|
|
210
|
+
' ' * left + cell_str + ' ' * (padding - left)
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
cell_str = cell_str + ' ' * padding
|
|
214
|
+
|
|
215
|
+
if row_idx == 0 and header_color:
|
|
216
|
+
cell_str = self._colorize(
|
|
217
|
+
cell_str, header_color + Colors.BOLD
|
|
218
|
+
)
|
|
187
219
|
padded.append(cell_str)
|
|
188
220
|
|
|
189
221
|
cells_formatted = [f' {cell} ' for cell in padded]
|
|
@@ -78,6 +78,22 @@ class MarkdownParser:
|
|
|
78
78
|
def is_separator_row(cells):
|
|
79
79
|
return bool(cells and all(re.match(r"^:?-+:?$", cell) for cell in cells))
|
|
80
80
|
|
|
81
|
+
@staticmethod
|
|
82
|
+
def parse_table_alignments(cells):
|
|
83
|
+
"""Return the alignment for each column from a separator row."""
|
|
84
|
+
alignments = []
|
|
85
|
+
for cell in cells:
|
|
86
|
+
cell = cell.strip()
|
|
87
|
+
left = cell.startswith(':')
|
|
88
|
+
right = cell.endswith(':')
|
|
89
|
+
if left and right:
|
|
90
|
+
alignments.append('center')
|
|
91
|
+
elif right:
|
|
92
|
+
alignments.append('right')
|
|
93
|
+
else:
|
|
94
|
+
alignments.append('left')
|
|
95
|
+
return alignments
|
|
96
|
+
|
|
81
97
|
def parse_checkbox(self, line):
|
|
82
98
|
match = self.CHECKBOX_PATTERN.match(line)
|
|
83
99
|
if match:
|
|
@@ -23,6 +23,7 @@ class RenderState:
|
|
|
23
23
|
self.in_table = False
|
|
24
24
|
self.table_rows = []
|
|
25
25
|
self.table_row_count = 0
|
|
26
|
+
self.table_alignments = []
|
|
26
27
|
self.in_blockquote = False
|
|
27
28
|
self.blockquote_lines = []
|
|
28
29
|
self.alert_type: Optional[str] = None
|
|
@@ -38,6 +39,7 @@ class RenderState:
|
|
|
38
39
|
self.in_table = False
|
|
39
40
|
self.table_rows = []
|
|
40
41
|
self.table_row_count = 0
|
|
42
|
+
self.table_alignments = []
|
|
41
43
|
|
|
42
44
|
def reset_blockquote(self):
|
|
43
45
|
self.in_blockquote = False
|
|
@@ -227,12 +229,16 @@ class MarkdownRenderer:
|
|
|
227
229
|
stripped = line.rstrip()
|
|
228
230
|
table_row = self.parser.parse_table_row(stripped)
|
|
229
231
|
if table_row is not None:
|
|
230
|
-
if
|
|
232
|
+
if self.parser.is_separator_row(table_row):
|
|
233
|
+
self.state.table_alignments = self.parser.parse_table_alignments(table_row)
|
|
234
|
+
else:
|
|
231
235
|
formatted_row = [self.parser.apply_inline_formatting(cell, self.formatter) for cell in table_row]
|
|
232
236
|
self.state.table_rows.append(formatted_row)
|
|
233
237
|
else:
|
|
234
238
|
if self.state.table_rows:
|
|
235
|
-
formatted = self.formatter.format_table(
|
|
239
|
+
formatted = self.formatter.format_table(
|
|
240
|
+
self.state.table_rows, self.state.table_alignments
|
|
241
|
+
)
|
|
236
242
|
self._write(formatted, 'table')
|
|
237
243
|
self.state.reset_table()
|
|
238
244
|
self.buffer = self._rebuild_buffer(complete_lines[i:], tail)
|
|
@@ -294,12 +300,17 @@ class MarkdownRenderer:
|
|
|
294
300
|
if not self.state.in_table:
|
|
295
301
|
self.state.in_table = True
|
|
296
302
|
self.state.table_rows = []
|
|
297
|
-
|
|
303
|
+
self.state.table_alignments = []
|
|
304
|
+
if self.parser.is_separator_row(table_row):
|
|
305
|
+
self.state.table_alignments = self.parser.parse_table_alignments(table_row)
|
|
306
|
+
else:
|
|
298
307
|
formatted_row = [self.parser.apply_inline_formatting(cell, self.formatter) for cell in table_row]
|
|
299
308
|
self.state.table_rows.append(formatted_row)
|
|
300
309
|
return
|
|
301
310
|
elif self.state.in_table:
|
|
302
|
-
formatted = self.formatter.format_table(
|
|
311
|
+
formatted = self.formatter.format_table(
|
|
312
|
+
self.state.table_rows, self.state.table_alignments
|
|
313
|
+
)
|
|
303
314
|
self._write(formatted, 'table')
|
|
304
315
|
self.state.reset_table()
|
|
305
316
|
|
|
@@ -383,7 +394,9 @@ class MarkdownRenderer:
|
|
|
383
394
|
self.state.reset_code()
|
|
384
395
|
|
|
385
396
|
if self.state.in_table and self.state.table_rows:
|
|
386
|
-
formatted = self.formatter.format_table(
|
|
397
|
+
formatted = self.formatter.format_table(
|
|
398
|
+
self.state.table_rows, self.state.table_alignments
|
|
399
|
+
)
|
|
387
400
|
self._write(formatted, 'table')
|
|
388
401
|
self.state.reset_table()
|
|
389
402
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: markrender
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Professional terminal markdown renderer for streaming LLM responses
|
|
5
5
|
Author: Praneeth Gandodi
|
|
6
6
|
License: MIT
|
|
@@ -422,6 +422,11 @@ Hello :wave: Let's :rocket: go!
|
|
|
422
422
|
- **Note-Taking Apps** - Terminal-based markdown note viewers
|
|
423
423
|
- **Log Viewers** - Render structured logs with markdown
|
|
424
424
|
|
|
425
|
+
## Changelog
|
|
426
|
+
|
|
427
|
+
Release notes come from conventional commit messages, so the history stays in
|
|
428
|
+
one place. See [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
|
|
429
|
+
|
|
425
430
|
## Development
|
|
426
431
|
|
|
427
432
|
### Setup Development Environment
|
|
@@ -458,12 +463,19 @@ no version number to edit by hand.
|
|
|
458
463
|
2. Choose `auto` (recommended) or an explicit `patch`, `minor`, or `major` bump.
|
|
459
464
|
3. The workflow runs the tests, creates the next `vX.Y.Z` tag, builds the
|
|
460
465
|
sdist and wheel, publishes to PyPI, and creates a GitHub release.
|
|
466
|
+
4. Release notes are generated from the commits since the last tag, and the
|
|
467
|
+
same entry is added to [CHANGELOG.md](CHANGELOG.md) on `main`.
|
|
461
468
|
|
|
462
469
|
With `auto`, commit messages since the last tag decide the bump:
|
|
463
470
|
|
|
464
471
|
- `feat!:` or a `BREAKING CHANGE:` footer → major
|
|
465
472
|
- `feat:` → minor
|
|
466
|
-
-
|
|
473
|
+
- `fix:`, `perf:`, `refactor:`, or `revert:` → patch
|
|
474
|
+
- `docs:`, `test:`, `chore:`, `ci:`, `build:`, and `style:` don't trigger a
|
|
475
|
+
release on their own
|
|
476
|
+
|
|
477
|
+
Write commit subjects in the `type(scope): summary` form so the changelog and
|
|
478
|
+
version bump stay accurate.
|
|
467
479
|
|
|
468
480
|
Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in
|
|
469
481
|
the repository. Configure it once:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
.gitignore
|
|
2
|
+
CHANGELOG.md
|
|
2
3
|
LICENSE
|
|
3
4
|
MANIFEST.in
|
|
4
5
|
README.md
|
|
@@ -36,6 +37,7 @@ markrender.egg-info/entry_points.txt
|
|
|
36
37
|
markrender.egg-info/requires.txt
|
|
37
38
|
markrender.egg-info/scm_file_list.json
|
|
38
39
|
markrender.egg-info/top_level.txt
|
|
40
|
+
tests/test_changelog.py
|
|
39
41
|
tests/test_colors.py
|
|
40
42
|
tests/test_dim_mode.py
|
|
41
43
|
tests/test_formatters.py
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
|
+
".github/scripts/changelog.py",
|
|
3
4
|
".github/scripts/next_version.py",
|
|
4
5
|
".github/workflows/ci.yml",
|
|
5
6
|
".github/workflows/publish.yml",
|
|
6
7
|
".gitignore",
|
|
8
|
+
"CHANGELOG.md",
|
|
7
9
|
"LICENSE",
|
|
8
10
|
"MANIFEST.in",
|
|
9
11
|
"README.md",
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
"requirements.txt",
|
|
34
36
|
"setup.py",
|
|
35
37
|
"test.md",
|
|
38
|
+
"tests/test_changelog.py",
|
|
36
39
|
"tests/test_colors.py",
|
|
37
40
|
"tests/test_dim_mode.py",
|
|
38
41
|
"tests/test_formatters.py",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Tests for the changelog helper used by the publish workflow."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
SCRIPT = REPO_ROOT / ".github" / "scripts" / "changelog.py"
|
|
11
|
+
|
|
12
|
+
pytestmark = pytest.mark.skipif(
|
|
13
|
+
not SCRIPT.exists(), reason="release helper is not part of this checkout"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def git(repo, *args):
|
|
18
|
+
subprocess.run(
|
|
19
|
+
["git", *args],
|
|
20
|
+
cwd=repo,
|
|
21
|
+
check=True,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def commit(repo, message, filename):
|
|
28
|
+
(repo / filename).write_text(filename, encoding="utf-8")
|
|
29
|
+
git(repo, "add", filename)
|
|
30
|
+
git(repo, "commit", "-m", message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_changelog(repo, *args):
|
|
34
|
+
return subprocess.run(
|
|
35
|
+
[sys.executable, str(SCRIPT), *args],
|
|
36
|
+
cwd=repo,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_repo(path):
|
|
43
|
+
git(path, "init", "-q")
|
|
44
|
+
git(path, "config", "user.email", "test@example.com")
|
|
45
|
+
git(path, "config", "user.name", "Test")
|
|
46
|
+
commit(path, "feat: initial release", "initial.txt")
|
|
47
|
+
git(path, "tag", "-a", "v1.1.0", "-m", "v1.1.0")
|
|
48
|
+
commit(path, "feat(renderer): add table alignment", "table.txt")
|
|
49
|
+
commit(path, "fix: stop crushing short columns", "fit.txt")
|
|
50
|
+
commit(path, "docs: mention the changelog", "readme.txt")
|
|
51
|
+
commit(path, "chore: tidy up", "chore.txt")
|
|
52
|
+
commit(path, "test: cover narrow tables", "test.txt")
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_entry_groups_conventional_commits(tmp_path):
|
|
57
|
+
repo = make_repo(tmp_path)
|
|
58
|
+
|
|
59
|
+
result = run_changelog(
|
|
60
|
+
repo, "--version", "1.1.1", "--tag", "v1.1.1", "--date", "2026-10-30"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert result.returncode == 0
|
|
64
|
+
assert "## [1.1.1] - 2026-10-30" in result.stdout
|
|
65
|
+
assert "### Added" in result.stdout
|
|
66
|
+
assert "**renderer:** Add table alignment" in result.stdout
|
|
67
|
+
assert "### Fixed" in result.stdout
|
|
68
|
+
assert "Stop crushing short columns" in result.stdout
|
|
69
|
+
assert "### Documentation" in result.stdout
|
|
70
|
+
assert "Tidy up" not in result.stdout
|
|
71
|
+
assert "Cover narrow tables" not in result.stdout
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_notes_file_omits_the_version_heading(tmp_path):
|
|
75
|
+
repo = make_repo(tmp_path)
|
|
76
|
+
notes = repo / "notes.md"
|
|
77
|
+
|
|
78
|
+
result = run_changelog(
|
|
79
|
+
repo,
|
|
80
|
+
"--version",
|
|
81
|
+
"1.1.1",
|
|
82
|
+
"--tag",
|
|
83
|
+
"v1.1.1",
|
|
84
|
+
"--date",
|
|
85
|
+
"2026-10-30",
|
|
86
|
+
"--notes-file",
|
|
87
|
+
str(notes),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert result.returncode == 0
|
|
91
|
+
body = notes.read_text(encoding="utf-8")
|
|
92
|
+
assert "## [1.1.1]" not in body
|
|
93
|
+
assert "### Added" in body
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_write_inserts_above_the_newest_release(tmp_path):
|
|
97
|
+
repo = make_repo(tmp_path)
|
|
98
|
+
changelog = repo / "CHANGELOG.md"
|
|
99
|
+
changelog.write_text(
|
|
100
|
+
"# Changelog\n\n"
|
|
101
|
+
"## [1.1.0] - 2026-10-01\n\n"
|
|
102
|
+
"### Added\n\n- Initial release.\n",
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
result = run_changelog(
|
|
107
|
+
repo,
|
|
108
|
+
"--version",
|
|
109
|
+
"1.1.1",
|
|
110
|
+
"--tag",
|
|
111
|
+
"v1.1.1",
|
|
112
|
+
"--date",
|
|
113
|
+
"2026-10-30",
|
|
114
|
+
"--write",
|
|
115
|
+
str(changelog),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert result.returncode == 0
|
|
119
|
+
text = changelog.read_text(encoding="utf-8")
|
|
120
|
+
assert text.index("## [1.1.1]") < text.index("## [1.1.0]")
|
|
121
|
+
assert text.startswith("# Changelog")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_write_is_idempotent(tmp_path):
|
|
125
|
+
repo = make_repo(tmp_path)
|
|
126
|
+
changelog = repo / "CHANGELOG.md"
|
|
127
|
+
changelog.write_text("# Changelog\n", encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
args = ("--version", "1.1.1", "--tag", "v1.1.1", "--write", str(changelog))
|
|
130
|
+
run_changelog(repo, *args)
|
|
131
|
+
first = changelog.read_text(encoding="utf-8")
|
|
132
|
+
run_changelog(repo, *args)
|
|
133
|
+
|
|
134
|
+
assert changelog.read_text(encoding="utf-8") == first
|
|
135
|
+
assert first.count("## [1.1.1]") == 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_release_with_only_maintenance_commits(tmp_path):
|
|
139
|
+
repo = tmp_path
|
|
140
|
+
git(repo, "init", "-q")
|
|
141
|
+
git(repo, "config", "user.email", "test@example.com")
|
|
142
|
+
git(repo, "config", "user.name", "Test")
|
|
143
|
+
commit(repo, "feat: initial release", "initial.txt")
|
|
144
|
+
git(repo, "tag", "-a", "v1.1.0", "-m", "v1.1.0")
|
|
145
|
+
commit(repo, "chore: tidy up", "chore.txt")
|
|
146
|
+
|
|
147
|
+
result = run_changelog(repo, "--version", "1.1.1", "--tag", "v1.1.1")
|
|
148
|
+
|
|
149
|
+
assert result.returncode == 0
|
|
150
|
+
assert "No user-facing changes" in result.stdout
|
|
@@ -367,3 +367,18 @@ class TestMarkdownFormatter:
|
|
|
367
367
|
"""Test strikethrough formatting"""
|
|
368
368
|
result = self.formatter.format_strikethrough("strike")
|
|
369
369
|
assert "strike" in result
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class TestTableAlignment:
|
|
373
|
+
"""Column alignment in formatted tables."""
|
|
374
|
+
|
|
375
|
+
def test_format_table_alignment(self):
|
|
376
|
+
formatter = MarkdownFormatter(get_theme('github-dark'), width=80)
|
|
377
|
+
rows = [
|
|
378
|
+
["Name", "Qty", "Status"],
|
|
379
|
+
["A", "2", "ok"],
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
result = formatter.format_table(rows, ["left", "right", "center"])
|
|
383
|
+
|
|
384
|
+
assert "│ A │ 2 │ ok │" in result
|
|
@@ -309,3 +309,21 @@ class TestMarkdownParser:
|
|
|
309
309
|
assert result == ""
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TestTableAlignments:
|
|
315
|
+
"""Alignment markers on table separator rows."""
|
|
316
|
+
|
|
317
|
+
def test_parse_table_alignments(self):
|
|
318
|
+
parser = MarkdownParser()
|
|
319
|
+
cells = parser.parse_table_row("|:---|:---:|---:|")
|
|
320
|
+
|
|
321
|
+
assert parser.parse_table_alignments(cells) == [
|
|
322
|
+
"left", "center", "right"
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
def test_parse_table_alignments_defaults_to_left(self):
|
|
326
|
+
parser = MarkdownParser()
|
|
327
|
+
cells = parser.parse_table_row("|---|---|")
|
|
328
|
+
|
|
329
|
+
assert parser.parse_table_alignments(cells) == ["left", "left"]
|
|
@@ -620,3 +620,62 @@ class TestMarkdownRenderer:
|
|
|
620
620
|
result = output.getvalue()
|
|
621
621
|
assert "A" in result
|
|
622
622
|
assert "code" in result
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class TestTableAlignment:
|
|
626
|
+
"""Table alignment and width handling through the renderer."""
|
|
627
|
+
|
|
628
|
+
def test_alignment_markers_are_rendered(self):
|
|
629
|
+
output = StringIO()
|
|
630
|
+
renderer = MarkdownRenderer(output=output)
|
|
631
|
+
|
|
632
|
+
renderer.render("| Item | Qty | Price |\n")
|
|
633
|
+
renderer.render("|:-----|----:|------:|\n")
|
|
634
|
+
renderer.render("| Apples | 3 | 1.20 |\n")
|
|
635
|
+
renderer.render("\n")
|
|
636
|
+
renderer.finalize()
|
|
637
|
+
|
|
638
|
+
assert "│ Apples │ 3 │ 1.20 │" in output.getvalue()
|
|
639
|
+
|
|
640
|
+
def test_short_columns_keep_their_text(self):
|
|
641
|
+
output = StringIO()
|
|
642
|
+
renderer = MarkdownRenderer(output=output, width=60)
|
|
643
|
+
|
|
644
|
+
renderer.render("| Name | Description | Status |\n")
|
|
645
|
+
renderer.render("|---|---|---|\n")
|
|
646
|
+
renderer.render(
|
|
647
|
+
"| markrender | Render markdown in the terminal with "
|
|
648
|
+
"streaming support and syntax highlighting | active |\n"
|
|
649
|
+
)
|
|
650
|
+
renderer.render("\n")
|
|
651
|
+
renderer.finalize()
|
|
652
|
+
|
|
653
|
+
result = output.getvalue()
|
|
654
|
+
assert "markrender" in result
|
|
655
|
+
assert "Status" in result
|
|
656
|
+
assert "markre…" not in result
|
|
657
|
+
assert "Stat…" not in result
|
|
658
|
+
|
|
659
|
+
def test_narrow_table_does_not_crush_a_single_column(self):
|
|
660
|
+
output = StringIO()
|
|
661
|
+
renderer = MarkdownRenderer(output=output, width=34)
|
|
662
|
+
|
|
663
|
+
renderer.render("| Name | Description | Status |\n")
|
|
664
|
+
renderer.render("|---|---|---|\n")
|
|
665
|
+
renderer.render(
|
|
666
|
+
"| markrender | Render markdown in the terminal with "
|
|
667
|
+
"streaming support and syntax highlighting | active |\n"
|
|
668
|
+
)
|
|
669
|
+
renderer.render("\n")
|
|
670
|
+
renderer.finalize()
|
|
671
|
+
|
|
672
|
+
lines = [
|
|
673
|
+
line for line in output.getvalue().splitlines()
|
|
674
|
+
if line.startswith("┌")
|
|
675
|
+
]
|
|
676
|
+
widths = [
|
|
677
|
+
len(segment) for segment in lines[0][1:-1].split("┬")
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
assert max(widths) - min(widths) <= 2
|
|
681
|
+
assert all(len(line) <= 34 for line in output.getvalue().splitlines())
|
|
@@ -77,6 +77,35 @@ def test_feature_commit_bumps_minor(tmp_path):
|
|
|
77
77
|
assert result.stdout.strip() == "1.1.0"
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def test_docs_only_commit_does_not_release(tmp_path):
|
|
81
|
+
repo = make_repo(tmp_path)
|
|
82
|
+
commit(repo, "docs: explain the width flag", "readme.txt")
|
|
83
|
+
|
|
84
|
+
result = next_version(repo, "--bump", "auto")
|
|
85
|
+
|
|
86
|
+
assert result.returncode == 3
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_chore_only_commit_does_not_release(tmp_path):
|
|
90
|
+
repo = make_repo(tmp_path)
|
|
91
|
+
commit(repo, "chore: remove a local harness", "chore.txt")
|
|
92
|
+
|
|
93
|
+
result = next_version(repo, "--bump", "auto")
|
|
94
|
+
|
|
95
|
+
assert result.returncode == 3
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_maintenance_commits_do_not_raise_the_bump(tmp_path):
|
|
99
|
+
repo = make_repo(tmp_path)
|
|
100
|
+
commit(repo, "feat: add a renderer option", "feature.txt")
|
|
101
|
+
commit(repo, "chore: tidy up", "chore.txt")
|
|
102
|
+
|
|
103
|
+
result = next_version(repo, "--bump", "auto")
|
|
104
|
+
|
|
105
|
+
assert result.returncode == 0
|
|
106
|
+
assert result.stdout.strip() == "1.1.0"
|
|
107
|
+
|
|
108
|
+
|
|
80
109
|
def test_breaking_commit_bumps_major(tmp_path):
|
|
81
110
|
repo = make_repo(tmp_path)
|
|
82
111
|
commit(repo, "feat!: change the public API", "breaking.txt")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|