Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Datetimes have a UTC timezone by default #601

Merged
merged 7 commits into from
Feb 4, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions examples/datetime_with_params.recipe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- object: Contact
fields:
FirstName:
fake: FirstName
LastName:
fake: LastName
EmailBouncedDate:
fake.datetime:
start_date: -10y
end_date: now
timezone:
relativedelta:
hours: +8
8 changes: 8 additions & 0 deletions examples/simple_datetime.recipe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- object: Contact
fields:
FirstName:
fake: FirstName
LastName:
fake: LastName
EmailBouncedDate:
fake: DateTime
8 changes: 6 additions & 2 deletions snowfakery/data_generator_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .data_gen_exceptions import DataGenSyntaxError, DataGenNameError
import snowfakery # noQA
from snowfakery.object_rows import NicknameSlot, SlotState, ObjectRow
from snowfakery.plugins import PluginContext, SnowfakeryPlugin
from snowfakery.plugins import PluginContext, SnowfakeryPlugin, Scalar
from snowfakery.utils.collections import OrderedSet

OutputStream = "snowfakery.output_streams.OutputStream"
Expand Down Expand Up @@ -585,7 +585,11 @@ def executable_blocks(self):
return {**self.field_funcs(), "fake": self.fake}

def fake(self, name):
return str(self.runtime_context.faker_template_library._get_fake_data(name))
val = self.runtime_context.faker_template_library._get_fake_data(name)
if isinstance(val, Scalar.__args__):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use typing.get_args() here instead:

Suggested change
if isinstance(val, Scalar.__args__):
if isinstance(val, T.get_args(scalar)):

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't do it exactly that way but: b622e7c

Copy link
Contributor

@jstvz jstvz Feb 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@prescod Did you mean to replace this with ScalarTypes?

return val
else:
return str(val)

def field_vars(self):
return {**self.simple_field_vars(), **self.field_funcs()}
Expand Down
79 changes: 71 additions & 8 deletions snowfakery/fakedata/fake_data_generator.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from difflib import get_close_matches
import typing as T
import random
from snowfakery.plugins import PluginContext
import typing as T
import datetime
from difflib import get_close_matches
from itertools import product
from datetime import datetime

import dateutil
from faker import Faker, Generator

from snowfakery.plugins import PluginContext
import snowfakery.data_gen_exceptions as exc

# .format language doesn't allow slicing. :(
first_name_patterns = ("{firstname}", "{firstname[0]}", "{firstname[0]}{firstname[1]}")
first_name_separators = ("", ".", "-", "_", "+")
Expand All @@ -19,7 +22,10 @@
)
]

this_year = datetime.today().year
this_year = datetime.datetime.today().year
DateLike = T.Union[datetime.date, datetime.datetime, datetime.timedelta, str, int]
TimeZoneAsRelDelta = T.Union[dateutil.relativedelta.relativedelta, T.Literal[False]]
UTCAsRelDelta = dateutil.relativedelta.relativedelta(hours=0)


class FakeNames(T.NamedTuple):
Expand Down Expand Up @@ -90,6 +96,62 @@ def postalcode(self):
"""Return whatever counts as a postalcode for a particular locale"""
return self.f.postcode()

def date_time_between(
self,
start_date: DateLike = "-30y",
end_date: DateLike = "now",
timezone: TimeZoneAsRelDelta = UTCAsRelDelta,
) -> datetime.datetime:
timezone = _normalize_timezone(timezone)
return self.f.date_time_between(start_date, end_date, timezone)

date_time_between_dates = date_time_between

def future_datetime(
self,
end_date: DateLike = "+30d",
timezone: TimeZoneAsRelDelta = UTCAsRelDelta,
) -> datetime.datetime:
timezone = _normalize_timezone(timezone)
return self.f.future_datetime(end_date, timezone)

