torrent-models 0.0.2__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.
- torrent_models/__init__.py +64 -0
- torrent_models/base.py +7 -0
- torrent_models/cli/__init__.py +12 -0
- torrent_models/cli/main.py +253 -0
- torrent_models/compat.py +18 -0
- torrent_models/const.py +21 -0
- torrent_models/create.py +381 -0
- torrent_models/hashing/__init__.py +6 -0
- torrent_models/hashing/base.py +315 -0
- torrent_models/hashing/hybrid.py +117 -0
- torrent_models/hashing/v1.py +91 -0
- torrent_models/hashing/v2.py +162 -0
- torrent_models/info.py +202 -0
- torrent_models/libtorrent.py +122 -0
- torrent_models/testing/__init__.py +15 -0
- torrent_models/testing/fabricators.py +90 -0
- torrent_models/torrent.py +307 -0
- torrent_models/types/__init__.py +61 -0
- torrent_models/types/common.py +66 -0
- torrent_models/types/serdes.py +127 -0
- torrent_models/types/v1.py +49 -0
- torrent_models/types/v2.py +405 -0
- torrent_models-0.0.2.dist-info/METADATA +100 -0
- torrent_models-0.0.2.dist-info/RECORD +27 -0
- torrent_models-0.0.2.dist-info/WHEEL +4 -0
- torrent_models-0.0.2.dist-info/entry_points.txt +7 -0
- torrent_models-0.0.2.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from torrent_models.const import BLOCK_SIZE, DEFAULT_TORRENT_CREATOR, GiB, KiB, MiB, TiB, size
|
|
2
|
+
from torrent_models.types import (
|
|
3
|
+
FileItem,
|
|
4
|
+
FileTree,
|
|
5
|
+
FileTreeItem,
|
|
6
|
+
FileTreeType,
|
|
7
|
+
MerkleTree,
|
|
8
|
+
MerkleTreeShape,
|
|
9
|
+
PieceLayerItem,
|
|
10
|
+
PieceLayers,
|
|
11
|
+
PieceLayersType,
|
|
12
|
+
Pieces,
|
|
13
|
+
SHA1Hash,
|
|
14
|
+
SHA256Hash,
|
|
15
|
+
TorrentVersion,
|
|
16
|
+
V1PieceLength,
|
|
17
|
+
V2PieceLength,
|
|
18
|
+
)
|
|
19
|
+
from torrent_models.info import (
|
|
20
|
+
InfoDictHybrid,
|
|
21
|
+
InfoDictHybridCreate,
|
|
22
|
+
InfoDictRoot,
|
|
23
|
+
InfoDictV1,
|
|
24
|
+
InfoDictV1Create,
|
|
25
|
+
InfoDictV2,
|
|
26
|
+
InfoDictV2Create,
|
|
27
|
+
)
|
|
28
|
+
from torrent_models.torrent import Torrent, pprint
|
|
29
|
+
from torrent_models.create import TorrentCreate
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BLOCK_SIZE",
|
|
33
|
+
"DEFAULT_TORRENT_CREATOR",
|
|
34
|
+
"GiB",
|
|
35
|
+
"KiB",
|
|
36
|
+
"MiB",
|
|
37
|
+
"TiB",
|
|
38
|
+
"FileItem",
|
|
39
|
+
"FileTree",
|
|
40
|
+
"FileTreeItem",
|
|
41
|
+
"FileTreeType",
|
|
42
|
+
"InfoDictHybrid",
|
|
43
|
+
"InfoDictHybridCreate",
|
|
44
|
+
"InfoDictRoot",
|
|
45
|
+
"InfoDictV1",
|
|
46
|
+
"InfoDictV1Create",
|
|
47
|
+
"InfoDictV2",
|
|
48
|
+
"InfoDictV2Create",
|
|
49
|
+
"MerkleTree",
|
|
50
|
+
"MerkleTreeShape",
|
|
51
|
+
"PieceLayerItem",
|
|
52
|
+
"PieceLayers",
|
|
53
|
+
"PieceLayersType",
|
|
54
|
+
"Pieces",
|
|
55
|
+
"SHA1Hash",
|
|
56
|
+
"SHA256Hash",
|
|
57
|
+
"Torrent",
|
|
58
|
+
"TorrentCreate",
|
|
59
|
+
"TorrentVersion",
|
|
60
|
+
"V1PieceLength",
|
|
61
|
+
"V2PieceLength",
|
|
62
|
+
"size",
|
|
63
|
+
"pprint",
|
|
64
|
+
]
|
torrent_models/base.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from importlib.util import find_spec
|
|
3
|
+
|
|
4
|
+
__all__ = ["main"]
|
|
5
|
+
|
|
6
|
+
if find_spec("click"):
|
|
7
|
+
|
|
8
|
+
from torrent_models.cli.main import main
|
|
9
|
+
else:
|
|
10
|
+
warnings.warn(
|
|
11
|
+
"cli dependencies are not installed - install with torrent-models[cli]", stacklevel=2
|
|
12
|
+
)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from time import time
|
|
4
|
+
from typing import Literal as L
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import humanize
|
|
9
|
+
from rich import print
|
|
10
|
+
from rich.console import Group
|
|
11
|
+
from rich.pretty import Pretty
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from torrent_models import TorrentCreate
|
|
15
|
+
from torrent_models.compat import get_size
|
|
16
|
+
from torrent_models.const import DEFAULT_TORRENT_CREATOR
|
|
17
|
+
from torrent_models.create import list_files
|
|
18
|
+
from torrent_models.info import InfoDictHybrid, InfoDictHybridCreate, InfoDictV1, InfoDictV2
|
|
19
|
+
from torrent_models.torrent import Torrent
|
|
20
|
+
from torrent_models.types.common import TorrentVersion
|
|
21
|
+
from torrent_models.types.v1 import FileItem, V1PieceLength
|
|
22
|
+
from torrent_models.types.v2 import V2PieceLength
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group("torrent")
|
|
26
|
+
def main() -> None:
|
|
27
|
+
"""
|
|
28
|
+
torrent-models CLI
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@main.command("make")
|
|
33
|
+
@click.option(
|
|
34
|
+
"-p",
|
|
35
|
+
"--path",
|
|
36
|
+
required=True,
|
|
37
|
+
help="Path to a directory or file to create .torrent from",
|
|
38
|
+
type=click.Path(exists=True),
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"-t",
|
|
42
|
+
"--tracker",
|
|
43
|
+
required=False,
|
|
44
|
+
default=None,
|
|
45
|
+
multiple=True,
|
|
46
|
+
help="Trackers to add to the torrent. can be used multiple times for multiple trackers. ",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"-s",
|
|
50
|
+
"--piece-size",
|
|
51
|
+
default=512 * (2**10),
|
|
52
|
+
help="Piece size, in bytes",
|
|
53
|
+
show_default=True,
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--comment",
|
|
57
|
+
default=None,
|
|
58
|
+
required=False,
|
|
59
|
+
help="Optional comment field for torrent",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--creator",
|
|
63
|
+
default=DEFAULT_TORRENT_CREATOR,
|
|
64
|
+
show_default=True,
|
|
65
|
+
required=False,
|
|
66
|
+
help="Optional creator field for torrent",
|
|
67
|
+
)
|
|
68
|
+
@click.option(
|
|
69
|
+
"-w",
|
|
70
|
+
"--webseed",
|
|
71
|
+
required=False,
|
|
72
|
+
default=None,
|
|
73
|
+
multiple=True,
|
|
74
|
+
help="Add HTTP webseeds as additional sources for torrent. Can be used multiple times. "
|
|
75
|
+
"See https://www.bittorrent.org/beps/bep_0019.html",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--similar",
|
|
79
|
+
required=False,
|
|
80
|
+
default=None,
|
|
81
|
+
multiple=True,
|
|
82
|
+
help="Add infohash of a similar torrent. "
|
|
83
|
+
"Similar torrents are torrents who have files in common with this torrent, "
|
|
84
|
+
"clients are able to reuse files from the other torrents if they already have them downloaded.",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--version",
|
|
88
|
+
default="hybrid",
|
|
89
|
+
type=click.Choice(["v1", "v2", "hybrid"]),
|
|
90
|
+
help="What kind of torrent to create, default is hybrid",
|
|
91
|
+
)
|
|
92
|
+
@click.option("--progress/--no-progress", default=True, help="Enable progress bar (default True)")
|
|
93
|
+
@click.option(
|
|
94
|
+
"-o",
|
|
95
|
+
"--output",
|
|
96
|
+
required=False,
|
|
97
|
+
default=None,
|
|
98
|
+
type=click.Path(exists=False),
|
|
99
|
+
help=".torrent file to write to. Otherwise to stdout",
|
|
100
|
+
)
|
|
101
|
+
def make(
|
|
102
|
+
path: Path,
|
|
103
|
+
tracker: list[str] | tuple[str] | None = None,
|
|
104
|
+
piece_size: V1PieceLength | V2PieceLength = 512 * (2**10),
|
|
105
|
+
comment: str | None = None,
|
|
106
|
+
creator: str = DEFAULT_TORRENT_CREATOR,
|
|
107
|
+
webseed: list[str] | None = None,
|
|
108
|
+
similar: list[str] | None = None,
|
|
109
|
+
version: L["v1", "v2", "hybrid"] = "hybrid",
|
|
110
|
+
progress: bool = True,
|
|
111
|
+
output: Path | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
path = Path(path)
|
|
114
|
+
files = list_files(path)
|
|
115
|
+
start_time = time()
|
|
116
|
+
created = TorrentCreate(
|
|
117
|
+
trackers=tracker,
|
|
118
|
+
files=files,
|
|
119
|
+
path_root=path,
|
|
120
|
+
comment=comment,
|
|
121
|
+
creator=creator,
|
|
122
|
+
info=InfoDictHybridCreate(piece_length=piece_size, name=path.name),
|
|
123
|
+
)
|
|
124
|
+
generated = created.generate(version=version, progress=progress)
|
|
125
|
+
bencoded = generated.bencode()
|
|
126
|
+
if output:
|
|
127
|
+
with open(output, "wb") as f:
|
|
128
|
+
f.write(bencoded)
|
|
129
|
+
|
|
130
|
+
torrent_size = get_size(Path(output))
|
|
131
|
+
|
|
132
|
+
end_time = time()
|
|
133
|
+
duration = end_time - start_time
|
|
134
|
+
total_size = generated.info.total_length
|
|
135
|
+
speed = total_size / duration
|
|
136
|
+
click.echo(
|
|
137
|
+
f"Created torrent {output}\n"
|
|
138
|
+
f"Total size: {humanize.naturalsize(total_size, binary=True)}\n"
|
|
139
|
+
f"Torrent size: {humanize.naturalsize(torrent_size, binary=True)}\n"
|
|
140
|
+
f"Duration: {humanize.naturaldelta(timedelta(seconds=duration))}\n"
|
|
141
|
+
f"Speed: {humanize.naturalsize(speed, binary=True)}/s"
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
click.echo(bencoded)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@main.command("print")
|
|
148
|
+
@click.argument("torrent", type=click.Path(exists=True))
|
|
149
|
+
@click.option(
|
|
150
|
+
"-v",
|
|
151
|
+
"--verbose",
|
|
152
|
+
count=True,
|
|
153
|
+
help="""
|
|
154
|
+
Verbose output, repeat for increasing verbosity\n
|
|
155
|
+
|
|
156
|
+
-v show files in separate table
|
|
157
|
+
-vv show truncated v1 piece hashes
|
|
158
|
+
-vvv show everything as-is
|
|
159
|
+
""",
|
|
160
|
+
)
|
|
161
|
+
def pprint(torrent: Path, verbose: int = 0) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Print the contents of a torrent file.
|
|
164
|
+
|
|
165
|
+
By default, prints only the top-level metadata in a way that should always be
|
|
166
|
+
smaller than one screen.
|
|
167
|
+
|
|
168
|
+
Increase verbosity to show more of the torrent.
|
|
169
|
+
|
|
170
|
+
Hashes are printed as hexadecimal numbers and split into individual pieces,
|
|
171
|
+
but they are properly encoded in the torrent.
|
|
172
|
+
"""
|
|
173
|
+
torrent = Path(torrent)
|
|
174
|
+
t = Torrent.read(torrent)
|
|
175
|
+
# summary stats
|
|
176
|
+
summary = {
|
|
177
|
+
"# Files": humanize.number.intcomma(t.n_files),
|
|
178
|
+
"Total Size": humanize.naturalsize(t.total_size, binary=True),
|
|
179
|
+
"Torrent Size": humanize.naturalsize(get_size(torrent), binary=True),
|
|
180
|
+
"Piece Size": humanize.naturalsize(t.info.piece_length, binary=True),
|
|
181
|
+
}
|
|
182
|
+
v1_infohash = t.v1_infohash
|
|
183
|
+
v2_infohash = t.v2_infohash
|
|
184
|
+
if v1_infohash:
|
|
185
|
+
summary["V1 Infohash"] = v1_infohash.hex()
|
|
186
|
+
if v2_infohash:
|
|
187
|
+
summary["V2 Infohash"] = v2_infohash.hex()
|
|
188
|
+
table = Table(title=str(torrent.name), show_header=False)
|
|
189
|
+
table.add_column("", justify="left", style="magenta bold", no_wrap=True)
|
|
190
|
+
table.add_column("")
|
|
191
|
+
for k, v in summary.items():
|
|
192
|
+
table.add_row(k, v)
|
|
193
|
+
|
|
194
|
+
exclude = {}
|
|
195
|
+
context = {"mode": "print", "hash_truncate": True}
|
|
196
|
+
file_table = None
|
|
197
|
+
if verbose <= 1:
|
|
198
|
+
exclude = {"info": {"pieces", "file tree", "file_tree", "files"}, "piece_layers": True}
|
|
199
|
+
elif verbose <= 2:
|
|
200
|
+
exclude = {"info": {"file tree", "file_tree", "files"}, "piece_layers": True}
|
|
201
|
+
else:
|
|
202
|
+
context["hash_truncate"] = False
|
|
203
|
+
|
|
204
|
+
# make file table
|
|
205
|
+
if 1 <= verbose <= 2:
|
|
206
|
+
file_table = Table(title="Files")
|
|
207
|
+
file_table.add_column("Path", no_wrap=True)
|
|
208
|
+
file_table.add_column("Size")
|
|
209
|
+
|
|
210
|
+
if t.torrent_version == TorrentVersion.v1:
|
|
211
|
+
t.info = cast(InfoDictV1, t.info)
|
|
212
|
+
tfiles = (
|
|
213
|
+
t.info.files
|
|
214
|
+
if t.info.files is not None
|
|
215
|
+
else [FileItem(path=t.info.name, length=t.info.length)]
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
files = [
|
|
219
|
+
("/".join(f.path), humanize.naturalsize(f.length, binary=True), "")
|
|
220
|
+
for f in tfiles
|
|
221
|
+
if f.attr not in (b"p", "p")
|
|
222
|
+
]
|
|
223
|
+
else:
|
|
224
|
+
t.info = cast(InfoDictV2 | InfoDictHybrid, t.info)
|
|
225
|
+
file_table.add_column("Hash")
|
|
226
|
+
tree = t.flat_files
|
|
227
|
+
assert tree is not None
|
|
228
|
+
files = [
|
|
229
|
+
(
|
|
230
|
+
str(k),
|
|
231
|
+
humanize.naturalsize(v["length"], binary=True),
|
|
232
|
+
v["pieces root"].hex()[0:8],
|
|
233
|
+
)
|
|
234
|
+
for k, v in tree.items()
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
for f in files:
|
|
238
|
+
file_table.add_row(*f)
|
|
239
|
+
|
|
240
|
+
dumped = t.model_dump(
|
|
241
|
+
by_alias=True, exclude=exclude, exclude_none=True, context=context # type: ignore
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if verbose < 1 or verbose > 2:
|
|
245
|
+
group = Group(
|
|
246
|
+
table,
|
|
247
|
+
Pretty(dumped),
|
|
248
|
+
)
|
|
249
|
+
elif verbose <= 2:
|
|
250
|
+
assert file_table is not None
|
|
251
|
+
group = Group(table, Pretty(dumped), file_table)
|
|
252
|
+
|
|
253
|
+
print(group)
|
torrent_models/compat.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import os.path
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_size(path: Path) -> int:
|
|
7
|
+
"""
|
|
8
|
+
Windows, helpfully, reports different sizes to stat,
|
|
9
|
+
so we have to use the os.path.getsize() function instead.
|
|
10
|
+
|
|
11
|
+
This *may* not be necessary, but is left in place in case we need
|
|
12
|
+
compatibility for windows sizes in the future,
|
|
13
|
+
which can be impacted by the NTFS filesystem.
|
|
14
|
+
"""
|
|
15
|
+
if sys.platform == "win32":
|
|
16
|
+
return os.path.getsize(path)
|
|
17
|
+
else:
|
|
18
|
+
return path.stat().st_size
|
torrent_models/const.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
DEFAULT_TORRENT_CREATOR = f"torrent-models ({version('torrent-models')})"
|
|
4
|
+
EXCLUDE_FILES = (".DS_Store", "Thumbs.db")
|
|
5
|
+
|
|
6
|
+
KiB = 2**10
|
|
7
|
+
MiB = 2**20
|
|
8
|
+
GiB = 2**30
|
|
9
|
+
TiB = 2**40
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class size:
|
|
13
|
+
"""Convenience accessor/importer for sizes"""
|
|
14
|
+
|
|
15
|
+
KiB = KiB
|
|
16
|
+
MiB = MiB
|
|
17
|
+
GiB = GiB
|
|
18
|
+
TiB = TiB
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
BLOCK_SIZE = 16 * KiB
|