Repository: joke2k/faker
Pull request #1860 (2023-05-03 17:29:50)

Passport Provider and EN_US Implementation
### What does this change
Added a passport faking functionality 

### What was wrong
#1853
Addressed one of the desired functionalities

### How this fixes it
This adds a new provider that fakes passport data, specifically numbers, issue and expiry dates, and birthdays


Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index ec08707a..8908816d 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 18.9.0
+current_version = 18.9.1
 files = VERSION faker/__init__.py docs/conf.py
 commit = True
 tag = True
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c8acd47..892c39fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 ## Changelog
 
+### [v18.9.1 - 2023-06-01](https://github.com/joke2k/faker/compare/v18.9.0...v18.9.1)
+
+* Fix `fr_FR` `postcode` length. Thanks @vmttn.
+
 ### [v18.9.0 - 2023-05-16](https://github.com/joke2k/faker/compare/v18.8.0...v18.9.0)
 
 * Add `xml` provider. Thanks @Elihaybe.
diff --git a/VERSION b/VERSION
index f7e67778..b9dfa5d6 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-18.9.0
+18.9.1
diff --git a/docs/conf.py b/docs/conf.py
index bdebde7f..98103b4b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -51,9 +51,9 @@ copyright = "2014, Daniele Faraglia"
 # built documents.
 #
 # The short X.Y version.
-version = "18.9.0"
+version = "18.9.1"
 # The full version, including alpha/beta/rc tags.
-release = "18.9.0"
+release = "18.9.1"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/faker/__init__.py b/faker/__init__.py
index a5fd7c11..b6dff9ec 100644
--- a/faker/__init__.py
+++ b/faker/__init__.py
@@ -2,6 +2,6 @@ from faker.factory import Factory
 from faker.generator import Generator
 from faker.proxy import Faker
 
-VERSION = "18.9.0"
+VERSION = "18.9.1"
 
 __all__ = ("Factory", "Generator", "Faker")
diff --git a/faker/providers/address/fr_FR/__init__.py b/faker/providers/address/fr_FR/__init__.py
index 1125716e..2ee23305 100644
--- a/faker/providers/address/fr_FR/__init__.py
+++ b/faker/providers/address/fr_FR/__init__.py
@@ -469,10 +469,10 @@ class Provider(AddressProvider):
 
     def postcode(self) -> str:
         """
-        Randomly returns a postcode generated from existing french depertment number.
+        Randomly returns a postcode generated from existing french department number.
         exemple: '33260'
         """
         department = self.department_number()
         if department in ["2A", "2B"]:
             department = "20"
