Skip to content

Commit

Permalink
Finalize proxy class implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
malefice committed Nov 18, 2019
1 parent 104564b commit 997b3c1
Show file tree
Hide file tree
Showing 2 changed files with 188 additions and 58 deletions.
81 changes: 76 additions & 5 deletions faker/proxy.py
Expand Up @@ -4,25 +4,39 @@

from collections import OrderedDict
import random
import re
import six

from faker.config import DEFAULT_LOCALE
from faker.factory import Factory
from faker.generator import Generator
from faker.utils.distribution import choices_distribution


class Faker(object):
"""Proxy class capable of supporting multiple locales"""

cache_pattern = re.compile(r'^_cached_\w*_mapping$')
generator_attrs = [attr for attr in dir(Generator) if not attr.startswith('__')]

def __init__(self, locale=None, providers=None,
generator=None, includes=None, **config):
self._factory_map = OrderedDict()
self._weights = None

if isinstance(locale, six.string_types):
locales = [locale]

# This guarantees a FIFO ordering of elements in `locales` based on the final
# locale string while discarding duplicates after processing
elif isinstance(locale, (list, set)):
assert all(isinstance(l, six.string_types) for l in locale)
locales = list({l.replace('-', '_') for l in locale})
locales = []
for l in locale:
final_locale = l.replace('-', '_')
if final_locale not in locales:
locales.append(final_locale)

