simdoc 0.1.1__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.
simdoc/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Public API for simdoc."""
2
+
3
+ from ._errors import SimDocError
4
+ from .doc import Doc
5
+
6
+ __all__ = ["Doc", "SimDocError"]
simdoc/_blocks.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Iterable, Sequence
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Heading:
9
+ level: int
10
+ text: str
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Paragraph:
15
+ text: str
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ListBlock:
20
+ ordered: bool
21
+ items: Sequence[Any]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CodeBlock:
26
+ text: str
27
+ lang: str | None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TableBlock:
32
+ headers: Sequence[str]
33
+ rows: Sequence[Sequence[Any]]
34
+ align: Sequence[str] | None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Hr:
39
+ pass
40
+
41
+
42
+ Block = Heading | Paragraph | ListBlock | CodeBlock | TableBlock | Hr
simdoc/_errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class SimDocError(Exception):
2
+ """Base exception for document/render issues."""
simdoc/_io.py ADDED
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from os import PathLike
4
+ from pathlib import Path
5
+
6
+
7
+ def save_markdown(text: str, path: str | PathLike[str]) -> Path:
8
+ output_path = Path(path)
9
+ output_path.parent.mkdir(parents=True, exist_ok=True)
10
+ output_path.write_text(text, encoding="utf-8", newline="\n")
11
+ return output_path
@@ -0,0 +1 @@
1
+ """Markdown renderer package."""
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+
5
+ from .._blocks import CodeBlock, Heading, Hr, ListBlock, Paragraph, TableBlock
6
+ from .._errors import SimDocError
7
+ from .._text import format_table_cell, normalize_newlines, select_code_fence
8
+
9
+
10
+ def render_markdown(blocks: Sequence[object]) -> str:
11
+ parts: list[str] = []
12
+ for block in blocks:
13
+ parts.append(_render_block(block))
14
+ if not parts:
15
+ return "\n"
16
+ return "\n\n".join(parts) + "\n"
17
+
18
+
19
+ def _render_block(block: object) -> str:
20
+ if isinstance(block, Heading):
21
+ return _render_heading(block)
22
+ if isinstance(block, Paragraph):
23
+ return block.text
24
+ if isinstance(block, ListBlock):
25
+ return _render_list(block)
26
+ if isinstance(block, CodeBlock):
27
+ return _render_code(block)
28
+ if isinstance(block, TableBlock):
29
+ return _render_table(block)
30
+ if isinstance(block, Hr):
31
+ return "---"
32
+ raise SimDocError(f"unknown block type: {type(block)!r}")
33
+
34
+
35
+ def _render_heading(block: Heading) -> str:
36
+ prefix = "#" * block.level
37
+ return f"{prefix} {block.text}"
38
+
39
+
40
+ def _render_list(block: ListBlock) -> str:
41
+ lines = _render_list_items(block.items, ordered=block.ordered, level=0)
42
+ return "\n".join(lines)
43
+
44
+
45
+ def _render_list_items(items: Sequence[object], ordered: bool, level: int) -> list[str]:
46
+ lines: list[str] = []
47
+ indent = " " * level
48
+ marker = "1." if ordered else "-"
49
+ for item in items:
50
+ if isinstance(item, (list, tuple)):
51
+ lines.extend(_render_list_items(item, ordered=ordered, level=level + 1))
52
+ continue
53
+ text = "" if item is None else str(item)
54
+ text = normalize_newlines(text)
55
+ if "\n" in text:
56
+ continuation = "\n" + indent + " "
57
+ text = text.replace("\n", continuation)
58
+ lines.append(f"{indent}{marker} {text}")
59
+ return lines
60
+
61
+
62
+ def _render_code(block: CodeBlock) -> str:
63
+ fence = select_code_fence(block.text)
64
+ lang = block.lang or ""
65
+ first_line = f"{fence}{lang}" if lang else fence
66
+ closing_newline = "" if block.text.endswith("\n") else "\n"
67
+ return f"{first_line}\n{block.text}{closing_newline}{fence}"
68
+
69
+
70
+ def _render_table(block: TableBlock) -> str:
71
+ headers = list(block.headers)
72
+ rows = [list(row) for row in block.rows]
73
+ column_count = max(len(headers), max((len(row) for row in rows), default=0))
74
+ if column_count == 0:
75
+ raise SimDocError("table requires at least one column")
76
+
77
+ headers = _pad_row(headers, column_count)
78
+ rows = [_pad_row(row, column_count) for row in rows]
79
+ align = _normalize_alignment(block.align, column_count)
80
+
81
+ header_line = _format_row(headers)
82
+ align_line = _format_alignment_row(align)
83
+ row_lines = [_format_row(row) for row in rows]
84
+
85
+ return "\n".join([header_line, align_line, *row_lines])
86
+
87
+
88
+ def _pad_row(row: Sequence[object], width: int) -> list[object]:
89
+ padded = list(row)
90
+ if len(padded) < width:
91
+ padded.extend([""] * (width - len(padded)))
92
+ return padded[:width]
93
+
94
+
95
+ def _normalize_alignment(align: Sequence[str] | None, width: int) -> list[str]:
96
+ if align is None:
97
+ return ["left"] * width
98
+ normalized: list[str] = []
99
+ for value in align:
100
+ name = value.lower()
101
+ if name in {"left", "l"}:
102
+ normalized.append("left")
103
+ elif name in {"center", "c"}:
104
+ normalized.append("center")
105
+ elif name in {"right", "r"}:
106
+ normalized.append("right")
107
+ else:
108
+ raise SimDocError(f"invalid alignment value: {value!r}")
109
+ if len(normalized) < width:
110
+ normalized.extend(["left"] * (width - len(normalized)))
111
+ return normalized[:width]
112
+
113
+
114
+ def _format_row(cells: Sequence[object]) -> str:
115
+ rendered = [format_table_cell(cell) for cell in cells]
116
+ return f"| {' | '.join(rendered)} |"
117
+
118
+
119
+ def _format_alignment_row(align: Sequence[str]) -> str:
120
+ markers: list[str] = []
121
+ for value in align:
122
+ if value == "left":
123
+ markers.append("---")
124
+ elif value == "center":
125
+ markers.append(":---:")
126
+ elif value == "right":
127
+ markers.append("---:")
128
+ else:
129
+ raise SimDocError(f"invalid alignment value: {value!r}")
130
+ return f"| {' | '.join(markers)} |"
simdoc/_text.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def normalize_newlines(text: str) -> str:
5
+ return text.replace("\r\n", "\n").replace("\r", "\n")
6
+
7
+
8
+ def escape_pipes(text: str) -> str:
9
+ return text.replace("|", "\\|")
10
+
11
+
12
+ def select_code_fence(text: str) -> str:
13
+ longest = 0
14
+ current = 0
15
+ for ch in text:
16
+ if ch == "`":
17
+ current += 1
18
+ if current > longest:
19
+ longest = current
20
+ else:
21
+ current = 0
22
+ fence_len = max(3, longest + 1)
23
+ return "`" * fence_len
24
+
25
+
26
+ def format_table_cell(value: object) -> str:
27
+ if value is None:
28
+ return ""
29
+ text = normalize_newlines(str(value))
30
+ text = text.replace("\n", "<br>")
31
+ return escape_pipes(text)
simdoc/doc.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping, Sequence
4
+ from os import PathLike
5
+ from typing import Any
6
+
7
+ from ._blocks import CodeBlock, Heading, Hr, ListBlock, Paragraph, TableBlock
8
+ from ._errors import SimDocError
9
+ from ._io import save_markdown
10
+ from ._render.markdown import render_markdown
11
+ from ._text import normalize_newlines
12
+
13
+
14
+ class Doc:
15
+ """Append-only document builder."""
16
+
17
+ def __init__(self) -> None:
18
+ self._blocks: list[
19
+ Heading | Paragraph | ListBlock | CodeBlock | TableBlock | Hr
20
+ ] = []
21
+
22
+ def __len__(self) -> int:
23
+ return len(self._blocks)
24
+
25
+ @property
26
+ def block_count(self) -> int:
27
+ return len(self._blocks)
28
+
29
+ def __repr__(self) -> str:
30
+ return f"Doc(blocks={len(self._blocks)})"
31
+
32
+ def h(self, level: int, text: object) -> None:
33
+ if level not in {1, 2, 3, 4, 5, 6}:
34
+ raise SimDocError("heading level must be between 1 and 6")
35
+ value = "" if text is None else str(text)
36
+ value = normalize_newlines(value).replace("\n", " ")
37
+ self._blocks.append(Heading(level=level, text=value))
38
+
39
+ def h1(self, text: object) -> None:
40
+ self.h(1, text)
41
+
42
+ def h2(self, text: object) -> None:
43
+ self.h(2, text)
44
+
45
+ def h3(self, text: object) -> None:
46
+ self.h(3, text)
47
+
48
+ def h4(self, text: object) -> None:
49
+ self.h(4, text)
50
+
51
+ def h5(self, text: object) -> None:
52
+ self.h(5, text)
53
+
54
+ def h6(self, text: object) -> None:
55
+ self.h(6, text)
56
+
57
+ def p(self, text: object) -> None:
58
+ value = "" if text is None else str(text)
59
+ value = normalize_newlines(value)
60
+ if value.strip() == "":
61
+ return None
62
+ self._blocks.append(Paragraph(text=value))
63
+
64
+ def ul(self, items: Iterable[Any]) -> None:
65
+ self._append_list(False, items)
66
+
67
+ def ol(self, items: Iterable[Any]) -> None:
68
+ self._append_list(True, items)
69
+
70
+ def _append_list(self, ordered: bool, items: Iterable[Any]) -> None:
71
+ if items is None:
72
+ raise SimDocError("list items cannot be None")
73
+ self._blocks.append(ListBlock(ordered=ordered, items=list(items)))
74
+
75
+ def code(self, text: object, lang: object | None = None) -> None:
76
+ value = "" if text is None else str(text)
77
+ value = normalize_newlines(value)
78
+ language = None if lang is None else str(lang)
79
+ self._blocks.append(CodeBlock(text=value, lang=language))
80
+
81
+ def table(
82
+ self,
83
+ rows: Iterable[Sequence[Any]] | Iterable[Mapping[str, Any]],
84
+ headers: Iterable[object] | None = None,
85
+ align: Iterable[object] | None = None,
86
+ ) -> None:
87
+ if rows is None:
88
+ raise SimDocError("table rows cannot be None")
89
+
90
+ rows_list = list(rows)
91
+ headers_list = (
92
+ ["" if h is None else str(h) for h in headers] if headers else None
93
+ )
94
+ align_list = [str(a) for a in align] if align else None
95
+
96
+ if rows_list and isinstance(rows_list[0], Mapping):
97
+ dict_rows: list[Mapping[str, Any]] = []
98
+ for row in rows_list:
99
+ if not isinstance(row, Mapping):
100
+ raise SimDocError("mixed table row types are not supported")
101
+ dict_rows.append(row)
102
+ if headers_list is None:
103
+ keys: set[str] = set()
104
+ for row in dict_rows:
105
+ keys.update(row.keys())
106
+ headers_list = sorted(keys)
107
+ rows_values = [
108
+ [row.get(header) for header in headers_list] for row in dict_rows
109
+ ]
110
+ else:
111
+ rows_values = []
112
+ for row in rows_list:
113
+ if isinstance(row, Mapping):
114
+ raise SimDocError("mixed table row types are not supported")
115
+ if not isinstance(row, Sequence) or isinstance(row, (str, bytes)):
116
+ raise SimDocError("table rows must be sequences")
117
+ rows_values.append(list(row))
118
+ if headers_list is None:
119
+ max_cols = max((len(row) for row in rows_values), default=0)
120
+ headers_list = [""] * max_cols
121
+
122
+ if headers_list is None:
123
+ headers_list = []
124
+
125
+ self._blocks.append(
126
+ TableBlock(headers=headers_list, rows=rows_values, align=align_list)
127
+ )
128
+
129
+ def hr(self) -> None:
130
+ self._blocks.append(Hr())
131
+
132
+ def to_markdown(self) -> str:
133
+ return render_markdown(self._blocks)
134
+
135
+ def save(self, path: str | PathLike[str]) -> Any:
136
+ text = self.to_markdown()
137
+ return save_markdown(text, path)
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: simdoc
3
+ Version: 0.1.1
4
+ Summary: A simple Python package for deterministic Markdown documents
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+
9
+ # simple-document
10
+
11
+ Deterministic, append-only Markdown documents with a small, explicit Python API.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ uv add simple-document
17
+ ```
18
+
19
+ Or with pip:
20
+
21
+ ```bash
22
+ pip install simple-document
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from simdoc import Doc
29
+
30
+ doc = Doc()
31
+ doc.h1("Simple Document")
32
+ doc.p("First line\nSecond line")
33
+ doc.ul(["alpha", ["beta", "gamma"], "delta"])
34
+ doc.code("print('hi')", lang="py")
35
+ doc.table([
36
+ {"b": "x|y", "a": "1\n2"},
37
+ {"a": None, "b": "ok"},
38
+ ])
39
+ doc.hr()
40
+
41
+ markdown = doc.to_markdown()
42
+ doc.save("example.md")
43
+ ```
44
+
45
+ ## Example script
46
+
47
+ Run the full example that exercises every block type:
48
+
49
+ ```bash
50
+ uv run python examples/example_all_blocks.py
51
+ ```
52
+
53
+ The script writes `examples/example_output.md`.
@@ -0,0 +1,12 @@
1
+ simdoc/__init__.py,sha256=8feg37CxNq9njqfyactQ6s4nuRD09rdnKV_PtPPP4gg,118
2
+ simdoc/_blocks.py,sha256=dsUx3BzA2EawWYbOs9BhLP0kUY-WEZoXfHEfzrl7Eqs,658
3
+ simdoc/_errors.py,sha256=ySl4O-fJqVAke1W22f8ZHtRv3bUfrXWTNGwFfsBnq3w,83
4
+ simdoc/_io.py,sha256=gHZw5mTth_fGACp_1BYDQmWQVwJVBhOQbyrbngPgexg,327
5
+ simdoc/_text.py,sha256=xAony3CUc-RSpWYebCafyZ7AdZeeJ-YR-xxiff8jovk,719
6
+ simdoc/doc.py,sha256=pAgXhhAgY7YzG-1erOvuoOuL3jnFxrQf2mBGRtmIY_Q,4626
7
+ simdoc/_render/__init__.py,sha256=N_ZCTGJXzf-hYgSi-Pq5kfJep6vRttt_upMdL2m0cEA,33
8
+ simdoc/_render/markdown.py,sha256=0GPoT8UWrpAZPNFaVTcltIdsZE161QW66-R_ij7p0lc,4273
9
+ simdoc-0.1.1.dist-info/METADATA,sha256=xGWol70B_ADsQTGrso7nJirka2pejV8SUZeCkZq9RBw,933
10
+ simdoc-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
+ simdoc-0.1.1.dist-info/licenses/LICENSE,sha256=PfedwwQNUs7785-aIIOQhDpM5-l2DzAQaeMPlaM3_Ho,1065
12
+ simdoc-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 placerte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.