Skip to content

Commit 2fe4341

Browse files
Merge pull request #14 from p2p-ld/fix-hash-printing
print hashes as strings always
2 parents dccd9fa + f51c0fd commit 2fe4341

File tree

9 files changed

+1143
-858
lines changed

9 files changed

+1143
-858
lines changed

.github/workflows/benchmark.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
python-version: ${{ matrix.python-version }}
2020
- name: Install deps
2121
run: |
22-
python -m pip install -e .[tests]
22+
python -m pip install -e .[benchmark]
2323
- name: Run benchmarks
2424
run: |
2525
python -m pytest tests/bench.py --codspeed -vv

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Upcoming
4+
5+
- Fix serialization of hashes in `print` mode, which now prints the hex-encoded version everywhere
6+
37
## v0.3.*
48

59
### v0.3.3 - 2025-09-30

pdm.lock

Lines changed: 1076 additions & 842 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,13 @@ tests = [
5050
"torrent-models[cli,libtorrent]",
5151
"pytest>=8.3.5",
5252
"pytest-cov>=6.0.0",
53-
"pytest-codspeed>=3.2.0",
5453
"torf>=4.3.0",
5554
"pytest-profiling>=1.8.1",
5655
]
56+
benchmark = [
57+
"torrent-models[tests]",
58+
"pytest-codspeed>=3.2.0",
59+
]
5760
dev = [
5861
"torrent-models[tests,mypy]",
5962
"ruff>=0.11.2",
@@ -185,7 +188,7 @@ line-length = 100
185188
mypy_path = "$MYPY_CONFIG_FILE_DIR/src,$MYPY_CONFIG_FILE_DIR/stubs"
186189
packages = ["torrent_models"]
187190
warn_redundant_casts = true
188-
warn_unused_ignores = true
191+
warn_unused_ignores = false
189192
show_error_context = true
190193
show_column_numbers = true
191194
show_error_code_links = true

src/torrent_models/types/common.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from urllib.parse import quote
77

88
from annotated_types import Ge, Len
9-
from pydantic import AfterValidator, AnyUrl, BaseModel, Field
9+
from pydantic import AfterValidator, AnyUrl, BaseModel, Field, PlainSerializer, SerializationInfo
1010

1111
from torrent_models.base import ConfiguredBase
1212
from torrent_models.types.serdes import ByteStr
@@ -29,8 +29,20 @@ class TorrentVersion(StrEnum):
2929
"""Placeholder in case specific validation is needed for filenames"""
3030
FilePart: TypeAlias = ByteStr
3131
"""Placeholder in case specific validation is needed for filenames"""
32-
SHA1Hash = Annotated[bytes, Len(20, 20)]
33-
SHA256Hash = Annotated[bytes, Len(32, 32)]
32+
33+
34+
def _serialize_hash(value: bytes, info: SerializationInfo) -> bytes | str:
35+
if info.context and info.context.get("mode") == "print":
36+
v = value.hex()
37+
if info.context.get("hash_truncate"):
38+
v = v[0:8]
39+
return v
40+
else:
41+
return value
42+
43+
44+
SHA1Hash = Annotated[bytes, Len(20, 20), PlainSerializer(_serialize_hash)]
45+
SHA256Hash = Annotated[bytes, Len(32, 32), PlainSerializer(_serialize_hash)]
3446

3547

3648
TrackerFields = TypedDict(

src/torrent_models/types/v2.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
from functools import cached_property
1010
from math import ceil
1111
from pathlib import Path
12-
from typing import TYPE_CHECKING, Annotated, Any, NotRequired, TypeAlias, cast
12+
from typing import TYPE_CHECKING, Annotated, Any, Callable, NotRequired, TypeAlias, cast
1313
from typing import Literal as L
1414

15-
from pydantic import AfterValidator, BaseModel, BeforeValidator, PlainSerializer
15+
from pydantic import AfterValidator, BaseModel, BeforeValidator, WrapSerializer
1616
from pydantic_core.core_schema import SerializationInfo
1717
from typing_extensions import TypeAliasType, TypedDict
1818

@@ -44,12 +44,11 @@ def _validate_v2_hash(value: bytes | list[bytes]) -> list[bytes]:
4444
return value
4545

4646

47-
def _serialize_v2_hash(value: list[bytes], info: SerializationInfo) -> bytes | str | list[str]:
47+
def _serialize_v2_hash(
48+
value: list[SHA256Hash], handler: Callable, info: SerializationInfo
49+
) -> bytes | list[str]:
50+
ret = handler(value)
4851
if info.context and info.context.get("mode") == "print":
49-
ret = [v.hex() if isinstance(v, bytes) else v for v in value]
50-
51-
if info.context.get("hash_truncate"):
52-
ret = [v[0:8] for v in ret]
5352
return ret
5453
else:
5554
return b"".join(value)
@@ -66,7 +65,7 @@ def _sort_keys(value: dict) -> dict:
6665

6766

6867
PieceLayerItem = Annotated[
69-
list[SHA256Hash], BeforeValidator(_validate_v2_hash), PlainSerializer(_serialize_v2_hash)
68+
list[SHA256Hash], BeforeValidator(_validate_v2_hash), WrapSerializer(_serialize_v2_hash)
7069
]
7170
PieceLayersType = dict[SHA256Hash, PieceLayerItem]
7271
FileTreeItem = TypedDict("FileTreeItem", {"length": int, "pieces root": NotRequired[SHA256Hash]})

tests/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1+
import sys
12
from pathlib import Path
23

4+
import pytest
5+
from _pytest.python import Function
6+
37
from .fixtures import *
48

59
DATA_DIR = Path(__file__).parent / "data"
10+
11+
12+
def pytest_collection_modifyitems(config: pytest.Config, items: list[Function]) -> None:
13+
# don't run libtorrent tests on windows
14+
if sys.platform == "win32":
15+
skip_lt = pytest.mark.skip(reason="libtorrent python wheels are bugged in windows!")
16+
for item in items:
17+
if item.get_closest_marker("libtorrent"):
18+
item.add_marker(skip_lt)

tests/test_torrent/test_torrent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from pathlib import Path
22

3-
import libtorrent
43
import pytest
54

65
from torrent_models import Torrent
@@ -15,6 +14,7 @@ def test_parse_hybrid():
1514
torrent = Torrent.read("tests/data/qbt_directory_hybrid.torrent")
1615

1716

17+
@pytest.mark.libtorrent
1818
@pytest.mark.parametrize(
1919
"tfile",
2020
[pytest.param(tf, id=str(tf.name)) for tf in ALL_TORRENTS],
@@ -23,6 +23,8 @@ def test_infohash(tfile: Path):
2323
"""
2424
Test that the infohash that we get from a torrent is the same as what libtorrent would compute
2525
"""
26+
import libtorrent
27+
2628
lt_torrent = libtorrent.load_torrent_file(str(tfile))
2729
t = Torrent.read(tfile)
2830

tests/test_types.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
import pytest
55

6-
from torrent_models import KiB, TorrentCreate
6+
from torrent_models import KiB, Torrent, TorrentCreate
7+
8+
from .conftest import DATA_DIR
79

810

911
@pytest.mark.parametrize("version", ["v1", "v2"])
@@ -61,3 +63,19 @@ def test_webseed_url_multifile(version: str, tmp_path: Path):
6163
# ensure name is prepended to directories
6264
assert prange.webseed_url("https://example.com/data/") == ws_expected
6365
assert prange.webseed_url("https://example.com/data") == ws_expected
66+
67+
68+
def test_print_hash():
69+
"""
70+
When serialized in `print` mode, hashes should be serialized as strings everywhere
71+
"""
72+
t = Torrent.read(DATA_DIR / "qbt_directory_hybrid.torrent")
73+
dumped = t.model_dump(context={"mode": "print"})
74+
assert all([isinstance(p, str) for p in dumped["info"]["pieces"]])
75+
assert all(
76+
[isinstance(f[""]["pieces root"], str) for f in dumped["info"]["file_tree"].values()]
77+
)
78+
assert all([isinstance(k, str) for k in dumped["piece_layers"]])
79+
for k, v in dumped["piece_layers"].items():
80+
assert isinstance(k, str)
81+
assert all([isinstance(v_hash, str) for v_hash in v])

0 commit comments

Comments
 (0)