-        return f"{department}{self.random_number(digits=5 - len(department))}"
+        return f"{department}{self.random_number(digits=5 - len(department), fix_len=True)}"
diff --git a/faker/providers/passport/__init__.py b/faker/providers/passport/__init__.py
new file mode 100644
index 00000000..b624be1e
--- /dev/null
+++ b/faker/providers/passport/__init__.py
@@ -0,0 +1,45 @@
+import datetime
+import re
+
+from string import ascii_uppercase
+from typing import Tuple
+
+from .. import BaseProvider, ElementsType
+
+localized = True
+
+
+class Provider(BaseProvider):
+    """Implement default Passport provider for Faker."""
+
+    passport_number_formats: ElementsType = ()
+
+    def passport_dob(self) -> datetime.date:
+        """Generate a datetime date of bisrth."""
+        birthday = self.generator.date_of_birth()
+        return birthday
+
+    def passport_owner(self, gender: str = "X") -> Tuple[str, str]:
+        """Generate a given_name and surname for a passport owner
+        The ``gender`` argument is the gender marker of a passport owner, which is a one character string
+        that is either male, female, or non-binary.
+        """
+        if gender == "M":
+            given_name = self.generator.parse("{{first_name_male}}")
+        elif gender == "F":
+            given_name = self.generator.parse("{{first_name_female}}")
+        else:
+            given_name = self.generator.parse("{{first_name_nonbinary}}")
+
+        surname = self.generator.parse("{{last_name}}")
+
+        return given_name, surname
+
+    def passport_number(self) -> str:
+        """Generate a passport number by replacing tokens to be alphanumeric"""
+        temp = re.sub(
+            r"\?",
+            lambda x: self.random_element(ascii_uppercase),
+            self.random_element(self.passport_number_formats),
+        )
+        return self.numerify(temp)
diff --git a/faker/providers/passport/en_US/__init__.py b/faker/providers/passport/en_US/__init__.py
new file mode 100644
index 00000000..cfccea18
--- /dev/null
+++ b/faker/providers/passport/en_US/__init__.py
@@ -0,0 +1,107 @@
+import random
+
+from datetime import date, timedelta
+from typing import Tuple
+
+from .. import Provider as PassportProvider
+
+
+class Provider(PassportProvider):
+    """Implement passport provider for ``en_US`` locale.
+
+    Sources:
+
+    - https://travel.state.gov/content/travel/en/passports/passport-help/next-generation-passport.html
+    - https://www.vitalrecordsonline.com/glossary/passport-book-number
+    """
+
+    passport_number_formats = (
+        # NGP
+        "?########",
+        # Pre-NGP
+        "#########",
+    )
+
+    def passport_dates(self, birthday: date = date.today()) -> Tuple[str, str, str]:
+        """Generates a formatted date of birth, issue, and expiration dates.
+        issue and expiration dates are conditioned to fall within U.S. standards of 5 and 10 year expirations
+
+
+        The ``birthday`` argument is a datetime.date object representing a date of birth.
+
+        Sources:
+
+        -https://travel.state.gov/content/travel/en/passports/passport-help/faqs.html
+        """
+        birth_date = birthday.strftime("%d ") + birthday.strftime("%b ") + birthday.strftime("%Y")
+        today = date.today()
+        age = (today - birthday).days // 365
+        if age < 16:
+            expiry_years = 5
+            issue_date = self.generator.date_time_between(today - timedelta(days=expiry_years * 365 - 1), today)
+            # Checks if age is less than 5 so issue date is not before birthdate
+            if age < 5:
+                issue_date = self.generator.date_time_between(birthday, today)
+            expiry_date = issue_date.replace(year=issue_date.year + expiry_years)
+
+            issue_date_fromat = issue_date.strftime("%d ") + issue_date.strftime("%b ") + issue_date.strftime("%Y")
+            expiry_date_format = expiry_date.strftime("%d ") + expiry_date.strftime("%b ") + expiry_date.strftime("%Y")
+            return birth_date, issue_date_fromat, expiry_date_format
+        elif age >= 26:
+            expiry_years = 10
+            issue_date = self.generator.date_time_between(today - timedelta(days=expiry_years * 365 - 1), today)
+            expiry_date = issue_date.replace(year=issue_date.year + expiry_years)
+            issue_date_fromat = issue_date.strftime("%d ") + issue_date.strftime("%b ") + issue_date.strftime("%Y")
+            expiry_date_format = expiry_date.strftime("%d ") + expiry_date.strftime("%b ") + expiry_date.strftime("%Y")
+            return birth_date, issue_date_fromat, expiry_date_format
+
+        else:
+            # In cases between age 16 and 26, the issue date is 5 years ago, but expiry may be in 10 or 5 years
+            expiry_years = 5
+            issue_date = self.generator.date_time_between(
+                today - timedelta(days=expiry_years * 365 - 1), birthday + timedelta(days=16 * 365 - 1)
+            )
+            # all people over 21 must have been over 16 when they recieved passport or it will be expired otherwise
+            if age >= 21:
+                issue_date = self.generator.date_time_between(today - timedelta(days=expiry_years * 365 - 1), today)
+                expiry_years = 10
+
+            expiry_date = issue_date.replace(year=issue_date.year + expiry_years)
+
+            issue_date_fromat = issue_date.strftime("%d ") + issue_date.strftime("%b ") + issue_date.strftime("%Y")
+            expiry_date_format = expiry_date.strftime("%d ") + expiry_date.strftime("%b ") + expiry_date.strftime("%Y")
+            return birth_date, issue_date_fromat, expiry_date_format
+
+    def passport_gender(self, seed: int = 0) -> str:
+        """Generates a string representing the gender displayed on a passport
+
+        Sources:
+
+        - https://williamsinstitute.law.ucla.edu/publications/x-gender-markers-passports/
+        """
+        if seed != 0:
+            random.seed(seed)
+
+        genders = ["M", "F", "X"]
+        gender = random.choices(genders, weights=[0.493, 0.493, 0.014], k=1)[0]
+        return gender
+
+    def passport_full(self) -> str:
+        """Generates a formatted sting with US Passport information"""
+        dob = self.passport_dob()
+        birth_date, issue_date, expiry_date = self.passport_dates(dob)
+        gender_g = self.passport_gender()
+        given_name, surname = self.passport_owner(gender=gender_g)
+        number = self.passport_number()
+
+        full_rep = """{first_name}\n{second_name}\n{gender}\n{dob}\n{issue}\n{expire}\n{num}\n"""
+        full_rep = full_rep.format(
+            first_name=given_name,
+            second_name=surname,
+            gender=gender_g,
+            dob=birth_date,
+            issue=issue_date,
+            expire=expiry_date,
+            num=number,
+        )
+        return full_rep