def iso8601(
self,
timezone: TimeZoneAsRelDelta = UTCAsRelDelta,
end_datetime: DateLike = None,
) -> str:
timezone = _normalize_timezone(timezone)
return self.f.iso8601(
timezone,
end_datetime,
)

date_time = datetime = date_time_between

# These faker types are not available in Snowfakery
# because they are redundant
date_time_this_year = NotImplemented
date_this_year = NotImplemented
date_time_this_month = NotImplemented
date_time_ad = NotImplemented
date_time_this_century = NotImplemented
date_time_this_decade = NotImplemented


def _normalize_timezone(timezone=None):
if timezone in (None, False):
return None
elif timezone is UTCAsRelDelta:
return datetime.timezone.utc
else:
if not isinstance(timezone, dateutil.relativedelta.relativedelta):
raise exc.DataGenError( # pragma: no cover
f"Type should be a relativedelta, not {type(timezone)}: {timezone}"
)
return datetime.timezone(
datetime.timedelta(hours=timezone.hours, minutes=timezone.minutes)
)


# we will use this to exclude Faker's internal book-keeping methods
# from our faker interface
Expand Down Expand Up @@ -142,15 +204,16 @@ def _get_fake_data(self, origname, *args, **kwargs):
# faker names are all lower-case
name = origname.lower()

meth = self.fake_names.get(name)
meth = self.fake_names.get(name, NotImplemented)

if meth:
if meth != NotImplemented:
ret = meth(*args, **kwargs)
local_faker_vars[name.replace("_", "")] = ret
return ret

msg = f"No fake data type named {origname}."
match_list = get_close_matches(name, self.fake_names.keys(), n=1)
all_fake_names = [k for k, v in self.fake_names.items() if v != NotImplemented]
match_list = get_close_matches(name, all_fake_names, n=1)
if match_list:
msg += f" Did you mean {match_list[0]}"
raise AttributeError(msg)
Expand Down
7 changes: 6 additions & 1 deletion snowfakery/output_streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ def noop(x):
return x


def format_datetime(dt: datetime.datetime):
"""Format into the Salesforce-preferred syntax."""
return str(dt).replace(" ", "T")
prescod marked this conversation as resolved.
Show resolved Hide resolved


class OutputStream(ABC):
"""Common base class for all output streams"""

Expand All @@ -46,7 +51,7 @@ class OutputStream(ABC):
int: int,
float: float,
datetime.date: noop,
datetime.datetime: noop,
datetime.datetime: format_datetime,
type(None): noop,
bool: int,
}
Expand Down
3 changes: 2 additions & 1 deletion snowfakery/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import yaml
from yaml.representer import Representer
from faker.providers import BaseProvider as FakerProvider
from dateutil.relativedelta import relativedelta

import snowfakery.data_gen_exceptions as exc
from .utils.yaml_utils import SnowfakeryDumper
Expand All @@ -18,7 +19,7 @@
from numbers import Number


Scalar = Union[str, Number, date, datetime, None]
Scalar = Union[str, Number, date, datetime, None, relativedelta]
FieldDefinition = "snowfakery.data_generator_runtime_object_model.FieldDefinition"
ObjectRow = "snowfakery.object_rows.ObjectRow"

Expand Down
18 changes: 12 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ def generated_rows(request):
def row_values(index, value):
return mockobj.mock_calls[index][1][1][value]

def table_values(tablename, index, field=None):
def table_values(tablename, index=None, field=None):
"""Look up a value from a table."""
index = index - 1 # use 1-based indexing like Snowfakery does

# create and cache a dict of table names to lists of rows
if type(mockobj._index) != dict:
Expand All @@ -38,10 +37,17 @@ def table_values(tablename, index, field=None):
table = row[1][0]
mockobj._index.setdefault(table, []).append(row[1][1])

if field: # return just one field
return mockobj._index[tablename][index][field]
else: # return a full row
return mockobj._index[tablename][index]
if index is None: # return all rows
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are some improvements to the testing infrastructure to make certain kinds of assertions easier to write. It doesn't require a deep review because it isn't user-facing code and it gets well-tested by virtue of being a test suite.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔬 🕵🏾‍♂️

