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.
Files changed (71) hide show
  1. {termrender-4.3.0 → termrender-4.3.2}/CHANGELOG.md +75 -0
  2. {termrender-4.3.0 → termrender-4.3.2}/PKG-INFO +1 -1
  3. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid.py +13 -3
  4. termrender-4.3.2/src/termrender/renderers/mermaid_gantt.py +459 -0
  5. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_journey.py +4 -1
  6. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_mindmap.py +10 -6
  7. termrender-4.3.2/src/termrender/renderers/mermaid_pie.py +128 -0
  8. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_timeline.py +4 -1
  9. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_dispatch.py +30 -0
  10. termrender-4.3.2/tests/test_mermaid_gantt.py +333 -0
  11. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_journey.py +57 -0
  12. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_mindmap.py +22 -0
  13. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_pie.py +63 -2
  14. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_timeline.py +28 -0
  15. termrender-4.3.0/src/termrender/renderers/mermaid_gantt.py +0 -222
  16. termrender-4.3.0/src/termrender/renderers/mermaid_pie.py +0 -84
  17. termrender-4.3.0/tests/test_mermaid_gantt.py +0 -154
  18. {termrender-4.3.0 → termrender-4.3.2}/.github/workflows/publish.yml +0 -0
  19. {termrender-4.3.0 → termrender-4.3.2}/.gitignore +0 -0
  20. {termrender-4.3.0 → termrender-4.3.2}/CLAUDE.md +0 -0
  21. {termrender-4.3.0 → termrender-4.3.2}/LICENSE +0 -0
  22. {termrender-4.3.0 → termrender-4.3.2}/README.md +0 -0
  23. {termrender-4.3.0 → termrender-4.3.2}/design.json +0 -0
  24. {termrender-4.3.0 → termrender-4.3.2}/pyproject.toml +0 -0
  25. {termrender-4.3.0 → termrender-4.3.2}/requirements.json +0 -0
  26. {termrender-4.3.0 → termrender-4.3.2}/scripts/build-mermaid-ascii.sh +0 -0
  27. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/CLAUDE.md +0 -0
  28. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/__init__.py +0 -0
  29. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/__main__.py +0 -0
  30. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  31. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/_mermaid_bin.py +0 -0
  32. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/blocks.py +0 -0
  33. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/emit.py +0 -0
  34. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/layout.py +0 -0
  35. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/parser.py +0 -0
  36. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/py.typed +0 -0
  37. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/CLAUDE.md +0 -0
  38. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/__init__.py +0 -0
  39. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/borders.py +0 -0
  40. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/charts.py +0 -0
  41. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/code.py +0 -0
  42. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/columns.py +0 -0
  43. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/diff.py +0 -0
  44. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/divider.py +0 -0
  45. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  46. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
  47. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
  48. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/panel.py +0 -0
  49. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/quote.py +0 -0
  50. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/stat.py +0 -0
  51. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/table.py +0 -0
  52. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/text.py +0 -0
  53. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/timeline.py +0 -0
  54. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/renderers/tree.py +0 -0
  55. {termrender-4.3.0 → termrender-4.3.2}/src/termrender/style.py +0 -0
  56. {termrender-4.3.0 → termrender-4.3.2}/tests/__init__.py +0 -0
  57. {termrender-4.3.0 → termrender-4.3.2}/tests/test_charts.py +0 -0
  58. {termrender-4.3.0 → termrender-4.3.2}/tests/test_cli_contract.py +0 -0
  59. {termrender-4.3.0 → termrender-4.3.2}/tests/test_column_alignment.py +0 -0
  60. {termrender-4.3.0 → termrender-4.3.2}/tests/test_diff.py +0 -0
  61. {termrender-4.3.0 → termrender-4.3.2}/tests/test_inline_badge.py +0 -0
  62. {termrender-4.3.0 → termrender-4.3.2}/tests/test_linebreak.py +0 -0
  63. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_compat.py +0 -0
  64. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_flow_model.py +0 -0
  65. {termrender-4.3.0 → termrender-4.3.2}/tests/test_mermaid_sequence.py +0 -0
  66. {termrender-4.3.0 → termrender-4.3.2}/tests/test_myst_gaps.py +0 -0
  67. {termrender-4.3.0 → termrender-4.3.2}/tests/test_stat.py +0 -0
  68. {termrender-4.3.0 → termrender-4.3.2}/tests/test_tasklist.py +0 -0
  69. {termrender-4.3.0 → termrender-4.3.2}/tests/test_timeline.py +0 -0
  70. {termrender-4.3.0 → termrender-4.3.2}/tests/test_variable_colons.py +0 -0
  71. {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.0
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 and any ``::icon(...)``/``:::class``
68
- decoration lines (termrender's tree has no icon/class concept); every
69
- other line's original indentation is preserved (depth comes from
70
- indentation, exactly as ``tree.py`` already expects) with its node-shape
71
- delimiters stripped down to label text. Returns the transformed
72
- multi-line indented string, empty if nothing survived.
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(" "))]