diff --git a/CHANGELOG.md b/CHANGELOG.md index fde79bb99..0ff6fbf35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ CHANGELOG ### Core - Fixed not resetting destination path statistics in the stats cache after restarting bot (Fixes [#2331](https://github.com/certtools/intelmq/issues/2331)) +- `intelmq.lib.datatypes`: Adds `TimeFormat` class to be used for the `time_format` bot parameter (PR#2329 by Filip Pokorný). +- `intelmq.lib.exceptions`: Fixes a bug in `InvalidArgument` exception (PR#2329 by Filip Pokorný). +- `intelmq.lib.harmonization`: Changes signature and names of `DateTime` conversion functions for consistency, backwards compatible (PR#2329 by Filip Pokorný). ### Development @@ -19,6 +22,8 @@ CHANGELOG #### Collectors #### Parsers +- `intelmq.bots.parsers.generic.parser_csv`: Changes `time_format` parameter to use new `TimeFormat` class (PR#2329 by Filip Pokorný). +- `intelmq.bots.parsers.html_table.parser`: Changes `time_format` parameter to use new `TimeFormat` class (PR#2329 by Filip Pokorný). #### Experts diff --git a/intelmq/bots/experts/sieve/expert.py b/intelmq/bots/experts/sieve/expert.py index 370de2f56..f0c9f3525 100644 --- a/intelmq/bots/experts/sieve/expert.py +++ b/intelmq/bots/experts/sieve/expert.py @@ -315,7 +315,7 @@ def process_bool_match(self, key, op, value, event): return self._bool_op_map[op](event[key], value) def compute_basic_math(self, action, event) -> str: - date = DateTime.parse_utc_isoformat(event[action.key], True) + date = DateTime.from_isoformat(event[action.key], True) delta = datetime.timedelta(minutes=parse_relative(action.value)) return self._basic_math_op_map[action.operator](date, delta).isoformat() diff --git a/intelmq/bots/parsers/abusech/parser_feodotracker.py b/intelmq/bots/parsers/abusech/parser_feodotracker.py index 5011249ff..8e3f51973 100644 --- a/intelmq/bots/parsers/abusech/parser_feodotracker.py +++ b/intelmq/bots/parsers/abusech/parser_feodotracker.py @@ -38,7 +38,7 @@ def parse_line(self, line, report): if line.get("first_seen"): try: event.add("time.source", - str(DateTime.convert_from_format(value=line.get("first_seen"), format="%Y-%m-%d %H:%M:%S")), + str(DateTime.from_format(value=line.get("first_seen"), format="%Y-%m-%d %H:%M:%S")), raise_failure=False) except ValueError: @@ -48,7 +48,7 @@ def parse_line(self, line, report): elif line.get("last_online"): try: event.add("time.source", - str(DateTime.convert_from_format_midnight(line.get("last_online"), format="%Y-%m-%d")), + str(DateTime.from_format_midnight(line.get("last_online"), format="%Y-%m-%d")), raise_failure=False) except ValueError: self.logger.warning("Failed to parse '%s' to DateTime.", line.get('last_online')) diff --git a/intelmq/bots/parsers/generic/parser_csv.py b/intelmq/bots/parsers/generic/parser_csv.py index 28482c6a1..3401c8276 100644 --- a/intelmq/bots/parsers/generic/parser_csv.py +++ b/intelmq/bots/parsers/generic/parser_csv.py @@ -21,18 +21,11 @@ import re from typing import Optional, Union, Iterable -from dateutil.parser import parse - from intelmq.lib import utils from intelmq.lib.bot import ParserBot from intelmq.lib.exceptions import InvalidArgument, InvalidValue -from intelmq.lib.harmonization import DateTime from intelmq.lib.utils import RewindableFileHandle - -TIME_CONVERSIONS = {'timestamp': DateTime.from_timestamp, - 'windows_nt': DateTime.from_windows_nt, - 'epoch_millis': DateTime.from_epoch_millis, - None: lambda value: parse(value, fuzzy=True).isoformat() + " UTC"} +from intelmq.lib.datatypes import TimeFormat DATA_CONVERSIONS = {'json': lambda data: json.loads(data)} DOCS = "https://intelmq.readthedocs.io/en/latest/guides/Bots.html#generic-csv-parser" @@ -49,7 +42,7 @@ class GenericCsvParserBot(ParserBot): delimiter: str = ',' filter_text = None filter_type = None - time_format = None + time_format: Optional[TimeFormat] = None type: Optional[str] = None type_translation = {} skip_header: Union[bool, int] = False @@ -67,14 +60,8 @@ def init(self): # prevents empty strings: self.column_regex_search = self.column_regex_search or {} + self.time_format = TimeFormat(self.time_format) - # handle empty strings, false etc. - if not self.time_format: - self.time_format = None - if self.time_format not in TIME_CONVERSIONS.keys(): - raise InvalidArgument('time_format', got=self.time_format, - expected=list(TIME_CONVERSIONS.keys()), - docs=DOCS) if self.filter_type and self.filter_type not in ('blacklist', 'whitelist'): raise InvalidArgument('filter_type', got=self.filter_type, expected=("blacklist", "whitelist"), @@ -137,7 +124,6 @@ def parse_line(self, row: list, report): if search: value = search.group(0) else: - type = None value = None if key in ("__IGNORE__", ""): @@ -147,7 +133,7 @@ def parse_line(self, row: list, report): value = DATA_CONVERSIONS[self.data_type[key]](value) if key in ("time.source", "time.destination"): - value = TIME_CONVERSIONS[self.time_format](value) + value = self.time_format.parse_datetime(value) elif key.endswith('.url'): if not value: continue diff --git a/intelmq/bots/parsers/html_table/parser.py b/intelmq/bots/parsers/html_table/parser.py index 2f73bc1e4..cfb7e3981 100644 --- a/intelmq/bots/parsers/html_table/parser.py +++ b/intelmq/bots/parsers/html_table/parser.py @@ -20,12 +20,12 @@ time_format: string type: string """ +from typing import Optional from intelmq.lib import utils from intelmq.lib.bot import ParserBot -from intelmq.lib.exceptions import InvalidArgument -from intelmq.lib.harmonization import DateTime from intelmq.lib.exceptions import MissingDependencyError +from intelmq.lib.datatypes import TimeFormat try: @@ -46,7 +46,7 @@ class HTMLTableParserBot(ParserBot): split_index = 0 split_separator = None table_index = 0 - time_format = None + time_format: Optional[TimeFormat] = None type = "c2-server" _parser = 'html.parser' @@ -69,11 +69,7 @@ def init(self): self.attr_value = self.attribute_value self.skip_head = self.skip_table_head self.skip_row = 1 if self.skip_head else 0 - - if self.time_format and self.time_format.split('|')[0] not in DateTime.TIME_CONVERSIONS.keys(): - raise InvalidArgument('time_format', got=self.time_format, - expected=list(DateTime.TIME_CONVERSIONS.keys()), - docs='https://intelmq.readthedocs.io/en/latest/guides/Bots.html#html-table-parser') + self.time_format = TimeFormat(self.time_format) def process(self): report = self.receive_message() @@ -119,7 +115,7 @@ def process(self): data = int(data) except ValueError: pass - data = DateTime.convert(data, format=self.time_format) + data = self.time_format.parse_datetime(data) elif key.endswith('.url'): if not data: diff --git a/intelmq/lib/datatypes.py b/intelmq/lib/datatypes.py index acaa76dfd..ccd29be41 100644 --- a/intelmq/lib/datatypes.py +++ b/intelmq/lib/datatypes.py @@ -1,26 +1,31 @@ # SPDX-FileCopyrightText: 2021 Birger Schacht # # SPDX-License-Identifier: AGPL-3.0-or-later - +from datetime import datetime from enum import Enum +from inspect import signature +from typing import Optional, Callable, Union, List + from termstyle import green -import json + +from intelmq.lib.exceptions import InvalidArgument +from intelmq.lib.harmonization import DateTime class BotType(str, Enum): - COLLECTOR = "Collector" - PARSER = "Parser" - EXPERT = "Expert" - OUTPUT = "Output" + COLLECTOR = "Collector" + PARSER = "Parser" + EXPERT = "Expert" + OUTPUT = "Output" def toJson(self): return self.value class ReturnType(str, Enum): - TEXT = "Text" - JSON = "Json" - PYTHON = "Python" + TEXT = "Text" + JSON = "Json" + PYTHON = "Python" def toJson(self): return self.value @@ -40,7 +45,6 @@ def toJson(self): 'restarting': 'Restarting %s...', } - ERROR_MESSAGES = { 'starting': 'Bot %s failed to START.', 'running': 'Bot %s is still running.', @@ -54,8 +58,85 @@ def toJson(self): class LogLevel(Enum): - DEBUG = 0 - INFO = 1 - WARNING = 2 - ERROR = 3 + DEBUG = 0 + INFO = 1 + WARNING = 2 + ERROR = 3 CRITICAL = 4 + + +class TimeFormat(str): + """ + Pydantic style Field Type class for bot parameter time_format. Used for validation. + """ + + def __new__(cls, value: Optional[str] = None): + value = "fuzzy" if (value is None or value == "") else value + return super().__new__(cls, value) + + def __init__(self, value: Optional[str] = None): + + self.convert: Callable + self.format_string: Optional[str] = None + + super(TimeFormat, self).__init__() + + if isinstance(value, TimeFormat): + self.convert = value.convert + self.format_string = value.format_string + else: + self.convert, self.format_string = TimeFormat.validate(self) + + def parse_datetime(self, value: str, return_datetime: bool = False) -> Union[datetime, str]: + """ + This function uses the selected conversion function to parse the datetime value. + + :param value: external datetime string + :param return_datetime: whether to return string or datetime object + :return: parsed datetime or string + """ + if self.format_string: + return self.convert(value=value, format=self.format_string, return_datetime=return_datetime) + else: + return self.convert(value=value, return_datetime=return_datetime) + + @classmethod + def __get_validators__(cls): + """ + This function is for Pydantic compatibility. + """ + yield cls + + @staticmethod + def validate(value: str) -> [Callable, Optional[str]]: + """ + This function validates the time_format parameter value. + + :param value: bot parameter for datetime conversion + :return: correct time conversion function and the format string + """ + + split_value: List[str] = value.split('|') + conversion: Callable + conversion_name: str = split_value[0] + format_string: Optional[str] = split_value[1] if len(split_value) > 1 else None + + # validation of the conversion name + if conversion_name in DateTime.TIME_CONVERSIONS.keys(): + conversion = DateTime.TIME_CONVERSIONS[conversion_name] + + else: + raise InvalidArgument(argument="time_format", got=value, + expected=[key for key in DateTime.TIME_CONVERSIONS.keys()]) + + # validate that we have format_string when the conversion function expects it + if not format_string and signature(conversion).parameters.get("format"): + raise InvalidArgument(argument="time_format", got=value, + expected=f"{conversion_name}|FORMAT_STRING") + + # validate that we do not have format_string when the conversion function doesn't expect it + elif format_string and not signature(conversion).parameters.get("format"): + raise InvalidArgument(argument="time_format", got=value, + expected=conversion_name) + + return conversion, format_string diff --git a/intelmq/lib/exceptions.py b/intelmq/lib/exceptions.py index 5c8230d8d..fa9df6d75 100644 --- a/intelmq/lib/exceptions.py +++ b/intelmq/lib/exceptions.py @@ -31,8 +31,8 @@ class InvalidArgument(IntelMQException): def __init__(self, argument: Any, got: Any = None, expected=None, docs: str = None): message = f"Argument {repr(argument)} is invalid." - if expected is list: - message += f" Should be one of: {list}." + if isinstance(expected, list): + message += f" Should be one of: {expected}." elif expected: # not None message += f" Should be of type: {expected}." if got: diff --git a/intelmq/lib/harmonization.py b/intelmq/lib/harmonization.py index 0fd314a59..3c983cea5 100644 --- a/intelmq/lib/harmonization.py +++ b/intelmq/lib/harmonization.py @@ -34,6 +34,7 @@ import json import re import socket +import warnings import urllib.parse as parse from typing import Optional, Union @@ -418,7 +419,7 @@ def sanitize(value: str) -> Optional[str]: @staticmethod def __parse(value: str) -> Optional[str]: try: - DateTime.parse_utc_isoformat(value) + DateTime.from_isoformat(value) except ValueError: pass else: @@ -436,9 +437,9 @@ def __parse(value: str) -> Optional[str]: return utils.decode(value) @staticmethod - def parse_utc_isoformat(value: str, return_datetime: bool = False) -> Union[datetime.datetime, str]: + def from_isoformat(value: str, return_datetime: bool = False) -> Union[datetime.datetime, str]: """ - Parse format generated by datetime.isoformat() method with UTC timezone. + Parses datetime string in ISO format. Naive datetime strings (without timezone) are assumed to be in UTC. It is much faster than universal dateutil parser. Can be used for parsing DateTime fields which are already parsed. @@ -456,35 +457,33 @@ def parse_utc_isoformat(value: str, return_datetime: bool = False) -> Union[date else: dtvalue = dtvalue.astimezone(tz=datetime.timezone.utc) - if return_datetime: - return dtvalue - - return value + return dtvalue if return_datetime else value @staticmethod - def from_epoch_millis(tstamp: Union[int, str]) -> str: + def from_epoch_millis(value: Union[int, str], return_datetime: bool = False) -> Union[datetime.datetime, str]: """ Returns ISO formatted datetime from given epoch timestamp with milliseconds. It ignores the milliseconds, converts it into normal timestamp and processes it. """ - bytecount = len(str(tstamp)) - int_tstamp = int(tstamp) + bytecount = len(str(value)) + int_tstamp = int(value) if bytecount == 10: - return DateTime.from_timestamp(int_tstamp) + return DateTime.from_timestamp(int_tstamp, return_datetime=return_datetime) if bytecount == 12: - return DateTime.from_timestamp(int_tstamp // 100) + return DateTime.from_timestamp(int_tstamp // 100, return_datetime=return_datetime) if bytecount == 13: - return DateTime.from_timestamp(int_tstamp // 1000) + return DateTime.from_timestamp(int_tstamp // 1000, return_datetime=return_datetime) @staticmethod - def from_timestamp(tstamp: Union[int, float, str]) -> str: + def from_timestamp(value: Union[int, float, str], return_datetime: bool = False) -> Union[datetime.datetime, str]: """ Returns ISO formatted datetime from given timestamp. """ - return datetime.datetime.fromtimestamp(float(tstamp), datetime.timezone.utc).isoformat() + value = datetime.datetime.fromtimestamp(float(value), datetime.timezone.utc) + return value if return_datetime else value.isoformat() @staticmethod - def from_windows_nt(tstamp: int) -> str: + def from_windows_nt(value: Union[int, str], return_datetime: bool = False) -> Union[datetime.datetime, str]: """ Converts the Windows NT / LDAP / Active Directory format to ISO format. @@ -492,7 +491,8 @@ def from_windows_nt(tstamp: int) -> str: UTC is assumed. Parameters: - tstamp: Time in LDAP format as integer or string. Will be converted if necessary. + value: Time in LDAP format as integer or string. Will be converted if necessary. + return_datetime: Whether to return datetime object or just string. Returns: Converted ISO format string @@ -501,8 +501,8 @@ def from_windows_nt(tstamp: int) -> str: https://www.epochconverter.com/ldap """ epoch = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) - dtime = epoch + datetime.timedelta(seconds=int(tstamp) * 10 ** -7) - return dtime.isoformat() + dtime = epoch + datetime.timedelta(seconds=int(value) * 10 ** -7) + return dtime if return_datetime else dtime.isoformat() @staticmethod def generate_datetime_now() -> str: @@ -511,7 +511,7 @@ def generate_datetime_now() -> str: return value.isoformat() @staticmethod - def convert_from_format(value: str, format: str) -> str: + def from_format(value: str, format: str, return_datetime: bool = False) -> Union[datetime.datetime, str]: """ Converts a datetime with the given format. """ @@ -520,26 +520,27 @@ def convert_from_format(value: str, format: str) -> str: value = value.replace(tzinfo=datetime.timezone.utc) else: value = value.astimezone(tz=datetime.timezone.utc) - return value.isoformat() + + return value if return_datetime else value.isoformat() @staticmethod - def convert_from_format_midnight(value: str, format: str) -> str: + def from_format_midnight(value: str, format: str, return_datetime: bool = False) -> Union[datetime.datetime, str]: """ Converts a date with the given format and adds time 00:00:00 to it. """ date = datetime.datetime.strptime(value, format) value = datetime.datetime.combine(date, DateTime.midnight, tzinfo=datetime.timezone.utc) - return value.isoformat() + return value if return_datetime else value.isoformat() @staticmethod - def convert_fuzzy(value) -> str: + def from_fuzzy(value, return_datetime: bool = False) -> Union[datetime.datetime, str]: value = dateutil.parser.parse(value, fuzzy=True) if not value.tzinfo: value = value.replace(tzinfo=datetime.timezone.utc) else: value = value.astimezone(tz=datetime.timezone.utc) - return value.isoformat() + return value if return_datetime else value.isoformat() @staticmethod def convert(value, format='fuzzy') -> str: @@ -558,22 +559,67 @@ def convert(value, format='fuzzy') -> str: if format is None: format = 'fuzzy' if format.startswith('from_format|'): - return DateTime.convert_from_format(value, format=format[12:]) + return DateTime.from_format(value, format=format[12:]) elif format.startswith('from_format_midnight|'): - return DateTime.convert_from_format_midnight(value, format=format[21:]) + return DateTime.from_format_midnight(value, format=format[21:]) else: return DateTime.TIME_CONVERSIONS[format](value) + @staticmethod + def parse_utc_isoformat(value: str, return_datetime: bool = False) -> Union[datetime.datetime, str]: + """ + This function is replaced by 'from_isoformat' function. + The original name is kept for backwards compatibility and will be removed in version 4.0. + """ + # TODO remove in IntelMQ 4.0 + warnings.warn("Function 'parse_utc_isoformat' is deprecated and will be removed in version 4.0." + "Use 'from_isoformat' instead.", DeprecationWarning) + return DateTime.from_isoformat(value=value, return_datetime=return_datetime) + + @staticmethod + def convert_from_format(value: str, format: str) -> str: + """ + This function is replaced by 'from_format' function. + The original name is kept for backwards compatibility and will be removed in version 4.0. + """ + # TODO remove in IntelMQ 4.0 + warnings.warn("Function 'convert_from_format' is deprecated and will be removed in version 4.0." + "Use 'from_format' instead.", DeprecationWarning) + return DateTime.from_format(value=value, format=format) + + @staticmethod + def convert_from_format_midnight(value: str, format: str) -> str: + """ + This function is replaced by 'from_format_midnight' function. + The original name is kept for backwards compatibility and will be removed in version 4.0. + """ + # TODO remove in IntelMQ 4.0 + warnings.warn("Function 'convert_from_format_midnight' is deprecated and will be removed in version 4.0." + "Use 'from_format_midnight' instead.", DeprecationWarning) + return DateTime.from_format_midnight(value=value, format=format) -DateTime.TIME_CONVERSIONS = {'timestamp': DateTime.from_timestamp, - 'windows_nt': DateTime.from_windows_nt, - 'epoch_millis': DateTime.from_epoch_millis, - 'from_format': DateTime.convert_from_format, - 'from_format_midnight': DateTime.convert_from_format_midnight, - 'utc_isoformat': DateTime.parse_utc_isoformat, - 'fuzzy': DateTime.convert_fuzzy, - None: DateTime.convert_fuzzy, - } + @staticmethod + def convert_fuzzy(value) -> str: + """ + This function is replaced by 'from_fuzzy' function. + The original name is kept for backwards compatibility and will be removed in version 4.0. + """ + # TODO remove in IntelMQ 4.0 + warnings.warn("Function 'convert_fuzzy' is deprecated and will be removed in version 4.0." + "Use 'from_fuzzy' instead.", DeprecationWarning) + return DateTime.from_fuzzy(value=value) + + +DateTime.TIME_CONVERSIONS = { + 'timestamp': DateTime.from_timestamp, + 'windows_nt': DateTime.from_windows_nt, + 'epoch_millis': DateTime.from_epoch_millis, + 'from_format': DateTime.from_format, + 'from_format_midnight': DateTime.from_format_midnight, + 'utc_isoformat': DateTime.from_isoformat, + 'fuzzy': DateTime.from_fuzzy, + None: DateTime.from_fuzzy, +} __convert_doc_position = DateTime.convert.__doc__.find('\n\n') + 1 DateTime.__doc__ += DateTime.convert.__doc__[__convert_doc_position:] diff --git a/intelmq/tests/lib/test_datatypes.py b/intelmq/tests/lib/test_datatypes.py new file mode 100644 index 000000000..5e9cdd206 --- /dev/null +++ b/intelmq/tests/lib/test_datatypes.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2023 Filip Pokorný +# +# SPDX-License-Identifier: AGPL-3.0-or-later +import unittest + +from intelmq.lib.datatypes import TimeFormat +from intelmq.lib.exceptions import InvalidArgument +from intelmq.lib.harmonization import DateTime + + +class TestTimeFormat(unittest.TestCase): + + def test_no_argument(self): + time_format = TimeFormat() + self.assertIs(time_format.convert, DateTime.from_fuzzy) + self.assertIsNone(time_format.format_string) + + def test_none(self): + time_format = TimeFormat(None) + self.assertIs(time_format.convert, DateTime.from_fuzzy) + self.assertIsNone(time_format.format_string) + + def test_empty_string(self): + time_format = TimeFormat("") + self.assertIs(time_format.convert, DateTime.from_fuzzy) + self.assertIsNone(time_format.format_string) + + def test_from_timestamp(self): + time_format = TimeFormat("timestamp") + self.assertIs(time_format.convert, DateTime.from_timestamp) + self.assertIsNone(time_format.format_string) + + def test_from_windows_nt(self): + time_format = TimeFormat("windows_nt") + self.assertIs(time_format.convert, DateTime.from_windows_nt) + self.assertIsNone(time_format.format_string) + + def test_from_epoch_millis(self): + time_format = TimeFormat("epoch_millis") + self.assertIs(time_format.convert, DateTime.from_epoch_millis) + self.assertIsNone(time_format.format_string) + + def test_from_format(self): + time_format = TimeFormat("from_format|%Y-%m-%d %H:%M:%S") + self.assertIs(time_format.convert, DateTime.from_format) + self.assertEqual(time_format.format_string, "%Y-%m-%d %H:%M:%S") + + def test_from_format_midnight(self): + time_format = TimeFormat("from_format_midnight|%Y-%m-%d") + self.assertIs(time_format.convert, DateTime.from_format_midnight) + self.assertEqual(time_format.format_string, "%Y-%m-%d") + + def test_from_utc_isoformat(self): + time_format = TimeFormat("utc_isoformat") + self.assertIs(time_format.convert, DateTime.from_isoformat) + self.assertIsNone(time_format.format_string) + + def test_from_fuzzy(self): + time_format = TimeFormat("fuzzy") + self.assertIs(time_format.convert, DateTime.from_fuzzy) + self.assertIsNone(time_format.format_string) + + def test_raise_on_invalid_conversion_name(self): + self.assertRaises(InvalidArgument, TimeFormat, "from_meatloaf") + + def test_raise_on_missing_format_string(self): + self.assertRaises(InvalidArgument, TimeFormat, "from_format") + self.assertRaises(InvalidArgument, TimeFormat, "from_format_midnight") + + def test_raise_on_extra_format_string(self): + self.assertRaises(InvalidArgument, TimeFormat, "timestamp|%Y-%m-%d") + self.assertRaises(InvalidArgument, TimeFormat, "windows_nt|%Y-%m-%d") + self.assertRaises(InvalidArgument, TimeFormat, "epoch_millis|%Y-%m-%d") + self.assertRaises(InvalidArgument, TimeFormat, "utc_isoformat|%Y-%m-%d") + self.assertRaises(InvalidArgument, TimeFormat, "fuzzy|%Y-%m-%d") diff --git a/intelmq/tests/lib/test_harmonization.py b/intelmq/tests/lib/test_harmonization.py index 897949a48..98731d686 100644 --- a/intelmq/tests/lib/test_harmonization.py +++ b/intelmq/tests/lib/test_harmonization.py @@ -245,19 +245,19 @@ def test_datetime_convert(self): def test_datetime_parse_utc_isoformat(self): """ Test DateTime.parse_utc_isoformat """ self.assertEqual('2020-12-31T12:00:00+00:00', - harmonization.DateTime.parse_utc_isoformat('2020-12-31T12:00:00+00:00')) + harmonization.DateTime.from_isoformat('2020-12-31T12:00:00+00:00')) self.assertEqual('2020-12-31T12:00:00.001+00:00', - harmonization.DateTime.parse_utc_isoformat('2020-12-31T12:00:00.001+00:00')) + harmonization.DateTime.from_isoformat('2020-12-31T12:00:00.001+00:00')) self.assertEqual(datetime.datetime(year=2020, month=12, day=31, hour=12, tzinfo=datetime.timezone.utc), - harmonization.DateTime.parse_utc_isoformat('2020-12-31T12:00:00+00:00', - return_datetime=True)) + harmonization.DateTime.from_isoformat('2020-12-31T12:00:00+00:00', + return_datetime=True)) def test_datetime_convert_fuzzy(self): """ Test DateTime.convert_fuzzy """ self.assertEqual('2020-12-31T12:00:00+00:00', - harmonization.DateTime.convert_fuzzy('2020-12-31T12:00:00+00:00')) + harmonization.DateTime.from_fuzzy('2020-12-31T12:00:00+00:00')) self.assertEqual('2020-12-31T12:00:00+00:00', - harmonization.DateTime.convert_fuzzy('31st December 2020 12:00')) + harmonization.DateTime.from_fuzzy('31st December 2020 12:00')) def test_fqdn_valid(self):