generated from Hochfrequenz/python_template_repository
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add Mapping/Conversion Logic (#3)
- Loading branch information
Showing
14 changed files
with
625 additions
and
49 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,8 @@ | ||
""" | ||
Chronomeleon is a Python package that helps you to migrate datetimes from one system to another. | ||
""" | ||
|
||
__all__ = ["ChronoAssumption", "MappingConfig", "adapt_to_target"] | ||
|
||
from .mapping import adapt_to_target | ||
from .models import ChronoAssumption, MappingConfig |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
""" | ||
This a docstring for the module. | ||
""" | ||
|
||
import datetime as dt_module | ||
from datetime import date, datetime, timedelta | ||
from typing import Union | ||
|
||
import pytz | ||
|
||
from chronomeleon.models.mapping_config import MappingConfig | ||
|
||
_berlin = pytz.timezone("Europe/Berlin") | ||
|
||
|
||
def _convert_source_date_or_datetime_to_aware_datetime( | ||
source_value: Union[date, datetime], config: MappingConfig | ||
) -> datetime: | ||
""" | ||
returns a datetime object which is aware of the timezone (i.e. not naive) and is an exclusive end | ||
regardless of whether the source was configured as an inclusive or exclusive end. | ||
""" | ||
source_value_datetime: datetime # a non-naive datetime (exclusive, if end) | ||
if isinstance(source_value, datetime): | ||
source_value_datetime = source_value | ||
if config.is_end and config.source.is_inclusive_end: | ||
assert config.source.resolution is not None # ensured by the consistency check | ||
source_value_datetime += config.source.resolution | ||
elif isinstance(source_value, date): | ||
if config.is_end and config.source.is_inclusive_end: | ||
source_value_datetime = datetime.combine(source_value + timedelta(days=1), datetime.min.time()) | ||
else: | ||
source_value_datetime = datetime.combine(source_value, datetime.min.time()) | ||
else: | ||
raise ValueError(f"source_value must be a date or datetime object but is {source_value.__class__.__name__}") | ||
if source_value_datetime.tzinfo is None: | ||
if config.source.implicit_timezone is not None: | ||
source_value_datetime = config.source.implicit_timezone.localize(source_value_datetime) | ||
else: | ||
# pylint:disable=line-too-long | ||
raise ValueError( | ||
"source_value must be timezone-aware or implicit_timezone must be set in the mapping configuration" | ||
) | ||
source_value_datetime = source_value_datetime.astimezone(pytz.utc) | ||
if config.source.is_gastag_aware and config.is_gas: | ||
berlin_local_datetime = source_value_datetime.astimezone(_berlin) | ||
if berlin_local_datetime.time() == dt_module.time(6, 0, 0): | ||
berlin_local_datetime = berlin_local_datetime.replace(hour=0).replace(tzinfo=None) | ||
# We need to re-localize the datetime, because the UTC offset might have changed | ||
# The Gastag does not always start 6h after midnight. | ||
# It might also be 5h or 7h on DST transition days. | ||
berlin_local_datetime = _berlin.localize(berlin_local_datetime) | ||
source_value_datetime = berlin_local_datetime.astimezone(pytz.utc) | ||
return source_value_datetime | ||
|
||
|
||
def _convert_aware_datetime_to_target(value: datetime, config: MappingConfig) -> datetime: | ||
""" | ||
returns a date or datetime object which is compatible with the target system | ||
""" | ||
if value.tzinfo is None: | ||
raise ValueError("value must be timezone-aware at this point") | ||
target_value: datetime = value | ||
if config.target.is_gastag_aware and config.is_gas: | ||
_berlin_local_datetime = value.astimezone(_berlin) | ||
if _berlin_local_datetime.time() == dt_module.time(0, 0, 0): | ||
_berlin_local_datetime = _berlin_local_datetime.replace(hour=6).replace(tzinfo=None) | ||
# We need to re-localize the datetime, because the UTC offset might have changed. | ||
# The Gastag does not always start 6h after midnight. | ||
# It might also be 5h or 7h on DST transition days. | ||
_berlin_local_datetime = _berlin.localize(_berlin_local_datetime) | ||
target_value = _berlin_local_datetime.astimezone(pytz.utc) | ||
if config.is_end and config.target.is_inclusive_end: | ||
assert config.target.resolution is not None # ensured by the consistency check | ||
target_value = target_value - config.target.resolution # converts the exclusive end to an inclusive end | ||
# and e.g. 2024-01-02 00:00:00 to 2024-01-01 23:59:59 if the resolution is timedelta(seconds=1) | ||
# Work because the original value is - if it is an end - always an exclusive end. | ||
if config.target.implicit_timezone is not None: | ||
target_value = target_value.astimezone(config.target.implicit_timezone) | ||
if config.target.is_date_only: | ||
target_value = datetime.combine(target_value.date(), datetime.min.time()) | ||
return target_value | ||
|
||
|
||
def adapt_to_target(source_value: Union[date, datetime], config: MappingConfig) -> datetime: | ||
""" | ||
maps the source value to a value compatible with the target system by using the given mapping configuration | ||
""" | ||
if source_value is None: | ||
raise ValueError("source_value must not be None") | ||
if config is None: | ||
raise ValueError("config must not be None") | ||
if not config.is_self_consistent(): | ||
raise ValueError("config is not self-consistent: " + ", ".join(config.get_consistency_errors())) | ||
# there are just 2 steps: | ||
# 1. convert the source from whatever it is to something unified with what we can work | ||
# 2. convert the unified source to the target (which might be just as obscure as the source) | ||
source_value_datetime = _convert_source_date_or_datetime_to_aware_datetime(source_value, config) # step 1 | ||
assert source_value_datetime.tzinfo is not None | ||
assert source_value_datetime.tzinfo == pytz.utc | ||
target_value = _convert_aware_datetime_to_target(source_value_datetime, config) # step 2 | ||
return target_value |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
"""contains the Mapping configuration class""" | ||
|
||
from dataclasses import dataclass | ||
from typing import Optional | ||
|
||
from .chrono_assumption import ChronoAssumption | ||
|
||
|
||
@dataclass(frozen=True, kw_only=True) | ||
class MappingConfig: | ||
""" | ||
represents the mapping rules for one date(time) field from one system to another | ||
""" | ||
|
||
source: ChronoAssumption | ||
""" | ||
assumptions about the interpretation of the date(time) field in the source system | ||
""" | ||
target: ChronoAssumption | ||
""" | ||
assumptions about the interpretation of the date(time) field in the source system | ||
""" | ||
|
||
is_end: Optional[bool] = None | ||
""" | ||
True if and only if the date or time is the end of a range. None if it doesn't matter. | ||
""" | ||
|
||
is_gas: Optional[bool] = None | ||
""" | ||
True if the sparte is Gas. | ||
Set to true to trigger the gas tag modifications in source, target or both, if necessary. Ignore otherwise. | ||
""" | ||
|
||
def get_consistency_errors(self) -> list[str]: | ||
""" | ||
returns a list of error messages if the mapping configuration is not self-consistent | ||
""" | ||
errors: list[str] = [] | ||
if not isinstance(self.source, ChronoAssumption): | ||
errors.append("source must be a ChronoAssumption object") | ||
else: | ||
errors.extend(["source: " + x for x in self.source.get_consistency_errors()]) | ||
if not isinstance(self.target, ChronoAssumption): | ||
errors.append("target must be a ChronoAssumption object") | ||
else: | ||
errors.extend(["target: " + x for x in self.target.get_consistency_errors()]) | ||
if (self.source.is_gastag_aware or self.target.is_gastag_aware) and self.is_gas is None: | ||
errors.append("if is_gastag_aware is set in either source or target, then is_gas must not be None") | ||
# The opposite is not the case: I can set is_gas to True without setting is_gastag_aware to True | ||
if ( | ||
self.source.is_inclusive_end is not None or self.target.is_inclusive_end is not None | ||
) and self.is_end is None: | ||
errors.append("if is_inclusive_end is set in either source or target, then is_end must not be None") | ||
if self.is_end is True and (self.source.is_inclusive_end is None or self.target.is_inclusive_end is None): | ||
errors.append("if is_end is True, then is_inclusive_end must not be None in both source and target") | ||
return errors | ||
|
||
def is_self_consistent(self) -> bool: | ||
""" | ||
checks if the mapping configuration is self-consistent | ||
""" | ||
return not any(self.get_consistency_errors()) |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
from datetime import timedelta | ||
|
||
import pytest | ||
|
||
from chronomeleon import ChronoAssumption | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"chrono_assumption, is_self_consistent", | ||
[ | ||
pytest.param( | ||
ChronoAssumption(resolution=timedelta(days=1)), | ||
True, | ||
), | ||
], | ||
) | ||
def test_self_consistency(chrono_assumption: ChronoAssumption, is_self_consistent: bool): | ||
assert chrono_assumption.is_self_consistent() == is_self_consistent |
Oops, something went wrong.