diff --git a/docs/ideas.rst b/docs/ideas.rst index b9f3691b..964cba0c 100644 --- a/docs/ideas.rst +++ b/docs/ideas.rst @@ -6,4 +6,4 @@ This is a list of future features that may be incorporated into factory_boy: * When a :class:`~factory.Factory` is built or created, pass the calling context throughout the calling chain instead of custom solutions everywhere * Define a proper set of rules for the support of third-party ORMs -* Properly evaluate nested declarations (e.g ``factory.fuzzy.FuzzyDate(start_date=factory.SelfAttribute('since'))``) +* Properly evaluate nested declarations (e.g ``factory.fuzzy.FuzzyDate(start_date=factory.SelfAttribute('since'))``) (Accomplished) diff --git a/factory/fuzzy.py b/factory/fuzzy.py index ce89d7f7..55ae5dc3 100644 --- a/factory/fuzzy.py +++ b/factory/fuzzy.py @@ -24,11 +24,16 @@ class BaseFuzzyAttribute(declarations.BaseDeclaration): Custom fuzzers should override the `fuzz()` method. """ - def fuzz(self): # pragma: no cover + def _resolve(self, value, instance, step): + if isinstance(value, declarations.BaseDeclaration): + return value.evaluate_pre(instance=instance, step=step, overrides={}) + return value + + def fuzz(self, instance, step): raise NotImplementedError() def evaluate(self, instance, step, extra): - return self.fuzz() + return self.fuzz(instance, step) class FuzzyAttribute(BaseFuzzyAttribute): @@ -43,7 +48,7 @@ def __init__(self, fuzzer): super().__init__() self.fuzzer = fuzzer - def fuzz(self): + def fuzz(self, instance, step): return self.fuzzer() @@ -71,7 +76,7 @@ def __init__(self, prefix='', length=12, suffix='', chars=string.ascii_letters): self.length = length self.chars = tuple(chars) # Unroll iterators - def fuzz(self): + def fuzz(self, instance, step): chars = [random.randgen.choice(self.chars) for _i in range(self.length)] return self.prefix + ''.join(chars) + self.suffix @@ -91,9 +96,10 @@ def __init__(self, choices, getter=None): self.getter = getter super().__init__() - def fuzz(self): + def fuzz(self, instance, step): if self.choices is None: - self.choices = list(self.choices_generator) + resolved = self._resolve(self.choices_generator, instance, step) + self.choices = list(resolved) value = random.randgen.choice(self.choices) if self.getter is None: return value @@ -104,36 +110,36 @@ class FuzzyInteger(BaseFuzzyAttribute): """Random integer within a given range.""" def __init__(self, low, high=None, step=1): - if high is None: - high = low - low = 0 - self.low = low self.high = high self.step = step - super().__init__() - def fuzz(self): - return random.randgen.randrange(self.low, self.high + 1, self.step) + def fuzz(self, instance, step): + low = self._resolve(self.low, instance, step) + high = self._resolve(self.high, instance, step) + if high is None: + high = low + low = 0 + return random.randgen.randrange(low, high + 1, self.step) class FuzzyDecimal(BaseFuzzyAttribute): """Random decimal within a given range.""" def __init__(self, low, high=None, precision=2): - if high is None: - high = low - low = 0.0 - self.low = low self.high = high self.precision = precision - super().__init__() - def fuzz(self): - base = decimal.Decimal(str(random.randgen.uniform(self.low, self.high))) + def fuzz(self, instance, step): + low = self._resolve(self.low, instance, step) + high = self._resolve(self.high, instance, step) + if high is None: + high = low + low = 0.0 + base = decimal.Decimal(str(random.randgen.uniform(low, high))) return base.quantize(decimal.Decimal(10) ** -self.precision) @@ -141,18 +147,18 @@ class FuzzyFloat(BaseFuzzyAttribute): """Random float within a given range.""" def __init__(self, low, high=None, precision=15): - if high is None: - high = low - low = 0 - self.low = low self.high = high self.precision = precision - super().__init__() - def fuzz(self): - base = random.randgen.uniform(self.low, self.high) + def fuzz(self, instance, step): + low = self._resolve(self.low, instance, step) + high = self._resolve(self.high, instance, step) + if high is None: + high = low + low = 0 + base = random.randgen.uniform(low, high) return float(format(base, '.%dg' % self.precision)) @@ -161,22 +167,40 @@ class FuzzyDate(BaseFuzzyAttribute): def __init__(self, start_date, end_date=None): super().__init__() - if end_date is None: - if random.randgen.state_set: - cls_name = self.__class__.__name__ - warnings.warn(random_seed_warning.format(cls_name), stacklevel=2) - end_date = datetime.date.today() - - if start_date > end_date: - raise ValueError( - "FuzzyDate boundaries should have start <= end; got %r > %r." - % (start_date, end_date)) - - self.start_date = start_date.toordinal() - self.end_date = end_date.toordinal() - - def fuzz(self): - return datetime.date.fromordinal(random.randgen.randint(self.start_date, self.end_date)) + self._start_is_decl = isinstance(start_date, declarations.BaseDeclaration) + self._end_is_decl = isinstance(end_date, declarations.BaseDeclaration) + self.start_date = start_date + self.end_date = end_date + if not self._start_is_decl and not self._end_is_decl: + if end_date is None: + if random.randgen.state_set: + cls_name = self.__class__.__name__ + warnings.warn(random_seed_warning.format(cls_name), stacklevel=2) + end_date = datetime.date.today() + self.end_date = end_date + if start_date > end_date: + raise ValueError( + "FuzzyDate boundaries should have start <= end; got %r > %r." + % (start_date, end_date)) + self._start_ord = start_date.toordinal() + self._end_ord = end_date.toordinal() + + def fuzz(self, instance, step): + if self._start_is_decl or self._end_is_decl: + start_date = self._resolve(self.start_date, instance, step) + end_date = self._resolve(self.end_date, instance, step) + if end_date is None: + end_date = datetime.date.today() + if start_date > end_date: + raise ValueError( + "FuzzyDate boundaries should have start <= end; got %r > %r." + % (start_date, end_date)) + start_ord = start_date.toordinal() + end_ord = end_date.toordinal() + else: + start_ord = self._start_ord + end_ord = self._end_ord + return datetime.date.fromordinal(random.randgen.randint(start_ord, end_ord)) class BaseFuzzyDateTime(BaseFuzzyAttribute): @@ -199,15 +223,8 @@ def __init__(self, start_dt, end_dt=None, force_hour=None, force_minute=None, force_second=None, force_microsecond=None): super().__init__() - - if end_dt is None: - if random.randgen.state_set: - cls_name = self.__class__.__name__ - warnings.warn(random_seed_warning.format(cls_name), stacklevel=2) - end_dt = self._now() - - self._check_bounds(start_dt, end_dt) - + self._start_is_decl = isinstance(start_dt, declarations.BaseDeclaration) + self._end_is_decl = isinstance(end_dt, declarations.BaseDeclaration) self.start_dt = start_dt self.end_dt = end_dt self.force_year = force_year @@ -217,13 +234,31 @@ def __init__(self, start_dt, end_dt=None, self.force_minute = force_minute self.force_second = force_second self.force_microsecond = force_microsecond - - def fuzz(self): - delta = self.end_dt - self.start_dt + if not self._start_is_decl and not self._end_is_decl: + if end_dt is None: + if random.randgen.state_set: + cls_name = self.__class__.__name__ + warnings.warn(random_seed_warning.format(cls_name), stacklevel=2) + end_dt = self._now() + self.end_dt = end_dt + self._check_bounds(start_dt, end_dt) + + def fuzz(self, instance, step): + if self._start_is_decl or self._end_is_decl: + start_dt = self._resolve(self.start_dt, instance, step) + end_dt = self._resolve(self.end_dt, instance, step) + if end_dt is None: + end_dt = self._now() + self._check_bounds(start_dt, end_dt) + else: + start_dt = self.start_dt + end_dt = self.end_dt + + delta = end_dt - start_dt microseconds = delta.microseconds + 1000000 * (delta.seconds + (delta.days * 86400)) offset = random.randgen.randint(0, microseconds) - result = self.start_dt + datetime.timedelta(microseconds=offset) + result = start_dt + datetime.timedelta(microseconds=offset) if self.force_year is not None: result = result.replace(year=self.force_year) diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index 67cf7030..4131cce7 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -7,7 +7,7 @@ import warnings from unittest import mock -from factory import fuzzy, random +from factory import Factory, LazyAttribute, LazyFunction, SelfAttribute, fuzzy, random from . import utils @@ -78,6 +78,47 @@ def test_getter(self): res = utils.evaluate_declaration(d) self.assertIn(res, [1, 2, 3]) + def test_lazy_attribute_as_choices(self): + """Issue #1050: FuzzyChoice should accept LazyAttribute.""" + + class MyFactory(Factory): + class Meta: + model = dict + country = fuzzy.FuzzyChoice( + LazyAttribute(lambda o: ['AU', 'GB', 'DE', 'US']) + ) + + for _i in range(5): + result = MyFactory() + self.assertIn(result['country'], ['AU', 'GB', 'DE', 'US']) + + def test_lazy_function_as_choices(self): + """Issue #1050: FuzzyChoice should accept LazyFunction.""" + + class MyFactory(Factory): + class Meta: + model = dict + country = fuzzy.FuzzyChoice( + LazyFunction(lambda: ['X', 'Y', 'Z']) + ) + + for _i in range(5): + result = MyFactory() + self.assertIn(result['country'], ['X', 'Y', 'Z']) + + def test_self_attribute_as_choices(self): + """Issue #1050: FuzzyChoice should accept SelfAttribute.""" + + class MyFactory(Factory): + class Meta: + model = dict + choices_tuple = ('AU', 'GB', 'DE', 'US') + country = fuzzy.FuzzyChoice(SelfAttribute('choices_tuple')) + + for _i in range(5): + result = MyFactory() + self.assertIn(result['country'], ('AU', 'GB', 'DE', 'US')) + class FuzzyIntegerTestCase(unittest.TestCase): def test_definition(self): @@ -122,6 +163,23 @@ def test_biased_with_step(self): self.assertEqual((5 + 8 + 1) * 3, res) + def test_lazy_attribute_as_bounds(self): + """FuzzyInteger should accept LazyAttribute for low/high.""" + + class MyFactory(Factory): + class Meta: + model = dict + low_bound = 5 + value = fuzzy.FuzzyInteger( + LazyAttribute(lambda o: o.low_bound), + LazyAttribute(lambda o: o.low_bound + 5) + ) + + for _i in range(5): + result = MyFactory() + self.assertGreaterEqual(result['value'], 5) + self.assertLessEqual(result['value'], 10) + class FuzzyDecimalTestCase(unittest.TestCase): def test_definition(self): @@ -193,6 +251,23 @@ def test_no_approximation(self): finally: decimal_context.traps[decimal.FloatOperation] = old_traps + def test_lazy_attribute_as_bounds(self): + """FuzzyDecimal should accept LazyAttribute for low/high.""" + + class MyFactory(Factory): + class Meta: + model = dict + low_bound = 5.0 + value = fuzzy.FuzzyDecimal( + LazyAttribute(lambda o: o.low_bound), + LazyAttribute(lambda o: o.low_bound + 5.0) + ) + + for _i in range(5): + result = MyFactory() + self.assertGreaterEqual(result['value'], decimal.Decimal('5.0')) + self.assertLessEqual(result['value'], decimal.Decimal('10.0')) + class FuzzyFloatTestCase(unittest.TestCase): def test_definition(self): @@ -253,6 +328,23 @@ def test_precision(self): self.assertEqual(8.001, res) + def test_lazy_attribute_as_bounds(self): + """FuzzyFloat should accept LazyAttribute for low/high.""" + + class MyFactory(Factory): + class Meta: + model = dict + low_bound = 5.0 + value = fuzzy.FuzzyFloat( + LazyAttribute(lambda o: o.low_bound), + LazyAttribute(lambda o: o.low_bound + 5.0) + ) + + for _i in range(5): + result = MyFactory() + self.assertGreaterEqual(result['value'], 5.0) + self.assertLessEqual(result['value'], 10.0) + class FuzzyDateTestCase(unittest.TestCase): @classmethod @@ -312,6 +404,37 @@ def test_biased_partial(self): self.assertEqual(datetime.date(2013, 1, 2), res) + def test_lazy_attribute_as_bounds(self): + """FuzzyDate should accept LazyAttribute for start/end dates.""" + + class MyFactory(Factory): + class Meta: + model = dict + base_date = datetime.date(2013, 1, 10) + value = fuzzy.FuzzyDate( + LazyAttribute(lambda o: o.base_date), + LazyAttribute(lambda o: o.base_date + datetime.timedelta(days=10)) + ) + + for _i in range(5): + result = MyFactory() + self.assertGreaterEqual(result['value'], datetime.date(2013, 1, 10)) + self.assertLessEqual(result['value'], datetime.date(2013, 1, 20)) + + def test_self_attribute_as_bounds(self): + """FuzzyDate should accept SelfAttribute for start/end dates.""" + + class MyFactory(Factory): + class Meta: + model = dict + since = datetime.date(2013, 1, 1) + value = fuzzy.FuzzyDate(start_date=SelfAttribute('since')) + + for _i in range(5): + result = MyFactory() + self.assertGreaterEqual(result['value'], datetime.date(2013, 1, 1)) + self.assertLessEqual(result['value'], datetime.date.today()) + class FuzzyNaiveDateTimeTestCase(unittest.TestCase): @classmethod @@ -548,6 +671,29 @@ def test_biased_partial(self): self.assertEqual(datetime.datetime(2013, 1, 2, tzinfo=datetime.timezone.utc), res) + def test_lazy_attribute_as_bounds(self): + """FuzzyDateTime should accept LazyAttribute for start/end datetimes.""" + + class MyFactory(Factory): + class Meta: + model = dict + base_dt = datetime.datetime(2013, 1, 10, tzinfo=datetime.timezone.utc) + value = fuzzy.FuzzyDateTime( + LazyAttribute(lambda o: o.base_dt), + LazyAttribute(lambda o: o.base_dt + datetime.timedelta(days=10)) + ) + + for _i in range(5): + result = MyFactory() + self.assertGreaterEqual( + result['value'], + datetime.datetime(2013, 1, 10, tzinfo=datetime.timezone.utc) + ) + self.assertLessEqual( + result['value'], + datetime.datetime(2013, 1, 20, tzinfo=datetime.timezone.utc) + ) + class FuzzyTextTestCase(unittest.TestCase):