Skip to content

Commit

Permalink
Add: New type ISBN (#116)
Browse files Browse the repository at this point in the history
* Add: New type ISBN

* ISBN: Fixed linting

* ISBN: Fixed Cov

* ISBN: Fixed format issues

* ISBN: Fixed mypy format logic issues

* ISBN: Simplified validate function

* Update pydantic_extra_types/isbn.py

* Update pydantic_extra_types/isbn.py

* Update test_json_schema.py

* Refactor JSON schema for property 'x'

* Fix ISBN-13 validation in ISBN class

* fix linting issue

---------

Co-authored-by: Yasser Tahiri <yasserth19@gmail.com>
  • Loading branch information
lucasmucidas and yezz123 authored Jan 2, 2024
1 parent 9585d57 commit 084ce94
Show file tree
Hide file tree
Showing 3 changed files with 300 additions and 0 deletions.
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

0 comments on commit 084ce94

Please sign in to comment.