itar 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.
itar-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.3
2
+ Name: itar
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Karim Abou Zeid
6
+ Author-email: Karim Abou Zeid <contact@ka.codes>
7
+ Requires-Dist: humanize>=4.12.3
8
+ Requires-Dist: msgpack>=1.1.1
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # itar
13
+
14
+ ## Minimal example
15
+
16
+ ```bash
17
+ echo Hello world! > bar.txt
18
+ tar cf foo-0.tar bar.txt # create regular tar file(s) with zero-padded shard number
19
+ itar create foo.itar # create the index
20
+ itar ls foo.itar # (optional) view the contents of the index
21
+ ```
22
+
23
+ Now you can open and efficiently access the archived files in python in constant time
24
+ ```python
25
+ from itar import ShardedIndexedTar
26
+
27
+ with ShardedIndexedTar.open("foo.itar") as itar:
28
+ f = itar.file("bar.txt") # file-like object
29
+ assert f.read().decode("utf-8") == "Hello world!\n"
30
+ ```
itar-0.1.0/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # itar
2
+
3
+ ## Minimal example
4
+
5
+ ```bash
6
+ echo Hello world! > bar.txt
7
+ tar cf foo-0.tar bar.txt # create regular tar file(s) with zero-padded shard number
8
+ itar create foo.itar # create the index
9
+ itar ls foo.itar # (optional) view the contents of the index
10
+ ```
11
+
12
+ Now you can open and efficiently access the archived files in python in constant time
13
+ ```python
14
+ from itar import ShardedIndexedTar
15
+
16
+ with ShardedIndexedTar.open("foo.itar") as itar:
17
+ f = itar.file("bar.txt") # file-like object
18
+ assert f.read().decode("utf-8") == "Hello world!\n"
19
+ ```
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "itar"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Karim Abou Zeid", email = "contact@ka.codes" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "humanize>=4.12.3",
12
+ "msgpack>=1.1.1",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "pytest>=8.4.0",
18
+ "ruff>=0.11.13",
19
+ ]
20
+
21
+ [project.scripts]
22
+ itar = "itar.sharded_indexed_tar:cli"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.9.0,<0.10.0"]
26
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ from .sharded_indexed_tar import ShardedIndexedTar
2
+
3
+ __all__ = [
4
+ "ShardedIndexedTar",
5
+ ]
File without changes
@@ -0,0 +1,256 @@
1
+ import os
2
+ from collections.abc import Mapping
3
+ from io import BufferedReader
4
+ from pathlib import Path
5
+ from tarfile import TarInfo
6
+ from typing import IO, Callable
7
+
8
+ from .utils import (
9
+ TarFileSectionIO,
10
+ TarIndexError,
11
+ TarMember,
12
+ ThreadSafeFileIO,
13
+ build_tar_index,
14
+ check_tar_index,
15
+ tar_file_info,
16
+ )
17
+
18
+ ShardedTarIndex = dict[str, (int, TarMember)] # fname -> (shard_idx, TarMember)
19
+
20
+
21
+ class ShardedIndexedTar(Mapping):
22
+ def __init__(
23
+ self,
24
+ shards: list[str | os.PathLike | IO[bytes]],
25
+ index: ShardedTarIndex | None = None,
26
+ open_fn: Callable[[str | os.PathLike], IO[bytes]] = None,
27
+ buffered_file_reader: bool = True,
28
+ progress_bar: bool = False,
29
+ ):
30
+ """
31
+ For best performance, we recommend using unbuffered shard IO in combination with buffered individual file section readers.
32
+ In our (limited) benchmarks, this was the fastest configuration for reading many small files.
33
+ It it possbile that in other scenarios, buffered shard IO and unbuffered file section readers are faster.
34
+ """
35
+ self._needs_open = [isinstance(s, str | os.PathLike) for s in shards]
36
+ self._file_reader = BufferedReader if buffered_file_reader else lambda x: x
37
+ open_fn = (
38
+ open_fn or ThreadSafeFileIO
39
+ ) # In our benchmarks, `ThreadSafeFileIO` is even faster than `partial(open, mode="rb", buffering=0)`. Likely due to `pread` being fewer syscalls than `seek` + `read`.
40
+ self._shard_file_objs: IO[bytes] = [
41
+ open_fn(tar) if needs_open else tar
42
+ for tar, needs_open in zip(shards, self._needs_open, strict=True)
43
+ ]
44
+ if progress_bar:
45
+ from tqdm import tqdm
46
+ else:
47
+ tqdm = lambda x, **kwargs: x # noqa: E731
48
+ self._index = (
49
+ index
50
+ if index is not None
51
+ else {
52
+ name: (i, member)
53
+ for i, file_obj in enumerate(
54
+ tqdm(self._shard_file_objs, desc="Building index", unit="shard")
55
+ )
56
+ for name, member in build_tar_index(file_obj).items()
57
+ }
58
+ )
59
+
60
+ @classmethod
61
+ def open(
62
+ cls,
63
+ path: str | os.PathLike,
64
+ shards: list[str | os.PathLike] | None = None,
65
+ open_fn: Callable[[str | os.PathLike], IO[bytes]] = None,
66
+ buffered_file_reader: bool = True,
67
+ ):
68
+ import msgpack
69
+
70
+ path = Path(path)
71
+ with open(path, "rb") as f:
72
+ num_shards, index = msgpack.load(f)
73
+ return cls(
74
+ shards
75
+ if shards is not None
76
+ else [cls.shard_path(path, num_shards, i) for i in range(num_shards)],
77
+ index,
78
+ open_fn=open_fn,
79
+ buffered_file_reader=buffered_file_reader,
80
+ )
81
+
82
+ def save(self, path: str | os.PathLike):
83
+ import msgpack
84
+
85
+ path = Path(path)
86
+ with open(path, "wb") as f:
87
+ msgpack.dump((len(self._shard_file_objs), self.index), f)
88
+
89
+ @staticmethod
90
+ def shard_path(path: str | os.PathLike, num_shards: int, shard_idx: int) -> Path:
91
+ path = Path(path)
92
+ return path.parent / f"{path.stem}-{shard_idx:0{len(str(num_shards - 1))}d}.tar"
93
+
94
+ def file(self, name: str) -> IO[bytes]:
95
+ i, member = self._index[name]
96
+ _, offset_data, size = member
97
+ if isinstance(size, str):
98
+ return self.file(size) # symlink or hard link
99
+ return self._file_reader(
100
+ TarFileSectionIO(self._shard_file_objs[i], offset_data, size)
101
+ )
102
+
103
+ def info(self, name: str) -> TarInfo:
104
+ i, member = self._index[name]
105
+ offset, _, _ = member
106
+ return tar_file_info(offset, self._shard_file_objs[i])
107
+
108
+ def check_tar_index(self, names: list[str] | None = None):
109
+ for name in names if names is not None else self:
110
+ i, member = self._index[name]
111
+ check_tar_index(name, member, self._shard_file_objs[i])
112
+
113
+ @property
114
+ def index(self) -> ShardedTarIndex:
115
+ return self._index
116
+
117
+ def close(self):
118
+ for needed_open, file_obj in zip(
119
+ self._needs_open, self._shard_file_objs, strict=True
120
+ ):
121
+ if needed_open:
122
+ # only close what we opened
123
+ file_obj.close()
124
+
125
+ def __enter__(self):
126
+ return self
127
+
128
+ def __exit__(self, exc_type, exc_value, traceback):
129
+ self.close()
130
+
131
+ def __getitem__(self, name: str):
132
+ return self.file(name)
133
+
134
+ def __contains__(self, name: str) -> bool:
135
+ return name in self._index
136
+
137
+ def __iter__(self):
138
+ return iter(self._index)
139
+
140
+ def __len__(self):
141
+ return len(self._index)
142
+
143
+ def keys(self):
144
+ return self._index.keys()
145
+
146
+ def values(self):
147
+ for name in self._index:
148
+ yield self[name]
149
+
150
+ def items(self):
151
+ for name in self._index:
152
+ yield (name, self[name])
153
+
154
+
155
+ def cli():
156
+ import argparse
157
+
158
+ parser = argparse.ArgumentParser(description="Create or check an itar index.")
159
+ subparsers = parser.add_subparsers(dest="command")
160
+
161
+ create_parser = subparsers.add_parser("create", help="Create an itar index")
162
+ create_parser.add_argument(
163
+ "itar", type=Path, help="Path to the new itar index that will be created"
164
+ )
165
+
166
+ check_parser = subparsers.add_parser("check", help="Check an existing itar index")
167
+ check_parser.add_argument("itar", type=Path, help="Path to the itar index")
168
+
169
+ ls_parser = subparsers.add_parser("ls", help="List files in an itar index")
170
+ ls_parser.add_argument("itar", type=Path, help="Path to the itar index")
171
+ ls_parser.add_argument(
172
+ "-l", "--long", action="store_true", help="Show long listing format"
173
+ )
174
+ ls_parser.add_argument(
175
+ "-H", "--human-readable", action="store_true", help="Use human-readable sizes"
176
+ )
177
+
178
+ args = parser.parse_args()
179
+
180
+ if args.command == "create":
181
+ _create(args)
182
+ elif args.command == "check":
183
+ _check(args)
184
+ elif args.command == "ls":
185
+ _ls(args)
186
+
187
+
188
+ def _create(args):
189
+ import re
190
+
191
+ itar_path = Path(args.itar)
192
+ stem = itar_path.stem
193
+ pattern = re.compile(rf"^{re.escape(stem)}-\d+\.tar$")
194
+ shards = sorted(
195
+ [
196
+ s
197
+ for s in itar_path.parent.glob(f"{itar_path.stem}-*.tar")
198
+ if s.is_file() and pattern.match(s.name)
199
+ ]
200
+ )
201
+ num_shards = len(shards)
202
+ if num_shards < 1:
203
+ print(
204
+ f"No shards found for {itar_path}.\n"
205
+ f"Please create shard files first. Expected pattern: "
206
+ f"'{itar_path.stem}-NN.tar' (where NN is the zero-padded shard index, starting from 0)."
207
+ )
208
+ exit(1)
209
+
210
+ # ensure shards are named correctly
211
+ expected = [
212
+ ShardedIndexedTar.shard_path(itar_path, num_shards, i)
213
+ for i in range(num_shards)
214
+ ]
215
+ assert shards == expected, (
216
+ f"Shards do not match expected names: {shards} != {expected}"
217
+ )
218
+
219
+ with ShardedIndexedTar(shards, progress_bar=True) as itar:
220
+ itar.save(itar_path)
221
+ print(f"Created itar index at {itar_path} with {num_shards} shards.")
222
+
223
+
224
+ def _check(args):
225
+ from tqdm import tqdm
226
+
227
+ did_error = False
228
+
229
+ with ShardedIndexedTar.open(args.itar) as itar:
230
+ for member in tqdm(itar, desc="Checking files", unit="file"):
231
+ try:
232
+ itar.check_tar_index([member])
233
+ except TarIndexError as e:
234
+ print(e)
235
+ did_error = True
236
+
237
+ if did_error:
238
+ exit(1)
239
+
240
+
241
+ def _ls(args):
242
+ with ShardedIndexedTar.open(args.itar) as itar:
243
+ index = itar.index
244
+ if args.long:
245
+ for member, (shard_idx, (offset, offset_data, size)) in index.items():
246
+ if args.human_readable:
247
+ from humanize import naturalsize
248
+
249
+ size = naturalsize(size, gnu=True)
250
+ print(
251
+ f"{member:<40} {shard_idx:>5} {offset:>12} {offset_data:>12} {size:>10}"
252
+ )
253
+ print(f"{'NAME':<40} {'SHARD':>5} {'OFFSET':>12} {'OFF_DATA':>12} {'SIZE':>10}")
254
+ else:
255
+ for member in index:
256
+ print(member)
@@ -0,0 +1,230 @@
1
+ import io
2
+ import os
3
+ import tarfile
4
+ import threading
5
+ from contextlib import nullcontext
6
+ from tarfile import TarFile, TarInfo
7
+ from types import SimpleNamespace
8
+ from typing import IO, BinaryIO
9
+
10
+ TarMember = tuple[int, int, int | str] # (offset, offset_data, size | linkname)
11
+
12
+
13
+ def tar_file_info(offset: int, file_obj: IO[bytes]) -> TarInfo:
14
+ file_obj.seek(offset)
15
+ return TarInfo.fromtarfile(
16
+ # want to avoid creating a new TarFile instance (potentially slow)
17
+ SimpleNamespace(
18
+ fileobj=file_obj,
19
+ # would be the defaults after TarFile.__init__
20
+ encoding=TarFile.encoding,
21
+ errors="surrogateescape",
22
+ pax_headers={},
23
+ )
24
+ )
25
+
26
+
27
+ def tarinfo2member(tarinfo: TarInfo) -> TarMember:
28
+ if tarinfo.issym():
29
+ size = os.path.normpath(
30
+ "/".join(filter(None, (os.path.dirname(tarinfo.name), tarinfo.linkname)))
31
+ )
32
+ elif tarinfo.islnk():
33
+ size = os.path.normpath(tarinfo.linkname)
34
+ else:
35
+ size = tarinfo.size
36
+
37
+ if tarinfo.sparse is not None:
38
+ raise NotImplementedError("Sparse files are not supported")
39
+
40
+ return (tarinfo.offset, tarinfo.offset_data, size)
41
+
42
+
43
+ def build_tar_index(
44
+ tar: str | os.PathLike | IO[bytes] | TarFile,
45
+ ) -> dict[str, TarMember]:
46
+ if isinstance(tar, str | os.PathLike):
47
+ tar = tarfile.open(tar, "r:")
48
+ elif isinstance(tar, TarFile):
49
+ tar = nullcontext(tar)
50
+ else:
51
+ tar.seek(0)
52
+ tar = tarfile.open(fileobj=tar, mode="r:")
53
+
54
+ with tar as f:
55
+ members = {member.name: member for member in f.getmembers()}
56
+ return {
57
+ member.name: tarinfo2member(member)
58
+ for member in members.values()
59
+ if (
60
+ # index only includes files and links. no directories, devices, etc.
61
+ member.isreg()
62
+ or (
63
+ member.type not in tarfile.SUPPORTED_TYPES
64
+ ) # Members with unknown types are treated as regular files.
65
+ or member.issym()
66
+ or member.islnk()
67
+ )
68
+ }
69
+
70
+
71
+ class TarIndexError(Exception):
72
+ pass
73
+
74
+
75
+ def check_tar_index(
76
+ name: str,
77
+ tar_offset: TarMember,
78
+ file_obj: IO[bytes],
79
+ ):
80
+ offset, offset_data, size = tar_offset
81
+ info = tar_file_info(offset, file_obj)
82
+ if (
83
+ info.offset != offset
84
+ or info.name != name
85
+ or info.offset_data != offset_data
86
+ or (info.size != size and not info.islnk() and not info.issym())
87
+ ):
88
+ # TODO: if link, check that linkname is correct
89
+ raise TarIndexError(
90
+ f"Index mismatch: "
91
+ f"expected ({name}, {offset}, {offset_data}, {size}), "
92
+ f"got ({info.name}, {info.offset}, {info.offset_data}, {info.size})"
93
+ )
94
+
95
+
96
+ class ThreadSafeFileIO(io.RawIOBase):
97
+ """
98
+ A thread-safe, file-like object that wraps a file descriptor
99
+ and uses os.pread() for concurrent reads.
100
+
101
+ Each thread has its own seek position.
102
+ """
103
+
104
+ def __init__(self, path: str | os.PathLike):
105
+ self._path = str(path)
106
+ self._fd = os.open(path, os.O_RDONLY)
107
+ self._local = threading.local()
108
+ self._closed = False
109
+
110
+ def _get_pos(self) -> int:
111
+ return getattr(self._local, "pos", 0)
112
+
113
+ def _set_pos(self, val: int) -> None:
114
+ self._local.pos = val
115
+
116
+ def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
117
+ pos = self._get_pos()
118
+ if whence == os.SEEK_SET:
119
+ self._set_pos(offset)
120
+ elif whence == os.SEEK_CUR:
121
+ self._set_pos(pos + offset)
122
+ elif whence == os.SEEK_END:
123
+ end = os.lseek(self._fd, 0, os.SEEK_END)
124
+ self._set_pos(end + offset)
125
+ else:
126
+ raise ValueError(f"Invalid whence: {whence}")
127
+ return self._get_pos()
128
+
129
+ def tell(self) -> int:
130
+ return self._get_pos()
131
+
132
+ def read(self, size: int = -1) -> bytes:
133
+ if size < 0:
134
+ raise NotImplementedError("read(size=-1) not supported")
135
+ pos = self._get_pos()
136
+ data = os.pread(self._fd, size, pos)
137
+ self._set_pos(pos + len(data))
138
+ return data
139
+
140
+ def readable(self) -> bool:
141
+ return True
142
+
143
+ def seekable(self) -> bool:
144
+ return True
145
+
146
+ def writable(self) -> bool:
147
+ return False
148
+
149
+ def close(self) -> None:
150
+ if not self._closed:
151
+ os.close(self._fd)
152
+ self._closed = True
153
+
154
+ def fileno(self) -> int:
155
+ return self._fd
156
+
157
+ @property
158
+ def name(self) -> str: # optional
159
+ return self._path
160
+
161
+ def __enter__(self):
162
+ return self
163
+
164
+ def __exit__(self, exc_type, exc_value, tb):
165
+ self.close()
166
+
167
+
168
+ class TarFileSectionIO(io.RawIOBase):
169
+ def __init__(self, fileobj: BinaryIO, offset: int, size: int):
170
+ self._fileobj = fileobj
171
+ self._start = offset
172
+ self._end = offset + size
173
+ self._pos = 0 # relative position within the slice
174
+
175
+ def read(self, size: int = -1) -> bytes:
176
+ if self._pos >= self._end - self._start:
177
+ return b""
178
+
179
+ abs_pos = self._start + self._pos
180
+ max_len = self._end - (self._start + self._pos)
181
+
182
+ if size < 0 or size > max_len:
183
+ size = max_len
184
+
185
+ self._fileobj.seek(abs_pos)
186
+ data = self._fileobj.read(size)
187
+ self._pos += len(data)
188
+ return data
189
+
190
+ def readinto(self, b: bytearray) -> int:
191
+ data = self.read(len(b))
192
+ n = len(data)
193
+ b[:n] = data
194
+ return n
195
+
196
+ def readall(self) -> bytes:
197
+ return self.read(-1)
198
+
199
+ def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
200
+ if whence == io.SEEK_SET:
201
+ new_pos = offset
202
+ elif whence == io.SEEK_CUR:
203
+ new_pos = self._pos + offset
204
+ elif whence == io.SEEK_END:
205
+ new_pos = (self._end - self._start) + offset
206
+ else:
207
+ raise ValueError(f"Invalid whence: {whence}")
208
+
209
+ if new_pos < 0:
210
+ raise ValueError("Negative seek position")
211
+ self._pos = min(new_pos, self._end - self._start)
212
+ return self._pos
213
+
214
+ def tell(self) -> int:
215
+ return self._pos
216
+
217
+ def readable(self) -> bool:
218
+ return True
219
+
220
+ def seekable(self) -> bool:
221
+ return True
222
+
223
+ def writable(self) -> bool:
224
+ return False
225
+
226
+ def close(self) -> None:
227
+ pass # Don't close the underlying file
228
+
229
+ def __len__(self) -> int:
230
+ return self._end - self._start