termrender 4.3.0__tar.gz → 4.3.2__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.
- {termrender-4.3.0 → termrender-4.3.2}/CHANGELOG.md +75 -0
- {termrender-4.3.0 → termrender-4.3.2}/PKG-INFO +1 -1
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid.py +13 -3
- termrender-4.3.2/src/termrender/renderers/mermaid_gantt.py +459 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_journey.py +4 -1
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_mindmap.py +10 -6
- termrender-4.3.2/src/termrender/renderers/mermaid_pie.py +128 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_timeline.py +4 -1
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_dispatch.py +30 -0
- termrender-4.3.2/tests/test_mermaid_gantt.py +333 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_journey.py +57 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_mindmap.py +22 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_pie.py +63 -2
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_timeline.py +28 -0
- termrender-4.3.0/src/termrender/renderers/mermaid_gantt.py +0 -222
- termrender-4.3.0/src/termrender/renderers/mermaid_pie.py +0 -84
- termrender-4.3.0/tests/test_mermaid_gantt.py +0 -154
- {termrender-4.3.0 → termrender-4.3.2}/.github/workflows/publish.yml +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/.gitignore +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/CLAUDE.md +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/LICENSE +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/README.md +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/design.json +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/pyproject.toml +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/requirements.json +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/__init__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/__main__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/blocks.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/emit.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/layout.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/parser.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/py.typed +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/code.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/table.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/text.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/src/termrender/style.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/__init__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_charts.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_cli_contract.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_column_alignment.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_diff.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_inline_badge.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_linebreak.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_myst_gaps.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_stat.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_tasklist.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_timeline.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/tests/test_variable_colons.py +0 -0
- {termrender-4.3.0 → termrender-4.3.2}/uv.lock +0 -0
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.3.2 (2026-07-06)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **mermaid**: Strip %% comments/directives from journey/mindmap/timeline
|
|
9
|
+
([`d61f113`](https://github.com/crouton-labs/termrender/commit/d61f113cf750c9379f00825e2f6fc7b3898e7b8f))
|
|
10
|
+
|
|
11
|
+
The dispatcher (mermaid.py) only used strip_prelude_lines() for type sniffing, then passed the
|
|
12
|
+
untouched original source to the native renderers, so a leading %%{init}%% directive or a mid-body
|
|
13
|
+
%% comment leaked into rendered output as fake tree nodes, task rows, or timeline events in the
|
|
14
|
+
journey/mindmap/timeline renderers.
|
|
15
|
+
|
|
16
|
+
- mermaid.py: pass prelude-stripped source to the three native renderers; the Go-binary fallback
|
|
17
|
+
path keeps receiving the raw source untouched. - mermaid_journey.py, mermaid_mindmap.py,
|
|
18
|
+
mermaid_timeline.py: skip %%-prefixed lines anywhere in the body, not just the leading prelude,
|
|
19
|
+
mirroring mermaid_sequence.py's existing handling.
|
|
20
|
+
|
|
21
|
+
Adds regression tests pinning each confirmed repro to render nothing from the comment/directive.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## v4.3.1 (2026-07-06)
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
- **mermaid-gantt**: Close remaining overflow and includes gaps from review
|
|
29
|
+
([`73a1354`](https://github.com/crouton-labs/termrender/commit/73a135465644073d58e293ed69e6ca968d4f5a05))
|
|
30
|
+
|
|
31
|
+
- _skip_excluded() and the implicit +1-day default end could still raise OverflowError past
|
|
32
|
+
datetime.max; both call sites now catch it and degrade the whole diagram to source instead of
|
|
33
|
+
crashing. - includes weekends was accepted but silently no-op'd (excludes weekends + includes
|
|
34
|
+
weekends still rendered the excluded schedule); includes now only supports explicit YYYY-MM-DD
|
|
35
|
+
dates and rejects weekends as _Unsupported rather than pretending to cancel the exclusion.
|
|
36
|
+
|
|
37
|
+
fix(mermaid-pie): reject invalid trailing header grammar
|
|
38
|
+
|
|
39
|
+
pie bogus (anything after pie/pie showData that isn't a valid inline title) was silently accepted
|
|
40
|
+
and rendered a plausible chart; it now degrades the whole diagram to source, matching the
|
|
41
|
+
strictness already applied to other invalid pie lines.
|
|
42
|
+
|
|
43
|
+
- **mermaid-gantt**: Implement excludes/includes, milestone, until; never-crash on bad
|
|
44
|
+
dateFormat/overflow
|
|
45
|
+
([`e182ae1`](https://github.com/crouton-labs/termrender/commit/e182ae1346af7fa4a531ad0014878e06caf5304e))
|
|
46
|
+
|
|
47
|
+
Several valid Mermaid gantt constructs rendered false schedules, and malformed input could crash:
|
|
48
|
+
|
|
49
|
+
- A malformed dateFormat (e.g. YYYY-YYYY, whose translated strptime pattern has a duplicate capture
|
|
50
|
+
group) raised re.error instead of being caught; the format is now validated by round-tripping a
|
|
51
|
+
sample date and an invalid format degrades the whole diagram to source. - Huge numeric durations
|
|
52
|
+
(e.g. 1000000000d) overflowed timedelta/datetime arithmetic; overflow is now caught and degrades
|
|
53
|
+
the whole diagram. - excludes weekends / excludes <YYYY-MM-DD> and includes <YYYY-MM-DD> are now
|
|
54
|
+
applied when resolving durations and auto-anchored start dates, instead of being ignored as
|
|
55
|
+
decorative metadata. An excludes/includes form we don't implement (e.g. day names) degrades the
|
|
56
|
+
whole diagram rather than rendering a false schedule. - milestone tasks are now tracked as
|
|
57
|
+
point-in-time markers (rendered as a single diamond marker) instead of ordinary duration spans. -
|
|
58
|
+
until <taskId> is now resolved to the referenced task's start date; an unknown reference degrades
|
|
59
|
+
the whole diagram. - %% comments (whole-line and trailing inline) are stripped before
|
|
60
|
+
tokenization, so a comment containing a colon can no longer be misparsed as a task.
|
|
61
|
+
|
|
62
|
+
- **mermaid-pie**: Degrade to source on invalid grammar, negative or overflowing values
|
|
63
|
+
([`765f613`](https://github.com/crouton-labs/termrender/commit/765f613d9e1b72ff79e8f0c32ac8d9f9cdceef69))
|
|
64
|
+
|
|
65
|
+
Native pie parsing previously treated any non-matching line as a silent ignorable and accepted
|
|
66
|
+
negative slice values, so malformed input rendered a plausible-but-wrong partial chart instead of
|
|
67
|
+
falling back to source. Arbitrarily long numeric literals could also reach charts._format_value()
|
|
68
|
+
as float('inf') and raise OverflowError.
|
|
69
|
+
|
|
70
|
+
- Invalid grammar (anything beyond header/title/accTitle/accDescr/%% comments/slice lines) now
|
|
71
|
+
discards the whole diagram and falls back to raw source, matching Mermaid's actual pie grammar. -
|
|
72
|
+
Negative slice values are rejected the same way (Mermaid disallows them). - Non-finite values
|
|
73
|
+
(from huge numeric literals) are rejected in parse_pie, and the summed total is re-checked in
|
|
74
|
+
render() as a second guard, both falling back to source instead of crashing. - Slice labels now
|
|
75
|
+
also accept single-quoted strings and escaped quotes inside double-quoted strings, per Mermaid's
|
|
76
|
+
real string grammar.
|
|
77
|
+
|
|
78
|
+
|
|
4
79
|
## v4.3.0 (2026-07-06)
|
|
5
80
|
|
|
6
81
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.3.
|
|
3
|
+
Version: 4.3.2
|
|
4
4
|
Summary: Rich terminal rendering of directive-flavored markdown
|
|
5
5
|
Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
|
|
6
6
|
Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
|
|
@@ -139,6 +139,16 @@ def _first_line_type(source: str) -> str:
|
|
|
139
139
|
return first.lower()
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
def _stripped(source: str) -> str:
|
|
143
|
+
"""Return ``source`` with its leading prelude (comments/directives/
|
|
144
|
+
frontmatter) removed, for native renderers that don't otherwise skip it.
|
|
145
|
+
|
|
146
|
+
The Go-binary fallback path (``_render_via_binary``) must keep receiving
|
|
147
|
+
the untouched original source — its behavior stays byte-for-byte.
|
|
148
|
+
"""
|
|
149
|
+
return "\n".join(strip_prelude_lines(source.splitlines()))
|
|
150
|
+
|
|
151
|
+
|
|
142
152
|
def _render_via_binary(source: str) -> str:
|
|
143
153
|
"""Render via the vendored mermaid-ascii binary, falling back to source.
|
|
144
154
|
|
|
@@ -182,11 +192,11 @@ def render_mermaid_lines(source: str, width: int) -> list[str]:
|
|
|
182
192
|
if diagram_type.startswith("sequencediagram"):
|
|
183
193
|
return mermaid_sequence.render_sequence(source, width)
|
|
184
194
|
if diagram_type.startswith("mindmap"):
|
|
185
|
-
return mermaid_mindmap.render(source, width)
|
|
195
|
+
return mermaid_mindmap.render(_stripped(source), width)
|
|
186
196
|
if diagram_type.startswith("journey"):
|
|
187
|
-
return mermaid_journey.render(source, width)
|
|
197
|
+
return mermaid_journey.render(_stripped(source), width)
|
|
188
198
|
if diagram_type.startswith("timeline"):
|
|
189
|
-
return mermaid_timeline.render(source, width)
|
|
199
|
+
return mermaid_timeline.render(_stripped(source), width)
|
|
190
200
|
return _render_via_binary(source).split("\n")
|
|
191
201
|
|
|
192
202
|
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Native renderer for mermaid ``gantt`` diagrams.
|
|
2
|
+
|
|
3
|
+
Mermaid gantt grammar (core subset)::
|
|
4
|
+
|
|
5
|
+
gantt
|
|
6
|
+
title A Gantt Diagram
|
|
7
|
+
dateFormat YYYY-MM-DD
|
|
8
|
+
excludes weekends
|
|
9
|
+
section Section A
|
|
10
|
+
Task1 :a1, 2024-01-01, 30d
|
|
11
|
+
Task2 :after a1, 20d
|
|
12
|
+
Task3 :milestone, m1, 2024-02-15, 0d
|
|
13
|
+
Task4 :until a1
|
|
14
|
+
|
|
15
|
+
Rendered as section-grouped rows with a horizontal time-span bar per task,
|
|
16
|
+
scaled to the overall date range — the gantt analogue of a timeline, per the
|
|
17
|
+
ratified plan in ``mermaid-inline-rendering.md``. Milestones render as a
|
|
18
|
+
single point marker instead of a span.
|
|
19
|
+
|
|
20
|
+
Never-crash contract: a diagram that uses a construct we don't implement
|
|
21
|
+
(an invalid ``dateFormat``, a numeric overflow, an ``until`` reference to an
|
|
22
|
+
unknown task id, an unsupported ``excludes``/``includes`` form, ...)
|
|
23
|
+
degrades the *entire* diagram to raw source rather than guessing — see
|
|
24
|
+
``_Unsupported`` below.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from datetime import date, datetime, timedelta
|
|
31
|
+
|
|
32
|
+
from termrender.style import visual_len, visual_ljust
|
|
33
|
+
|
|
34
|
+
_HEADER_RE = re.compile(r"^\s*gantt\b", re.IGNORECASE)
|
|
35
|
+
_TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
|
|
36
|
+
_DATEFORMAT_RE = re.compile(r"^\s*dateFormat\s+(\S+)\s*$", re.IGNORECASE)
|
|
37
|
+
_SECTION_RE = re.compile(r"^\s*section\s+(.*\S)\s*$", re.IGNORECASE)
|
|
38
|
+
_EXCLUDES_RE = re.compile(r"^\s*excludes\s+(.*\S)\s*$", re.IGNORECASE)
|
|
39
|
+
_INCLUDES_RE = re.compile(r"^\s*includes\s+(.*\S)\s*$", re.IGNORECASE)
|
|
40
|
+
_SKIP_RE = re.compile(
|
|
41
|
+
r"^\s*(?:axisFormat|todayMarker|weekend|tickInterval)\b",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
_COMMENT_RE = re.compile(r"%%.*$")
|
|
45
|
+
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
46
|
+
_STATUS_TOKENS = {"done", "active", "crit", "milestone", "vert"}
|
|
47
|
+
_DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*(d|w|h|m|s)$", re.IGNORECASE)
|
|
48
|
+
_ID_RE = re.compile(r"^[A-Za-z_][\w-]*$")
|
|
49
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
50
|
+
|
|
51
|
+
# Mermaid dateFormat tokens, longest-first so e.g. "YYYY" isn't partially
|
|
52
|
+
# consumed by a "YY" replacement first.
|
|
53
|
+
_DATE_TOKEN_MAP = [
|
|
54
|
+
("YYYY", "%Y"),
|
|
55
|
+
("YY", "%y"),
|
|
56
|
+
("MM", "%m"),
|
|
57
|
+
("DD", "%d"),
|
|
58
|
+
("HH", "%H"),
|
|
59
|
+
("mm", "%M"),
|
|
60
|
+
("ss", "%S"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Unsupported(Exception):
|
|
65
|
+
"""Internal signal: the diagram uses a construct we can't safely
|
|
66
|
+
render (invalid dateFormat, numeric overflow, an unresolvable
|
|
67
|
+
``until`` reference, an unimplemented ``excludes``/``includes`` form,
|
|
68
|
+
...). Caught by ``parse_gantt``, which degrades the whole diagram to
|
|
69
|
+
raw source rather than rendering a guess.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _strptime_format(date_format: str) -> str:
|
|
74
|
+
"""Translate a mermaid ``dateFormat`` token string to a strptime pattern."""
|
|
75
|
+
out = date_format
|
|
76
|
+
for token, code in _DATE_TOKEN_MAP:
|
|
77
|
+
out = out.replace(token, code)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _valid_strptime_format(fmt: str) -> bool:
|
|
82
|
+
"""Best-effort validation that ``fmt`` is usable as a strptime pattern.
|
|
83
|
+
|
|
84
|
+
A malformed ``dateFormat`` such as ``YYYY-YYYY`` translates to
|
|
85
|
+
``%Y-%Y``, which has a duplicate named capture group; that only
|
|
86
|
+
surfaces as ``re.error`` when strptime actually compiles the pattern
|
|
87
|
+
to parse a value, not at translation time. Round-tripping a sample
|
|
88
|
+
date through ``strftime``/``strptime`` exercises that compile step.
|
|
89
|
+
"""
|
|
90
|
+
sample = datetime(2024, 3, 4, 5, 6, 7)
|
|
91
|
+
try:
|
|
92
|
+
datetime.strptime(sample.strftime(fmt), fmt)
|
|
93
|
+
except (ValueError, re.error):
|
|
94
|
+
return False
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_date(token: str, fmt: str) -> datetime | None:
|
|
99
|
+
try:
|
|
100
|
+
return datetime.strptime(token, fmt)
|
|
101
|
+
except (ValueError, re.error):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_excludes_tokens(spec: str) -> tuple[bool, set[date]]:
|
|
106
|
+
"""Parse the token list of an ``excludes`` directive.
|
|
107
|
+
|
|
108
|
+
Supports the common cases: the ``weekends`` keyword and explicit
|
|
109
|
+
``YYYY-MM-DD`` dates. Any other form (e.g. day names like ``friday``)
|
|
110
|
+
isn't implemented, so it raises ``_Unsupported`` to degrade the whole
|
|
111
|
+
diagram rather than silently ignoring a calendar rule that changes the
|
|
112
|
+
schedule.
|
|
113
|
+
"""
|
|
114
|
+
weekends = False
|
|
115
|
+
dates: set[date] = set()
|
|
116
|
+
for tok in re.split(r"[,\s]+", spec.strip()):
|
|
117
|
+
if not tok:
|
|
118
|
+
continue
|
|
119
|
+
if tok.lower() == "weekends":
|
|
120
|
+
weekends = True
|
|
121
|
+
continue
|
|
122
|
+
if _ISO_DATE_RE.match(tok):
|
|
123
|
+
dt = _parse_date(tok, "%Y-%m-%d")
|
|
124
|
+
if dt is None:
|
|
125
|
+
raise _Unsupported(f"unparseable excludes date {tok!r}")
|
|
126
|
+
dates.add(dt.date())
|
|
127
|
+
continue
|
|
128
|
+
raise _Unsupported(f"unsupported excludes token {tok!r}")
|
|
129
|
+
return weekends, dates
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_includes_tokens(spec: str) -> set[date]:
|
|
133
|
+
"""Parse the token list of an ``includes`` directive.
|
|
134
|
+
|
|
135
|
+
Only explicit ``YYYY-MM-DD`` dates are implemented (re-including a
|
|
136
|
+
specific day that ``excludes`` would otherwise skip). ``weekends``
|
|
137
|
+
isn't a supported ``includes`` token — there's no "re-include every
|
|
138
|
+
weekend" semantics implemented — so it raises ``_Unsupported`` to
|
|
139
|
+
degrade the whole diagram rather than silently no-op-ing a directive
|
|
140
|
+
that looks like it should cancel an ``excludes weekends`` rule.
|
|
141
|
+
"""
|
|
142
|
+
dates: set[date] = set()
|
|
143
|
+
for tok in re.split(r"[,\s]+", spec.strip()):
|
|
144
|
+
if not tok:
|
|
145
|
+
continue
|
|
146
|
+
if _ISO_DATE_RE.match(tok):
|
|
147
|
+
dt = _parse_date(tok, "%Y-%m-%d")
|
|
148
|
+
if dt is None:
|
|
149
|
+
raise _Unsupported(f"unparseable includes date {tok!r}")
|
|
150
|
+
dates.add(dt.date())
|
|
151
|
+
continue
|
|
152
|
+
raise _Unsupported(f"unsupported includes token {tok!r}")
|
|
153
|
+
return dates
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_excluded(
|
|
157
|
+
d: date, exclude_weekends: bool, exclude_dates: set[date], include_dates: set[date]
|
|
158
|
+
) -> bool:
|
|
159
|
+
if d in include_dates:
|
|
160
|
+
return False
|
|
161
|
+
if exclude_weekends and d.weekday() >= 5:
|
|
162
|
+
return True
|
|
163
|
+
return d in exclude_dates
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _skip_excluded(
|
|
167
|
+
start: datetime,
|
|
168
|
+
exclude_weekends: bool,
|
|
169
|
+
exclude_dates: set[date],
|
|
170
|
+
include_dates: set[date],
|
|
171
|
+
) -> datetime:
|
|
172
|
+
"""If an auto-resolved ``start`` falls on an excluded day, advance to
|
|
173
|
+
the next working day."""
|
|
174
|
+
if not exclude_weekends and not exclude_dates:
|
|
175
|
+
return start
|
|
176
|
+
while _is_excluded(start.date(), exclude_weekends, exclude_dates, include_dates):
|
|
177
|
+
start = start + timedelta(days=1)
|
|
178
|
+
return start
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _advance_working_days(
|
|
182
|
+
start: datetime,
|
|
183
|
+
days: float,
|
|
184
|
+
exclude_weekends: bool,
|
|
185
|
+
exclude_dates: set[date],
|
|
186
|
+
include_dates: set[date],
|
|
187
|
+
) -> datetime:
|
|
188
|
+
"""Advance ``start`` by ``days`` working days, skipping excluded days
|
|
189
|
+
(which extend the span but don't count toward the duration).
|
|
190
|
+
|
|
191
|
+
Raises ``OverflowError`` (propagated to the caller as ``_Unsupported``)
|
|
192
|
+
for magnitudes ``datetime`` can't represent, both to never crash on
|
|
193
|
+
huge durations and to bound the day-by-day walk below.
|
|
194
|
+
"""
|
|
195
|
+
start + timedelta(days=days) # bounds-check before the exclusion walk
|
|
196
|
+
if not exclude_weekends and not exclude_dates:
|
|
197
|
+
return start + timedelta(days=days)
|
|
198
|
+
whole = int(days)
|
|
199
|
+
frac = days - whole
|
|
200
|
+
cur = start
|
|
201
|
+
remaining = whole
|
|
202
|
+
while remaining > 0:
|
|
203
|
+
cur = cur + timedelta(days=1)
|
|
204
|
+
if _is_excluded(cur.date(), exclude_weekends, exclude_dates, include_dates):
|
|
205
|
+
continue
|
|
206
|
+
remaining -= 1
|
|
207
|
+
if frac:
|
|
208
|
+
cur = cur + timedelta(days=frac)
|
|
209
|
+
return cur
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_gantt(source: str) -> dict:
|
|
213
|
+
"""Parse the core mermaid gantt grammar into a title + section/task tree.
|
|
214
|
+
|
|
215
|
+
Handles ``dateFormat``, ``title``, ``section``, ``excludes``
|
|
216
|
+
(``weekends`` and explicit dates), ``includes`` (explicit dates only),
|
|
217
|
+
``%%`` comments, ``milestone`` tasks, ``until <taskId>``, and task
|
|
218
|
+
lines of the form ``name : [status,] [id,] [start-date|after id,]
|
|
219
|
+
duration-or-end``. Lines that don't match a recognized shape (a task
|
|
220
|
+
with no resolvable start anchor, an unrecognized status keyword, ...)
|
|
221
|
+
are skipped rather than raising — agents emit a wide variety of
|
|
222
|
+
gantt dialects and an unparseable line must degrade, not crash.
|
|
223
|
+
|
|
224
|
+
A construct we can't safely resolve (an invalid ``dateFormat``, a
|
|
225
|
+
numeric or date-arithmetic overflow, an ``until`` reference to an
|
|
226
|
+
unknown task id, or an ``excludes``/``includes`` form beyond
|
|
227
|
+
``weekends``/explicit dates) degrades the *whole* diagram: this
|
|
228
|
+
returns ``{"title": None, "sections": []}``, which the renderer
|
|
229
|
+
falls back to raw source for.
|
|
230
|
+
|
|
231
|
+
Returns ``{"title": str | None, "sections": [{"name": str | None,
|
|
232
|
+
"tasks": [{"label": str, "start": datetime, "end": datetime,
|
|
233
|
+
"milestone": bool}]}]}``. Sections with no successfully parsed tasks
|
|
234
|
+
are dropped.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
return _parse_gantt(source)
|
|
238
|
+
except _Unsupported:
|
|
239
|
+
return {"title": None, "sections": []}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_gantt(source: str) -> dict:
|
|
243
|
+
fmt = "%Y-%m-%d"
|
|
244
|
+
title: str | None = None
|
|
245
|
+
sections: list[dict] = [{"name": None, "tasks": []}]
|
|
246
|
+
task_ends: dict[str, datetime] = {}
|
|
247
|
+
task_starts: dict[str, datetime] = {}
|
|
248
|
+
last_end: datetime | None = None
|
|
249
|
+
exclude_weekends = False
|
|
250
|
+
exclude_dates: set[date] = set()
|
|
251
|
+
include_dates: set[date] = set()
|
|
252
|
+
|
|
253
|
+
for raw_line in source.splitlines():
|
|
254
|
+
line = _COMMENT_RE.sub("", raw_line)
|
|
255
|
+
if not line.strip() or _HEADER_RE.match(line) or _SKIP_RE.match(line):
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
m = _TITLE_RE.match(line)
|
|
259
|
+
if m:
|
|
260
|
+
title = m.group(1)
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
m = _DATEFORMAT_RE.match(line)
|
|
264
|
+
if m:
|
|
265
|
+
candidate = _strptime_format(m.group(1))
|
|
266
|
+
if not _valid_strptime_format(candidate):
|
|
267
|
+
raise _Unsupported(f"invalid dateFormat {m.group(1)!r}")
|
|
268
|
+
fmt = candidate
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
m = _SECTION_RE.match(line)
|
|
272
|
+
if m:
|
|
273
|
+
sections.append({"name": m.group(1), "tasks": []})
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
m = _EXCLUDES_RE.match(line)
|
|
277
|
+
if m:
|
|
278
|
+
w, d = _parse_excludes_tokens(m.group(1))
|
|
279
|
+
exclude_weekends = exclude_weekends or w
|
|
280
|
+
exclude_dates |= d
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
m = _INCLUDES_RE.match(line)
|
|
284
|
+
if m:
|
|
285
|
+
include_dates |= _parse_includes_tokens(m.group(1))
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
if ":" not in line:
|
|
289
|
+
continue
|
|
290
|
+
label, _, spec = line.partition(":")
|
|
291
|
+
label = label.strip()
|
|
292
|
+
if not label:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
task_id: str | None = None
|
|
296
|
+
start: datetime | None = None
|
|
297
|
+
end: datetime | None = None
|
|
298
|
+
duration_amount: float | None = None
|
|
299
|
+
duration_unit: str | None = None
|
|
300
|
+
until_id: str | None = None
|
|
301
|
+
is_milestone = False
|
|
302
|
+
after_ids: list[str] = []
|
|
303
|
+
|
|
304
|
+
for raw_tok in spec.split(","):
|
|
305
|
+
tok = raw_tok.strip()
|
|
306
|
+
if not tok:
|
|
307
|
+
continue
|
|
308
|
+
low = tok.lower()
|
|
309
|
+
if low in _STATUS_TOKENS:
|
|
310
|
+
if low == "milestone":
|
|
311
|
+
is_milestone = True
|
|
312
|
+
continue
|
|
313
|
+
if low.startswith("after "):
|
|
314
|
+
after_ids.extend(tok[len("after "):].split())
|
|
315
|
+
continue
|
|
316
|
+
if low.startswith("until "):
|
|
317
|
+
until_id = tok[len("until "):].strip()
|
|
318
|
+
continue
|
|
319
|
+
dm = _DURATION_RE.match(tok)
|
|
320
|
+
if dm:
|
|
321
|
+
duration_amount, duration_unit = float(dm.group(1)), dm.group(2).lower()
|
|
322
|
+
continue
|
|
323
|
+
dt = _parse_date(tok, fmt)
|
|
324
|
+
if dt is not None:
|
|
325
|
+
if start is None:
|
|
326
|
+
start = dt
|
|
327
|
+
else:
|
|
328
|
+
end = dt
|
|
329
|
+
continue
|
|
330
|
+
if task_id is None and _ID_RE.match(tok):
|
|
331
|
+
task_id = tok
|
|
332
|
+
continue
|
|
333
|
+
# Unrecognized token (e.g. an unmapped status keyword): ignore
|
|
334
|
+
# and keep scanning the remaining tokens on this line.
|
|
335
|
+
|
|
336
|
+
if start is None:
|
|
337
|
+
if after_ids:
|
|
338
|
+
resolved = [task_ends[i] for i in after_ids if i in task_ends]
|
|
339
|
+
start = max(resolved) if resolved else last_end
|
|
340
|
+
else:
|
|
341
|
+
start = last_end
|
|
342
|
+
if start is not None:
|
|
343
|
+
try:
|
|
344
|
+
start = _skip_excluded(
|
|
345
|
+
start, exclude_weekends, exclude_dates, include_dates
|
|
346
|
+
)
|
|
347
|
+
except OverflowError:
|
|
348
|
+
raise _Unsupported("date overflow skipping excluded days") from None
|
|
349
|
+
|
|
350
|
+
if start is None:
|
|
351
|
+
# No date, no "after" dependency, no prior task to anchor to —
|
|
352
|
+
# this task can't be placed on the timeline. Skip it.
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
if is_milestone:
|
|
356
|
+
end = start
|
|
357
|
+
elif end is None:
|
|
358
|
+
if until_id is not None:
|
|
359
|
+
if until_id not in task_starts:
|
|
360
|
+
raise _Unsupported(f"until references unknown task {until_id!r}")
|
|
361
|
+
end = task_starts[until_id]
|
|
362
|
+
elif duration_amount is not None:
|
|
363
|
+
try:
|
|
364
|
+
if duration_unit in ("d", "w"):
|
|
365
|
+
days = duration_amount * (7 if duration_unit == "w" else 1)
|
|
366
|
+
end = _advance_working_days(
|
|
367
|
+
start, days, exclude_weekends, exclude_dates, include_dates
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
end = start + timedelta(
|
|
371
|
+
seconds=duration_amount * _UNIT_SECONDS[duration_unit]
|
|
372
|
+
)
|
|
373
|
+
except OverflowError:
|
|
374
|
+
raise _Unsupported("duration overflow") from None
|
|
375
|
+
else:
|
|
376
|
+
try:
|
|
377
|
+
end = start + timedelta(days=1)
|
|
378
|
+
except OverflowError:
|
|
379
|
+
raise _Unsupported("date overflow computing default end") from None
|
|
380
|
+
if end < start:
|
|
381
|
+
end = start
|
|
382
|
+
|
|
383
|
+
sections[-1]["tasks"].append(
|
|
384
|
+
{"label": label, "start": start, "end": end, "milestone": is_milestone}
|
|
385
|
+
)
|
|
386
|
+
last_end = end
|
|
387
|
+
if task_id:
|
|
388
|
+
task_ends[task_id] = end
|
|
389
|
+
task_starts[task_id] = start
|
|
390
|
+
|
|
391
|
+
sections = [s for s in sections if s["tasks"]]
|
|
392
|
+
return {"title": title, "sections": sections}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _draw_span(width: int, start_ratio: float, end_ratio: float) -> str:
|
|
396
|
+
"""Draw a ``width``-wide track with a filled span from start to end ratio."""
|
|
397
|
+
if width <= 0:
|
|
398
|
+
return ""
|
|
399
|
+
start_col = max(0, min(int(round(start_ratio * width)), width))
|
|
400
|
+
end_col = max(start_col + 1, min(int(round(end_ratio * width)), width))
|
|
401
|
+
return "░" * start_col + "█" * (end_col - start_col) + "░" * (width - end_col)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _draw_milestone(width: int, ratio: float) -> str:
|
|
405
|
+
"""Draw a ``width``-wide track with a single point marker (a milestone
|
|
406
|
+
is an instant, not a span)."""
|
|
407
|
+
if width <= 0:
|
|
408
|
+
return ""
|
|
409
|
+
col = max(0, min(int(round(ratio * width)), width - 1))
|
|
410
|
+
return "░" * col + "◆" + "░" * (width - col - 1)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _fmt_range(task: dict) -> str:
|
|
414
|
+
start, end = task["start"], task["end"]
|
|
415
|
+
if start.date() == end.date():
|
|
416
|
+
return start.strftime("%Y-%m-%d")
|
|
417
|
+
return f"{start:%Y-%m-%d}\u2192{end:%Y-%m-%d}"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def render(source: str, width: int) -> list[str]:
|
|
421
|
+
"""Render mermaid gantt syntax as section-grouped rows with time-span bars.
|
|
422
|
+
|
|
423
|
+
Milestone tasks render as a single point marker rather than a span.
|
|
424
|
+
Diagrams with no parseable tasks (including diagrams degraded by
|
|
425
|
+
``parse_gantt`` due to an unsupported construct) fall back to the raw
|
|
426
|
+
source text.
|
|
427
|
+
"""
|
|
428
|
+
parsed = parse_gantt(source)
|
|
429
|
+
tasks = [t for s in parsed["sections"] for t in s["tasks"]]
|
|
430
|
+
if not tasks:
|
|
431
|
+
return source.splitlines() or [""]
|
|
432
|
+
|
|
433
|
+
min_start = min(t["start"] for t in tasks)
|
|
434
|
+
max_end = max(t["end"] for t in tasks)
|
|
435
|
+
span = (max_end - min_start).total_seconds() or 1.0
|
|
436
|
+
|
|
437
|
+
label_w = max(visual_len(t["label"]) for t in tasks)
|
|
438
|
+
date_w = max(visual_len(_fmt_range(t)) for t in tasks)
|
|
439
|
+
bar_w = max(width - label_w - date_w - 4, 5)
|
|
440
|
+
|
|
441
|
+
lines: list[str] = []
|
|
442
|
+
if parsed["title"]:
|
|
443
|
+
lines.append(visual_ljust(parsed["title"], width))
|
|
444
|
+
|
|
445
|
+
for section in parsed["sections"]:
|
|
446
|
+
if section["name"]:
|
|
447
|
+
lines.append(visual_ljust(section["name"], width))
|
|
448
|
+
for t in section["tasks"]:
|
|
449
|
+
start_ratio = (t["start"] - min_start).total_seconds() / span
|
|
450
|
+
if t["milestone"]:
|
|
451
|
+
bar = _draw_milestone(bar_w, start_ratio)
|
|
452
|
+
else:
|
|
453
|
+
end_ratio = (t["end"] - min_start).total_seconds() / span
|
|
454
|
+
bar = _draw_span(bar_w, start_ratio, end_ratio)
|
|
455
|
+
label = visual_ljust(t["label"], label_w)
|
|
456
|
+
date_str = _fmt_range(t).rjust(date_w)
|
|
457
|
+
lines.append(visual_ljust(f"{label} {bar} {date_str}", width))
|
|
458
|
+
|
|
459
|
+
return lines
|
|
@@ -40,7 +40,8 @@ def parse_journey(source: str) -> dict:
|
|
|
40
40
|
still captured with ``score=None``; a non-numeric score is likewise
|
|
41
41
|
tolerated (``score=None``) rather than raising. A task line before any
|
|
42
42
|
``section`` header lands in an unnamed leading section. Sections with
|
|
43
|
-
no tasks are dropped.
|
|
43
|
+
no tasks are dropped. ``%%`` comment and directive lines are skipped
|
|
44
|
+
wherever they appear in the body, not just in the leading prelude.
|
|
44
45
|
|
|
45
46
|
Returns ``{"title": str | None, "sections": [{"name": str | None,
|
|
46
47
|
"tasks": [{"name": str, "score": int | None, "actors": list[str]}]}]}``.
|
|
@@ -51,6 +52,8 @@ def parse_journey(source: str) -> dict:
|
|
|
51
52
|
for line in source.splitlines():
|
|
52
53
|
if not line.strip() or _HEADER_RE.match(line):
|
|
53
54
|
continue
|
|
55
|
+
if line.strip().startswith("%%"):
|
|
56
|
+
continue
|
|
54
57
|
|
|
55
58
|
m = _TITLE_RE.match(line)
|
|
56
59
|
if m:
|
|
@@ -64,12 +64,14 @@ def _node_label(text: str) -> str:
|
|
|
64
64
|
def parse_mindmap(source: str) -> str:
|
|
65
65
|
"""Parse mermaid mindmap syntax into ``tree.py``'s plain indented format.
|
|
66
66
|
|
|
67
|
-
Drops the ``mindmap`` header line
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
Drops the ``mindmap`` header line, any ``%%`` comment/directive line
|
|
68
|
+
(wherever it appears in the body, not just in the leading prelude), and
|
|
69
|
+
any ``::icon(...)``/``:::class`` decoration lines (termrender's tree has
|
|
70
|
+
no icon/class concept); every other line's original indentation is
|
|
71
|
+
preserved (depth comes from indentation, exactly as ``tree.py`` already
|
|
72
|
+
expects) with its node-shape delimiters stripped down to label text.
|
|
73
|
+
Returns the transformed multi-line indented string, empty if nothing
|
|
74
|
+
survived.
|
|
73
75
|
"""
|
|
74
76
|
out_lines: list[str] = []
|
|
75
77
|
for raw_line in source.splitlines():
|
|
@@ -78,6 +80,8 @@ def parse_mindmap(source: str) -> str:
|
|
|
78
80
|
if _HEADER_RE.match(raw_line):
|
|
79
81
|
continue
|
|
80
82
|
stripped = raw_line.strip()
|
|
83
|
+
if stripped.startswith("%%"):
|
|
84
|
+
continue
|
|
81
85
|
if _ICON_RE.match(stripped) or _CLASS_RE.match(stripped):
|
|
82
86
|
continue
|
|
83
87
|
indent = raw_line[: len(raw_line) - len(raw_line.lstrip(" "))]
|