if field is None: # return full rows
return mockobj._index[tablename]
else: # return a single field
return [row[field] for row in mockobj._index[tablename]]
else: # return data from just one row
index = index - 1 # use 1-based indexing like Snowfakery does
if field: # return just one field
return mockobj._index[tablename][index][field]
else: # return a full row
return mockobj._index[tablename][index]

with patch(
"snowfakery.output_streams.DebugOutputStream.write_single_row"
Expand Down
67 changes: 67 additions & 0 deletions tests/test_datetimes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate: 2020-11-29 08:33:39+00:00

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate:
fake: DateTime

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate:
fake.datetime:
timezone:
relativedelta:
hours: 8

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate:
fake.DateTimeBetween:
start_date: -10y
end_date: now

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate:
fake.DateTimeBetween:
start_date: now
end_date: +20y

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate:
fake: DateTime

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate:
fake: FutureDatetime

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate:
fake: iso8601

- object: Contact
fields:
LastName:
fake: LastName
EmailBouncedDate: ${{fake.iso8601(timezone=False)}}Z
63 changes: 61 additions & 2 deletions tests/test_faker.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from io import StringIO
from unittest import mock
from datetime import date
from datetime import date, datetime, timezone

import pytest

from dateutil import parser as dateparser
from snowfakery.data_generator import generate
from snowfakery import data_gen_exceptions as exc

Expand Down Expand Up @@ -123,6 +123,65 @@ def test_months_past(self, write_row_mock):
assert (date.today() - the_date).days > 80
assert (date.today() - the_date).days < 130

def test_date_times(self, generated_rows):
with open("tests/test_datetimes.yml") as yaml:
generate(yaml)

for dt in generated_rows.table_values("Contact", field="EmailBouncedDate"):
assert "+0" in dt or dt.endswith("Z"), dt
assert dateparser.parse(dt).tzinfo
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using dateparser.isoparse() instead? parse is pretty liberal about what it accepts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


def test_hidden_fakers(self):
yaml = """
- object: A
fields:
date:
fake: DateTimeThisCentury
"""
with pytest.raises(exc.DataGenError) as e:
generate(StringIO(yaml))

assert e

def test_bad_tz_param(self):
yaml = """
- object: A
fields:
date:
fake.datetime:
timezone: PST
"""
with pytest.raises(exc.DataGenError) as e:
generate(StringIO(yaml))

assert "timezone" in str(e.value)
assert "relativedelta" in str(e.value)

def test_no_timezone(self, generated_rows):
yaml = """
- object: A
fields:
date:
fake.datetime:
timezone: False
"""
generate(StringIO(yaml))
date = generated_rows.table_values("A", 0, "date")
assert dateparser.parse(date).tzinfo is None

def test_relative_dates(self, generated_rows):
with open("tests/test_relative_dates.yml") as f:
generate(f)
now = datetime.now(timezone.utc)
# there is a miniscule chance that FutureDateTime picks a DateTime 1 second in the future
# and then by the time we get here it isn't the future anymore. We'll see if it ever
# happens in practice
assert dateparser.parse(generated_rows.table_values("Test", 1, "future")) >= now
assert (
dateparser.parse(generated_rows.table_values("Test", 1, "future2")) >= now
)
assert dateparser.parse(generated_rows.table_values("Test", 1, "past")) <= now

@mock.patch(write_row_path)
def test_snowfakery_names(self, write_row_mock):
yaml = """
Expand Down
14 changes: 14 additions & 0 deletions tests/test_relative_dates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
- object: Test
fields:
future:
fake.date_time_between:
start_date: +1m
end_date: +1h

past:
fake.date_time_between:
start_date: -1h
end_date: now

future2:
fake: FutureDateTime
Loading