termrender 4.3.0__tar.gz → 4.3.1__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.1}/CHANGELOG.md +55 -0
- {termrender-4.3.0 → termrender-4.3.1}/PKG-INFO +1 -1
- termrender-4.3.1/src/termrender/renderers/mermaid_gantt.py +459 -0
- termrender-4.3.1/src/termrender/renderers/mermaid_pie.py +128 -0
- termrender-4.3.1/tests/test_mermaid_gantt.py +333 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_pie.py +63 -2
- 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.1}/.github/workflows/publish.yml +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/.gitignore +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/CLAUDE.md +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/LICENSE +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/README.md +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/design.json +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/pyproject.toml +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/requirements.json +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/__init__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/__main__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/blocks.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/emit.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/layout.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/parser.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/py.typed +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/code.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/table.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/text.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/src/termrender/style.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/__init__.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_charts.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_cli_contract.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_column_alignment.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_diff.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_inline_badge.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_linebreak.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_myst_gaps.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_stat.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_tasklist.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_timeline.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/tests/test_variable_colons.py +0 -0
- {termrender-4.3.0 → termrender-4.3.1}/uv.lock +0 -0
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.3.1 (2026-07-06)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **mermaid-gantt**: Close remaining overflow and includes gaps from review
|
|
9
|
+
([`73a1354`](https://github.com/crouton-labs/termrender/commit/73a135465644073d58e293ed69e6ca968d4f5a05))
|
|
10
|
+
|
|
11
|
+
- _skip_excluded() and the implicit +1-day default end could still raise OverflowError past
|
|
12
|
+
datetime.max; both call sites now catch it and degrade the whole diagram to source instead of
|
|
13
|
+
crashing. - includes weekends was accepted but silently no-op'd (excludes weekends + includes
|
|
14
|
+
weekends still rendered the excluded schedule); includes now only supports explicit YYYY-MM-DD
|
|
15
|
+
dates and rejects weekends as _Unsupported rather than pretending to cancel the exclusion.
|
|
16
|
+
|
|
17
|
+
fix(mermaid-pie): reject invalid trailing header grammar
|
|
18
|
+
|
|
19
|
+
pie bogus (anything after pie/pie showData that isn't a valid inline title) was silently accepted
|
|
20
|
+
and rendered a plausible chart; it now degrades the whole diagram to source, matching the
|
|
21
|
+
strictness already applied to other invalid pie lines.
|
|
22
|
+
|
|
23
|
+
- **mermaid-gantt**: Implement excludes/includes, milestone, until; never-crash on bad
|
|
24
|
+
dateFormat/overflow
|
|
25
|
+
([`e182ae1`](https://github.com/crouton-labs/termrender/commit/e182ae1346af7fa4a531ad0014878e06caf5304e))
|
|
26
|
+
|
|
27
|
+
Several valid Mermaid gantt constructs rendered false schedules, and malformed input could crash:
|
|
28
|
+
|
|
29
|
+
- A malformed dateFormat (e.g. YYYY-YYYY, whose translated strptime pattern has a duplicate capture
|
|
30
|
+
group) raised re.error instead of being caught; the format is now validated by round-tripping a
|
|
31
|
+
sample date and an invalid format degrades the whole diagram to source. - Huge numeric durations
|
|
32
|
+
(e.g. 1000000000d) overflowed timedelta/datetime arithmetic; overflow is now caught and degrades
|
|
33
|
+
the whole diagram. - excludes weekends / excludes <YYYY-MM-DD> and includes <YYYY-MM-DD> are now
|
|
34
|
+
applied when resolving durations and auto-anchored start dates, instead of being ignored as
|
|
35
|
+
decorative metadata. An excludes/includes form we don't implement (e.g. day names) degrades the
|
|
36
|
+
whole diagram rather than rendering a false schedule. - milestone tasks are now tracked as
|
|
37
|
+
point-in-time markers (rendered as a single diamond marker) instead of ordinary duration spans. -
|
|
38
|
+
until <taskId> is now resolved to the referenced task's start date; an unknown reference degrades
|
|
39
|
+
the whole diagram. - %% comments (whole-line and trailing inline) are stripped before
|
|
40
|
+
tokenization, so a comment containing a colon can no longer be misparsed as a task.
|
|
41
|
+
|
|
42
|
+
- **mermaid-pie**: Degrade to source on invalid grammar, negative or overflowing values
|
|
43
|
+
([`765f613`](https://github.com/crouton-labs/termrender/commit/765f613d9e1b72ff79e8f0c32ac8d9f9cdceef69))
|
|
44
|
+
|
|
45
|
+
Native pie parsing previously treated any non-matching line as a silent ignorable and accepted
|
|
46
|
+
negative slice values, so malformed input rendered a plausible-but-wrong partial chart instead of
|
|
47
|
+
falling back to source. Arbitrarily long numeric literals could also reach charts._format_value()
|
|
48
|
+
as float('inf') and raise OverflowError.
|
|
49
|
+
|
|
50
|
+
- Invalid grammar (anything beyond header/title/accTitle/accDescr/%% comments/slice lines) now
|
|
51
|
+
discards the whole diagram and falls back to raw source, matching Mermaid's actual pie grammar. -
|
|
52
|
+
Negative slice values are rejected the same way (Mermaid disallows them). - Non-finite values
|
|
53
|
+
(from huge numeric literals) are rejected in parse_pie, and the summed total is re-checked in
|
|
54
|
+
render() as a second guard, both falling back to source instead of crashing. - Slice labels now
|
|
55
|
+
also accept single-quoted strings and escaped quotes inside double-quoted strings, per Mermaid's
|
|
56
|
+
real string grammar.
|
|
57
|
+
|
|
58
|
+
|
|
4
59
|
## v4.3.0 (2026-07-06)
|
|
5
60
|
|
|
6
61
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.3.
|
|
3
|
+
Version: 4.3.1
|
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Native renderer for mermaid ``pie`` diagrams.
|
|
2
|
+
|
|
3
|
+
Mermaid pie grammar (subset)::
|
|
4
|
+
|
|
5
|
+
pie [showData]
|
|
6
|
+
title An Optional Title
|
|
7
|
+
"Label A" : 40
|
|
8
|
+
'Label B' : 60
|
|
9
|
+
|
|
10
|
+
A text pie chart is worse than a labeled bar chart at the same information
|
|
11
|
+
density, so pie diagrams are re-expressed as a horizontal bar chart (see the
|
|
12
|
+
ratified plan in ``mermaid-inline-rendering.md``), reusing ``charts.py``'s
|
|
13
|
+
``render_bar`` so the visual style matches the rest of termrender's charts.
|
|
14
|
+
|
|
15
|
+
Never-crash / never-lie contract: real Mermaid pie grammar only permits the
|
|
16
|
+
header, ``title``/``accTitle``/``accDescr``/``%%``-comment lines, and slice
|
|
17
|
+
declarations — everything else is a parse error. A line that doesn't match
|
|
18
|
+
one of those, or a negative slice value (Mermaid rejects negative pie
|
|
19
|
+
values), degrades the *whole* diagram to raw source rather than rendering a
|
|
20
|
+
plausible-but-wrong chart with the bad line silently dropped.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
from termrender.blocks import Block, BlockType
|
|
29
|
+
from termrender.renderers.charts import render_bar
|
|
30
|
+
|
|
31
|
+
_TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
|
|
32
|
+
_ACC_RE = re.compile(r"^\s*acc(?:Title|Descr)\b", re.IGNORECASE)
|
|
33
|
+
_COMMENT_RE = re.compile(r"^\s*%%")
|
|
34
|
+
_HEADER_RE = re.compile(r"^\s*pie\b(?:\s+showData\b)?\s*(.*)$", re.IGNORECASE)
|
|
35
|
+
# Group 1: double-quoted label body (escaped quotes allowed, unescaped below).
|
|
36
|
+
# Group 2: single-quoted label body.
|
|
37
|
+
_DATA_RE = re.compile(
|
|
38
|
+
r'^\s*(?:"((?:[^"\\]|\\.)*)"|\'([^\']*)\')\s*:\s*(-?\d+(?:\.\d+)?)\s*$'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _unescape_double_quoted(label: str) -> str:
|
|
43
|
+
return label.replace('\\"', '"').replace("\\\\", "\\")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_pie(source: str) -> tuple[str | None, list[dict]]:
|
|
47
|
+
"""Parse mermaid pie syntax into ``(title, items)``.
|
|
48
|
+
|
|
49
|
+
``items`` is a list of ``{"label": str, "value": float}`` dicts, in
|
|
50
|
+
source order. Blank lines, ``%%`` comments, and ``accTitle``/
|
|
51
|
+
``accDescr`` lines are legitimate Mermaid ignorables and are skipped.
|
|
52
|
+
Any other line that isn't the header, a ``title``, or a
|
|
53
|
+
``"label" : value`` slice (double- or single-quoted, with escaped
|
|
54
|
+
quotes supported in double-quoted labels) is invalid Mermaid grammar;
|
|
55
|
+
a negative slice value is likewise invalid (Mermaid rejects negative
|
|
56
|
+
pie values). Either case discards everything parsed so far and
|
|
57
|
+
returns ``(None, [])`` so the caller falls back to raw source instead
|
|
58
|
+
of rendering a partial, misleading chart.
|
|
59
|
+
"""
|
|
60
|
+
title: str | None = None
|
|
61
|
+
items: list[dict] = []
|
|
62
|
+
for line in source.splitlines():
|
|
63
|
+
if not line.strip():
|
|
64
|
+
continue
|
|
65
|
+
if _COMMENT_RE.match(line) or _ACC_RE.match(line):
|
|
66
|
+
continue
|
|
67
|
+
m = _HEADER_RE.match(line)
|
|
68
|
+
if m:
|
|
69
|
+
# Mermaid allows the title inline on the header line:
|
|
70
|
+
# ``pie title X`` / ``pie showData title X``. Anything else
|
|
71
|
+
# trailing the header is invalid grammar.
|
|
72
|
+
tail = m.group(1)
|
|
73
|
+
if tail:
|
|
74
|
+
tm = _TITLE_RE.match(tail)
|
|
75
|
+
if not tm:
|
|
76
|
+
return None, []
|
|
77
|
+
title = tm.group(1)
|
|
78
|
+
continue
|
|
79
|
+
m = _TITLE_RE.match(line)
|
|
80
|
+
if m:
|
|
81
|
+
title = m.group(1)
|
|
82
|
+
continue
|
|
83
|
+
m = _DATA_RE.match(line)
|
|
84
|
+
if m:
|
|
85
|
+
if m.group(1) is not None:
|
|
86
|
+
label = _unescape_double_quoted(m.group(1))
|
|
87
|
+
else:
|
|
88
|
+
label = m.group(2)
|
|
89
|
+
value = float(m.group(3))
|
|
90
|
+
if value < 0 or not math.isfinite(value):
|
|
91
|
+
return None, []
|
|
92
|
+
items.append({"label": label, "value": value})
|
|
93
|
+
continue
|
|
94
|
+
# Invalid grammar: not header, title, acc line, comment, or data.
|
|
95
|
+
return None, []
|
|
96
|
+
return title, items
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render(source: str, width: int) -> list[str]:
|
|
100
|
+
"""Render mermaid pie syntax as a labeled horizontal bar chart.
|
|
101
|
+
|
|
102
|
+
Each bar's value is annotated with its percentage share of the total.
|
|
103
|
+
Diagrams with no parseable data lines (including diagrams degraded by
|
|
104
|
+
``parse_pie`` due to invalid grammar or a negative value) degrade to
|
|
105
|
+
the raw source text.
|
|
106
|
+
"""
|
|
107
|
+
title, items = parse_pie(source)
|
|
108
|
+
if not items:
|
|
109
|
+
return source.splitlines() or [""]
|
|
110
|
+
|
|
111
|
+
total = sum(it["value"] for it in items) or 1.0
|
|
112
|
+
if not math.isfinite(total):
|
|
113
|
+
return source.splitlines() or [""]
|
|
114
|
+
bar_items = [
|
|
115
|
+
{
|
|
116
|
+
"label": it["label"],
|
|
117
|
+
"value": it["value"],
|
|
118
|
+
"unit": f" ({it['value'] / total * 100:.1f}%)",
|
|
119
|
+
}
|
|
120
|
+
for it in items
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
block = Block(
|
|
124
|
+
type=BlockType.BAR,
|
|
125
|
+
attrs={"items": bar_items, "title": title, "color": "cyan"},
|
|
126
|
+
width=width,
|
|
127
|
+
)
|
|
128
|
+
return render_bar(block, color=False)
|