tomefile 0.1.0__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.
- tomefile-0.1.0/LICENSE +21 -0
- tomefile-0.1.0/MANIFEST.in +3 -0
- tomefile-0.1.0/PKG-INFO +44 -0
- tomefile-0.1.0/README.md +15 -0
- tomefile-0.1.0/pyproject.toml +53 -0
- tomefile-0.1.0/setup.cfg +4 -0
- tomefile-0.1.0/src/tomefile/__init__.py +3 -0
- tomefile-0.1.0/src/tomefile/archive.py +93 -0
- tomefile-0.1.0/src/tomefile/build.py +143 -0
- tomefile-0.1.0/src/tomefile/chunking.py +66 -0
- tomefile-0.1.0/src/tomefile/cli.py +261 -0
- tomefile-0.1.0/src/tomefile/database.py +347 -0
- tomefile-0.1.0/src/tomefile/errors.py +10 -0
- tomefile-0.1.0/src/tomefile/gateway.py +192 -0
- tomefile-0.1.0/src/tomefile/graph.py +66 -0
- tomefile-0.1.0/src/tomefile/hashing.py +20 -0
- tomefile-0.1.0/src/tomefile/inspecting.py +167 -0
- tomefile-0.1.0/src/tomefile/manifest.py +298 -0
- tomefile-0.1.0/src/tomefile/models.py +66 -0
- tomefile-0.1.0/src/tomefile/opening.py +154 -0
- tomefile-0.1.0/src/tomefile/pack.py +100 -0
- tomefile-0.1.0/src/tomefile/runtime.py +234 -0
- tomefile-0.1.0/src/tomefile/signing.py +96 -0
- tomefile-0.1.0/src/tomefile.egg-info/PKG-INFO +44 -0
- tomefile-0.1.0/src/tomefile.egg-info/SOURCES.txt +34 -0
- tomefile-0.1.0/src/tomefile.egg-info/dependency_links.txt +1 -0
- tomefile-0.1.0/src/tomefile.egg-info/entry_points.txt +2 -0
- tomefile-0.1.0/src/tomefile.egg-info/requires.txt +9 -0
- tomefile-0.1.0/src/tomefile.egg-info/top_level.txt +1 -0
- tomefile-0.1.0/tests/__init__.py +1 -0
- tomefile-0.1.0/tests/support.py +62 -0
- tomefile-0.1.0/tests/test_chunking.py +35 -0
- tomefile-0.1.0/tests/test_gateway.py +83 -0
- tomefile-0.1.0/tests/test_graph.py +169 -0
- tomefile-0.1.0/tests/test_inspect.py +89 -0
- tomefile-0.1.0/tests/test_package.py +219 -0
tomefile-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simeon Emanuilov
|
|
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.
|
tomefile-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tomefile
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local RAG as a single file
|
|
5
|
+
Author-email: Simeon Emanuilov <simeon.emanuilov@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tomefile.com
|
|
8
|
+
Project-URL: Repository, https://github.com/s-emanuilov/tomefile
|
|
9
|
+
Keywords: rag,embeddings,sqlite,local
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: typer>=0.12
|
|
21
|
+
Requires-Dist: fastapi>=0.115
|
|
22
|
+
Requires-Dist: uvicorn>=0.32
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: sqlite-vec>=0.1.6
|
|
25
|
+
Requires-Dist: cryptography>=43
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# Tomefile
|
|
31
|
+
|
|
32
|
+
A local RAG index you pack **into one file**.
|
|
33
|
+
|
|
34
|
+
Fine-tuning a small model on your knowledgebase will not make it remember them. Retrieval will. Most RAG still calls a remote store, an embeddings API, and a set of tools. A `.tome` is the other shape: chunks, vectors, and the embedding model in one archive you open on `127.0.0.1`.
|
|
35
|
+
|
|
36
|
+
Build the index once. Hand someone the file.
|
|
37
|
+
|
|
38
|
+
- **`.tome`** — documents already chunked and embedded, plus the embedder (GGUF inside, or a SHA-256 reference). Checksums on every member. Optional Ed25519 signature.
|
|
39
|
+
- **`.tbox`** — the same file, with a chat model packed in or pinned by hash.
|
|
40
|
+
- **`tomefile serve`** — OpenAI-compatible `/v1/chat/completions` on the loopback interface. Answers cite source files and character offsets.
|
|
41
|
+
|
|
42
|
+
Chunk text is plaintext SQLite; this is not a way to hide a corpus. Vector search uses the embedder named in the file. A different model with the same vector size will retrieve the wrong neighbours.
|
|
43
|
+
|
|
44
|
+
Site: [tomefile.com](https://tomefile.com).
|
tomefile-0.1.0/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Tomefile
|
|
2
|
+
|
|
3
|
+
A local RAG index you pack **into one file**.
|
|
4
|
+
|
|
5
|
+
Fine-tuning a small model on your knowledgebase will not make it remember them. Retrieval will. Most RAG still calls a remote store, an embeddings API, and a set of tools. A `.tome` is the other shape: chunks, vectors, and the embedding model in one archive you open on `127.0.0.1`.
|
|
6
|
+
|
|
7
|
+
Build the index once. Hand someone the file.
|
|
8
|
+
|
|
9
|
+
- **`.tome`** — documents already chunked and embedded, plus the embedder (GGUF inside, or a SHA-256 reference). Checksums on every member. Optional Ed25519 signature.
|
|
10
|
+
- **`.tbox`** — the same file, with a chat model packed in or pinned by hash.
|
|
11
|
+
- **`tomefile serve`** — OpenAI-compatible `/v1/chat/completions` on the loopback interface. Answers cite source files and character offsets.
|
|
12
|
+
|
|
13
|
+
Chunk text is plaintext SQLite; this is not a way to hide a corpus. Vector search uses the embedder named in the file. A different model with the same vector size will retrieve the wrong neighbours.
|
|
14
|
+
|
|
15
|
+
Site: [tomefile.com](https://tomefile.com).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tomefile"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Local RAG as a single file"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Simeon Emanuilov", email = "simeon.emanuilov@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["rag", "embeddings", "sqlite", "local"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"typer>=0.12",
|
|
24
|
+
"fastapi>=0.115",
|
|
25
|
+
"uvicorn>=0.32",
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"sqlite-vec>=0.1.6",
|
|
28
|
+
"cryptography>=43",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=8"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://tomefile.com"
|
|
36
|
+
Repository = "https://github.com/s-emanuilov/tomefile"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
tomefile = "tomefile.cli:app"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["setuptools>=77"]
|
|
43
|
+
build-backend = "setuptools.build_meta"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.dynamic]
|
|
46
|
+
version = { attr = "tomefile.__version__" }
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
pythonpath = ["."]
|
tomefile-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import zipfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tomefile.errors import TomeError
|
|
7
|
+
from tomefile.hashing import sha256_bytes, sha256_file
|
|
8
|
+
from tomefile.manifest import SHA_RE
|
|
9
|
+
|
|
10
|
+
CHECKSUMS = "checksums.sha256"
|
|
11
|
+
SIGNATURE = "signature.json"
|
|
12
|
+
EXCLUDED = {CHECKSUMS, SIGNATURE}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def checksum_document(members: dict[str, bytes]) -> bytes:
|
|
16
|
+
lines: list[str] = []
|
|
17
|
+
names = sorted((name for name in members if name not in EXCLUDED), key=lambda name: name.encode("utf-8"))
|
|
18
|
+
for name in names:
|
|
19
|
+
lines.append(f"{sha256_bytes(members[name])} {name}")
|
|
20
|
+
return ("\n".join(lines) + "\n").encode("utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_checksums(raw: bytes) -> dict[str, str]:
|
|
24
|
+
try:
|
|
25
|
+
text = raw.decode("utf-8")
|
|
26
|
+
except UnicodeDecodeError as exc:
|
|
27
|
+
raise TomeError("checksums.sha256 is not UTF-8") from exc
|
|
28
|
+
listed: dict[str, str] = {}
|
|
29
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
30
|
+
if line == "":
|
|
31
|
+
continue
|
|
32
|
+
digest, separator, name = line.partition(" ") # sha256sum: digest, two spaces, name
|
|
33
|
+
if separator != " " or SHA_RE.fullmatch(digest) is None or not _safe_name(name):
|
|
34
|
+
raise TomeError(f"checksums.sha256 line {number} is malformed")
|
|
35
|
+
if name in listed:
|
|
36
|
+
raise TomeError(f"checksums.sha256 lists {name} twice")
|
|
37
|
+
listed[name] = digest
|
|
38
|
+
return listed
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verify_checksums(members: dict[str, bytes]) -> bytes:
|
|
42
|
+
raw = members.get(CHECKSUMS)
|
|
43
|
+
if raw is None:
|
|
44
|
+
raise TomeError("checksums.sha256 is missing")
|
|
45
|
+
listed = parse_checksums(raw)
|
|
46
|
+
payload = {name: data for name, data in members.items() if name not in EXCLUDED}
|
|
47
|
+
if set(listed) != set(payload):
|
|
48
|
+
raise TomeError("checksums.sha256 does not match the archive members")
|
|
49
|
+
for name, digest in listed.items():
|
|
50
|
+
if sha256_bytes(payload[name]) != digest:
|
|
51
|
+
raise TomeError(f"checksum mismatch for {name}")
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_archive(path: Path, members: dict[str, bytes]) -> None:
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
|
|
58
|
+
for name in sorted(members, key=lambda item: item.encode("utf-8")):
|
|
59
|
+
info = zipfile.ZipInfo(filename=name, date_time=(1980, 1, 1, 0, 0, 0)) # stable hashes
|
|
60
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
61
|
+
info.flag_bits |= 0x800 # UTF-8 names
|
|
62
|
+
info.external_attr = 0o644 << 16
|
|
63
|
+
archive.writestr(info, members[name])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_archive(path: Path) -> tuple[str, dict[str, bytes]]:
|
|
67
|
+
digest = sha256_file(path)
|
|
68
|
+
try:
|
|
69
|
+
archive = zipfile.ZipFile(path)
|
|
70
|
+
except zipfile.BadZipFile as exc:
|
|
71
|
+
raise TomeError(f"{path} is not a ZIP archive") from exc
|
|
72
|
+
members: dict[str, bytes] = {}
|
|
73
|
+
with archive:
|
|
74
|
+
for info in archive.infolist():
|
|
75
|
+
if info.is_dir():
|
|
76
|
+
continue
|
|
77
|
+
if info.compress_type != zipfile.ZIP_STORED:
|
|
78
|
+
raise TomeError(f"{info.filename} is compressed; members must use store method")
|
|
79
|
+
if not _safe_name(info.filename):
|
|
80
|
+
raise TomeError(f"unsafe member {info.filename}")
|
|
81
|
+
if info.filename in members:
|
|
82
|
+
raise TomeError(f"duplicate member {info.filename}")
|
|
83
|
+
members[info.filename] = archive.read(info)
|
|
84
|
+
if not members:
|
|
85
|
+
raise TomeError(f"{path} is empty")
|
|
86
|
+
return digest, members
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _safe_name(name: str) -> bool:
|
|
90
|
+
if name == "" or name.startswith("/") or name.endswith("/"):
|
|
91
|
+
return False
|
|
92
|
+
parts = name.split("/")
|
|
93
|
+
return ".." not in parts and "." not in parts and "" not in parts
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tomefile import __version__
|
|
8
|
+
from tomefile.archive import CHECKSUMS, SIGNATURE, checksum_document, write_archive
|
|
9
|
+
from tomefile.chunking import Chunk, chunk_documents, discover_documents
|
|
10
|
+
from tomefile.database import create_database
|
|
11
|
+
from tomefile.errors import TomeError
|
|
12
|
+
from tomefile.hashing import sha256_file
|
|
13
|
+
from tomefile.manifest import (
|
|
14
|
+
CHUNK_SIZE,
|
|
15
|
+
TOP_K,
|
|
16
|
+
LicenseGrant,
|
|
17
|
+
Manifest,
|
|
18
|
+
ModelRef,
|
|
19
|
+
default_name,
|
|
20
|
+
default_signer,
|
|
21
|
+
require_id,
|
|
22
|
+
)
|
|
23
|
+
from tomefile.models import cache_model_file, cached_model
|
|
24
|
+
from tomefile.signing import sign_checksums
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_tome(
|
|
28
|
+
input_dir: Path,
|
|
29
|
+
output: Path,
|
|
30
|
+
*,
|
|
31
|
+
package_id: str,
|
|
32
|
+
revision: int,
|
|
33
|
+
model: Path,
|
|
34
|
+
embed_id: str,
|
|
35
|
+
embed_license_name: str,
|
|
36
|
+
embed: Callable[[str], list[float]],
|
|
37
|
+
name: str | None = None,
|
|
38
|
+
knowledge_license: str = "internal",
|
|
39
|
+
knowledge_commercial: bool = False,
|
|
40
|
+
knowledge_redistribute: bool = False,
|
|
41
|
+
attribution: bool = True,
|
|
42
|
+
redistribute_embed: bool = True,
|
|
43
|
+
embed_model: bool = False,
|
|
44
|
+
recommended_llm: str = "",
|
|
45
|
+
sign_key: Path | None = None,
|
|
46
|
+
signer: str | None = None,
|
|
47
|
+
cache_root: Path,
|
|
48
|
+
) -> None:
|
|
49
|
+
package_id = require_id(package_id, "id")
|
|
50
|
+
if revision < 1:
|
|
51
|
+
raise TomeError("revision must be an integer >= 1")
|
|
52
|
+
if output.suffix.lower() != ".tome":
|
|
53
|
+
raise TomeError("output must end in .tome")
|
|
54
|
+
if not embed_id:
|
|
55
|
+
raise TomeError("--embed-id must not be empty")
|
|
56
|
+
if not embed_license_name:
|
|
57
|
+
raise TomeError("--embed-license must not be empty")
|
|
58
|
+
if embed_model and not redistribute_embed:
|
|
59
|
+
raise TomeError("--embed-model requires a redistributable embedding license")
|
|
60
|
+
documents = discover_documents(input_dir)
|
|
61
|
+
chunks = chunk_documents(documents, CHUNK_SIZE)
|
|
62
|
+
if not chunks:
|
|
63
|
+
raise TomeError("no text to index")
|
|
64
|
+
vectors = [_embed_chunk(embed, chunk) for chunk in chunks]
|
|
65
|
+
dimension = len(vectors[0])
|
|
66
|
+
digest = sha256_file(model)
|
|
67
|
+
already_cached = cached_model(cache_root, digest)
|
|
68
|
+
if not redistribute_embed and already_cached is None:
|
|
69
|
+
raise TomeError(
|
|
70
|
+
f"embedding model is not redistributable and {digest} is not in the model cache"
|
|
71
|
+
)
|
|
72
|
+
include_model = redistribute_embed and (embed_model or already_cached is None)
|
|
73
|
+
if already_cached is None and redistribute_embed:
|
|
74
|
+
cache_model_file(model, cache_root)
|
|
75
|
+
manifest = Manifest(
|
|
76
|
+
type="data_tome",
|
|
77
|
+
id=package_id,
|
|
78
|
+
revision=revision,
|
|
79
|
+
name=name or default_name(package_id),
|
|
80
|
+
embed="models/embed.gguf" if include_model else ModelRef(digest, model.name),
|
|
81
|
+
chat=None,
|
|
82
|
+
embedding_id=embed_id,
|
|
83
|
+
embedding_sha256=digest,
|
|
84
|
+
dimensions=dimension,
|
|
85
|
+
recommended_llm=recommended_llm,
|
|
86
|
+
builder="tomefile",
|
|
87
|
+
builder_version=__version__,
|
|
88
|
+
chunker="fixed-size",
|
|
89
|
+
knowledge=LicenseGrant(
|
|
90
|
+
knowledge_license or "internal",
|
|
91
|
+
knowledge_commercial,
|
|
92
|
+
knowledge_redistribute,
|
|
93
|
+
attribution,
|
|
94
|
+
),
|
|
95
|
+
embed_license=LicenseGrant(embed_license_name, True, redistribute_embed, attribution),
|
|
96
|
+
chat_license=None,
|
|
97
|
+
chunk_size=CHUNK_SIZE,
|
|
98
|
+
retrieve_top_k=TOP_K,
|
|
99
|
+
)
|
|
100
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
101
|
+
database_path = Path(temporary) / "vectors.db"
|
|
102
|
+
create_database(
|
|
103
|
+
database_path,
|
|
104
|
+
chunks,
|
|
105
|
+
vectors,
|
|
106
|
+
{
|
|
107
|
+
"package_id": package_id,
|
|
108
|
+
"revision": str(revision),
|
|
109
|
+
"metric": "cosine",
|
|
110
|
+
"embedding_id": embed_id,
|
|
111
|
+
"embedding_sha256": digest,
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
members: dict[str, bytes] = {
|
|
115
|
+
"manifest.json": manifest.dumps(),
|
|
116
|
+
"knowledge/vectors.db": database_path.read_bytes(),
|
|
117
|
+
}
|
|
118
|
+
if include_model:
|
|
119
|
+
members["models/embed.gguf"] = model.read_bytes()
|
|
120
|
+
_finish_archive(output, members, sign_key, signer or default_signer(package_id))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _embed_chunk(embed: Callable[[str], list[float]], chunk: Chunk) -> list[float]:
|
|
124
|
+
try:
|
|
125
|
+
vector = embed(chunk.text)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
raise TomeError(f"embedder rejected {chunk.source}: {exc}") from exc
|
|
128
|
+
if not vector:
|
|
129
|
+
raise TomeError(f"embedder rejected {chunk.source}: empty vector")
|
|
130
|
+
return [float(value) for value in vector]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _finish_archive(
|
|
134
|
+
output: Path,
|
|
135
|
+
members: dict[str, bytes],
|
|
136
|
+
sign_key: Path | None,
|
|
137
|
+
signer: str,
|
|
138
|
+
) -> None:
|
|
139
|
+
checksums = checksum_document(members)
|
|
140
|
+
members[CHECKSUMS] = checksums
|
|
141
|
+
if sign_key is not None:
|
|
142
|
+
members[SIGNATURE] = sign_checksums(checksums, sign_key, signer)
|
|
143
|
+
write_archive(output, members)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tomefile.errors import TomeError
|
|
7
|
+
|
|
8
|
+
TEXT_SUFFIXES = {".txt", ".md"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Chunk:
|
|
13
|
+
id: int
|
|
14
|
+
text: str
|
|
15
|
+
source: str
|
|
16
|
+
start: int
|
|
17
|
+
end: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def discover_documents(input_dir: Path) -> list[tuple[str, str]]:
|
|
21
|
+
found: list[tuple[str, str]] = []
|
|
22
|
+
for path in input_dir.rglob("*"):
|
|
23
|
+
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
|
|
24
|
+
continue
|
|
25
|
+
relative = path.relative_to(input_dir).as_posix()
|
|
26
|
+
try:
|
|
27
|
+
text = path.read_text(encoding="utf-8")
|
|
28
|
+
except UnicodeDecodeError as exc:
|
|
29
|
+
raise TomeError(f"{relative} is not valid UTF-8") from exc
|
|
30
|
+
found.append((relative, text))
|
|
31
|
+
found.sort(key=lambda item: item[0].encode("utf-8"))
|
|
32
|
+
return found
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def chunk_text(text: str, size: int) -> list[tuple[int, int, str]]:
|
|
36
|
+
if size < 1:
|
|
37
|
+
raise TomeError("chunk size must be positive")
|
|
38
|
+
pieces: list[tuple[int, int, str]] = []
|
|
39
|
+
index = 0
|
|
40
|
+
length = len(text)
|
|
41
|
+
while index < length:
|
|
42
|
+
end = min(index + size, length)
|
|
43
|
+
if end < length:
|
|
44
|
+
# last whitespace in the window; keep it in this chunk
|
|
45
|
+
window = text[index:end]
|
|
46
|
+
cut = -1
|
|
47
|
+
for offset, char in enumerate(window):
|
|
48
|
+
if char.isspace():
|
|
49
|
+
cut = offset
|
|
50
|
+
if cut >= 0:
|
|
51
|
+
end = index + cut + 1
|
|
52
|
+
if end <= index:
|
|
53
|
+
end = index + 1
|
|
54
|
+
pieces.append((index, end, text[index:end]))
|
|
55
|
+
index = end
|
|
56
|
+
return pieces
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def chunk_documents(documents: list[tuple[str, str]], size: int) -> list[Chunk]:
|
|
60
|
+
chunks: list[Chunk] = []
|
|
61
|
+
next_id = 1
|
|
62
|
+
for source, text in documents:
|
|
63
|
+
for start, end, piece in chunk_text(text, size):
|
|
64
|
+
chunks.append(Chunk(next_id, piece, source, start, end))
|
|
65
|
+
next_id += 1
|
|
66
|
+
return chunks
|