Repository: joke2k/faker (python)

Issue:
Feature: Elector code for es_MX ssn provider
* Faker version: 18.11.2
* OS: NA

The National Electoral Institute in Mexico issues a unique identifier code for electors, it would be great if the library could fake those.

### Steps to reproduce

NA

### Expected behavior

```python
>>> fake.elector_code()
"GLVLMR80070509M900"
```

### Actual behavior

It's not implemented 😅 


Pull request description:
Feature: Elector code for es_MX SSN provider
### What does this change

Adds the elector code issued by the National Electoral Institute in Mexico.

### What was wrong

Wasn't implemented.

### How this fixes it

It implements it.

Fixes #1886


Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index a392635f..f3fed20b 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 18.11.2
+current_version = 18.13.0
 files = VERSION faker/__init__.py docs/conf.py
 commit = True
 tag = True
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81411978..ac2a557b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 ## Changelog
 
+### [v18.13.0 - 2023-07-07](https://github.com/joke2k/faker/compare/v18.12.0...v18.13.0)
+
+* Add `.optional` proxy that may or may not return a fake value. Thanks @ligne.
+
+### [v18.12.0 - 2023-07-07](https://github.com/joke2k/faker/compare/v18.11.2...v18.12.0)
+
+*  Add fake automotive `vin` number function. Thanks @cyanghsieh.
+
 ### [v18.11.2 - 2023-06-27](https://github.com/joke2k/faker/compare/v18.11.1...v18.11.2)
 
 * Fix area codes for `fr_FR` mobile phone numbers. Thanks @QuentinFchx.
diff --git a/VERSION b/VERSION
index 0ec67166..d939939b 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-18.11.2
+18.13.0
diff --git a/docs/conf.py b/docs/conf.py
index de2064d9..7587165a 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.11.2"
+version = "18.13.0"
 # The full version, including alpha/beta/rc tags.
-release = "18.11.2"
+release = "18.13.0"
 
 # 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 cfbf27ff..d46861c1 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.11.2"
+VERSION = "18.13.0"
 
 __all__ = ("Factory", "Generator", "Faker")
diff --git a/faker/providers/automotive/__init__.py b/faker/providers/automotive/__init__.py
index 4a3156e3..1f89d37c 100644
--- a/faker/providers/automotive/__init__.py
+++ b/faker/providers/automotive/__init__.py
@@ -7,6 +7,36 @@ from .. import BaseProvider, ElementsType
 localized = True
 
 
+def calculate_vin_str_weight(s: str, weight_factor: list) -> int:
+    """
+    multiply s(str) by weight_factor char by char
+    e.g.
+    input: s="ABCDE", weight_factor=[1, 2, 3, 4, 5]
+    return: A*1 + B*2 + C*3 + D*4 + E*5
+
+    will multiply 0 when len(weight_factor) less than len(s)
+    """
+
+    def _get_char_weight(c: str) -> int:
+        """A=1, B=2, ...., I=9,
+        J=1, K=2, ..., R=9,
+        S=2, T=3, ..., Z=9
+        """
+        if ord(c) <= 64:  # 0-9
+            return int(c)
+        if ord(c) <= 73:  # A-I
+            return ord(c) - 64
+        if ord(c) <= 82:  # J-R
+            return ord(c) - 73
+        # S-Z
+        return ord(c) - 81
+
+    res = 0
+    for i, c in enumerate(s):
+        res += _get_char_weight(c) * weight_factor[i] if i < len(weight_factor) else 0
+    return res
+
+
 class Provider(BaseProvider):
     """Implement default automotive provider for Faker."""
 
@@ -20,3 +50,14 @@ class Provider(BaseProvider):
             self.random_element(self.license_formats),
         )
         return self.numerify(temp)
+
+    def vin(self) -> str:
+        """Generate vin number."""
+        vin_chars = "1234567890ABCDEFGHJKLMNPRSTUVWXYZ"  # I, O, Q are restricted
+        front_part = self.bothify("????????", letters=vin_chars)
+        rear_part = self.bothify("????????", letters=vin_chars)
+        front_part_weight = calculate_vin_str_weight(front_part, [8, 7, 6, 5, 4, 3, 2, 10])
+        rear_part_weight = calculate_vin_str_weight(rear_part, [9, 8, 7, 6, 5, 4, 3, 2])
+        checksum = (front_part_weight + rear_part_weight) % 11
+        checksum_char = "X" if checksum == 10 else str(checksum)
+        return front_part + checksum_char + rear_part
diff --git a/faker/providers/automotive/en_PH/__init__.py b/faker/providers/automotive/en_PH/__init__.py
index f78c0f9f..71f05d3e 100644
--- a/faker/providers/automotive/en_PH/__init__.py
+++ b/faker/providers/automotive/en_PH/__init__.py
@@ -1,10 +1,10 @@
 from string import ascii_uppercase
 from typing import List
 
