intenttext 1.0.0__py3-none-any.whl

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.
intenttext/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ from .merge import merge_data, parse_and_merge
2
+ from .parser import parse, parse_safe
3
+ from .query import query
4
+ from .renderer import render_html, render_markdown, render_print
5
+ from .source import to_source
6
+ from .types import (
7
+ InlineSegment,
8
+ IntentBlock,
9
+ IntentDocument,
10
+ IntentMetadata,
11
+ ParseResult,
12
+ ParseWarning,
13
+ ValidationIssue,
14
+ ValidationResult,
15
+ )
16
+ from .validate import validate
17
+
18
+ __version__ = "1.0.0"
19
+
20
+ __all__ = [
21
+ "parse",
22
+ "parse_safe",
23
+ "render_html",
24
+ "render_print",
25
+ "render_markdown",
26
+ "merge_data",
27
+ "parse_and_merge",
28
+ "validate",
29
+ "query",
30
+ "to_source",
31
+ "IntentDocument",
32
+ "IntentBlock",
33
+ "IntentMetadata",
34
+ "InlineSegment",
35
+ "ParseResult",
36
+ "ParseWarning",
37
+ "ValidationResult",
38
+ "ValidationIssue",
39
+ ]
intenttext/merge.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from copy import deepcopy
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from .parser import parse
9
+ from .types import IntentDocument
10
+
11
+
12
+ def merge_data(template: IntentDocument, data: dict[str, Any]) -> IntentDocument:
13
+ doc = deepcopy(template)
14
+ now = datetime.now()
15
+ system_vars = {
16
+ "timestamp": now.isoformat(),
17
+ "date": now.strftime("%d %B %Y"),
18
+ "year": str(now.year),
19
+ }
20
+ merged_data = {**system_vars, **data}
21
+
22
+ for block in doc.blocks:
23
+ block.content = _resolve_string(block.content, merged_data)
24
+ block.original_content = _resolve_string(block.original_content, merged_data)
25
+ block.properties = {
26
+ k: _resolve_string(v, merged_data) if isinstance(v, str) else v
27
+ for k, v in block.properties.items()
28
+ }
29
+
30
+ _refresh_metadata(doc)
31
+
32
+ return doc
33
+
34
+
35
+ def parse_and_merge(template_source: str, data: dict[str, Any]) -> IntentDocument:
36
+ template = parse(template_source)
37
+ return merge_data(template, data)
38
+
39
+
40
+ def _resolve_string(text: str, data: dict[str, Any]) -> str:
41
+ def replacer(match: re.Match[str]) -> str:
42
+ path = match.group(1).strip()
43
+ if path in ("page", "pages"):
44
+ return match.group(0)
45
+ value = _get_by_path(data, path)
46
+ return str(value) if value is not None else match.group(0)
47
+
48
+ return re.sub(r"\{\{([^}]+)\}\}", replacer, text)
49
+
50
+
51
+ def _get_by_path(obj: Any, path: str) -> Any:
52
+ parts = path.split(".")
53
+ current = obj
54
+
55
+ for part in parts:
56
+ if current is None:
57
+ return None
58
+
59
+ if isinstance(current, list):
60
+ try:
61
+ current = current[int(part)]
62
+ except (ValueError, IndexError):
63
+ return None
64
+ elif isinstance(current, dict):
65
+ current = current.get(part)
66
+ else:
67
+ return None
68
+
69
+ return current
70
+
71
+
72
+ def _refresh_metadata(doc: IntentDocument) -> None:
73
+ for block in doc.blocks:
74
+ if block.type == "title":
75
+ doc.metadata.title = block.content
76
+ elif block.type == "summary":
77
+ doc.metadata.summary = block.content
78
+ elif block.type == "agent":
79
+ doc.metadata.agent = block.content
80
+ if "model" in block.properties:
81
+ doc.metadata.model = str(block.properties["model"])
82
+ elif block.type == "context":
83
+ doc.metadata.context.update(
84
+ {k: str(v) for k, v in block.properties.items()}
85
+ )
intenttext/parser.py ADDED
@@ -0,0 +1,389 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Optional
5
+
6
+ from .types import (
7
+ InlineSegment,
8
+ IntentBlock,
9
+ IntentDocument,
10
+ IntentMetadata,
11
+ ParseResult,
12
+ ParseWarning,
13
+ )
14
+
15
+ DOCUMENT_HEADER_KEYWORDS = {
16
+ "agent",
17
+ "context",
18
+ "font",
19
+ "page",
20
+ }
21
+
22
+ STRUCTURE_KEYWORDS = {
23
+ "title",
24
+ "summary",
25
+ "section",
26
+ "sub",
27
+ "note",
28
+ "toc",
29
+ "break",
30
+ }
31
+
32
+ CONTENT_KEYWORDS = {
33
+ "task",
34
+ "done",
35
+ "ask",
36
+ "quote",
37
+ "info",
38
+ "warning",
39
+ "tip",
40
+ "success",
41
+ "link",
42
+ "image",
43
+ "code",
44
+ "ref",
45
+ }
46
+
47
+ WRITER_KEYWORDS = {
48
+ "byline",
49
+ "epigraph",
50
+ "caption",
51
+ "footnote",
52
+ "dedication",
53
+ }
54
+
55
+ AGENTIC_KEYWORDS = {
56
+ "step",
57
+ "decision",
58
+ "parallel",
59
+ "loop",
60
+ "call",
61
+ "gate",
62
+ "wait",
63
+ "retry",
64
+ "error",
65
+ "trigger",
66
+ "checkpoint",
67
+ "handoff",
68
+ "audit",
69
+ "emit",
70
+ "result",
71
+ "progress",
72
+ "import",
73
+ "export",
74
+ }
75
+
76
+ ALL_KEYWORDS = (
77
+ DOCUMENT_HEADER_KEYWORDS
78
+ | STRUCTURE_KEYWORDS
79
+ | CONTENT_KEYWORDS
80
+ | WRITER_KEYWORDS
81
+ | AGENTIC_KEYWORDS
82
+ )
83
+
84
+ _ID_COUNTER = 0
85
+
86
+
87
+ def parse(source: str) -> IntentDocument:
88
+ result = parse_safe(source)
89
+ if result.errors:
90
+ raise ValueError(f"Parse errors: {result.errors[0].message}")
91
+ return result.document
92
+
93
+
94
+ def parse_safe(
95
+ source: str,
96
+ unknown_keyword: str = "note",
97
+ max_blocks: int = 10000,
98
+ max_line_length: int = 50000,
99
+ ) -> ParseResult:
100
+ warnings: list[ParseWarning] = []
101
+ errors: list[ParseWarning] = []
102
+ blocks: list[IntentBlock] = []
103
+ metadata = IntentMetadata()
104
+ in_code_fence = False
105
+ fence_lines: list[str] = []
106
+
107
+ lines = source.splitlines()
108
+
109
+ for line_num, raw_line in enumerate(lines, 1):
110
+ original = raw_line
111
+ if len(raw_line) > max_line_length:
112
+ raw_line = raw_line[:max_line_length]
113
+ warnings.append(
114
+ ParseWarning(
115
+ line=line_num,
116
+ message=f"Line truncated at {max_line_length} characters",
117
+ code="LINE_TRUNCATED",
118
+ original=original[:100] + "...",
119
+ )
120
+ )
121
+
122
+ line = raw_line.strip()
123
+
124
+ if line.startswith("```"):
125
+ if in_code_fence:
126
+ blocks.append(
127
+ IntentBlock(
128
+ id=_generate_id(),
129
+ type="code",
130
+ content="\n".join(fence_lines),
131
+ original_content="\n".join(fence_lines),
132
+ )
133
+ )
134
+ in_code_fence = False
135
+ fence_lines = []
136
+ else:
137
+ in_code_fence = True
138
+ continue
139
+
140
+ if in_code_fence:
141
+ fence_lines.append(raw_line)
142
+ continue
143
+
144
+ if not line:
145
+ continue
146
+
147
+ if line.startswith("//"):
148
+ continue
149
+
150
+ if line == "---":
151
+ blocks.append(
152
+ IntentBlock(
153
+ id=_generate_id(),
154
+ type="divider",
155
+ content="",
156
+ original_content="---",
157
+ )
158
+ )
159
+ continue
160
+
161
+ if line.startswith("|") and line.endswith("|"):
162
+ cells = [c.strip() for c in line[1:-1].split("|")]
163
+ if all(re.match(r"^[-:]+$", c.strip()) for c in cells if c.strip()):
164
+ continue
165
+ if blocks and blocks[-1].type == "table":
166
+ rows = blocks[-1].properties.get("rows", [])
167
+ rows.append(cells)
168
+ blocks[-1].properties["rows"] = rows
169
+ else:
170
+ blocks.append(
171
+ IntentBlock(
172
+ id=_generate_id(),
173
+ type="table",
174
+ content="",
175
+ original_content=line,
176
+ properties={"rows": [cells]},
177
+ )
178
+ )
179
+ continue
180
+
181
+ if len(blocks) >= max_blocks:
182
+ warnings.append(
183
+ ParseWarning(
184
+ line=line_num,
185
+ message=f"Max blocks ({max_blocks}) reached, stopping parse",
186
+ code="MAX_BLOCKS_REACHED",
187
+ original=line,
188
+ )
189
+ )
190
+ break
191
+
192
+ block = _parse_keyword_line(
193
+ line=line,
194
+ line_num=line_num,
195
+ unknown_keyword=unknown_keyword,
196
+ warnings=warnings,
197
+ errors=errors,
198
+ )
199
+ if block is not None:
200
+ blocks.append(block)
201
+ _update_metadata(metadata, block)
202
+
203
+ if in_code_fence:
204
+ warnings.append(
205
+ ParseWarning(
206
+ line=len(lines),
207
+ message="Unclosed code fence, auto-closed at end of file",
208
+ code="UNCLOSED_CODE_FENCE",
209
+ original="```",
210
+ )
211
+ )
212
+ blocks.append(
213
+ IntentBlock(
214
+ id=_generate_id(),
215
+ type="code",
216
+ content="\n".join(fence_lines),
217
+ original_content="\n".join(fence_lines),
218
+ )
219
+ )
220
+
221
+ document = IntentDocument(version="2.0", blocks=blocks, metadata=metadata)
222
+ return ParseResult(document=document, warnings=warnings, errors=errors)
223
+
224
+
225
+ def _parse_keyword_line(
226
+ line: str,
227
+ line_num: int,
228
+ unknown_keyword: str,
229
+ warnings: list[ParseWarning],
230
+ errors: list[ParseWarning],
231
+ ) -> Optional[IntentBlock]:
232
+ match = re.match(r"^(\w+):\s*(.*)$", line)
233
+ if not match:
234
+ return None
235
+
236
+ keyword = match.group(1).lower()
237
+ rest = match.group(2)
238
+
239
+ if keyword not in ALL_KEYWORDS:
240
+ if unknown_keyword == "skip":
241
+ warnings.append(
242
+ ParseWarning(
243
+ line=line_num,
244
+ message=f"Unknown keyword '{keyword}' skipped",
245
+ code="UNKNOWN_KEYWORD",
246
+ original=line,
247
+ )
248
+ )
249
+ return None
250
+ if unknown_keyword == "throw":
251
+ errors.append(
252
+ ParseWarning(
253
+ line=line_num,
254
+ message=f"Unknown keyword '{keyword}'",
255
+ code="UNKNOWN_KEYWORD",
256
+ original=line,
257
+ )
258
+ )
259
+ return None
260
+
261
+ warnings.append(
262
+ ParseWarning(
263
+ line=line_num,
264
+ message=f"Unknown keyword '{keyword}' treated as note",
265
+ code="UNKNOWN_KEYWORD",
266
+ original=line,
267
+ )
268
+ )
269
+ keyword = "note"
270
+
271
+ content, properties = _parse_content_and_properties(rest)
272
+
273
+ if keyword == "done":
274
+ keyword = "task"
275
+ properties["status"] = "done"
276
+
277
+ block_id = str(properties.pop("id", _generate_id()))
278
+
279
+ return IntentBlock(
280
+ id=block_id,
281
+ type=keyword,
282
+ content=_strip_inline(content),
283
+ original_content=content,
284
+ inline=_parse_inline(content),
285
+ properties=properties,
286
+ )
287
+
288
+
289
+ def _parse_content_and_properties(rest: str) -> tuple[str, dict[str, Any]]:
290
+ parts = [p.strip() for p in rest.split("|")]
291
+ content = parts[0].strip() if parts else ""
292
+ properties: dict[str, Any] = {}
293
+
294
+ for part in parts[1:]:
295
+ kv_match = re.match(r"^(\w[\w-]*):\s*(.*)$", part.strip())
296
+ if not kv_match:
297
+ continue
298
+ key = kv_match.group(1)
299
+ value: Any = kv_match.group(2).strip()
300
+
301
+ if value.lower() in ("true", "false"):
302
+ value = value.lower() == "true"
303
+ elif key in ("max", "delay", "leading", "depth", "columns"):
304
+ try:
305
+ value = int(value) if "." not in value else float(value)
306
+ except ValueError:
307
+ pass
308
+
309
+ properties[key] = value
310
+
311
+ return content, properties
312
+
313
+
314
+ def _parse_inline(text: str) -> list[InlineSegment]:
315
+ segments: list[InlineSegment] = []
316
+ pattern = re.compile(
317
+ r"\*([^*]+)\*"
318
+ r"|_([^_]+)_"
319
+ r"|~([^~]+)~"
320
+ r"|\^([^^]+)\^"
321
+ r"|`([^`]+)`"
322
+ r"|\[([^\]]+)\]\(([^)]+)\)"
323
+ r"|\[\^(\d+)\]"
324
+ r"|@(\w+)"
325
+ r"|#(\w+)"
326
+ )
327
+
328
+ last_end = 0
329
+ for match in pattern.finditer(text):
330
+ if match.start() > last_end:
331
+ segments.append(InlineSegment(type="text", value=text[last_end : match.start()]))
332
+
333
+ if match.group(1):
334
+ segments.append(InlineSegment(type="bold", value=match.group(1)))
335
+ elif match.group(2):
336
+ segments.append(InlineSegment(type="italic", value=match.group(2)))
337
+ elif match.group(3):
338
+ segments.append(InlineSegment(type="strikethrough", value=match.group(3)))
339
+ elif match.group(4):
340
+ segments.append(InlineSegment(type="highlight", value=match.group(4)))
341
+ elif match.group(5):
342
+ segments.append(InlineSegment(type="code", value=match.group(5)))
343
+ elif match.group(6):
344
+ segments.append(
345
+ InlineSegment(type="link", value=match.group(6), href=match.group(7))
346
+ )
347
+ elif match.group(8):
348
+ segments.append(InlineSegment(type="footnote-ref", value=match.group(8)))
349
+ elif match.group(9):
350
+ segments.append(InlineSegment(type="mention", value=match.group(9)))
351
+ elif match.group(10):
352
+ segments.append(InlineSegment(type="tag", value=match.group(10)))
353
+
354
+ last_end = match.end()
355
+
356
+ if last_end < len(text):
357
+ segments.append(InlineSegment(type="text", value=text[last_end:]))
358
+
359
+ return segments
360
+
361
+
362
+ def _strip_inline(text: str) -> str:
363
+ text = re.sub(r"\*([^*]+)\*", r"\1", text)
364
+ text = re.sub(r"_([^_]+)_", r"\1", text)
365
+ text = re.sub(r"~([^~]+)~", r"\1", text)
366
+ text = re.sub(r"\^([^^]+)\^", r"\1", text)
367
+ text = re.sub(r"`([^`]+)`", r"\1", text)
368
+ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
369
+ text = re.sub(r"\[\^(\d+)\]", r"[\1]", text)
370
+ return text
371
+
372
+
373
+ def _generate_id() -> str:
374
+ global _ID_COUNTER
375
+ _ID_COUNTER += 1
376
+ return f"b{_ID_COUNTER:04d}"
377
+
378
+
379
+ def _update_metadata(metadata: IntentMetadata, block: IntentBlock) -> None:
380
+ if block.type == "title":
381
+ metadata.title = block.content
382
+ elif block.type == "summary":
383
+ metadata.summary = block.content
384
+ elif block.type == "agent":
385
+ metadata.agent = block.content
386
+ if "model" in block.properties:
387
+ metadata.model = str(block.properties["model"])
388
+ elif block.type == "context":
389
+ metadata.context.update({k: str(v) for k, v in block.properties.items()})
intenttext/query.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .types import IntentBlock, IntentDocument
6
+
7
+
8
+ def query(
9
+ doc: IntentDocument,
10
+ type: Optional[str | list[str]] = None,
11
+ section: Optional[str] = None,
12
+ properties: Optional[dict[str, Any]] = None,
13
+ text: Optional[str] = None,
14
+ limit: Optional[int] = None,
15
+ ) -> list[IntentBlock]:
16
+ results: list[IntentBlock] = []
17
+ current_section = ""
18
+
19
+ for block in doc.blocks:
20
+ if block.type == "section":
21
+ current_section = block.content
22
+
23
+ if type is not None:
24
+ if isinstance(type, str) and block.type != type:
25
+ continue
26
+ if isinstance(type, list) and block.type not in type:
27
+ continue
28
+
29
+ if section is not None and current_section != section:
30
+ continue
31
+
32
+ if text is not None and text.lower() not in block.content.lower():
33
+ continue
34
+
35
+ if properties:
36
+ matches = True
37
+ for key, expected in properties.items():
38
+ actual = block.properties.get(key)
39
+ if callable(getattr(expected, "search", None)):
40
+ if actual is None or expected.search(str(actual)) is None:
41
+ matches = False
42
+ break
43
+ else:
44
+ if str(actual) != str(expected):
45
+ matches = False
46
+ break
47
+ if not matches:
48
+ continue
49
+
50
+ results.append(block)
51
+
52
+ if limit is not None and len(results) >= limit:
53
+ break
54
+
55
+ return results
intenttext/renderer.py ADDED
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+
5
+ from .types import IntentBlock, IntentDocument
6
+
7
+
8
+ BASE_CSS = """
9
+ .it-document { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; color: #222; }
10
+ .it-block { margin: 0.6rem 0; }
11
+ .it-section { margin-top: 1.4rem; }
12
+ .it-sub { margin-top: 1rem; }
13
+ .it-callout { padding: 0.75rem 1rem; border-radius: 6px; border-left: 4px solid; }
14
+ .it-callout.info { background: #eef6ff; border-color: #1d70b8; }
15
+ .it-callout.warning { background: #fff7e6; border-color: #b26a00; }
16
+ .it-callout.tip { background: #eefbf2; border-color: #1f7a3d; }
17
+ .it-callout.success { background: #eefbf2; border-color: #1f7a3d; }
18
+ .it-task { display: flex; gap: 0.5rem; }
19
+ .it-task.done { color: #666; text-decoration: line-through; }
20
+ .it-table { width: 100%; border-collapse: collapse; }
21
+ .it-table th, .it-table td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
22
+ .it-table th { background: #f5f5f5; }
23
+ .it-divider { border: 0; border-top: 1px solid #ddd; margin: 1rem 0; }
24
+ """.strip()
25
+
26
+
27
+ def render_html(doc: IntentDocument, include_css: bool = True) -> str:
28
+ parts: list[str] = []
29
+ if include_css:
30
+ parts.append(f"<style>{BASE_CSS}</style>")
31
+
32
+ parts.append('<div class="it-document">')
33
+ for block in doc.blocks:
34
+ parts.append(_render_block_html(block))
35
+ parts.append("</div>")
36
+
37
+ return "\n".join(p for p in parts if p)
38
+
39
+
40
+ def render_print(doc: IntentDocument) -> str:
41
+ css = (
42
+ BASE_CSS
43
+ + "\n@media print { .it-document { font-family: Georgia, serif; } .it-block { break-inside: avoid; } }"
44
+ )
45
+ body = render_html(doc, include_css=False)
46
+ return (
47
+ "<!doctype html><html><head><meta charset=\"utf-8\"/>"
48
+ f"<style>{css}</style></head><body>{body}</body></html>"
49
+ )
50
+
51
+
52
+ def render_markdown(doc: IntentDocument) -> str:
53
+ lines: list[str] = []
54
+
55
+ for block in doc.blocks:
56
+ t = block.type
57
+ content = block.original_content or block.content
58
+
59
+ if t == "title":
60
+ lines.append(f"# {content}")
61
+ elif t == "section":
62
+ lines.append(f"## {content}")
63
+ elif t == "sub":
64
+ lines.append(f"### {content}")
65
+ elif t == "note":
66
+ lines.append(content)
67
+ elif t == "task":
68
+ checked = str(block.properties.get("status", "")).lower() == "done"
69
+ lines.append(f"- [{'x' if checked else ' '}] {content}")
70
+ elif t == "quote":
71
+ author = block.properties.get("by")
72
+ lines.append(f"> {content}")
73
+ if author:
74
+ lines.append(f"> - {author}")
75
+ elif t in {"info", "warning", "tip"}:
76
+ lines.append(f"> **{t.upper()}:** {content}")
77
+ elif t == "table":
78
+ rows = block.properties.get("rows", [])
79
+ if rows:
80
+ header = rows[0]
81
+ lines.append("| " + " | ".join(str(v) for v in header) + " |")
82
+ lines.append("| " + " | ".join("---" for _ in header) + " |")
83
+ for row in rows[1:]:
84
+ lines.append("| " + " | ".join(str(v) for v in row) + " |")
85
+ elif t == "divider":
86
+ lines.append("---")
87
+ else:
88
+ lines.append(f"{content}")
89
+
90
+ if lines and lines[-1] != "":
91
+ lines.append("")
92
+
93
+ return "\n".join(lines).strip() + "\n"
94
+
95
+
96
+ def _render_block_html(block: IntentBlock) -> str:
97
+ t = block.type
98
+ content = html.escape(block.content)
99
+
100
+ if t == "title":
101
+ return f'<h1 class="it-block it-title">{content}</h1>'
102
+ if t == "summary":
103
+ return f'<p class="it-block it-summary">{content}</p>'
104
+ if t == "section":
105
+ return f'<h2 class="it-block it-section">{content}</h2>'
106
+ if t == "sub":
107
+ return f'<h3 class="it-block it-sub">{content}</h3>'
108
+ if t == "quote":
109
+ by = block.properties.get("by")
110
+ footer = f"<cite>{html.escape(str(by))}</cite>" if by else ""
111
+ return f'<blockquote class="it-block it-quote"><p>{content}</p>{footer}</blockquote>'
112
+ if t in {"note", "info", "warning", "tip", "success"}:
113
+ extra = "it-callout " + (t if t != "note" else "info")
114
+ return f'<div class="it-block {extra}"><span class="it-keyword">{t}:</span> {content}</div>'
115
+ if t == "task":
116
+ done = str(block.properties.get("status", "")).lower() == "done"
117
+ mark = "[x]" if done else "[ ]"
118
+ done_cls = " done" if done else ""
119
+ return f'<div class="it-block it-task{done_cls}"><span>{mark}</span><span>{content}</span></div>'
120
+ if t == "image":
121
+ src = html.escape(str(block.properties.get("src", "")))
122
+ alt = html.escape(str(block.properties.get("alt", block.content)))
123
+ return f'<figure class="it-block it-image"><img src="{src}" alt="{alt}"/></figure>'
124
+ if t == "link":
125
+ href = html.escape(str(block.properties.get("href", "#")))
126
+ return f'<p class="it-block it-link"><a href="{href}">{content}</a></p>'
127
+ if t == "code":
128
+ return f'<pre class="it-block it-code"><code>{content}</code></pre>'
129
+ if t == "table":
130
+ rows = block.properties.get("rows", [])
131
+ if not rows:
132
+ return ""
133
+ header = rows[0]
134
+ body_rows = rows[1:]
135
+ thead = "".join(f"<th>{html.escape(str(c))}</th>" for c in header)
136
+ tbody = "".join(
137
+ "<tr>" + "".join(f"<td>{html.escape(str(c))}</td>" for c in row) + "</tr>"
138
+ for row in body_rows
139
+ )
140
+ return f'<table class="it-block it-table"><thead><tr>{thead}</tr></thead><tbody>{tbody}</tbody></table>'
141
+ if t == "divider":
142
+ return '<hr class="it-block it-divider"/>'
143
+
144
+ return f'<p class="it-block it-{html.escape(t)}">{content}</p>'
intenttext/source.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from .types import IntentDocument
4
+
5
+
6
+ def to_source(doc: IntentDocument) -> str:
7
+ lines: list[str] = []
8
+
9
+ for block in doc.blocks:
10
+ if block.type == "divider":
11
+ lines.append("---")
12
+ continue
13
+
14
+ if block.type == "table":
15
+ for row in block.properties.get("rows", []):
16
+ lines.append("| " + " | ".join(str(c) for c in row) + " |")
17
+ continue
18
+
19
+ keyword = block.type
20
+ props = dict(block.properties)
21
+
22
+ if block.type == "task" and str(props.get("status", "")).lower() == "done":
23
+ keyword = "done"
24
+ props.pop("status", None)
25
+
26
+ parts = [f"{keyword}: {block.original_content or block.content}".rstrip()]
27
+ for key, value in props.items():
28
+ parts.append(f"{key}: {value}")
29
+
30
+ lines.append(" | ".join(parts))
31
+
32
+ return "\n".join(lines)
intenttext/types.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Optional
5
+
6
+
7
+ @dataclass
8
+ class InlineSegment:
9
+ type: str
10
+ value: str
11
+ href: Optional[str] = None
12
+
13
+
14
+ @dataclass
15
+ class IntentBlock:
16
+ id: str
17
+ type: str
18
+ content: str
19
+ original_content: str
20
+ inline: list[InlineSegment] = field(default_factory=list)
21
+ properties: dict[str, Any] = field(default_factory=dict)
22
+
23
+
24
+ @dataclass
25
+ class IntentMetadata:
26
+ title: Optional[str] = None
27
+ summary: Optional[str] = None
28
+ agent: Optional[str] = None
29
+ model: Optional[str] = None
30
+ language: str = "ltr"
31
+ context: dict[str, str] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass
35
+ class IntentDocument:
36
+ version: str
37
+ blocks: list[IntentBlock]
38
+ metadata: IntentMetadata = field(default_factory=IntentMetadata)
39
+
40
+
41
+ @dataclass
42
+ class ParseWarning:
43
+ line: int
44
+ message: str
45
+ code: str
46
+ original: str
47
+
48
+
49
+ @dataclass
50
+ class ParseResult:
51
+ document: IntentDocument
52
+ warnings: list[ParseWarning] = field(default_factory=list)
53
+ errors: list[ParseWarning] = field(default_factory=list)
54
+
55
+
56
+ @dataclass
57
+ class ValidationIssue:
58
+ block_id: str
59
+ block_type: str
60
+ type: str
61
+ code: str
62
+ message: str
63
+
64
+
65
+ @dataclass
66
+ class ValidationResult:
67
+ valid: bool
68
+ issues: list[ValidationIssue] = field(default_factory=list)
intenttext/validate.py ADDED
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Iterable
5
+
6
+ from .types import IntentDocument, ValidationIssue, ValidationResult
7
+
8
+
9
+ def validate(doc: IntentDocument) -> ValidationResult:
10
+ issues: list[ValidationIssue] = []
11
+
12
+ step_ids = {
13
+ block.id
14
+ for block in doc.blocks
15
+ if block.type in {"step", "decision", "parallel", "gate", "call", "result"}
16
+ }
17
+ all_ids = [block.id for block in doc.blocks]
18
+
19
+ # Duplicate block IDs
20
+ seen: set[str] = set()
21
+ for block_id in all_ids:
22
+ if block_id in seen:
23
+ issues.append(
24
+ ValidationIssue(
25
+ block_id=block_id,
26
+ block_type="document",
27
+ type="error",
28
+ code="DUPLICATE_ID",
29
+ message=f"Duplicate block id '{block_id}'",
30
+ )
31
+ )
32
+ seen.add(block_id)
33
+
34
+ context_vars = set(doc.metadata.context.keys())
35
+ produced_vars = {
36
+ str(block.properties.get("output"))
37
+ for block in doc.blocks
38
+ if "output" in block.properties and str(block.properties.get("output"))
39
+ }
40
+
41
+ for block in doc.blocks:
42
+ if block.type == "gate" and not block.properties.get("approver"):
43
+ issues.append(
44
+ ValidationIssue(
45
+ block_id=block.id,
46
+ block_type=block.type,
47
+ type="error",
48
+ code="GATE_APPROVER_REQUIRED",
49
+ message="gate: block requires approver property",
50
+ )
51
+ )
52
+
53
+ depends = block.properties.get("depends")
54
+ if isinstance(depends, str) and depends and depends not in step_ids:
55
+ issues.append(
56
+ ValidationIssue(
57
+ block_id=block.id,
58
+ block_type=block.type,
59
+ type="error",
60
+ code="STEP_REF_MISSING",
61
+ message=f"depends references missing step '{depends}'",
62
+ )
63
+ )
64
+
65
+ if block.type == "decision":
66
+ for key in ("then", "else"):
67
+ ref = block.properties.get(key)
68
+ if isinstance(ref, str) and ref and ref not in step_ids:
69
+ issues.append(
70
+ ValidationIssue(
71
+ block_id=block.id,
72
+ block_type=block.type,
73
+ type="error",
74
+ code="STEP_REF_MISSING",
75
+ message=f"decision {key} references missing step '{ref}'",
76
+ )
77
+ )
78
+
79
+ if block.type == "parallel":
80
+ steps = block.properties.get("steps")
81
+ if isinstance(steps, str):
82
+ for ref in [s.strip() for s in steps.split(",") if s.strip()]:
83
+ if ref not in step_ids:
84
+ issues.append(
85
+ ValidationIssue(
86
+ block_id=block.id,
87
+ block_type=block.type,
88
+ type="error",
89
+ code="STEP_REF_MISSING",
90
+ message=f"parallel steps references missing step '{ref}'",
91
+ )
92
+ )
93
+
94
+ for var in _extract_vars(block):
95
+ # runtime placeholders are intentionally unresolved
96
+ if var in {"page", "pages"}:
97
+ continue
98
+ top = var.split(".")[0]
99
+ if top not in context_vars and top not in produced_vars:
100
+ issues.append(
101
+ ValidationIssue(
102
+ block_id=block.id,
103
+ block_type=block.type,
104
+ type="warning",
105
+ code="VARIABLE_UNRESOLVED",
106
+ message=f"Variable '{{{{{var}}}}}' is not declared in context: or step output",
107
+ )
108
+ )
109
+
110
+ has_errors = any(issue.type == "error" for issue in issues)
111
+ return ValidationResult(valid=not has_errors, issues=issues)
112
+
113
+
114
+ def _extract_vars(block) -> Iterable[str]:
115
+ pattern = re.compile(r"\{\{([^}]+)\}\}")
116
+ values = [block.content, block.original_content]
117
+ values.extend(str(v) for v in block.properties.values() if isinstance(v, str))
118
+
119
+ for value in values:
120
+ for match in pattern.finditer(value):
121
+ yield match.group(1).strip()
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: intenttext
3
+ Version: 1.0.0
4
+ Summary: IntentText - the semantic document format that is natively JSON
5
+ Project-URL: Homepage, https://github.com/intenttext/IntentText
6
+ Project-URL: Repository, https://github.com/intenttext/intenttext-python
7
+ Project-URL: Documentation, https://github.com/intenttext/IntentText/blob/main/docs/SPEC.md
8
+ License: MIT
9
+ Requires-Python: >=3.10
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Requires-Dist: pytest-cov; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # IntentText Python
16
+
17
+ Python implementation of the IntentText parser and renderer.
18
+
19
+ Independent implementation (not a Node.js wrapper), designed for Python workflows and AI stacks.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install intenttext
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```python
30
+ from intenttext import parse, render_html, merge_data, validate, query
31
+
32
+ # Parse a document
33
+ source = """
34
+ title: Sprint Planning
35
+ section: Tasks
36
+ task: Write tests | owner: Ahmed | due: Friday
37
+ task: Deploy to staging | owner: Sarah | due: Monday
38
+ gate: Final approval | approver: Lead | timeout: 24h
39
+ """.strip()
40
+
41
+ doc = parse(source)
42
+
43
+ # Query for tasks
44
+ tasks = query(doc, type="task")
45
+ for task in tasks:
46
+ print(f"{task.content} -> {task.properties.get('owner', 'unassigned')}")
47
+
48
+ # Validate workflow semantics
49
+ result = validate(doc)
50
+ if not result.valid:
51
+ for issue in result.issues:
52
+ print(f"[{issue.type.upper()}] {issue.message}")
53
+
54
+ # Render to HTML
55
+ html = render_html(doc)
56
+ ```
57
+
58
+ ## API
59
+
60
+ - `parse(source: str) -> IntentDocument`
61
+ - `parse_safe(source: str, ...) -> ParseResult`
62
+ - `render_html(doc: IntentDocument, include_css: bool = True) -> str`
63
+ - `render_print(doc: IntentDocument) -> str`
64
+ - `render_markdown(doc: IntentDocument) -> str`
65
+ - `merge_data(template: IntentDocument, data: dict) -> IntentDocument`
66
+ - `parse_and_merge(template_source: str, data: dict) -> IntentDocument`
67
+ - `validate(doc: IntentDocument) -> ValidationResult`
68
+ - `query(doc: IntentDocument, ...) -> list[IntentBlock]`
69
+ - `to_source(doc: IntentDocument) -> str`
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ pip install -e .[dev]
75
+ pytest
76
+ ```
77
+
78
+ ## Release (PyPI)
79
+
80
+ ```bash
81
+ # 1) Ensure tests pass
82
+ python3 -m pytest -q
83
+
84
+ # 2) Build source + wheel
85
+ python3 -m pip install -U hatch twine
86
+ hatch build
87
+
88
+ # 3) Validate package metadata and long description
89
+ twine check dist/*
90
+
91
+ # 4) Upload (interactive)
92
+ twine upload dist/*
93
+ ```
94
+
95
+ Tag-based release flow:
96
+
97
+ ```bash
98
+ git tag v1.0.0
99
+ git push origin v1.0.0
100
+ ```
101
+
102
+ GitHub Action publish workflow uses `PYPI_API_TOKEN` for automated release on `v*` tags.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,11 @@
1
+ intenttext/__init__.py,sha256=2aztUfxzJzY631dzrU4J3Ra-8U-Ayn5qnGub5lJ5c5Y,801
2
+ intenttext/merge.py,sha256=_yO0LXaoXPKQ5nEAoCBYggMaPwuZYCM724IMVGogkAw,2525
3
+ intenttext/parser.py,sha256=1wGxeHcXSk6F66H7k5_vW3rGua__3l89W3qwnKSovKA,10592
4
+ intenttext/query.py,sha256=LpyuaT6mMBGj1iNLYay8AMTsdrqTCjB9aLZLC_E4K4Q,1624
5
+ intenttext/renderer.py,sha256=E8gYJeSBXDdarb_nbKccFtiG4Gpk05ZvRMj_NqBHSuw,5704
6
+ intenttext/source.py,sha256=wkhaEhfHPSYwSLPvIqWmz9TRoNyIgXivt9HEdSoMN8A,909
7
+ intenttext/types.py,sha256=okWFdR-hdTPG8i3owMUn8fWXxKmQ6C9H4LTmjUXh-bY,1345
8
+ intenttext/validate.py,sha256=RJ2DfUpO-Tzl-dPfN8X8AqP-LGNprxAq59GTKXmtD10,4459
9
+ intenttext-1.0.0.dist-info/METADATA,sha256=5IDUqVHA5S_vozjHejSPhvSd261jzSWuK0BFWp8XX9o,2544
10
+ intenttext-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ intenttext-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any