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 +6 -0
- simdoc/_blocks.py +42 -0
- simdoc/_errors.py +2 -0
- simdoc/_io.py +11 -0
- simdoc/_render/__init__.py +1 -0
- simdoc/_render/markdown.py +130 -0
- simdoc/_text.py +31 -0
- simdoc/doc.py +137 -0
- simdoc-0.1.1.dist-info/METADATA +53 -0
- simdoc-0.1.1.dist-info/RECORD +12 -0
- simdoc-0.1.1.dist-info/WHEEL +4 -0
- simdoc-0.1.1.dist-info/licenses/LICENSE +21 -0
simdoc/__init__.py
ADDED
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
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,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.
|