diff options
author | Raphaël Barrois <raphael.barrois@polytechnique.org> | 2013-04-21 19:35:43 +0200 |
---|---|---|
committer | Raphaël Barrois <raphael.barrois@polytechnique.org> | 2013-04-21 19:38:50 +0200 |
commit | a48f6482f21994a44da14dafdeff8e7e1237cec9 (patch) | |
tree | daa9e1dc5ffdeeaf069cb2c759ce31bc7d4b4c0e | |
parent | b6ecfdf7b8f0b05b3e78fe3aafaf26d9c00c3259 (diff) | |
download | factory-boy-a48f6482f21994a44da14dafdeff8e7e1237cec9.tar factory-boy-a48f6482f21994a44da14dafdeff8e7e1237cec9.tar.gz |
Add FuzzyDateTime/FuzzyNaiveDateTime.
-rw-r--r-- | docs/fuzzy.rst | 131 | ||||
-rw-r--r-- | factory/compat.py | 28 | ||||
-rw-r--r-- | factory/fuzzy.py | 102 | ||||
-rw-r--r-- | tests/test_fuzzy.py | 238 | ||||
-rw-r--r-- | tests/utils.py | 100 |
5 files changed, 598 insertions, 1 deletions
diff --git a/docs/fuzzy.rst b/docs/fuzzy.rst index 7d56995..d88cb5a 100644 --- a/docs/fuzzy.rst +++ b/docs/fuzzy.rst @@ -94,7 +94,136 @@ FuzzyDate :class:`datetime.date`, the inclusive higher bound of generated dates - int, the inclusive higher bound of generated dates + +FuzzyDateTime +------------- + +.. class:: FuzzyDateTime(start_dt[, end_dt], tz=UTC, force_year=None, force_month=None, force_day=None, force_hour=None, force_minute=None, force_second=None, force_microsecond=None) + + The :class:`FuzzyDateTime` fuzzer generates random timezone-aware datetime within a given + inclusive range. + + The :attr:`end_dt` bound may be omitted, in which case it defaults to ``datetime.datetime.now()`` + localized into the UTC timezone. + + .. code-block:: pycon + + >>> fdt = FuzzyDateTime(datetime.datetime(2008, 1, 1, tzinfo=UTC)) + >>> fdt.start_dt, fdt.end_dt + datetime.datetime(2008, 1, 1, tzinfo=UTC), datetime.datetime(2013, 4, 21, 19, 13, 32, 458487, tzinfo=UTC) + + + The ``force_XXX`` keyword arguments force the related value of generated datetimes: + + .. code-block:: pycon + + >>> fdt = FuzzyDateTime(datetime.datetime(2008, 1, 1, tzinfo=UTC), datetime.datetime(2009, 1, 1, tzinfo=UTC), + ... force_day=3, force_second=42) + >>> fdt.evaluate(2, None, False) # Actual code used by ``SomeFactory.build()`` + datetime.datetime(2008, 5, 3, 12, 13, 42, 124848, tzinfo=UTC) + + + .. attribute:: start_dt + + :class:`datetime.datetime`, the inclusive lower bound of generated datetimes + + .. attribute:: end_dt + + :class:`datetime.datetime`, the inclusive upper bound of generated datetimes + + + .. attribute:: force_year + + int or None; if set, forces the :attr:`~datetime.datetime.year` of generated datetime. + + .. attribute:: force_month + + int or None; if set, forces the :attr:`~datetime.datetime.month` of generated datetime. + + .. attribute:: force_day + + int or None; if set, forces the :attr:`~datetime.datetime.day` of generated datetime. + + .. attribute:: force_hour + + int or None; if set, forces the :attr:`~datetime.datetime.hour` of generated datetime. + + .. attribute:: force_minute + + int or None; if set, forces the :attr:`~datetime.datetime.minute` of generated datetime. + + .. attribute:: force_second + + int or None; if set, forces the :attr:`~datetime.datetime.second` of generated datetime. + + .. attribute:: force_microsecond + + int or None; if set, forces the :attr:`~datetime.datetime.microsecond` of generated datetime. + + +FuzzyNaiveDateTime +------------------ + +.. class:: FuzzyNaiveDateTime(start_dt[, end_dt], force_year=None, force_month=None, force_day=None, force_hour=None, force_minute=None, force_second=None, force_microsecond=None) + + The :class:`FuzzyNaiveDateTime` fuzzer generates random naive datetime within a given + inclusive range. + + The :attr:`end_dt` bound may be omitted, in which case it defaults to ``datetime.datetime.now()``: + + .. code-block:: pycon + + >>> fdt = FuzzyNaiveDateTime(datetime.datetime(2008, 1, 1)) + >>> fdt.start_dt, fdt.end_dt + datetime.datetime(2008, 1, 1), datetime.datetime(2013, 4, 21, 19, 13, 32, 458487) + + + The ``force_XXX`` keyword arguments force the related value of generated datetimes: + + .. code-block:: pycon + + >>> fdt = FuzzyNaiveDateTime(datetime.datetime(2008, 1, 1), datetime.datetime(2009, 1, 1), + ... force_day=3, force_second=42) + >>> fdt.evaluate(2, None, False) # Actual code used by ``SomeFactory.build()`` + datetime.datetime(2008, 5, 3, 12, 13, 42, 124848) + + + .. attribute:: start_dt + + :class:`datetime.datetime`, the inclusive lower bound of generated datetimes + + .. attribute:: end_dt + + :class:`datetime.datetime`, the inclusive upper bound of generated datetimes + + + .. attribute:: force_year + + int or None; if set, forces the :attr:`~datetime.datetime.year` of generated datetime. + + .. attribute:: force_month + + int or None; if set, forces the :attr:`~datetime.datetime.month` of generated datetime. + + .. attribute:: force_day + + int or None; if set, forces the :attr:`~datetime.datetime.day` of generated datetime. + + .. attribute:: force_hour + + int or None; if set, forces the :attr:`~datetime.datetime.hour` of generated datetime. + + .. attribute:: force_minute + + int or None; if set, forces the :attr:`~datetime.datetime.minute` of generated datetime. + + .. attribute:: force_second + + int or None; if set, forces the :attr:`~datetime.datetime.second` of generated datetime. + + .. attribute:: force_microsecond + + int or None; if set, forces the :attr:`~datetime.datetime.microsecond` of generated datetime. Custom fuzzy fields diff --git a/factory/compat.py b/factory/compat.py index 84f31b7..9594550 100644 --- a/factory/compat.py +++ b/factory/compat.py @@ -23,6 +23,7 @@ """Compatibility tools""" +import datetime import sys PY2 = (sys.version_info[0] == 2) @@ -33,3 +34,30 @@ if PY2: else: def is_string(obj): return isinstance(obj, str) + +try: + # Python >= 3.2 + UTC = datetime.timezone.utc +except AttributeError: + try: + # Fallback to pytz + from pytz import UTC + except ImportError: + + # Ok, let's write our own. + class _UTC(datetime.tzinfo): + """The UTC tzinfo.""" + + def utcoffset(self, dt): + return datetime.timedelta(0) + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return datetime.timedelta(0) + + def localize(self, dt): + dt.astimezone(self) + + UTC = _UTC() diff --git a/factory/fuzzy.py b/factory/fuzzy.py index fea7b05..e266b93 100644 --- a/factory/fuzzy.py +++ b/factory/fuzzy.py @@ -27,6 +27,7 @@ import random import datetime +from . import compat from . import declarations @@ -104,3 +105,104 @@ class FuzzyDate(BaseFuzzyAttribute): def fuzz(self): return datetime.date.fromordinal(random.randint(self.start_date, self.end_date)) + + +class BaseFuzzyDateTime(BaseFuzzyAttribute): + """Base class for fuzzy datetime-related attributes. + + Provides fuzz() computation, forcing year/month/day/hour/... + """ + + def _check_bounds(self, start_dt, end_dt): + if start_dt > end_dt: + raise ValueError( + """%s boundaries should have start <= end, got %r > %r""" % ( + self.__class__.__name__, start_dt, end_dt)) + + def __init__(self, start_dt, end_dt=None, + force_year=None, force_month=None, force_day=None, + force_hour=None, force_minute=None, force_second=None, + force_microsecond=None, **kwargs): + super(BaseFuzzyDateTime, self).__init__(**kwargs) + + if end_dt is None: + end_dt = self._now() + + self._check_bounds(start_dt, end_dt) + + self.start_dt = start_dt + self.end_dt = end_dt + self.force_year = force_year + self.force_month = force_month + self.force_day = force_day + self.force_hour = force_hour + 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 + microseconds = delta.microseconds + 1000000 * (delta.seconds + (delta.days * 86400)) + + offset = random.randint(0, microseconds) + result = self.start_dt + datetime.timedelta(microseconds=offset) + + if self.force_year is not None: + result = result.replace(year=self.force_year) + if self.force_month is not None: + result = result.replace(month=self.force_month) + if self.force_day is not None: + result = result.replace(day=self.force_day) + if self.force_hour is not None: + result = result.replace(hour=self.force_hour) + if self.force_minute is not None: + result = result.replace(minute=self.force_minute) + if self.force_second is not None: + result = result.replace(second=self.force_second) + if self.force_microsecond is not None: + result = result.replace(microsecond=self.force_microsecond) + + return result + + +class FuzzyNaiveDateTime(BaseFuzzyDateTime): + """Random naive datetime within a given range. + + If no upper bound is given, will default to datetime.datetime.utcnow(). + """ + + def _now(self): + return datetime.datetime.now() + + def _check_bounds(self, start_dt, end_dt): + if start_dt.tzinfo is not None: + raise ValueError( + "FuzzyNaiveDateTime only handles naive datetimes, got start=%r" + % start_dt) + if end_dt.tzinfo is not None: + raise ValueError( + "FuzzyNaiveDateTime only handles naive datetimes, got end=%r" + % end_dt) + super(FuzzyNaiveDateTime, self)._check_bounds(start_dt, end_dt) + + +class FuzzyDateTime(BaseFuzzyDateTime): + """Random timezone-aware datetime within a given range. + + If no upper bound is given, will default to datetime.datetime.now() + If no timezone is given, will default to utc. + """ + + def _now(self): + return datetime.datetime.now(tz=compat.UTC) + + def _check_bounds(self, start_dt, end_dt): + if start_dt.tzinfo is None: + raise ValueError( + "FuzzyDateTime only handles aware datetimes, got start=%r" + % start_dt) + if end_dt.tzinfo is None: + raise ValueError( + "FuzzyDateTime only handles aware datetimes, got end=%r" + % end_dt) + super(FuzzyDateTime, self)._check_bounds(start_dt, end_dt) diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index e3b5772..97abece 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -23,9 +23,11 @@ import datetime +from factory import compat from factory import fuzzy from .compat import mock, unittest +from . import utils class FuzzyAttributeTestCase(unittest.TestCase): @@ -163,3 +165,239 @@ class FuzzyDateTestCase(unittest.TestCase): res = fuzz.evaluate(2, None, False) self.assertEqual(datetime.date(2013, 1, 2), res) + + +class FuzzyNaiveDateTimeTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Setup useful constants + cls.jan1 = datetime.datetime(2013, 1, 1) + cls.jan3 = datetime.datetime(2013, 1, 3) + cls.jan31 = datetime.datetime(2013, 1, 31) + + def test_accurate_definition(self): + """Tests explicit definition of a FuzzyNaiveDateTime.""" + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan31) + + def test_partial_definition(self): + """Test defining a FuzzyNaiveDateTime without passing an end date.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan3) + + def test_aware_start(self): + """Tests that a timezone-aware start datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan1.replace(tzinfo=compat.UTC), self.jan31) + + def test_aware_end(self): + """Tests that a timezone-aware end datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan1, self.jan31.replace(tzinfo=compat.UTC)) + + def test_force_year(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_year=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.year) + + def test_force_month(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_month=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.month) + + def test_force_day(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_day=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.day) + + def test_force_hour(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_hour=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.hour) + + def test_force_minute(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_minute=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.minute) + + def test_force_second(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_second=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.second) + + def test_force_microsecond(self): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_microsecond=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.microsecond) + + def test_invalid_definition(self): + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan31, self.jan1) + + def test_invalid_partial_definition(self): + with utils.mocked_datetime_now(self.jan1, fuzzy): + self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, + self.jan31) + + def test_biased(self): + """Tests a FuzzyDate with a biased random.randint.""" + + fake_randint = lambda low, high: (low + high) // 2 + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31) + + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 16), res) + + def test_biased_partial(self): + """Tests a FuzzyDate with a biased random and implicit upper bound.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1) + + fake_randint = lambda low, high: (low + high) // 2 + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 2), res) + + +class FuzzyDateTimeTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Setup useful constants + cls.jan1 = datetime.datetime(2013, 1, 1, tzinfo=compat.UTC) + cls.jan3 = datetime.datetime(2013, 1, 3, tzinfo=compat.UTC) + cls.jan31 = datetime.datetime(2013, 1, 31, tzinfo=compat.UTC) + + def test_accurate_definition(self): + """Tests explicit definition of a FuzzyDateTime.""" + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan31) + + def test_partial_definition(self): + """Test defining a FuzzyDateTime without passing an end date.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyDateTime(self.jan1) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertLessEqual(self.jan1, res) + self.assertLessEqual(res, self.jan3) + + def test_invalid_definition(self): + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan31, self.jan1) + + def test_invalid_partial_definition(self): + with utils.mocked_datetime_now(self.jan1, fuzzy): + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan31) + + def test_naive_start(self): + """Tests that a timezone-naive start datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan1.replace(tzinfo=None), self.jan31) + + def test_naive_end(self): + """Tests that a timezone-naive end datetime is rejected.""" + self.assertRaises(ValueError, fuzzy.FuzzyDateTime, + self.jan1, self.jan31.replace(tzinfo=None)) + + def test_force_year(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_year=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.year) + + def test_force_month(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_month=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.month) + + def test_force_day(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_day=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.day) + + def test_force_hour(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_hour=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.hour) + + def test_force_minute(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_minute=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.minute) + + def test_force_second(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_second=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.second) + + def test_force_microsecond(self): + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_microsecond=4) + + for _i in range(20): + res = fuzz.evaluate(2, None, False) + self.assertEqual(4, res.microsecond) + + def test_biased(self): + """Tests a FuzzyDate with a biased random.randint.""" + + fake_randint = lambda low, high: (low + high) // 2 + fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31) + + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 16, tzinfo=compat.UTC), res) + + def test_biased_partial(self): + """Tests a FuzzyDate with a biased random and implicit upper bound.""" + with utils.mocked_datetime_now(self.jan3, fuzzy): + fuzz = fuzzy.FuzzyDateTime(self.jan1) + + fake_randint = lambda low, high: (low + high) // 2 + with mock.patch('random.randint', fake_randint): + res = fuzz.evaluate(2, None, False) + + self.assertEqual(datetime.datetime(2013, 1, 2, tzinfo=compat.UTC), res) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..823e82b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2010 Mark Sandstrom +# Copyright (c) 2011-2013 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import datetime + +from .compat import mock + + +class MultiModulePatcher(object): + """An abstract context processor for patching multiple modules.""" + + replaced_symbol = None + replaced_symbol_module = None + module_imported_as = '' + + def __init__(self, *target_modules, **kwargs): + super(MultiModulePatcher, self).__init__(**kwargs) + if not self.module_imported_as: + self.module_imported_as = replaced_symbol.__module__.__name__ + self.patchers = [self._build_patcher(mod) for mod in target_modules] + + def _check_module(self, target_module): + if not self.replaced_symbol_module: + # No check to perform + return + + replaced_import = getattr(target_module, self.module_imported_as) + assert replaced_import is self.replaced_symbol_module, ( + "Cannot patch import %s.%s (%r != %r)" % ( + target_module.__name__, self.module_imported_as, + replaced_import, self.replaced_symbol_module)) + + def _build_patcher(self, target_module): + """Build a mock patcher for the target module.""" + self._check_module(target_module) + + return mock.patch.object( + getattr(target_module, self.module_imported_as), + self.replaced_symbol.__name__, + mock.Mock(wraps=self.replaced_symbol), + ) + + def setup_mocked_symbol(self, mocked_symbol): + """Setup a mocked symbol for later use.""" + pass + + def __enter__(self): + for patcher in self.patchers: + mocked_symbol = patcher.start() + self.setup_mocked_symbol(mocked_symbol) + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + for patcher in self.patchers: + patcher.stop() + + +class mocked_date_today(MultiModulePatcher): + """A context processor changing the value of date.today().""" + replaced_symbol = datetime.date + replaced_symbol_module = datetime + module_imported_as = 'datetime' + + def __init__(self, target_date, *target_modules, **kwargs): + self.target_date = target_date + super(mocked_date_today, self).__init__(*target_modules, **kwargs) + + def setup_mocked_symbol(self, mocked_date): + mocked_date.today.return_value = self.target_date + + +class mocked_datetime_now(MultiModulePatcher): + replaced_symbol = datetime.datetime + replaced_symbol_module = datetime + module_imported_as = 'datetime' + + def __init__(self, target_dt, *target_modules, **kwargs): + self.target_dt = target_dt + super(mocked_datetime_now, self).__init__(*target_modules, **kwargs) + + def setup_mocked_symbol(self, mocked_datetime): + mocked_datetime.now.return_value = self.target_dt |