From dc5b09048d642d012618db37585c7d0edb8153d1 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 2 Oct 2026 16:29:33 +0200 Subject: [PATCH] feat: add Sha256Str type --- HISTORY.md | 1 + pydantic_extra_types/sha256.py | 48 ++++++++++++++++++++++++++++++++++ tests/test_json_schema.py | 16 ++++++++++++ tests/test_sha256.py | 45 +++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 pydantic_extra_types/sha256.py create mode 100644 tests/test_sha256.py diff --git a/HISTORY.md b/HISTORY.md index a7fc61f..6c37226 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,7 @@ ## Latest Changes +* feat(sha256): add `Sha256Str` for hex-encoded SHA-256 digests, normalized to lowercase. * fix(pendulum_dt): stop `DateTime` defaulting a timezone-naive input to UTC; it now stays naive, matching pydantic's stdlib `datetime` contract. * fix(mac_address): reject MAC octets with a sign or whitespace (e.g. `-a:...`, `+a:...`, ` a:...`) that `int(..., 16)` accepted. diff --git a/pydantic_extra_types/sha256.py b/pydantic_extra_types/sha256.py new file mode 100644 index 0000000..4a7086d --- /dev/null +++ b/pydantic_extra_types/sha256.py @@ -0,0 +1,48 @@ +"""The `pydantic_extra_types.sha256` module provides the `Sha256Str` data type. + +A SHA-256 digest is 32 bytes long and is usually written as 64 hexadecimal characters, +like the output of `hashlib.sha256(...).hexdigest()` or `sha256sum`. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema + + +class Sha256Str(str): + """A hex-encoded SHA-256 digest, normalized to lowercase. + + ```py + from pydantic import BaseModel + + from pydantic_extra_types.sha256 import Sha256Str + + + class Artifact(BaseModel): + checksum: Sha256Str + + + artifact = Artifact(checksum='E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855') + print(artifact) + # > checksum='e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ``` + """ + + @classmethod + def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema: + """Return a Pydantic CoreSchema with the SHA-256 digest validation. + + Args: + source: The source type to be converted. + handler: The handler to get the CoreSchema. + + Returns: + A Pydantic CoreSchema with the SHA-256 digest validation. + """ + return core_schema.no_info_after_validator_function( + cls, + core_schema.str_schema(pattern=r'^[0-9a-fA-F]{64}$', strip_whitespace=True, to_lower=True), + ) diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py index 6bf1bf2..4656f4d 100644 --- a/tests/test_json_schema.py +++ b/tests/test_json_schema.py @@ -27,6 +27,7 @@ from pydantic_extra_types.s3 import S3Path from pydantic_extra_types.script_code import ISO_15924 from pydantic_extra_types.semantic_version import SemanticVersion from pydantic_extra_types.semver import _VersionPydanticAnnotation +from pydantic_extra_types.sha256 import Sha256Str from pydantic_extra_types.timezone_name import TimeZoneName from pydantic_extra_types.ulid import ULID @@ -318,6 +319,21 @@ USNumberE164 = Annotated[ 'type': 'object', }, ), + ( + Sha256Str, + { + 'properties': { + 'x': { + 'pattern': '^[0-9a-fA-F]{64}$', + 'title': 'X', + 'type': 'string', + } + }, + 'required': ['x'], + 'title': 'Model', + 'type': 'object', + }, + ), ( DateTime, { diff --git a/tests/test_sha256.py b/tests/test_sha256.py new file mode 100644 index 0000000..b05c5fb --- /dev/null +++ b/tests/test_sha256.py @@ -0,0 +1,45 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from pydantic_extra_types.sha256 import Sha256Str + +EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + + +class Artifact(BaseModel): + checksum: Sha256Str + + +@pytest.mark.parametrize('value', [EMPTY_SHA256, EMPTY_SHA256.upper(), f' {EMPTY_SHA256}\n']) +def test_valid_sha256(value: str) -> None: + checksum = Artifact(checksum=value).checksum + assert checksum == EMPTY_SHA256 + assert isinstance(checksum, Sha256Str) + + +def test_sha256_from_json() -> None: + artifact = Artifact.model_validate_json(f'{{"checksum": "{EMPTY_SHA256.upper()}"}}') + assert artifact.checksum == EMPTY_SHA256 + assert artifact.model_dump_json() == f'{{"checksum":"{EMPTY_SHA256}"}}' + + +@pytest.mark.parametrize( + 'value', + [ + '', + EMPTY_SHA256[:-1], + EMPTY_SHA256 + '0', + 'g' + EMPTY_SHA256[1:], + '0x' + EMPTY_SHA256[2:], + EMPTY_SHA256[:30] + ' ' + EMPTY_SHA256[31:], + EMPTY_SHA256[:-1] + '\u0663', + ], +) +def test_invalid_sha256(value: str) -> None: + with pytest.raises(ValidationError, match='string_pattern_mismatch'): + Artifact(checksum=value) + + +def test_sha256_not_a_string() -> None: + with pytest.raises(ValidationError, match='string_type'): + Artifact(checksum=1234)