Repository: joke2k/faker
Pull request #471 (2017-03-09 01:26:10)

Implement ISBN
Per discussion in #469 this PR allows faker to support the generation of valid ISBNs. See [ISBN International](https://www.isbn-international.org/content/what-isbn) for an overview of the individual numbers that make up a valid ISBN.
 
An important thing to consider with ISBN is that the hyphenation rules are dynamic, numerous, and subject to change by ISBN International. This PR only implements the ruleset for English language books. If we are interested in getting fancy, we can always utilize other python projects that actually keep up-to-date with information like this.

Example usage:
```python
from faker import Factory
fake = Factory.create()

fake.isbn10()  # '1-67423-180-6'
fake.isbn13()  # '978-0-584-45740-7'
fake.isbn13(separator=' ')  # '978 1 917745 78 9'
```

This is my first ever PR to an open source project, so let me know if anything is out of order.


Patch:
diff --git a/faker/providers/isbn/__init__.py b/faker/providers/isbn/__init__.py
new file mode 100644
index 00000000..ada84898
--- /dev/null
+++ b/faker/providers/isbn/__init__.py
@@ -0,0 +1,72 @@
+# coding=utf-8
+
+from __future__ import unicode_literals
+from .. import BaseProvider
+from .isbn import ISBN, ISBN10, ISBN13
+from .rules import RULES
+
+
+class Provider(BaseProvider):
+    """ Generates fake ISBNs. ISBN rules vary across languages/regions
+    so this class makes no attempt at replicating all of the rules. It
+    only replicates the 978 EAN prefix for the English registration
+    groups, meaning the first 4 digits of the ISBN-13 will either be
+    978-0 or 978-1. Since we are only replicating 978 prefixes, every
+    ISBN-13 will have a direct mapping to an ISBN-10.
+
+    See https://www.isbn-international.org/content/what-isbn for the
+    format of ISBNs.
+    See https://www.isbn-international.org/range_file_generation for the
+    list of rules pertaining to each prefix/registration group.
+    """
+
+    def _body(self):
+        """ Generate the information required to create an ISBN-10 or
+        ISBN-13.
+        """
+        ean = self.random_element(RULES.keys())
+        reg_group = self.random_element(RULES[ean].keys())
+
+        # Given the chosen ean/group, decide how long the
+        #   registrant/publication string may be.
+        # We must allocate for the calculated check digit, so
+        #   subtract 1
+        reg_pub_len = ISBN.MAX_LENGTH - len(ean) - len(reg_group) - 1
+
+        # Generate a registrant/publication combination
+        reg_pub = self.numerify('#' * reg_pub_len)
+
+        # Use rules to separate the registrant from the publication
+        rules = RULES[ean][reg_group]
+        registrant, publication = self._registrant_publication(reg_pub, rules)
+        return [ean, reg_group, registrant, publication]
+
+    @staticmethod
+    def _registrant_publication(reg_pub, rules):
+        """ Separate the registration from the publication in a given
+        string.
+        :param reg_pub: A string of digits representing a registration
+            and publication.
+        :param rules: A list of RegistrantRules which designate where
+            to separate the values in the string.
+        :returns: A (registrant, publication) tuple of strings.
+        """
+        for rule in rules:
+            if rule.min <= reg_pub <= rule.max:
+                reg_len = rule.registrant_length
+                break
+        else:
+            raise Exception('Registrant/Publication not found in registrant '
+                            'rule list.')
+        registrant, publication = reg_pub[:reg_len], reg_pub[reg_len:]
+        return registrant, publication
+
+    def isbn13(self, separator='-'):
+        ean, group, registrant, publication = self._body()
+        isbn = ISBN13(ean, group, registrant, publication)
+        return isbn.format(separator)
+
+    def isbn10(self, separator='-'):
+        ean, group, registrant, publication = self._body()
+        isbn = ISBN10(ean, group, registrant, publication)
+        return isbn.format(separator)
diff --git a/faker/providers/isbn/en_US/__init__.py b/faker/providers/isbn/en_US/__init__.py
new file mode 100644
index 00000000..e9192823
--- /dev/null
+++ b/faker/providers/isbn/en_US/__init__.py
@@ -0,0 +1,5 @@
+from .. import Provider as ISBNProvider
+
+
+class Provider(ISBNProvider):
+    pass
\ No newline at end of file
diff --git a/faker/providers/isbn/isbn.py b/faker/providers/isbn/isbn.py
new file mode 100644
index 00000000..56145e1e
--- /dev/null
+++ b/faker/providers/isbn/isbn.py
@@ -0,0 +1,62 @@
+# coding=utf-8
+"""
+This module is responsible for generating the check digit and formatting
+ISBN numbers.
+"""
+
+
+class ISBN(object):
+
+    MAX_LENGTH = 13
+
+    def __init__(self, ean=None, group=None, registrant=None, publication=None):
+        self.ean = ean
+        self.group = group
+        self.registrant = registrant
+        self.publication = publication
+
+
+class ISBN13(ISBN):
+
+    def __init__(self, *args, **kwargs):
+        super(ISBN13, self).__init__(*args, **kwargs)
+        self.check_digit = self._check_digit()
+
+    def _check_digit(self):
+        """ Calculate the check digit for ISBN-13.
+        See https://en.wikipedia.org/wiki/International_Standard_Book_Number
+        for calculation.
+        """
+        weights = (1 if x % 2 == 0 else 3 for x in range(12))
+        body = ''.join([self.ean, self.group, self.registrant,
+                        self.publication])
+        remainder = sum(int(b) * w for b, w in zip(body, weights)) % 10
+        diff = 10 - remainder
+        check_digit = 0 if diff == 10 else diff
+        return str(check_digit)
+
+    def format(self, separator=''):
+        return separator.join([self.ean, self.group, self.registrant,
+                               self.publication, self.check_digit])
+
+
+class ISBN10(ISBN):
+
+    def __init__(self, *args, **kwargs):
+        super(ISBN10, self).__init__(*args, **kwargs)
+        self.check_digit = self._check_digit()
+
+    def _check_digit(self):
+        """ Calculate the check digit for ISBN-10.
+        See https://en.wikipedia.org/wiki/International_Standard_Book_Number
+        for calculation.
+        """
+        weights = range(1, 10)
+        body = ''.join([self.group, self.registrant, self.publication])
+        remainder = sum(int(b) * w for b, w in zip(body, weights)) % 11
+        check_digit = 'X' if remainder == 10 else str(remainder)
+        return str(check_digit)
+
+    def format(self, separator=''):
+        return separator.join([self.group, self.registrant, self.publication,
+                               self.check_digit])
diff --git a/faker/providers/isbn/rules.py b/faker/providers/isbn/rules.py
new file mode 100644
index 00000000..a18ecf4e
--- /dev/null
+++ b/faker/providers/isbn/rules.py
@@ -0,0 +1,45 @@
+# coding=utf-8
+"""
+This module exists solely to figure how long a registrant/publication
+number may be within an ISBN. The rules change based on the prefix and
+language/region. This list of rules only encapsulates the 978 prefix
+for English books. 978 is the largest and, until recently, the only
+prefix.
+
+The complete list of prefixes and rules can be found at
+https://www.isbn-international.org/range_file_generation
+"""
+
+from collections import namedtuple
+
+RegistrantRule = namedtuple('RegistrantRule', ['min', 'max', 'registrant_length'])
+
+# Structure: RULES[`EAN Prefix`][`Registration Group`] = [Rule1, Rule2, ...]
+RULES = {
+    '978': {
+        '0': [
+            RegistrantRule('0000000', '1999999', 2),
+            RegistrantRule('2000000', '2279999', 3),
+            RegistrantRule('2280000', '2289999', 4),
+            RegistrantRule('2290000', '6479999', 3),
+            RegistrantRule('6480000', '6489999', 7),
+            RegistrantRule('6490000', '6999999', 3),
+            RegistrantRule('7000000', '8499999', 4),
+            RegistrantRule('8500000', '8999999', 5),
+            RegistrantRule('9000000', '9499999', 6),
+            RegistrantRule('9500000', '9999999', 7)
+        ],
+        '1': [
+            RegistrantRule('0000000', '0999999', 2),
+            RegistrantRule('1000000', '3999999', 3),
+            RegistrantRule('4000000', '5499999', 4),
+            RegistrantRule('5500000', '7319999', 5),
+            RegistrantRule('7320000', '7399999', 7),
+            RegistrantRule('7400000', '8697999', 5),
+            RegistrantRule('8698000', '9729999', 6),
+            RegistrantRule('9730000', '9877999', 4),
+            RegistrantRule('9878000', '9989999', 6),
+            RegistrantRule('9990000', '9999999', 7)
+        ]
+    }
+}


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 60267722..3606e0fe 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -134,6 +134,7 @@ class UtilsTestCase(unittest.TestCase):
             'faker.providers.date_time',
             'faker.providers.file',
             'faker.providers.internet',
