Skip to content

Commit 7002716

Browse files
author
PR Eval
committed
Merge branch 'pr-2312'
2 parents 6845234 + c75dd9c commit 7002716

3 files changed

Lines changed: 145 additions & 27 deletions

File tree

faker2/proxy.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import copy
44
import functools
5+
import logging
56
import re
67

78
from collections import OrderedDict
@@ -19,10 +20,13 @@
1920

2021
RetType = TypeVar("RetType")
2122

23+
logger = logging.getLogger(__name__)
24+
2225

2326
class Faker:
2427
"""Proxy class capable of supporting multiple locales"""
2528

29+
cache_attr_name = "_cached_{method_name}_mapping"
2630
cache_pattern: Pattern = re.compile(r"^_cached_\w*_mapping$")
2731
generator_attrs = [
2832
attr for attr in dir(Generator) if not attr.startswith("__") and attr not in ["seed", "seed_instance", "random"]
@@ -39,8 +43,6 @@ def __init__(
3943
) -> None:
4044
self._factory_map: OrderedDict[str, Generator | Faker] = OrderedDict()
4145
self._weights = None
42-
self._unique_proxy = UniqueProxy(self)
43-
self._optional_proxy = OptionalProxy(self)
4446

4547
if isinstance(locale, str):
4648
locales = [locale.replace("-", "_")]
@@ -146,21 +148,26 @@ def __deepcopy__(self, memodict):
146148
result._factory_map = copy.deepcopy(self._factory_map, memodict)
147149
result._factories = list(result._factory_map.values())
148150
result._weights = copy.deepcopy(self._weights, memodict)
149-
result._unique_proxy = UniqueProxy(result)
150-
result._unique_proxy._seen = {k: {result._unique_proxy._sentinel} for k in self._unique_proxy._seen.keys()}
151-
result._optional_proxy = OptionalProxy(result)
151+
result.unique._seen = {k: {result.unique._sentinel} for k in self.unique._seen.keys()}
152+
result.preferred_unique._seen = {
153+
k: {result.preferred_unique._sentinel} for k in self.preferred_unique._seen.keys()
154+
}
152155
return result
153156

154157
def __setstate__(self, state: Any) -> None:
155158
self.__dict__.update(state)
156159

157-
@property
160+
@functools.cached_property
158161
def unique(self) -> UniqueProxy:
159-
return self._unique_proxy
162+
return UniqueProxy(self)
160163

161-
@property
164+
@functools.cached_property
165+
def preferred_unique(self) -> UniqueProxy:
166+
return UniqueProxy(self, only_prefer_uniqueness=True)
167+
168+
@functools.cached_property
162169
def optional(self) -> OptionalProxy:
163-
return self._optional_proxy
170+
return OptionalProxy(self)
164171

165172
def _select_factory(self, method_name: str) -> Factory:
166173
"""
@@ -182,6 +189,7 @@ def _select_factory(self, method_name: str) -> Factory:
182189
factory = self._select_factory_distribution(factories, weights)
183190
else:
184191
factory = self._select_factory_choice(factories)
192+
185193
return factory
186194

187195
def _select_factory_distribution(self, factories, weights):
@@ -202,7 +210,7 @@ def _map_provider_method(self, method_name: str) -> tuple[list[Factory], list[fl
202210
"""
203211

204212
# Return cached mapping if it exists for given method
205-
attr = f"_cached_{method_name}_mapping"
213+
attr = self.cache_attr_name.format(method_name=method_name)
206214
if hasattr(self, attr):
207215
return getattr(self, attr)
208216

@@ -299,11 +307,16 @@ def items(self) -> list[tuple[str, Generator | Faker]]:
299307

300308

301309
class UniqueProxy:
302-
def __init__(self, proxy: Faker, excluded_types: tuple[type, ...] = ()):
310+
def __init__(self, proxy: Faker, excluded_types: tuple[type, ...] = (), only_prefer_uniqueness: bool = False):
303311
self._proxy = proxy
304312
self._seen: dict = {}
305313
self._sentinel = object()
306314
self._excluded_types = excluded_types
315+
self._only_prefer_uniqueness = only_prefer_uniqueness
316+
# Round-robin state for ``current_*`` methods. Kept private to this
317+
# proxy so that the factory mapping cached on the ``Faker`` proxy and
318+
# shared with regular (non-unique) calls is never mutated.
319+
self._current_factory_pool: dict = {}
307320

308321
def clear(self) -> None:
309322
self._seen = {}
@@ -337,11 +350,13 @@ def __getitem__(self, locale: str) -> UniqueProxy:
337350
return unique_proxy
338351

339352
def __getattr__(self, name: str) -> Any:
353+
if name.startswith("current_"):
354+
return self._select_current_factory_method(name)
355+
340356
obj = getattr(self._proxy, name)
341-
if callable(obj):
357+
if callable(obj) and not name.startswith("__"):
342358
return self._wrap(name, obj)
343-
else:
344-
raise TypeError("Accessing non-functions through .unique is not supported.")
359+
return obj
345360

346361
def __getstate__(self):
347362
# Copy the object's state from self.__dict__ which contains
@@ -366,6 +381,9 @@ def _make_hashable(self, value: Any) -> Any:
366381
def _wrap(self, name: str, function: Callable) -> Callable:
367382
@functools.wraps(function)
368383
def wrapper(*args, **kwargs):
384+
key = (name, args, tuple(sorted(kwargs.items())))
385+
generated = self._seen.setdefault(key, {self._sentinel})
386+
369387
# If types are excluded, call function once to check return type
370388
if self._excluded_types:
371389
retval = function(*args, **kwargs)
@@ -375,8 +393,6 @@ def wrapper(*args, **kwargs):
375393
# If not excluded, continue with normal uniqueness logic
376394
# but we already have a value, so we'll use it if unique
377395
hashable_retval = self._make_hashable(retval)
378-
key = (name, args, tuple(sorted(kwargs.items())))
379-
generated = self._seen.setdefault(key, {self._sentinel})
380396

381397
# Check if this first value is unique
382398
if hashable_retval not in generated:
@@ -385,26 +401,66 @@ def wrapper(*args, **kwargs):
385401
# Not unique, continue with normal loop below
386402
else:
387403
# No exclusions, use original logic
388-
key = (name, args, tuple(sorted(kwargs.items())))
389-
generated = self._seen.setdefault(key, {self._sentinel})
390404
retval = self._sentinel
391405
hashable_retval = self._make_hashable(retval)
392406

393407
# Original uniqueness logic (with potential first attempt already done)
394-
for i in range(_UNIQUE_ATTEMPTS):
395-
if hashable_retval not in generated:
408+
for _ in range(_UNIQUE_ATTEMPTS):
409+
if hashable_retval is None or hashable_retval not in generated:
396410
break
397411
retval = function(*args, **kwargs)
398412
hashable_retval = self._make_hashable(retval)
399413
else:
400-
raise UniquenessException(f"Got duplicated values after {_UNIQUE_ATTEMPTS:,} iterations.")
414+
if self._only_prefer_uniqueness:
415+
logger.warning(
416+
f'There seem to be no more unique values for generator "{name}". '
417+
"Resetting store of generated values as uniqueness is not being enforced."
418+
)
419+
generated.clear()
420+
else:
421+
raise UniquenessException(f"Got duplicated values after {_UNIQUE_ATTEMPTS:,} iterations.")
401422

402423
generated.add(hashable_retval)
403424

404425
return retval
405426

406427
return wrapper
407428

429+
def _select_current_factory_method(self, name: str) -> Any:
430+
"""Round-robin through the factories supporting a ``current_*`` method.
431+
432+
The value of a ``current_*`` method is a property of its locale, so it
433+
cannot be made unique by re-rolling it. Instead, draw factories without
434+
replacement from a pool that is refilled once exhausted, so that
435+
consecutive calls go through all possible provider options.
436+
437+
The pool is stored on this proxy: the factory mapping cached on the
438+
``Faker`` proxy is only ever read here, never mutated, so regular
439+
(non-unique) calls keep access to every locale.
440+
"""
441+
# No need to re-roll the factory list if only one is present
442+
if len(getattr(self._proxy, "factories", [])) <= 1:
443+
return getattr(self._proxy, name)
444+
445+
factories, weights = self._proxy._map_provider_method(name)
446+
if not factories:
447+
# Let the proxy raise the usual AttributeError
448+
return getattr(self._proxy, name)
449+
450+
pool = self._current_factory_pool.setdefault(name, list(factories))
451+
if weights:
452+
pool_weights = [weights[factories.index(factory)] for factory in pool]
453+
factory = choices_distribution(pool, pool_weights, self._proxy._factories[0].random, length=1)[0]
454+
else:
455+
factory = self._proxy._factories[0].random.choice(pool)
456+
457+
pool.remove(factory)
458+
# Refill the pool once every factory has been used
459+
if not pool:
460+
self._current_factory_pool[name] = list(factories)
461+
462+
return getattr(factory, name)
463+
408464

409465
class OptionalProxy:
410466
"""

tests/test_proxy.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,8 +427,6 @@ def test_dir_include_all_providers_attribute_in_list(self):
427427
"_locales",
428428
"_factory_map",
429429
"_weights",
430-
"_unique_proxy",
431-
"_optional_proxy",
432430
]
433431
)
434432
for factory in fake.factories:

tests/test_unique.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import logging
2+
13
import pytest
24

35
from faker2 import Faker
6+
from faker2.config import AVAILABLE_LOCALES, DEFAULT_LOCALE
47
from faker2.exceptions import UniquenessException
58

9+
LOGGER = logging.getLogger(__name__)
10+
611

712
class TestUniquenessClass:
813
def test_uniqueness(self):
@@ -52,14 +57,13 @@ def test_exclusive_arguments(self):
5257
# this would throw a sanity exception
5358
fake.unique.random_int(min=2, max=10)
5459

55-
def test_functions_only(self):
60+
def test_accessing_non_function(self):
5661
"""Accessing non-functions through the `.unique` attribute
57-
will throw a TypeError."""
62+
is allowed."""
5863

5964
fake = Faker()
6065

61-
with pytest.raises(TypeError, match="Accessing non-functions through .unique is not supported."):
62-
fake.unique.locales
66+
assert fake.unique.locales == [DEFAULT_LOCALE]
6367

6468
def test_complex_return_types_is_supported(self):
6569
"""The unique decorator supports complex return types
@@ -95,3 +99,63 @@ def test_unique_locale_access(self):
9599

96100
with pytest.raises(UniquenessException, match=r"Got duplicated values after [\d,]+ iterations."):
97101
fake.unique["ja_JP"].random_int(min=1, max=10)
102+
103+
def test_preferred_uniqueness(self, caplog):
104+
fake = Faker()
105+
106+
with caplog.at_level(logging.WARNING):
107+
for i in range(3):
108+
_ = fake.preferred_unique.boolean()
109+
assert (
110+
'There seem to be no more unique values for generator "boolean". '
111+
"Resetting store of generated values as uniqueness is not being enforced."
112+
) in caplog.text
113+
114+
def test_current_values_exempt_from_unique_check(self):
115+
fake = Faker()
116+
117+
country_first_attempt = fake.unique.current_country()
118+
assert country_first_attempt == fake.unique.current_country()
119+
120+
def test_initial_current_values_with_multiple_locales_are_unique(self):
121+
fake = Faker(AVAILABLE_LOCALES)
122+
123+
all_country_codes_with_locales = {Faker(locale).current_country_code() for locale in AVAILABLE_LOCALES}
124+
generated_country_codes = {fake.unique.current_country_code() for _ in range(len(AVAILABLE_LOCALES))}
125+
126+
assert all_country_codes_with_locales == generated_country_codes
127+
128+
def test_current_values_start_repeating_after_locales_exhausted(self):
129+
fake = Faker({"en_US": 1, "fr_FR": 2}, use_weighting=True)
130+
131+
locale_count = len(fake.locales)
132+
generated_countries = {fake.unique.current_country() for _ in range(locale_count)}
133+
assert len(generated_countries) == locale_count
134+
assert fake.unique.current_country() in generated_countries
135+
136+
def test_unique_current_does_not_shrink_shared_factory_cache(self):
137+
"""Round-robin over `current_*` factories must stay private to `.unique`.
138+
139+
Using `fake.unique.current_country()` must not evict locales from the
140+
factory mapping cached on the proxy and shared with regular calls.
141+
"""
142+
locales = ["en_US", "fr_FR", "de_DE", "ja_JP"]
143+
fake = Faker(locales)
144+
expected_countries = {Faker(locale).current_country() for locale in locales}
145+
146+
# Consume one factory through the unique proxy
147+
fake.unique.current_country()
148+
149+
# Regular calls must still be able to reach every locale
150+
generated = {fake.current_country() for _ in range(1000)}
151+
assert generated == expected_countries
152+
153+
# ... and the unique proxy still cycles through all of them
154+
remaining = {fake.unique.current_country() for _ in range(len(locales) - 1)}
155+
assert len(remaining) == len(locales) - 1
156+
157+
def test_none_values_exempt_from_unique_check(self):
158+
fake = Faker()
159+
160+
for _ in range(2):
161+
assert fake.unique.seed_locale("en_US", 0) is None

0 commit comments

Comments
 (0)