chunkwise 0.1.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.
chunkwise/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """chunkwise: text chunking for RAG with reversible offsets.
2
+
3
+ Every chunk carries the exact character range it came from, so
4
+ ``document[chunk.start:chunk.end] == chunk.text`` always holds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._spans import DEFAULT_TOKENIZER
10
+ from .strategies import chunk_fixed, chunk_markdown, chunk_recursive
11
+ from .types import Chunk, Tokenizer
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ _STRATEGIES = {
16
+ "fixed": chunk_fixed,
17
+ "recursive": chunk_recursive,
18
+ "markdown": chunk_markdown,
19
+ }
20
+
21
+
22
+ def chunk(
23
+ document: str,
24
+ strategy: str = "recursive",
25
+ max_tokens: int = 512,
26
+ overlap: int = 0,
27
+ tokenizer: Tokenizer = DEFAULT_TOKENIZER,
28
+ ) -> list[Chunk]:
29
+ """Split `document` into a list of Chunk objects.
30
+
31
+ Args:
32
+ document: the source text to split.
33
+ strategy: one of "fixed", "recursive" (default), "markdown".
34
+ max_tokens: the token budget per chunk, as measured by `tokenizer`.
35
+ overlap: how many tokens of overlap between consecutive chunks.
36
+ tokenizer: a callable from str to int token count. Defaults to
37
+ character counting, keeping the base install dependency-free.
38
+ """
39
+ try:
40
+ strategy_fn = _STRATEGIES[strategy]
41
+ except KeyError:
42
+ raise ValueError(
43
+ f"Unknown strategy {strategy!r}. Available: {sorted(_STRATEGIES)}"
44
+ ) from None
45
+ return strategy_fn(document, max_tokens, overlap, tokenizer)
46
+
47
+
48
+ __all__ = ["chunk", "Chunk", "Tokenizer", "__version__"]
chunkwise/_spans.py ADDED
@@ -0,0 +1,47 @@
1
+ """Internal offset arithmetic shared by strategies.
2
+
3
+ Strategies operate on (start, end) integer spans, not strings, until the
4
+ final materialization step. This is what makes the offset invariant
5
+ structurally impossible to break.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .types import Chunk, Tokenizer
11
+
12
+ def _char_count(text: str) -> int:
13
+ return len(text)
14
+
15
+
16
+ DEFAULT_TOKENIZER: Tokenizer = _char_count
17
+
18
+
19
+ def materialize(document: str, spans: list[tuple[int, int]]) -> list[Chunk]:
20
+ """Turn (start, end) spans into Chunk objects with text sliced from document."""
21
+ return [
22
+ Chunk(text=document[start:end], start=start, end=end, index=i)
23
+ for i, (start, end) in enumerate(spans)
24
+ ]
25
+
26
+
27
+ def find_boundary(
28
+ document: str,
29
+ start: int,
30
+ budget: int,
31
+ tokenizer: Tokenizer,
32
+ ) -> int:
33
+ """Binary search for the largest end offset such that tokenizer(document[start:end]) <= budget.
34
+
35
+ Works with any opaque tokenizer rather than assuming a fixed characters-to-tokens ratio.
36
+ """
37
+ lo, hi = start, len(document)
38
+ if tokenizer(document[start:hi]) <= budget:
39
+ return hi
40
+
41
+ while hi - lo > 1:
42
+ mid = (lo + hi) // 2
43
+ if tokenizer(document[start:mid]) <= budget:
44
+ lo = mid
45
+ else:
46
+ hi = mid
47
+ return lo if lo > start else min(start + 1, len(document))
chunkwise/py.typed ADDED
File without changes
@@ -0,0 +1,7 @@
1
+ """Chunking strategies."""
2
+
3
+ from .fixed import chunk_fixed
4
+ from .markdown import chunk_markdown
5
+ from .recursive import chunk_recursive
6
+
7
+ __all__ = ["chunk_fixed", "chunk_recursive", "chunk_markdown"]
@@ -0,0 +1,19 @@
1
+ """Code-aware strategy: splits on function and class boundaries.
2
+
3
+ Planned for v0.2. Will use `ast` for Python and `tree-sitter` for other
4
+ languages to find boundaries, then chunk on those spans the same way
5
+ `recursive` chunks on separators.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..types import Chunk, Tokenizer
11
+
12
+
13
+ def chunk_code(
14
+ document: str,
15
+ max_tokens: int,
16
+ overlap: int,
17
+ tokenizer: Tokenizer,
18
+ ) -> list[Chunk]:
19
+ raise NotImplementedError("chunk_code is planned for v0.2")
@@ -0,0 +1,36 @@
1
+ """Fixed-window strategy: hard windows of N tokens with configurable overlap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .._spans import find_boundary, materialize
6
+ from ..types import Chunk, Tokenizer
7
+
8
+
9
+ def chunk_fixed(
10
+ document: str,
11
+ max_tokens: int,
12
+ overlap: int,
13
+ tokenizer: Tokenizer,
14
+ ) -> list[Chunk]:
15
+ """Split document into hard windows of at most max_tokens, overlapping by `overlap` tokens.
16
+
17
+ Overlap widens a chunk backwards into real preceding text rather than
18
+ gluing on a copied prefix, preserving the offset invariant.
19
+ """
20
+ if max_tokens <= 0:
21
+ raise ValueError("max_tokens must be positive")
22
+ if overlap < 0 or overlap >= max_tokens:
23
+ raise ValueError("overlap must be in [0, max_tokens)")
24
+
25
+ spans: list[tuple[int, int]] = []
26
+ start = 0
27
+ n = len(document)
28
+ while start < n:
29
+ end = find_boundary(document, start, max_tokens, tokenizer)
30
+ spans.append((start, end))
31
+ if end >= n:
32
+ break
33
+ back_start = find_boundary(document, start, max_tokens - overlap, tokenizer)
34
+ start = max(back_start, start + 1)
35
+
36
+ return materialize(document, spans)
@@ -0,0 +1,75 @@
1
+ """Markdown-aware strategy: keeps fenced code blocks intact, tags chunks with heading paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .._spans import find_boundary
8
+ from ..types import Chunk, Tokenizer
9
+
10
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$", re.MULTILINE)
11
+ FENCE_RE = re.compile(r"^```.*?$.*?^```$", re.MULTILINE | re.DOTALL)
12
+
13
+
14
+ def _code_fence_spans(document: str) -> list[tuple[int, int]]:
15
+ return [(m.start(), m.end()) for m in FENCE_RE.finditer(document)]
16
+
17
+
18
+ def _heading_path_at(document: str, offset: int) -> tuple[str, ...]:
19
+ stack: list[tuple[int, str]] = []
20
+ for match in HEADING_RE.finditer(document):
21
+ if match.start() > offset:
22
+ break
23
+ level = len(match.group(1))
24
+ title = match.group(2).strip()
25
+ stack = [(lvl, t) for lvl, t in stack if lvl < level]
26
+ stack.append((level, title))
27
+ return tuple(title for _, title in stack)
28
+
29
+
30
+ def chunk_markdown(
31
+ document: str,
32
+ max_tokens: int,
33
+ overlap: int,
34
+ tokenizer: Tokenizer,
35
+ ) -> list[Chunk]:
36
+ """Split markdown document, never breaking a fenced code block, tagging heading paths."""
37
+ if max_tokens <= 0:
38
+ raise ValueError("max_tokens must be positive")
39
+ if overlap < 0 or overlap >= max_tokens:
40
+ raise ValueError("overlap must be in [0, max_tokens)")
41
+
42
+ fences = _code_fence_spans(document)
43
+ spans: list[tuple[int, int]] = []
44
+ start = 0
45
+ n = len(document)
46
+
47
+ while start < n:
48
+ hard_limit = find_boundary(document, start, max_tokens, tokenizer)
49
+
50
+ for fence_start, fence_end in fences:
51
+ if fence_start < hard_limit < fence_end:
52
+ hard_limit = fence_end
53
+ break
54
+
55
+ end = max(hard_limit, start + 1)
56
+ end = min(end, n)
57
+ spans.append((start, end))
58
+ if end >= n:
59
+ break
60
+ if overlap == 0:
61
+ start = end
62
+ else:
63
+ back_start = find_boundary(document, start, max_tokens - overlap, tokenizer)
64
+ start = max(min(back_start, end), start + 1)
65
+
66
+ return [
67
+ Chunk(
68
+ text=document[s:e],
69
+ start=s,
70
+ end=e,
71
+ index=i,
72
+ heading_path=_heading_path_at(document, s),
73
+ )
74
+ for i, (s, e) in enumerate(spans)
75
+ ]
@@ -0,0 +1,61 @@
1
+ """Recursive strategy: falls back through a separator chain to find natural breaks.
2
+
3
+ Breaks on the coarsest boundary that fits the budget — paragraphs, then lines,
4
+ then sentences, then words. This is the default strategy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .._spans import find_boundary, materialize
12
+ from ..types import Chunk, Tokenizer
13
+
14
+ SEPARATOR_CHAIN = [
15
+ re.compile(r"\n\s*\n"), # paragraphs
16
+ re.compile(r"\n"), # lines
17
+ re.compile(r"(?<=[.!?])\s+"), # sentences
18
+ re.compile(r"\s+"), # words
19
+ ]
20
+
21
+
22
+ def _best_split_point(document: str, start: int, limit: int) -> int:
23
+ """Find the best separator-aligned split point within (start, limit]."""
24
+ window = document[start:limit]
25
+ for separator in SEPARATOR_CHAIN:
26
+ matches = list(separator.finditer(window))
27
+ if matches:
28
+ last = matches[-1]
29
+ split = start + last.end()
30
+ if split > start:
31
+ return split
32
+ return limit
33
+
34
+
35
+ def chunk_recursive(
36
+ document: str,
37
+ max_tokens: int,
38
+ overlap: int,
39
+ tokenizer: Tokenizer,
40
+ ) -> list[Chunk]:
41
+ """Split document on the coarsest separator that keeps each chunk within budget."""
42
+ if max_tokens <= 0:
43
+ raise ValueError("max_tokens must be positive")
44
+ if overlap < 0 or overlap >= max_tokens:
45
+ raise ValueError("overlap must be in [0, max_tokens)")
46
+
47
+ spans: list[tuple[int, int]] = []
48
+ start = 0
49
+ n = len(document)
50
+ while start < n:
51
+ hard_limit = find_boundary(document, start, max_tokens, tokenizer)
52
+ end = _best_split_point(document, start, hard_limit) if hard_limit < n else hard_limit
53
+ if end <= start:
54
+ end = hard_limit
55
+ spans.append((start, end))
56
+ if end >= n:
57
+ break
58
+ back_start = find_boundary(document, start, max_tokens - overlap, tokenizer)
59
+ start = max(min(back_start, end), start + 1)
60
+
61
+ return materialize(document, spans)
@@ -0,0 +1,20 @@
1
+ """Semantic strategy: cuts where sentence-embedding similarity drops.
2
+
3
+ Planned for v0.3, shipping as an optional extra (`pip install chunkwise[semantic]`)
4
+ so the base install stays dependency-free.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..types import Chunk, Tokenizer
10
+
11
+
12
+ def chunk_semantic(
13
+ document: str,
14
+ max_tokens: int,
15
+ overlap: int,
16
+ tokenizer: Tokenizer,
17
+ ) -> list[Chunk]:
18
+ raise NotImplementedError(
19
+ "chunk_semantic is planned for v0.3 and requires the 'semantic' extra"
20
+ )
chunkwise/types.py ADDED
@@ -0,0 +1,41 @@
1
+ """Core types: the Chunk dataclass and the Tokenizer protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol, runtime_checkable
7
+
8
+
9
+ @runtime_checkable
10
+ class Tokenizer(Protocol):
11
+ """Any callable from str to an integer token count.
12
+
13
+ The only requirement is that the count is monotonic in string length —
14
+ every real tokenizer (character counting, tiktoken, transformers) satisfies this.
15
+ """
16
+
17
+ def __call__(self, text: str) -> int: ...
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Chunk:
22
+ """A chunk of a source document.
23
+
24
+ The invariant ``document[chunk.start:chunk.end] == chunk.text`` is enforced
25
+ on construction: a Chunk cannot exist with a span that doesn't match its text.
26
+ """
27
+
28
+ text: str
29
+ start: int
30
+ end: int
31
+ index: int = 0
32
+ heading_path: tuple[str, ...] = field(default_factory=tuple)
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.end - self.start != len(self.text):
36
+ raise ValueError(
37
+ f"Chunk span ({self.start}, {self.end}) does not match "
38
+ f"text length {len(self.text)}"
39
+ )
40
+ if self.start < 0 or self.end < self.start:
41
+ raise ValueError(f"Invalid span: ({self.start}, {self.end})")
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.5
2
+ Name: chunkwise
3
+ Version: 0.1.0
4
+ Summary: Text chunking for RAG with reversible offsets — every chunk is an exact slice of the source document.
5
+ Project-URL: Homepage, https://github.com/amin-albooyeh/chunkwise
6
+ Project-URL: Repository, https://github.com/amin-albooyeh/chunkwise
7
+ Project-URL: Documentation, https://github.com/amin-albooyeh/chunkwise/tree/main/docs
8
+ Project-URL: Changelog, https://github.com/amin-albooyeh/chunkwise/blob/main/CHANGELOG.md
9
+ Author-email: Amin Albooyeh <amin.albooyeh@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: chunking,embeddings,nlp,rag,retrieval
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Text Processing :: Linguistic
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Provides-Extra: dev
25
+ Requires-Dist: hypothesis>=6.0; extra == 'dev'
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Provides-Extra: semantic
30
+ Requires-Dist: numpy; extra == 'semantic'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # chunkwise
34
+
35
+ **Text chunking for RAG with reversible offsets — every chunk is an exact slice of the source document.**
36
+
37
+ [![CI](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml/badge.svg)](https://github.com/amin-albooyeh/chunkwise/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/chunkwise.svg)](https://pypi.org/project/chunkwise/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/chunkwise.svg)](https://pypi.org/project/chunkwise/)
40
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
41
+
42
+ chunkwise splits documents into embeddable chunks for retrieval-augmented generation. Unlike most splitters, which return detached strings, every chunk carries the exact character range it came from, so `document[chunk.start:chunk.end] == chunk.text` always holds — making source highlighting, citation, and context expansion possible. Zero runtime dependencies, bring-your-own tokenizer, and structure-aware strategies for plain text and Markdown.
43
+
44
+ ## Why chunkwise
45
+
46
+ Most chunking libraries hand back a `list[str]`. The moment text is detached from its origin, three things become impossible:
47
+
48
+ - You can't **highlight** the passage a retrieval hit came from in the original document.
49
+ - You can't **expand** a result to show the surrounding paragraph.
50
+ - You can't tell **which section or heading** a passage lived under.
51
+
52
+ Worse, splitters that strip or normalize whitespace silently drop characters — a failure mode that shows up only as retrieval quietly missing content, which is hard to notice and harder to debug.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install chunkwise
58
+ ```
59
+
60
+ No dependencies are pulled in. (An optional `semantic` extra will be added in v0.3 for embedding-based chunking.)
61
+
62
+ ## Quickstart
63
+
64
+ ```python
65
+ from chunkwise import chunk
66
+
67
+ document = open("report.md").read()
68
+ chunks = chunk(document, strategy="recursive", max_tokens=512, overlap=50)
69
+
70
+ for c in chunks:
71
+ assert document[c.start:c.end] == c.text # always true, for every chunk
72
+ print(c.index, c.start, c.end, c.heading_path)
73
+ ```
74
+
75
+ ## The guarantee
76
+
77
+ chunkwise holds one invariant across every strategy and every input:
78
+
79
+ ```python
80
+ document[chunk.start:chunk.end] == chunk.text
81
+ ```
82
+
83
+ This is enforced three ways:
84
+
85
+ 1. **Structurally** — internal logic operates on `(start, end)` integer spans and only materializes text at the final step, so no strategy (present or future) can break the invariant even by accident.
86
+ 2. **At runtime** — the `Chunk` type validates its own span on construction.
87
+ 3. **In CI** — a Hypothesis property test runs against generated documents on every commit, alongside a test that concatenating non-overlapping chunks reproduces the source byte for byte.
88
+
89
+ The consequences are deliberate: chunks are never stripped, normalized, or rewritten, and overlap widens a chunk backwards into real preceding text rather than gluing on a copied prefix.
90
+
91
+ See [docs/design.md](docs/design.md) for the full rationale.
92
+
93
+ ## Strategies
94
+
95
+ | Strategy | Behavior |
96
+ |---|---|
97
+ | `fixed` | Hard windows of N tokens with configurable overlap. Fast, boundary-blind. Suited to logs and unstructured text. |
98
+ | `recursive` | Falls back through a separator chain — paragraphs, then lines, then sentences, then words — breaking on the coarsest boundary that fits the budget. **The default.** |
99
+ | `markdown` | Keeps fenced code blocks intact and tags every chunk with its heading path (e.g. `["Guide", "Install"]`), giving retrieval results a sense of place. |
100
+
101
+ Planned: `code` (v0.2 — AST / tree-sitter boundaries) and `semantic` (v0.3 — embedding similarity cuts), the latter shipping as an optional extra so the base install stays dependency-free.
102
+
103
+ See [docs/strategies.md](docs/strategies.md) for guidance on choosing one.
104
+
105
+ ## Tokenizer independence
106
+
107
+ Token budgets depend on which model you're embedding for, so chunkwise doesn't hard-code one. The `tokenizer` parameter accepts any callable from `str` to `int`:
108
+
109
+ ```python
110
+ from chunkwise import chunk
111
+ import tiktoken
112
+
113
+ enc = tiktoken.get_encoding("cl100k_base")
114
+ chunks = chunk(document, max_tokens=512, tokenizer=lambda text: len(enc.encode(text)))
115
+ ```
116
+
117
+ The default counts characters, keeping the base install at zero dependencies. The only requirement on a tokenizer is that its count is monotonic in string length — every real tokenizer satisfies this. See [docs/tokenizers.md](docs/tokenizers.md).
118
+
119
+ ## API
120
+
121
+ ```python
122
+ def chunk(
123
+ document: str,
124
+ strategy: str = "recursive", # "fixed" | "recursive" | "markdown"
125
+ max_tokens: int = 512,
126
+ overlap: int = 0,
127
+ tokenizer: Tokenizer = ..., # str -> int, defaults to len
128
+ ) -> list[Chunk]: ...
129
+ ```
130
+
131
+ Each `Chunk` is a frozen dataclass:
132
+
133
+ ```python
134
+ @dataclass(frozen=True, slots=True)
135
+ class Chunk:
136
+ text: str
137
+ start: int
138
+ end: int
139
+ index: int
140
+ heading_path: tuple[str, ...] # populated by the markdown strategy
141
+ ```
142
+
143
+ ## Status
144
+
145
+ v0.1.0 · Python 3.10+ · MIT licensed · zero runtime dependencies · tested on CI across Python 3.10–3.13.
146
+
147
+ ## Documentation
148
+
149
+ - [docs/index.md](docs/index.md) — overview and quickstart
150
+ - [docs/strategies.md](docs/strategies.md) — strategy reference and how to choose
151
+ - [docs/tokenizers.md](docs/tokenizers.md) — bringing your own tokenizer
152
+ - [docs/design.md](docs/design.md) — why spans, not strings
153
+
154
+ ## Benchmarks
155
+
156
+ ```bash
157
+ python benchmarks/throughput.py
158
+ ```
159
+
160
+ Compares chunkwise strategies against a naive fixed-width splitter on a synthetic document.
161
+
162
+ ## Contributing
163
+
164
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, checks, and guidelines (in particular, the offset invariant that any new strategy must preserve).
165
+
166
+ ## License
167
+
168
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ chunkwise/__init__.py,sha256=MevyLwjm1SBbXhM7WXtId_iyWerJTT2r6j3qDegN1do,1487
2
+ chunkwise/_spans.py,sha256=B862WnbFxUsmltwIQsM6ZbsvaJsw5CHjY7BuD87OSUc,1345
3
+ chunkwise/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ chunkwise/types.py,sha256=UAefS_j9X9LsptcfAzbfgK7_zQIvc_XIsgyl2gEMtLs,1282
5
+ chunkwise/strategies/__init__.py,sha256=nCuao_4iyo2sp7MF9ELm2Gpr5E0x5j55-xvXvEjnmVI,199
6
+ chunkwise/strategies/code.py,sha256=QhAQQ7jmTxnh9Z1zO2bJ-1DJn6ngPTlR7Msrus13zt4,503
7
+ chunkwise/strategies/fixed.py,sha256=f8fbDVeejIzHvQxoM5vkNjAxNlz8NzAGvmosKM40Lxk,1162
8
+ chunkwise/strategies/markdown.py,sha256=W-JEezPYNFQeVeZIdK9YOZv_g32emD5JY16P6mVs2cw,2279
9
+ chunkwise/strategies/recursive.py,sha256=iyn13N_5SW_A2DhT1MgZkM4ywH9NaO5jVHAdP7isaLk,1950
10
+ chunkwise/strategies/semantic.py,sha256=OzKBSFF9lVeAO9qNHbqCvSA4Xj_Heg8dtXEAQI3GUeI,516
11
+ chunkwise-0.1.0.dist-info/METADATA,sha256=6v40mQ29hqSxtI_-icdrzNyf2t7YQeM8JCSma7HVkfA,7121
12
+ chunkwise-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ chunkwise-0.1.0.dist-info/licenses/LICENSE,sha256=191kzGOjX1TzmWu8p4TT2jdQz0BnqmFybQKbzCaEL9I,1070
14
+ chunkwise-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amin Albooyeh
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.