+            'faker.providers.isbn',
             'faker.providers.job',
             'faker.providers.lorem',
             'faker.providers.misc',
@@ -142,7 +143,7 @@ class UtilsTestCase(unittest.TestCase):
             'faker.providers.profile',
             'faker.providers.python',
             'faker.providers.ssn',
-            'faker.providers.user_agent',
+            'faker.providers.user_agent'
         ]))
         self.assertEqual(providers, expected_providers)
 
diff --git a/tests/isbn/__init__.py b/tests/isbn/__init__.py
new file mode 100644
index 00000000..98e18f39
--- /dev/null
+++ b/tests/isbn/__init__.py
@@ -0,0 +1,52 @@
+import unittest
+from faker.providers.isbn.en_US import Provider as ISBNProvider
+from faker.providers.isbn import ISBN10, ISBN13
+from faker.providers.isbn.rules import RegistrantRule
+
+
+class TestISBN10(unittest.TestCase):
+
+    def test_check_digit_is_correct(self):
+        isbn = ISBN10(group='1', registrant='4516', publication='7331')
+        assert isbn.check_digit == '0'
+        isbn = ISBN10(group='0', registrant='06', publication='230125')
+        assert isbn.check_digit == 'X'
+        isbn = ISBN10(group='1', registrant='4936', publication='8222')
+        assert isbn.check_digit == '9'
+
+    def test_format_length(self):
+        isbn = ISBN10(group='1', registrant='4516', publication='7331')
+        assert len(isbn.format()) == 10
+
+
+class TestISBN13(unittest.TestCase):
+
+    def test_check_digit_is_correct(self):
+        isbn = ISBN13(ean='978', group='1', registrant='4516', publication='7331')
+        assert isbn.check_digit == '9'
+        isbn = ISBN13(ean='978', group='1', registrant='59327', publication='599')
+        assert isbn.check_digit == '0'
+        isbn = ISBN13(ean='978', group='1', registrant='4919', publication='2757')
+        assert isbn.check_digit == '1'
+
+    def test_format_length(self):
+        isbn = ISBN13(ean='978', group='1', registrant='4516', publication='7331')
+        assert len(isbn.format()) == 13
+
+
+class TestProvider(unittest.TestCase):
+
+    def setUp(self):
+        self.prov = ISBNProvider(None)
+
+    def test_reg_pub_separation(self):
+        r1 = RegistrantRule('0000000', '0000001', 1)
+        r2 = RegistrantRule('0000002', '0000003', 2)
+        assert self.prov._registrant_publication('0000000', [r1, r2]) == ('0', '000000')
+        assert self.prov._registrant_publication('0000002', [r1, r2]) == ('00', '00002')
+
+    def test_rule_not_found(self):
+        with self.assertRaises(Exception):
+            r = RegistrantRule('0000000', '0000001', 1)
+            self.prov._registrant_publication('0000002', [r])
+