Test patch:
diff --git a/tests/providers/test_address.py b/tests/providers/test_address.py
index 0e5c47ef..0ecef674 100644
--- a/tests/providers/test_address.py
+++ b/tests/providers/test_address.py
@@ -870,6 +870,7 @@ class TestFrFr:
         for _ in range(num_samples):
             postcode = faker.postcode()
             assert isinstance(postcode, str)
+            assert len(postcode) == 5
             assert (
                 postcode[:3] in department_numbers  # for 3 digits deparments number
                 or postcode[:2] == "20"  # for Corsica : "2A" or "2B"
diff --git a/tests/test_passport.py b/tests/test_passport.py
new file mode 100644
index 00000000..091b5ca0
--- /dev/null
+++ b/tests/test_passport.py
@@ -0,0 +1,82 @@
+from datetime import date, datetime, timedelta
+
+from faker.providers.person.en_US import Provider as EnUSPersonProvider
+
+female_first_name = list(EnUSPersonProvider.first_names_female.keys())
+male_first_name = list(EnUSPersonProvider.first_names_male.keys())
+nb_first_name = list(EnUSPersonProvider.first_names_nonbinary.keys())
+last_name = list(EnUSPersonProvider.last_names.keys())
+
+
+class TestPassportProvider:
+    def testDOB(self, faker, num_samples=100):
+        for _ in range(num_samples):
+            dob = faker.passport_dob()
+            age = (date.today() - dob).days // 365
+            assert isinstance(dob, date)
+            assert age <= 115
+            assert age >= 0
+
+    def testOwnerNames(self, faker, num_samples=100):
+        for _ in range(num_samples):
+            given_name, surname = faker.passport_owner("F")
+            assert given_name in female_first_name
+            assert surname in last_name
+
+            given_name, surname = faker.passport_owner("M")
+            assert given_name in male_first_name
+            assert surname in last_name
+
+            given_name, surname = faker.passport_owner("X")
+            assert given_name in nb_first_name
+            assert surname in last_name
+
+    def testNumFormat(self, faker, num_samples=100):
+        for _ in range(num_samples):
+            pass_number = faker.passport_number()
+            if pass_number[0].isalpha():
+                assert pass_number[1:].isdigit()
+            else:
+                assert pass_number.isdigit()
+            assert len(pass_number) == 9
+
+
+class TestEnUS:
+    def testDates(self, faker, num_samples=20):
+        age4 = date.today() - timedelta(days=4 * 365)
+        age12 = date.today() - timedelta(days=12 * 365)
+        age17 = date.today() - timedelta(days=17 * 365)
+        age23 = date.today() - timedelta(days=23 * 365)
+        age30 = date.today() - timedelta(days=30 * 365)
+
+        birthdays = [(age4, 4), (age12, 12), (age17, 17), (age23, 23), (age30, 30)]
+        for _ in range(num_samples):
+            for birthday in birthdays:
+                birth_date_f, issue_date_f, expiry_date_f = faker.passport_dates(birthday[0])
+                birth_date = datetime.strptime(birth_date_f, "%d %b %Y").date()
+                issue_date = datetime.strptime(issue_date_f, "%d %b %Y").date()
+                expiry_date = datetime.strptime(expiry_date_f, "%d %b %Y").date()
+
+                if birthday[1] < 21:
+                    assert (expiry_date - issue_date).days // 365 == 5
+                else:
+                    assert (expiry_date - issue_date).days // 365 == 10
+
+                assert expiry_date > issue_date
+                assert birth_date < issue_date
+
+    def testGender(self, faker, num_samples=100):
+        for _ in range(num_samples):
+            assert faker.passport_gender(7899) in ["M", "F", "X"]
+
+    def testfull(self, faker, num_samples=100):
+        for _ in range(num_samples):
+            pass_data = faker.passport_full().split("\n")
+            assert pass_data[1] in last_name
+            assert pass_data[2] in ["M", "F", "X"]
+            if pass_data[2] == "M":
+                assert pass_data[0] in male_first_name
+            elif pass_data[2] == "F":
+                assert pass_data[0] in female_first_name
+            elif pass_data[2] == "X":
+                assert pass_data[0] in nb_first_name
diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py
index c07b9081..f0f69dfa 100644
--- a/tests/utils/test_utils.py
+++ b/tests/utils/test_utils.py
@@ -87,6 +87,7 @@ class UtilsTestCase(unittest.TestCase):
                     "faker.providers.job",
                     "faker.providers.lorem",
                     "faker.providers.misc",
+                    "faker.providers.passport",
                     "faker.providers.person",
                     "faker.providers.phone_number",
                     "faker.providers.profile",