Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: New type ISBN #116

Merged
merged 12 commits into from
Jan 2, 2024
131 changes: 131 additions & 0 deletions pydantic_extra_types/isbn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
The `pydantic_extra_types.isbn` module provides functionality to recieve and validate ISBN
(International Standard Book Number) in 10-digit and 13-digit formats. The output is always ISBN-13.
"""

from __future__ import annotations

from typing import Any

from pydantic import GetCoreSchemaHandler
from pydantic_core import PydanticCustomError, core_schema


def isbn10_digit_calc(isbn: str) -> str:
"""
Calc a ISBN-10 last digit from the provided str value. More information of validation algorithm on [Wikipedia](https://en.wikipedia.org/wiki/ISBN#Check_digits)

Args:
value: The str value representing the ISBN in 10 digits.

Returns:
The calculated last digit.
"""
total = sum(int(digit) * (10 - idx) for idx, digit in enumerate(isbn[:9]))

for check_digit in range(1, 11):
if (total + check_digit) % 11 == 0:
valid_check_digit = 'X' if check_digit == 10 else str(check_digit)

return valid_check_digit


def isbn13_digit_calc(isbn: str) -> str:
"""
Calc a ISBN-13 last digit from the provided str value. More information of validation algorithm on [Wikipedia](https://en.wikipedia.org/wiki/ISBN#Check_digits)

Args:
value: The str value representing the ISBN in 13 digits.

Returns:
The calculated last digit.
"""
total = sum(int(digit) * (1 if idx % 2 == 0 else 3) for idx, digit in enumerate(isbn[:12]))

check_digit = (10 - (total % 10)) % 10

return str(check_digit)


class ISBN(str):
"""Represents a ISBN and provides methods for conversion, validation, and serialization.

```py
from pydantic import BaseModel

from pydantic_extra_types.isbn import ISBN


class Book(BaseModel):
isbn: ISBN

book = Book(isbn="8537809667")
print(book)
#> isbn='9788537809662'
```
"""

@classmethod
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.with_info_before_validator_function(
cls._validate,
core_schema.str_schema(),
)

@classmethod
def _validate(cls, __input_value: str, _: Any) -> str:
cls.validate_isbn_format(__input_value)

return cls.convert_isbn10_to_isbn13(__input_value)

@staticmethod
def validate_isbn_format(value: str) -> None:
"""
Validate a ISBN format from the provided str value.

Args:
value: The str value representing the ISBN in 10 or 13 digits.

Raises:
PydanticCustomError: If the value is not a valid ISBN.
"""

isbn_length = len(value)

if isbn_length not in (10, 13):
raise PydanticCustomError('isbn_length', f'Length for ISBN must be 10 or 13 digits, not {isbn_length}')

if isbn_length == 10:
if not value[:-1].isdigit() or ((value[-1] != 'X') and (not value[-1].isdigit())):
raise PydanticCustomError('isbn10_invalid_characters', 'First 9 digits of ISBN-10 must be integers')
if isbn10_digit_calc(value) != value[-1]:
raise PydanticCustomError('isbn_invalid_digit_check_isbn10', 'Provided digit is invalid for given ISBN')

if isbn_length == 13:
if not value.isdigit():
raise PydanticCustomError('isbn13_invalid_characters', 'All digits of ISBN-13 must be integers')
if value[:3] not in ('978', '979'):
raise PydanticCustomError(
'isbn_invalid_early_characters', 'The first 3 digits of ISBN-13 must be 978 or 979'
)
if isbn13_digit_calc(value) != value[-1]:
raise PydanticCustomError('isbn_invalid_digit_check_isbn13', 'Provided digit is invalid for given ISBN')

@staticmethod
def convert_isbn10_to_isbn13(value: str) -> str:
"""
Convert an ISBN-10 to ISBN-13.

Args:
value: The str value representing the ISBN.

Returns:
The converted ISBN or the original value if no conversion is necessary.
"""

if len(value) == 10:
base_isbn = f'978{value[:-1]}'
isbn13_digit = isbn13_digit_calc(base_isbn)
return ISBN(f'{base_isbn}{isbn13_digit}')

return ISBN(value)
154 changes: 154 additions & 0 deletions tests/test_isbn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from typing import Any

import pytest
from pydantic import BaseModel, ValidationError

from pydantic_extra_types.isbn import ISBN


class Book(BaseModel):
isbn: ISBN


isbn_length_test_cases = [
# Valid ISBNs
('8537809667', '9788537809662', True), # ISBN-10 as input
('9788537809662', '9788537809662', True), # ISBN-13 as input
('080442957X', '9780804429573', True), # ISBN-10 ending in "X" as input
('9788584390670', '9788584390670', True), # ISBN-13 Starting with 978
('9790306406156', '9790306406156', True), # ISBN-13 starting with 979
# Invalid ISBNs
('97885843906701', None, False), # Length: 14 (Higher)
('978858439067', None, False), # Length: 12 (In Between)
('97885843906', None, False), # Length: 11 (In Between)
('978858439', None, False), # Length: 9 (Lower)
('', None, False), # Length: 0 (Lower)
]


@pytest.mark.parametrize('input_isbn, output_isbn, valid', isbn_length_test_cases)
def test_isbn_length(input_isbn: Any, output_isbn: str, valid: bool) -> None:
if valid:
assert Book(isbn=ISBN(input_isbn)).isbn == output_isbn
else:
with pytest.raises(ValidationError, match='isbn_length'):
Book(isbn=ISBN(input_isbn))


isbn10_digits_test_cases = [
# Valid ISBNs
('8537809667', '9788537809662', True), # ISBN-10 as input
('080442957X', '9780804429573', True), # ISBN-10 ending in "X" as input
# Invalid ISBNs
('@80442957X', None, False), # Non Integer in [0] position
('8@37809667', None, False), # Non Integer in [1] position
('85@7809667', None, False), # Non Integer in [2] position
('853@809667', None, False), # Non Integer in [3] position
('8537@09667', None, False), # Non Integer in [4] position
('85378@9667', None, False), # Non Integer in [5] position
('853780@667', None, False), # Non Integer in [6] position
('8537809@67', None, False), # Non Integer in [7] position
('85378096@7', None, False), # Non Integer in [8] position
('853780966@', None, False), # Non Integer or X in [9] position
]


@pytest.mark.parametrize('input_isbn, output_isbn, valid', isbn10_digits_test_cases)
def test_isbn10_digits(input_isbn: Any, output_isbn: str, valid: bool) -> None:
if valid:
assert Book(isbn=ISBN(input_isbn)).isbn == output_isbn
else:
with pytest.raises(ValidationError, match='isbn10_invalid_characters'):
Book(isbn=ISBN(input_isbn))


isbn13_digits_test_cases = [
# Valid ISBNs
('9788537809662', '9788537809662', True), # ISBN-13 as input
('9780306406157', '9780306406157', True), # ISBN-13 as input
('9788584390670', '9788584390670', True), # ISBN-13 Starting with 978
('9790306406156', '9790306406156', True), # ISBN-13 starting with 979
# Invalid ISBNs
('@788537809662', None, False), # Non Integer in [0] position
('9@88537809662', None, False), # Non Integer in [1] position
('97@8537809662', None, False), # Non Integer in [2] position
('978@537809662', None, False), # Non Integer in [3] position
('9788@37809662', None, False), # Non Integer in [4] position
('97885@7809662', None, False), # Non Integer in [5] position
('978853@809662', None, False), # Non Integer in [6] position
('9788537@09662', None, False), # Non Integer in [7] position
('97885378@9662', None, False), # Non Integer in [8] position
('978853780@662', None, False), # Non Integer in [9] position
('9788537809@62', None, False), # Non Integer in [10] position
('97885378096@2', None, False), # Non Integer in [11] position
('978853780966@', None, False), # Non Integer in [12] position
]


@pytest.mark.parametrize('input_isbn, output_isbn, valid', isbn13_digits_test_cases)
def test_isbn13_digits(input_isbn: Any, output_isbn: str, valid: bool) -> None:
if valid:
assert Book(isbn=ISBN(input_isbn)).isbn == output_isbn
else:
with pytest.raises(ValidationError, match='isbn13_invalid_characters'):
Book(isbn=ISBN(input_isbn))


isbn13_early_digits_test_cases = [
# Valid ISBNs
('9780306406157', '9780306406157', True), # ISBN-13 as input
('9788584390670', '9788584390670', True), # ISBN-13 Starting with 978
('9790306406156', '9790306406156', True), # ISBN-13 starting with 979
# Invalid ISBNs
('1788584390670', None, False), # Does not start with 978 or 979
('9288584390670', None, False), # Does not start with 978 or 979
('9738584390670', None, False), # Does not start with 978 or 979
]


@pytest.mark.parametrize('input_isbn, output_isbn, valid', isbn13_early_digits_test_cases)
def test_isbn13_early_digits(input_isbn: Any, output_isbn: str, valid: bool) -> None:
if valid:
assert Book(isbn=ISBN(input_isbn)).isbn == output_isbn
else:
with pytest.raises(ValidationError, match='isbn_invalid_early_characters'):
Book(isbn=ISBN(input_isbn))


isbn_last_digit_test_cases = [
# Valid ISBNs
('8537809667', '9788537809662', True), # ISBN-10 as input
('9788537809662', '9788537809662', True), # ISBN-13 as input
('080442957X', '9780804429573', True), # ISBN-10 ending in "X" as input
('9788584390670', '9788584390670', True), # ISBN-13 Starting with 978
('9790306406156', '9790306406156', True), # ISBN-13 starting with 979
# Invalid ISBNs
('8537809663', None, False), # ISBN-10 as input with wrong last digit
('9788537809661', None, False), # ISBN-13 as input with wrong last digit
('080442953X', None, False), # ISBN-10 ending in "X" as input with wrong last digit
('9788584390671', None, False), # ISBN-13 Starting with 978 with wrong last digit
('9790306406155', None, False), # ISBN-13 starting with 979 with wrong last digit
]


@pytest.mark.parametrize('input_isbn, output_isbn, valid', isbn_last_digit_test_cases)
def test_isbn_last_digit(input_isbn: Any, output_isbn: str, valid: bool) -> None:
if valid:
assert Book(isbn=ISBN(input_isbn)).isbn == output_isbn
else:
with pytest.raises(ValidationError, match='isbn_invalid_digit_check_isbn'):
Book(isbn=ISBN(input_isbn))


isbn_conversion_test_cases = [
# Valid ISBNs
('8537809667', '9788537809662'),
('080442957X', '9780804429573'),
('9788584390670', '9788584390670'),
('9790306406156', '9790306406156'),
]


@pytest.mark.parametrize('input_isbn, output_isbn', isbn_conversion_test_cases)
def test_isbn_conversion(input_isbn: Any, output_isbn: str) -> None:
assert Book(isbn=ISBN(input_isbn)).isbn == output_isbn
15 changes: 15 additions & 0 deletions tests/test_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
CountryOfficialName,
CountryShortName,
)
from pydantic_extra_types.isbn import ISBN
from pydantic_extra_types.mac_address import MacAddress
from pydantic_extra_types.payment import PaymentCardNumber
from pydantic_extra_types.ulid import ULID
Expand Down Expand Up @@ -185,6 +186,20 @@
'type': 'object',
},
),
(
ISBN,
{
'properties': {
'x': {
'title': 'X',
'type': 'string',
}
},
'required': ['x'],
'title': 'Model',
'type': 'object',
},
),
],
)
def test_json_schema(cls, expected):
Expand Down