Skip to content

Commit

Permalink
feat: dotnet implementation of date functions (#55)
Browse files Browse the repository at this point in the history
  • Loading branch information
arjendev authored Jan 30, 2024
1 parent 4aabd54 commit bdc6501
Show file tree
Hide file tree
Showing 9 changed files with 513 additions and 355 deletions.
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,24 @@ These tests are great to have, but miss the following benefits that unit tests,
## Getting started

1. Set up an empty Python project with your favorite testing library
1. Install the dotnet runtime from [here](https://dotnet.microsoft.com/en-us/download/dotnet/8.0).
Using only the runtime and not the SDK should be sufficient.
This is required to run some expression functions on dotnet just like in Data Factory.
2. Set up an empty Python project with your favorite testing library

More information:
[docs_Setup](/docs/environment_setup/unit_test_setup.md)

2. Install the package using your preferred package manager:
3. Install the package using your preferred package manager:

Pip: `pip install data-factory-testing-framework`

3. Create a Folder in your project and copy the JSON Files with the pipeline definitions locally.
4. Create a Folder in your project and copy the JSON Files with the pipeline definitions locally.

More information:
[docs Json](/docs/environment_setup/json_pipeline_files.md)

4. Start writing tests
5. Start writing tests

## Features - Examples

Expand Down
443 changes: 260 additions & 183 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ include = [ "README.md", "LICENSE" ]
keywords = ["fabric", "datafactory", "unit-testing", "functional-testing", "azure"]

[tool.poetry.dependencies]
python = "^3.9"
python = ">=3.9,<3.13"
azure-core = "^1.29.5"
xmltodict = "^0.13.0"
lxml = "^4.9.3"
lark = "^1.1.8"
pythonnet = "^3.0.3"

[tool.poetry.group.dev.dependencies]
mutatest = "^3.1.0"
Expand All @@ -22,7 +23,6 @@ ruff = "^0.1.5"
pre-commit = "^3.5.0"
astor = "^0.8.1"
docstring-parser = "^0.15"
freezegun = "^1.2.2"


[build-system]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,4 @@
from datetime import datetime, timedelta
from typing import Union

from dateutil.relativedelta import relativedelta


def _datetime_from_isoformat(timestamp: str, fmt: str = None) -> datetime:
return datetime.fromisoformat(timestamp.rstrip("Z"))


def _datetime_to_isoformat(date_time: datetime, fmt: str = None) -> str:
# TODO: Implement other fmt's if valuable. Use cases where you need to assert certain format should be rare.
return date_time.isoformat(timespec="microseconds") + "Z"


def _add_time_delta_to_iso_timestamp(timestamp: str, time_delta: Union[timedelta, relativedelta]) -> str:
date_time = _datetime_from_isoformat(timestamp) + time_delta
return _datetime_to_isoformat(date_time)
from data_factory_testing_framework.pythonnet.csharp_datetime import CSharpDateTime


def utcnow(fmt: str = None) -> str:
Expand All @@ -26,7 +9,7 @@ def utcnow(fmt: str = None) -> str:
Note: This function does not implement formatting for now and
"""
return _datetime_to_isoformat(datetime.utcnow())
return CSharpDateTime.utcnow().format_date_time(fmt)


def ticks(timestamp: str) -> int:
Expand All @@ -35,9 +18,7 @@ def ticks(timestamp: str) -> int:
Args:
timestamp (str): The string for a timestamp
"""
date_time = _datetime_from_isoformat(timestamp)
delta = date_time - datetime(1, 1, 1)
return int(delta.total_seconds()) * 10000000 + delta.microseconds * 10
return CSharpDateTime.parse(timestamp).ticks()


def add_days(timestamp: str, days: int, fmt: str = None) -> str:
Expand All @@ -48,7 +29,7 @@ def add_days(timestamp: str, days: int, fmt: str = None) -> str:
days (int): The positive or negative number of days to add
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
return _add_time_delta_to_iso_timestamp(timestamp, timedelta(days=days))
return CSharpDateTime.parse(timestamp).add_days(days).format_date_time(fmt)


def add_hours(timestamp: str, hours: int, fmt: str = None) -> str:
Expand All @@ -59,7 +40,7 @@ def add_hours(timestamp: str, hours: int, fmt: str = None) -> str:
hours (int): The positive or negative number of hours to add
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
return _add_time_delta_to_iso_timestamp(timestamp, timedelta(hours=hours))
return CSharpDateTime.parse(timestamp).add_hours(hours).format_date_time(fmt)


def add_minutes(timestamp: str, minutes: int, fmt: str = None) -> str:
Expand All @@ -70,7 +51,7 @@ def add_minutes(timestamp: str, minutes: int, fmt: str = None) -> str:
minutes (int): The positive or negative number of minutes to add
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
return _add_time_delta_to_iso_timestamp(timestamp, timedelta(minutes=minutes))
return CSharpDateTime.parse(timestamp).add_minutes(minutes).format_date_time(fmt)


def add_seconds(timestamp: str, seconds: int, fmt: str = None) -> str:
Expand All @@ -81,7 +62,7 @@ def add_seconds(timestamp: str, seconds: int, fmt: str = None) -> str:
seconds (int): The positive or negative number of seconds to add
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
return _add_time_delta_to_iso_timestamp(timestamp, timedelta(seconds=seconds))
return CSharpDateTime.parse(timestamp).add_seconds(seconds).format_date_time(fmt)


def add_to_time(timestamp: str, interval: int, time_unit: str, fmt: str = None) -> str:
Expand All @@ -102,11 +83,11 @@ def add_to_time(timestamp: str, interval: int, time_unit: str, fmt: str = None)
elif time_unit == "Day":
return add_days(timestamp, interval, fmt)
elif time_unit == "Week":
return _add_time_delta_to_iso_timestamp(timestamp, timedelta(weeks=interval))
return CSharpDateTime.parse(timestamp).add_days(7 * interval).format_date_time(fmt)
elif time_unit == "Month":
return _add_time_delta_to_iso_timestamp(timestamp, relativedelta(months=interval))
return CSharpDateTime.parse(timestamp).add_months(interval).format_date_time(fmt)
elif time_unit == "Year":
return _add_time_delta_to_iso_timestamp(timestamp, relativedelta(years=interval))
return CSharpDateTime.parse(timestamp).add_years(interval).format_date_time(fmt)
else:
raise ValueError(f"Invalid time unit: {time_unit}")

Expand All @@ -120,8 +101,9 @@ def convert_from_utc(timestamp: str, destination_timezone: str, fmt: str = None)
For time zone names, see Microsoft Time Zone Values, but you might have to remove any punctuation from the time zone name.
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
# TODO: Implement other fmt's if valuable. Use cases where you need to assert certain format should be rare.
return timestamp
return (
CSharpDateTime.parse(timestamp).convert_timestamp_to_target_timezone(destination_timezone).format_date_time(fmt)
)


def convert_time_zone(timestamp: str, source_timezone: str, destination_timezone: str, fmt: str = None) -> str:
Expand All @@ -135,8 +117,11 @@ def convert_time_zone(timestamp: str, source_timezone: str, destination_timezone
For time zone names, see Microsoft Time Zone Values, but you might have to remove any punctuation from the time zone name.
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
# TODO: Implement other fmt's if valuable. Use cases where you need to assert certain format should be rare.
return timestamp
return (
CSharpDateTime.parse(timestamp)
.convert_timestamp_from_timezone_to_target_timezone(source_timezone, destination_timezone)
.format_date_time(fmt)
)


def convert_to_utc(timestamp: str, source_timezone: str, fmt: str = None) -> str:
Expand All @@ -148,8 +133,7 @@ def convert_to_utc(timestamp: str, source_timezone: str, fmt: str = None) -> str
For time zone names, see Microsoft Time Zone Values, but you might have to remove any punctuation from the time zone name.
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
# TODO: Implement other fmt's if valuable. Use cases where you need to assert certain format should be rare.
return timestamp
return CSharpDateTime.parse(timestamp).convert_timestamp_from_timezone_to_utc(source_timezone).format_date_time(fmt)


def day_of_month(timestamp: str) -> int:
Expand All @@ -158,8 +142,7 @@ def day_of_month(timestamp: str) -> int:
Args:
timestamp (str): The string that contains the timestamp
"""
date_time = _datetime_from_isoformat(timestamp)
return date_time.day
return CSharpDateTime.parse(timestamp).day_of_month()


def day_of_week(timestamp: str) -> int:
Expand All @@ -168,8 +151,7 @@ def day_of_week(timestamp: str) -> int:
Args:
timestamp (str): The string that contains the timestamp
"""
date_time = _datetime_from_isoformat(timestamp)
return date_time.weekday() + 1
return CSharpDateTime.parse(timestamp).day_of_week()


def day_of_year(timestamp: str) -> int:
Expand All @@ -178,8 +160,7 @@ def day_of_year(timestamp: str) -> int:
Args:
timestamp (str): The string that contains the timestamp
"""
date_time = _datetime_from_isoformat(timestamp)
return date_time.timetuple().tm_yday
return CSharpDateTime.parse(timestamp).day_of_year()


def format_date_time(timestamp: str, fmt: str = None) -> str:
Expand All @@ -189,8 +170,7 @@ def format_date_time(timestamp: str, fmt: str = None) -> str:
timestamp (str): The string that contains the timestamp
fmt (str): The format to use when converting the timestamp to a string
"""
date_time = _datetime_from_isoformat(timestamp)
return _datetime_to_isoformat(date_time, fmt)
return CSharpDateTime.parse(timestamp).format_date_time(fmt)


def get_future_time(interval: int, time_unit: str, fmt: str = None) -> str:
Expand Down Expand Up @@ -222,9 +202,7 @@ def start_of_day(timestamp: str, fmt: str = None) -> str:
timestamp (str): The string that contains the timestamp
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
date_time = _datetime_from_isoformat(timestamp)
date_time = date_time.replace(hour=0, minute=0, second=0, microsecond=0)
return _datetime_to_isoformat(date_time, fmt)
return CSharpDateTime.parse(timestamp).start_of_day().format_date_time(fmt)


def start_of_hour(timestamp: str, fmt: str = None) -> str:
Expand All @@ -234,9 +212,7 @@ def start_of_hour(timestamp: str, fmt: str = None) -> str:
timestamp (str): The string that contains the timestamp
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
date_time = _datetime_from_isoformat(timestamp)
date_time = date_time.replace(minute=0, second=0, microsecond=0)
return _datetime_to_isoformat(date_time, fmt)
return CSharpDateTime.parse(timestamp).start_of_hour().format_date_time(fmt)


def start_of_month(timestamp: str, fmt: str = None) -> str:
Expand All @@ -246,9 +222,7 @@ def start_of_month(timestamp: str, fmt: str = None) -> str:
timestamp (str): The string that contains the timestamp
fmt (str, optional): Optionally, you can specify a different format with the <format> parameter.
"""
date_time = _datetime_from_isoformat(timestamp)
date_time = date_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return _datetime_to_isoformat(date_time, fmt)
return CSharpDateTime.parse(timestamp).start_of_month().format_date_time(fmt)


def subtract_from_time(timestamp: str, interval: int, time_unit: str, fmt: str = None) -> str:
Expand Down
6 changes: 6 additions & 0 deletions src/data_factory_testing_framework/pythonnet/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import pythonnet

pythonnet.load("coreclr")
import clr # noqa: E402

clr.AddReference("System")
85 changes: 85 additions & 0 deletions src/data_factory_testing_framework/pythonnet/csharp_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# These imports are dotnet namespace imports which are registered via __init__.py
import System # noqa: F401
from System import DateTime # noqa: F401


class CSharpDateTime:
def __init__(self, date_time: DateTime) -> None:
"""Initializes a new instance of the CSharpDateTime class."""
self.date_time = date_time

@staticmethod
def parse(timestamp: str) -> "CSharpDateTime":
return CSharpDateTime(
DateTime.Parse(
timestamp,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
)
)

@staticmethod
def utcnow() -> "CSharpDateTime":
return CSharpDateTime(DateTime.UtcNow)

def ticks(self) -> int:
return self.date_time.get_Ticks()

def add_seconds(self, seconds: int) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.AddSeconds(seconds))

def add_minutes(self, seconds: int) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.AddMinutes(seconds))

def add_hours(self, hours: int) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.AddHours(hours))

def add_days(self, days: int) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.AddDays(days))

def add_months(self, months: int) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.AddMonths(months))

def add_years(self, years: int) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.AddYears(years))

def day_of_week(self) -> int:
return int(self.date_time.get_DayOfWeek())

def day_of_month(self) -> int:
return int(self.date_time.get_Day())

def day_of_year(self) -> int:
return int(self.date_time.get_DayOfYear())

def format_date_time(self, fmt: str = None) -> str:
if fmt is None:
fmt = "o"

return self.date_time.ToString(fmt)

def start_of_day(self) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.Date)

def start_of_hour(self) -> "CSharpDateTime":
return CSharpDateTime(self.date_time.Date.AddHours(self.date_time.Hour))

def start_of_month(self) -> "CSharpDateTime":
return CSharpDateTime(
DateTime(self.date_time.Year, self.date_time.Month, 1, 0, 0, 0, self.date_time.get_Kind())
)

def convert_timestamp_to_target_timezone(self, target_timezone: str) -> "CSharpDateTime":
target_time_zone = System.TimeZoneInfo.FindSystemTimeZoneById(target_timezone)
return CSharpDateTime(System.TimeZoneInfo.ConvertTime(self.date_time, target_time_zone))

def convert_timestamp_from_timezone_to_target_timezone(
self, source_timezone: str, target_timezone: str
) -> "CSharpDateTime":
source_time_zone = System.TimeZoneInfo.FindSystemTimeZoneById(source_timezone)
target_time_zone = System.TimeZoneInfo.FindSystemTimeZoneById(target_timezone)
return CSharpDateTime(System.TimeZoneInfo.ConvertTime(self.date_time, source_time_zone, target_time_zone))

def convert_timestamp_from_timezone_to_utc(self, source_timezone: str) -> "CSharpDateTime":
source_time_zone = System.TimeZoneInfo.FindSystemTimeZoneById(source_timezone)
return CSharpDateTime(System.TimeZoneInfo.ConvertTimeToUtc(self.date_time, source_time_zone))
10 changes: 6 additions & 4 deletions tests/unit/functions/test_expression_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
)
from data_factory_testing_framework.exceptions.variable_not_found_error import VariableNotFoundError
from data_factory_testing_framework.functions.expression_evaluator import ExpressionEvaluator
from data_factory_testing_framework.pythonnet.csharp_datetime import CSharpDateTime
from data_factory_testing_framework.state.dependency_condition import DependencyCondition
from data_factory_testing_framework.state.pipeline_run_state import PipelineRunState
from data_factory_testing_framework.state.pipeline_run_variable import PipelineRunVariable
from data_factory_testing_framework.state.run_parameter import RunParameter
from data_factory_testing_framework.state.run_parameter_type import RunParameterType
from freezegun import freeze_time
from lark import Token, Tree
from pytest import param as p

Expand Down Expand Up @@ -670,7 +670,7 @@ def test_parse(expression: str, expected: Tree[Token]) -> None:
p(
"@utcNow()",
PipelineRunState(),
"2021-11-24T12:11:49.753132Z",
"2021-11-24T12:11:49.7531321Z",
id="function_call_with_zero_parameters",
),
p(
Expand Down Expand Up @@ -704,9 +704,11 @@ def test_parse(expression: str, expected: Tree[Token]) -> None:
),
],
)
@freeze_time("2021-11-24 12:11:49.753132")
def test_evaluate(expression: str, state: PipelineRunState, expected: Union[str, int, bool, float]) -> None:
def test_evaluate(
expression: str, state: PipelineRunState, expected: Union[str, int, bool, float], monkeypatch: pytest.MonkeyPatch
) -> None:
# Arrange
monkeypatch.setattr(CSharpDateTime, "utcnow", lambda: CSharpDateTime.parse("2021-11-24T12:11:49.7531321Z"))
evaluator = ExpressionEvaluator()

# Act
Expand Down
Loading

0 comments on commit bdc6501

Please sign in to comment.