-from ... import BaseProvider
+from .. import Provider as AutomotiveProvider
 
 
-class Provider(BaseProvider):
+class Provider(AutomotiveProvider):
     """Implement automotive provider for ``en_PH`` locale.
 
     Vehicle registration in the Philippines has many controversies and is full
diff --git a/faker/providers/ssn/es_MX/__init__.py b/faker/providers/ssn/es_MX/__init__.py
index 7207c3db..c91e18ba 100644
--- a/faker/providers/ssn/es_MX/__init__.py
+++ b/faker/providers/ssn/es_MX/__init__.py
@@ -7,9 +7,16 @@ Registry Code (CURP) and Federal Taxpayer Registry ID (RFC).
 
 import random
 import string
+from typing import Optional
 
 from .. import Provider as BaseProvider
 
+try:
+    from typing import Literal  # type: ignore
+except ImportError:
+    from typing_extensions import Literal  # type: ignore
+
+
 ALPHABET = string.ascii_uppercase
 ALPHANUMERIC = string.digits + ALPHABET
 VOWELS = "AEIOU"
@@ -224,3 +231,30 @@ class Provider(BaseProvider):
         random_rfc = name_initials + birth_date + disambiguation_code
 
         return random_rfc
+
+    def elector_code(self, gender: Literal["H", "M"] = None) -> str:
+        """
+        Unique elector code issued by INE (Instituto Nacional Electoral) in Mexico.
+
+        :param gender: Gender for which to generate the code. Will be randomly
+            selected if not provided.
+        :type gender: str
+        :return: a random INE elector code
+
+        :sample:
+        :sample: gender='M'
+        """
+        if gender and gender not in ("H", "M"):
+            raise ValueError("Gender must be 'H' or 'M'")
+
+        gender = gender or random.choice("HM")
+
+        consonants = "".join(random.choices(CONSONANTS, k=6))
+
+        birthday = self.generator.date_of_birth()
+        birth_date = birthday.strftime("%y%m%d")
+
+        entity = random.randint(1, 33)
+        disambiguation_code = "".join(random.choices(string.digits, k=3))
+
+        return f"{consonants}{birth_date}{entity:02d}{gender}{disambiguation_code}"
diff --git a/faker/proxy.py b/faker/proxy.py
index 78f159bc..653a5943 100644
--- a/faker/proxy.py
+++ b/faker/proxy.py
@@ -4,7 +4,7 @@ import re
 
 from collections import OrderedDict
 from random import Random
-from typing import Any, Callable, Dict, List, Optional, Pattern, Sequence, Tuple, Union
+from typing import Any, Callable, Dict, List, Optional, Pattern, Sequence, Tuple, TypeVar, Union
 
 from .config import DEFAULT_LOCALE
 from .exceptions import UniquenessException
@@ -15,6 +15,8 @@ from .utils.distribution import choices_distribution
 
 _UNIQUE_ATTEMPTS = 1000
 
+RetType = TypeVar("RetType")
+
 
 class Faker:
     """Proxy class capable of supporting multiple locales"""
@@ -36,6 +38,7 @@ class Faker:
         self._factory_map = OrderedDict()
         self._weights = None
         self._unique_proxy = UniqueProxy(self)
+        self._optional_proxy = OptionalProxy(self)
 
         if isinstance(locale, str):
             locales = [locale.replace("-", "_")]
@@ -137,6 +140,10 @@ class Faker:
     def unique(self) -> "UniqueProxy":
         return self._unique_proxy
 
+    @property
+    def optional(self) -> "OptionalProxy":
+        return self._optional_proxy
+
     def _select_factory(self, method_name: str) -> Factory:
         """
         Returns a random factory that supports the provider method
@@ -322,3 +329,38 @@ class UniqueProxy:
             return retval
 
         return wrapper
+
+
+class OptionalProxy:
+    """
+    Return either a fake value or None, with a customizable probability.
+    """
+
+    def __init__(self, proxy: Faker):
+        self._proxy = proxy
+
+    def __getattr__(self, name: str) -> Any:
+        obj = getattr(self._proxy, name)
+        if callable(obj):
+            return self._wrap(name, obj)
+        else:
+            raise TypeError("Accessing non-functions through .optional is not supported.")
+
+    def __getstate__(self):
+        # Copy the object's state from self.__dict__ which contains
+        # all our instance attributes. Always use the dict.copy()
+        # method to avoid modifying the original state.
+        state = self.__dict__.copy()
+        return state
+
+    def __setstate__(self, state):
+        self.__dict__.update(state)
+
+    def _wrap(self, name: str, function: Callable[..., RetType]) -> Callable[..., Optional[RetType]]:
+        @functools.wraps(function)
+        def wrapper(*args: Any, prob: float = 0.5, **kwargs: Any) -> Optional[RetType]:
+            if not 0 < prob <= 1.0:
+                raise ValueError("prob must be between 0 and 1")
+            return function(*args, **kwargs) if self._proxy.boolean(chance_of_getting_true=int(prob * 100)) else None
+
+        return wrapper