elif isinstance(locale, OrderedDict):
assert all(isinstance(v, (int, float)) for v in locale.values())
odict = OrderedDict()
Expand All @@ -31,6 +45,7 @@ def __init__(self, locale=None, providers=None,
odict[key] = v
locales = list(odict.keys())
self._weights = list(odict.values())

else:
locales = [DEFAULT_LOCALE]

Expand All @@ -44,13 +59,37 @@ def __getitem__(self, locale):
return self._factory_map[locale]

def __getattr__(self, attr):
"""
Handles the "attribute resolution" behavior of this proxy class
This method checks the specified `attr` in this order:
1. Regardless of how many locales were specified, first try to return
the attribute `attr` if it is present in this proxy class
2a. In single locale mode, proxy all __getattr__ calls to the only
internal `Generator` object that will be created
2b. In multiple locale mode,
This, however, does not proxy calls to setters, so getters and setters
should be defined separately.
:param attr: attribute name
:return: the appropriate attribute
"""

try:
return object.__getattribute__(self, attr)
except AttributeError:
if attr.startswith('_cached') and attr.endswith('_mapping'):
raise AttributeError()
factory = self._select_factory(attr)
return getattr(factory, attr)
if len(self._factories) == 1:
return getattr(self._factories[0], attr)
elif attr in self.generator_attrs:
msg = 'Proxying calls to `%s` is not implemented in multiple locale mode.' % attr
raise NotImplementedError(msg)
elif self.cache_pattern.match(attr):
msg = 'Cached attribute `%s` does not exist' % attr
raise AttributeError(msg)
else:
factory = self._select_factory(attr)
return getattr(factory, attr)

def _select_factory(self, method_name):
"""
Expand Down Expand Up @@ -113,6 +152,38 @@ def _map_provider_method(self, method_name):
setattr(self, attr, mapping)
return mapping

@property
def random(self):
"""
Proxies `random` getter calls
In single locale mode, this will be proxied to the `random` getter
of the only internal `Generator` object. Subclasses will have to
implement desired behavior in multiple locale mode.
"""

if len(self._factories) == 1:
return self._factories[0].random
else:
msg = 'Proxying `random` getter calls is not implemented in multiple locale mode.'
raise NotImplementedError(msg)

@random.setter
def random(self, value):
"""
Proxies `random` setter calls
In single locale mode, this will be proxied to the `random` setter
of the only internal `Generator` object. Subclasses will have to
implement desired behavior in multiple locale mode.
"""

if len(self._factories) == 1:
self._factories[0].random = value
else:
msg = 'Proxying `random` setter calls is not implemented in multiple locale mode.'
raise NotImplementedError(msg)

@property
def locales(self):
return list(self._locales)
Expand Down
165 changes: 112 additions & 53 deletions tests/test_proxy.py
Expand Up @@ -5,9 +5,9 @@
from collections import OrderedDict
import unittest
try:
from unittest.mock import patch
from unittest.mock import patch, PropertyMock
except ImportError:
from mock import patch
from mock import patch, PropertyMock

from faker import Faker
from faker.config import DEFAULT_LOCALE
Expand All @@ -33,15 +33,19 @@ def test_locale_as_string(self):
assert self.faker.locales[0] == locale

def test_locale_as_list(self):
locale = ['en_US', 'en_PH', 'ja_JP', 'de_DE']
self.faker = Faker(locale)
assert set(self.faker.locales) == set(locale)
assert len(self.faker.factories) == len(set(locale))
locale = ['en-US', 'en_PH', 'ja_JP', 'de-DE']
expected = ['en_US', 'en_PH', 'ja_JP', 'de_DE']
for _ in range(10):
self.faker = Faker(locale)
assert self.faker.locales == expected
assert len(self.faker.factories) == len(expected)

locale = locale * 3
self.faker = Faker(locale)
assert set(self.faker.locales) == set(locale)
assert len(self.faker.factories) == len(set(locale))
locale = ['en-US', 'en_PH', 'ja_JP', 'de-DE', 'ja-JP', 'de_DE', 'en-US'] * 3
expected = ['en_US', 'en_PH', 'ja_JP', 'de_DE']
for _ in range(10):
self.faker = Faker(locale)
assert self.faker.locales == expected
assert len(self.faker.factories) == len(expected)

def test_locale_as_ordereddict(self):
locale = OrderedDict([
Expand Down Expand Up @@ -84,65 +88,117 @@ def test_dunder_getitem(self):
with self.assertRaises(KeyError):
self.faker['en-US']

@patch('faker.proxy.Faker._map_provider_method')
def test_dunder_getattr_single_locale(self, mock_map_method):
locales = [None, 'en-US']
def test_single_locale_proxy_behavior(self):
self.faker = Faker()
internal_factory = self.faker.factories[0]

# Test if `Generator` attributes are proxied properly
for attr in self.faker.generator_attrs:
assert getattr(self.faker, attr) == getattr(internal_factory, attr)

# Test if `random` getter and setter are proxied properly
tmp_random = self.faker.random
assert internal_factory.random != 1
self.faker.random = 1
assert internal_factory.random == 1
self.faker.random = tmp_random

# Test if a valid provider method is proxied properly
# Factory selection logic should not be triggered
with patch('faker.proxy.Faker._select_factory') as mock_select_factory:
mock_select_factory.assert_not_called()
assert self.faker.name == internal_factory.name
self.faker.name()
mock_select_factory.assert_not_called()

def test_multiple_locale_basic_proxy_behavior(self):
self.faker = Faker(['de-DE', 'en-US', 'en-PH', 'ja-JP'])

for locale in locales:
self.faker = Faker(locale)
# `Generator` attributes are not implemented
for attr in self.faker.generator_attrs:
with self.assertRaises(NotImplementedError):
getattr(self.faker, attr)

# The `random` getter is not implemented
with self.assertRaises(NotImplementedError):
random = self.faker.random
random.seed(0)

# The `random` setter is not implemented
with self.assertRaises(NotImplementedError):
self.faker.random = 1

def test_multiple_locale_caching_behavior(self):
self.faker = Faker(['de_DE', 'en-US', 'en-PH', 'ja_JP'])

with patch('faker.proxy.Faker._map_provider_method',
wraps=self.faker._map_provider_method) as mock_map_method:
mock_map_method.assert_not_called()
assert not hasattr(self.faker, '_cached_name_mapping')

# Multi-locale factory selection logic should not be triggered
# since there is only one factory (which is immediately returned)
# Test cache creation
self.faker.name()
mock_map_method.assert_not_called()
assert hasattr(self.faker, '_cached_name_mapping')
mock_map_method.assert_called_once_with('name')

# Reset mock object
mock_map_method.reset_mock()
# Test subsequent cache access
with patch.object(Faker, '_cached_name_mapping', create=True,
new_callable=PropertyMock) as mock_cached_map:
# Keep test fast by patching the cached mapping to return something simpler
mock_cached_map.return_value = [self.faker['en_US']], [1]
for _ in range(100):
self.faker.name()

# Python's hasattr() internally calls getattr()
# So each call to name() accesses the cached mapping twice
assert mock_cached_map.call_count == 200

@patch('faker.proxy.random.choice')
@patch('faker.proxy.choices_distribution')
def test_dunder_getattr_multi_locale_no_weights(self, mock_choices_fn, mock_random_choice):
locale = ['de_DE', 'en-US', 'en-PH', 'ja_JP']
self.faker = Faker(locale)
mock_choices_fn.assert_not_called()
mock_random_choice.assert_not_called()

# There are multiple locales, so provider mapping logic is guaranteed to run, and since there
# are no distribution weights, factory selection logic (if necessary) will use `random.choice`
with patch('faker.proxy.Faker._map_provider_method',
wraps=self.faker._map_provider_method) as mock_map_method:
def test_multiple_locale_factory_selection_no_weights(self, mock_choices_fn, mock_random_choice):
self.faker = Faker(['de_DE', 'en-US', 'en-PH', 'ja_JP'])

# There are no distribution weights, so factory selection logic will use `random.choice`
# if multiple factories have the specified provider method
with patch('faker.proxy.Faker._select_factory',
wraps=self.faker._select_factory) as mock_select_factory:
mock_select_factory.assert_not_called()
mock_choices_fn.assert_not_called()
mock_random_choice.assert_not_called()

# All factories for the listed locales have the `name` provider method
self.faker.name()
mock_map_method.assert_called_once()
mock_select_factory.assert_called_once_with('name')
mock_choices_fn.assert_not_called()
mock_random_choice.assert_called_once_with(self.faker.factories)
mock_select_factory.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()

# Only `en_PH` factory has provider method `luzon_province`, so there is no
# need for `random.choice` factory selection logic to run
mock_map_method.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()
self.faker.luzon_province()
mock_map_method.assert_called_once()
mock_select_factory.assert_called_with('luzon_province')
mock_choices_fn.assert_not_called()
mock_random_choice.assert_not_called()

# Both `en_US` and `ja_JP` factories have provider method `zipcode`
mock_map_method.reset_mock()
mock_select_factory.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()

# Both `en_US` and `ja_JP` factories have provider method `zipcode`
self.faker.zipcode()
mock_map_method.assert_called_once()
mock_select_factory.assert_called_once_with('zipcode')
mock_choices_fn.assert_not_called()
mock_random_choice.assert_called_once_with(
[self.faker['en_US'], self.faker['ja_JP']],
)
mock_select_factory.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()

@patch('faker.proxy.random.choice')
@patch('faker.proxy.choices_distribution')
def test_dunder_getattr_with_weights(self, mock_choices_fn, mock_random_choice):
def test_multiple_locale_factory_selection_no_weights(self, mock_choices_fn, mock_random_choice):
locale = OrderedDict([
('de_DE', 3),
('en-US', 2),
Expand All @@ -153,34 +209,37 @@ def test_dunder_getattr_with_weights(self, mock_choices_fn, mock_random_choice):
mock_choices_fn.assert_not_called()
mock_random_choice.assert_not_called()

# There are multiple locales, so provider mapping logic is guaranteed to run, and since there are
# distribution weights, factory selection logic (if necessary) will use `choices_distribution`
with patch('faker.proxy.Faker._map_provider_method',
wraps=self.faker._map_provider_method) as mock_map_method:
# Distribution weights have been specified, so factory selection logic will use
# `choices_distribution` if multiple factories have the specified provider method
with patch('faker.proxy.Faker._select_factory',
wraps=self.faker._select_factory) as mock_select_factory:

# All factories for the listed locales have the `name` provider method
self.faker.name()
mock_map_method.assert_called_once()
mock_select_factory.assert_called_once_with('name')
mock_choices_fn.assert_called_once_with(self.faker.factories, self.faker.weights, length=1)
mock_random_choice.assert_not_called()
mock_select_factory.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()

# Only `en_PH` factory has provider method `luzon_province`, so there is no
# need for `choices_distribution` factory selection logic to run
mock_map_method.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()
self.faker.luzon_province()
mock_map_method.assert_called_once()
mock_select_factory.assert_called_once_with('luzon_province')
mock_choices_fn.assert_not_called()
mock_random_choice.assert_not_called()

# Both `en_US` and `ja_JP` factories have provider method `zipcode`
mock_map_method.reset_mock()
mock_select_factory.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()

# Both `en_US` and `ja_JP` factories have provider method `zipcode`
self.faker.zipcode()
mock_map_method.assert_called_once()
mock_select_factory.assert_called_once()
mock_choices_fn.assert_called_once_with(
[self.faker['en_US'], self.faker['ja_JP']], [2, 5], length=1,
)
mock_random_choice.assert_not_called()
mock_select_factory.reset_mock()
mock_choices_fn.reset_mock()
mock_random_choice.reset_mock()

0 comments on commit 997b3c1

Please sign in to comment.