Skip to content

Commit 2e0c8e7

Browse files
print hashes as strings always
1 parent dccd9fa commit 2e0c8e7

File tree

4 files changed

+45
-12
lines changed

4 files changed

+45
-12
lines changed

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

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/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)