Test patch:
diff --git a/tests/providers/test_automotive.py b/tests/providers/test_automotive.py
index 62f725b0..ad193bc1 100644
--- a/tests/providers/test_automotive.py
+++ b/tests/providers/test_automotive.py
@@ -2,6 +2,7 @@ import re
 
 from typing import Pattern
 
+from faker.providers.automotive import calculate_vin_str_weight
 from faker.providers.automotive.de_DE import Provider as DeDeAutomotiveProvider
 from faker.providers.automotive.es_ES import Provider as EsEsAutomotiveProvider
 from faker.providers.automotive.ro_RO import Provider as RoRoAutomotiveProvider
@@ -23,6 +24,19 @@ class _SimpleAutomotiveTestMixin:
             assert match is not None
             self.perform_extra_checks(license_plate, match)
 
+    def test_vin(self, faker, num_samples):
+        for _ in range(num_samples):
+            vin_number = faker.vin()
+            # length check: 17
+            assert len(vin_number) == 17
+
+            # verify checksum: vin_number[8]
+            front_part_weight = calculate_vin_str_weight(vin_number[:8], [8, 7, 6, 5, 4, 3, 2, 10])
+            rear_part_weight = calculate_vin_str_weight(vin_number[9:], [9, 8, 7, 6, 5, 4, 3, 2])
+            checksum = (front_part_weight + rear_part_weight) % 11
+            checksum_str = "X" if checksum == 10 else str(checksum)
+            assert vin_number[8] == checksum_str
+
 
 class TestArBh(_SimpleAutomotiveTestMixin):
     """Test ar_BH automotive provider methods"""
diff --git a/tests/providers/test_ssn.py b/tests/providers/test_ssn.py
index 0b2b83d4..6765f54c 100644
--- a/tests/providers/test_ssn.py
+++ b/tests/providers/test_ssn.py
@@ -630,8 +630,8 @@ class TestEsCA(TestEsES):
         Faker.seed(0)
 
 
-class TestEsMX(unittest.TestCase):
-    def setUp(self):
+class TestEsMX:
+    def setup_method(self):
         self.fake = Faker("es_MX")
         Faker.seed(0)
 
@@ -665,6 +665,26 @@ class TestEsMX(unittest.TestCase):
             assert len(rfc) == 12
             assert re.search(r"^[A-Z]{3}\d{6}[0-9A-Z]{3}$", rfc)
 
+    @pytest.mark.parametrize(
+        "gender,pattern",
+        [
+            ("M", r"^[A-Z]{6}\d{8}M\d{3}$"),
+            ("H", r"^[A-Z]{6}\d{8}H\d{3}$"),
+            (None, r"^[A-Z]{6}\d{8}[HM]\d{3}$"),
+        ],
+        ids=["woman", "man", "any"],
+    )
+    def test_elector_code(self, gender, pattern):
+        for _ in range(100):
+            elector_code = self.fake.elector_code(gender=gender)
+
+            assert len(elector_code) == 18
+            assert re.search(pattern, elector_code)
+
+    def test_elector_code_unsupported_gender(self):
+        with pytest.raises(ValueError, match="Gender must be"):
+            self.fake.elector_code("Z")
+
 
 class TestEsCL(unittest.TestCase):
     def setUp(self):
diff --git a/tests/test_optional.py b/tests/test_optional.py
new file mode 100644
index 00000000..f84bcdb3
--- /dev/null
+++ b/tests/test_optional.py
@@ -0,0 +1,44 @@
+import pytest
+
+from faker import Faker
+
+
+class TestOptionalClass:
+    def test_optional(self) -> None:
+        fake = Faker()
+
+        assert {fake.optional.boolean() for _ in range(10)} == {True, False, None}
+
+    def test_optional_probability(self) -> None:
+        """The probability is configurable."""
+        fake = Faker()
+
+        fake.optional.name(prob=0.1)
+
+    def test_optional_arguments(self) -> None:
+        """Other arguments are passed through to the function."""
+        fake = Faker()
+
+        fake.optional.pyint(1, 2, prob=0.4)
+
+    def test_optional_valid_range(self) -> None:
+        """Only probabilities in the range (0, 1]."""
+        fake = Faker()
+
+        with pytest.raises(ValueError, match=""):
+            fake.optional.name(prob=0)
+
+        with pytest.raises(ValueError, match=""):
+            fake.optional.name(prob=1.1)
+
+        with pytest.raises(ValueError, match=""):
+            fake.optional.name(prob=-3)
+
+    def test_functions_only(self):
+        """Accessing non-functions through the `.optional` attribute
+        will throw a TypeError."""
+
+        fake = Faker()
+
+        with pytest.raises(TypeError, match="Accessing non-functions through .optional is not supported."):
+            fake.optional.locales
diff --git a/tests/test_proxy.py b/tests/test_proxy.py
index 5a7e8dbd..322bc396 100644
--- a/tests/test_proxy.py
+++ b/tests/test_proxy.py
@@ -428,6 +428,7 @@ class TestFakerProxyClass:
                 "_factory_map",
                 "_weights",
                 "_unique_proxy",
+                "_optional_proxy",
             ]
         )
         for factory in fake.factories: