-
Notifications
You must be signed in to change notification settings - Fork 58
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
9585d57
commit 084ce94
Showing
3 changed files
with
300 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters