Repository: joke2k/faker

Issue:
Incorrect language set on Provider during locale fallback
* Faker version: 11.3.0

When faker is initialised with a locale not implemented by some of the built-in providers, it falls back to the DEFAULT_LOCALE for these providers. However, the language set on the provider’s instance in `__lang__` is still the requested locale and not the locale in effect.

This is due to `Factory._find_provider_class` not returning the locale it chose, in: https://github.com/joke2k/faker/blob/001ddee39c33b6b82196fe6a5ecc131bca3b964c/faker/factory.py#L102-L104 to `Factory._get_provider_class` which then proceeds to return the locale value as it was passed in at the first place. Thus, `provider.__lang__` does not contain the found locale (as the variable name `lang_found` would suggest): https://github.com/joke2k/faker/blob/001ddee39c33b6b82196fe6a5ecc131bca3b964c/faker/factory.py#L61

### Expected behavior

`provider.__lang__` should be set to the actual language / locale being used by the provider.

### Actual behavior

`provider.__lang__` is set to the locale that was requested but is not offered by this provider.


Discussion:
Thank you for the report @interDist ! Do you have time to prepare a Pul Request ?
> Do you have time to prepare a Pul Request ?

I will try, this will probably happen in beginning of February.
@fcurella I started working on the PR, but I am confused by line 74 (and later) in `factory.py`
https://github.com/joke2k/faker/blob/001ddee39c33b6b82196fe6a5ecc131bca3b964c/faker/factory.py#L74-L78
I cannot see an execution path that would reach this line. The test coverage also seems to confirm this (lines 74 until 86 are not covered by tests). Maybe there is some legacy to `_get_provider_class` and `_find_provider_class` and some history I am not aware of? 
@interDist I've looked into this and there's definitely a bug.

I can see we already handle the fallback in `_find_provider_class`:

https://github.com/joke2k/faker/blob/001ddee39c33b6b82196fe6a5ecc131bca3b964c/faker/factory.py#L102-L112

 and we return the correct provider:

https://github.com/joke2k/faker/blob/001ddee39c33b6b82196fe6a5ecc131bca3b964c/faker/factory.py#L133-L136

So yeah, I think that's legacy code that should be removed 👍🏼 

Resolving patch:
diff --git a/faker/documentor.py b/faker/documentor.py
--- a/faker/documentor.py
+++ b/faker/documentor.py
@@ -30,7 +30,7 @@ def get_formatters(
         formatters = []
         providers: List[BaseProvider] = self.generator.get_providers()
         for provider in providers[::-1]:  # reverse
-            if locale and provider.__lang__ != locale:
+            if locale and provider.__lang__ and provider.__lang__ != locale:
                 continue
             formatters.append(
                 (provider, self.get_provider_formatters(provider, **kwargs)),
diff --git a/faker/factory.py b/faker/factory.py
--- a/faker/factory.py
+++ b/faker/factory.py
@@ -54,7 +54,7 @@ def create(
             if prov_name == "faker.providers":
                 continue
 
-            prov_cls, lang_found = cls._get_provider_class(prov_name, locale)
+            prov_cls, lang_found, _ = cls._find_provider_class(prov_name, locale)
             provider = prov_cls(faker)
             provider.__use_weighting__ = use_weighting
             provider.__provider__ = prov_name
@@ -64,31 +64,14 @@ def create(
         return faker
 
     @classmethod
-    def _get_provider_class(cls, provider: str, locale: Optional[str] = "") -> Tuple[Any, Optional[str]]:
-
-        provider_class = cls._find_provider_class(provider, locale)
-
-        if provider_class:
-            return provider_class, locale
-
-        if locale and locale != DEFAULT_LOCALE:
-            # fallback to default locale
-            provider_class = cls._find_provider_class(provider, DEFAULT_LOCALE)
-            if provider_class:
-                return provider_class, DEFAULT_LOCALE
-
-        # fallback to no locale
-        provider_class = cls._find_provider_class(provider)
-        if provider_class:
-            return provider_class, None
-
-        msg = f"Unable to find provider `{provider}` with locale `{locale}`"
-        raise ValueError(msg)
-
-    @classmethod
-    def _find_provider_class(cls, provider_path: str, locale: Optional[str] = None) -> Any:
+    def _find_provider_class(
+        cls,
+        provider_path: str,
+        locale: Optional[str] = None,
+    ) -> Tuple[Any, Optional[str], Optional[str]]:
 
         provider_module = import_module(provider_path)
+        default_locale = getattr(provider_module, "default_locale", "")
 
         if getattr(provider_module, "localized", False):
 
@@ -101,7 +84,7 @@ def _find_provider_class(cls, provider_path: str, locale: Optional[str] = None)
             available_locales = list_module(provider_module)
             if not locale or locale not in available_locales:
                 unavailable_locale = locale
-                locale = getattr(provider_module, "default_locale", DEFAULT_LOCALE)
+                locale = default_locale or DEFAULT_LOCALE
                 logger.debug(
                     "Specified locale `%s` is not available for "
                     "provider `%s`. Locale reset to `%s` for this "
@@ -122,15 +105,14 @@ def _find_provider_class(cls, provider_path: str, locale: Optional[str] = None)
 
         else:
 
-            logger.debug(
-                "Provider `%s` does not feature localization. "
-                "Specified locale `%s` is not utilized for this "
-                "provider.",
-                provider_module.__name__,
-                locale,
-            )
-
-            if locale is not None:
-                provider_module = import_module(provider_path)
+            if locale:
+                logger.debug(
+                    "Provider `%s` does not feature localization. "
+                    "Specified locale `%s` is not utilized for this "
+                    "provider.",
+                    provider_module.__name__,
+                    locale,
+                )
+            locale = default_locale = None
 
-        return provider_module.Provider  # type: ignore
+        return provider_module.Provider, locale, default_locale  # type: ignore


Test patch:
diff --git a/tests/test_factory.py b/tests/test_factory.py
--- a/tests/test_factory.py
+++ b/tests/test_factory.py
@@ -3,6 +3,8 @@
 import sys
 import unittest
 
+from unittest.mock import MagicMock, patch
+
 import pytest
 
 from faker import Faker, Generator
@@ -108,6 +110,74 @@ def test_cli_verbosity(self):
         finally:
             sys.stdout = orig_stdout
 
+    def test_unknown_provider(self):
+        with pytest.raises(ModuleNotFoundError) as excinfo:
+            Factory.create(providers=["dummy_provider"])
+        assert str(excinfo.value) == "No module named 'dummy_provider'"
+
+        with pytest.raises(ModuleNotFoundError) as excinfo:
+            Factory.create(providers=["dummy_provider"], locale="it_IT")
+        assert str(excinfo.value) == "No module named 'dummy_provider'"
+
+    def test_unknown_locale(self):
+        with pytest.raises(AttributeError) as excinfo:
+            Factory.create(locale="77")
+        assert str(excinfo.value) == "Invalid configuration for faker locale `77`"
+
+        with pytest.raises(AttributeError) as excinfo:
+            Factory.create(locale="77_US")
+        assert str(excinfo.value) == "Invalid configuration for faker locale `77_US`"
+
+    def test_lang_unlocalized_provider(self):
+        for locale in (None, "", "en_GB", "it_IT"):
+            factory = Factory.create(providers=["faker.providers.file"], locale=locale)
+            assert len(factory.providers) == 1
+            assert factory.providers[0].__provider__ == "faker.providers.file"
+            assert factory.providers[0].__lang__ is None
+
+    def test_lang_localized_provider(self, with_default=True):
+        class DummyProviderModule:
+            localized = True
+
+            def __init__(self):
+                if with_default:
+                    self.default_locale = "ar_EG"
+
+            @property
+            def __name__(self):
+                return self.__class__.__name__
+
+            class Provider:
+                def __init__(self, *args, **kwargs):
+                    pass
+
+        with patch.multiple(
+            "faker.factory",
+            import_module=MagicMock(return_value=DummyProviderModule()),
+            list_module=MagicMock(return_value=("en_GB", "it_IT")),
+            DEFAULT_LOCALE="ko_KR",
+        ):
+            test_cases = [
+                (None, False),
+                ("", False),
+                ("ar", False),
+                ("es_CO", False),
+                ("en", False),
+                ("en_GB", True),
+                ("ar_EG", with_default),  # True if module defines a default locale
+            ]
+            for locale, expected_used in test_cases:
+                factory = Factory.create(providers=["dummy"], locale=locale)
+                assert factory.providers[0].__provider__ == "dummy"
+                from faker.config import DEFAULT_LOCALE
+
+                print(f"requested locale = {locale} , DEFAULT LOCALE {DEFAULT_LOCALE}")
+                expected_locale = locale if expected_used else ("ar_EG" if with_default else "ko_KR")
+                assert factory.providers[0].__lang__ == expected_locale
+
+    def test_lang_localized_provider_without_default(self):
+        self.test_lang_localized_provider(with_default=False)
+
     def test_slugify(self):
         slug = text.slugify("a'b/c")
         assert slug == "abc"
diff --git a/tests/test_providers_formats.py b/tests/test_providers_formats.py
--- a/tests/test_providers_formats.py
+++ b/tests/test_providers_formats.py
@@ -3,7 +3,7 @@
 import pytest
 
 from faker import Factory
-from faker.config import AVAILABLE_LOCALES, PROVIDERS
+from faker.config import AVAILABLE_LOCALES, DEFAULT_LOCALE, PROVIDERS
 
 locales = AVAILABLE_LOCALES
 
@@ -26,8 +26,16 @@ def test_no_invalid_formats(locale):
     for provider in PROVIDERS:
         if provider == "faker.providers":
             continue
-        prov_cls, lang = Factory._get_provider_class(provider, locale)
-        assert lang == locale
+        prov_cls, lang, default_lang = Factory._find_provider_class(provider, locale)
+        if default_lang is None:
+            # for non-localized providers, the discovered language will be None
+            assert lang is None
+        else:
+            # for localized providers, the discovered language will either be
+            # the requested one
+            # or the default language of the provider
+            # or the fallback locale
+            assert lang in (locale, default_lang or DEFAULT_LOCALE)
 
         attributes = set(dir(prov_cls))