diff --git a/mypy.ini b/mypy.ini --- a/mypy.ini +++ b/mypy.ini @@ -11,6 +11,9 @@ [mypy-dulwich.*] ignore_missing_imports = True +[mypy-iso8601.*] +ignore_missing_imports = True + [mypy-pkg_resources.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ attrs hypothesis python-dateutil +iso8601 diff --git a/swh/model/model.py b/swh/model/model.py --- a/swh/model/model.py +++ b/swh/model/model.py @@ -7,10 +7,11 @@ from abc import ABCMeta, abstractmethod from enum import Enum -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Union import attr import dateutil.parser +import iso8601 from .identifiers import ( normalize_timestamp, directory_identifier, revision_identifier, @@ -124,15 +125,31 @@ raise ValueError('offset too large: %d minutes' % value) @classmethod - def from_dict(cls, d): + def from_dict(cls, obj: Union[Dict, datetime.datetime, int]): """Builds a TimestampWithTimezone from any of the formats accepted by :func:`swh.model.normalize_timestamp`.""" - d = normalize_timestamp(d) + # TODO: this accept way more types than just dicts; find a better + # name + d = normalize_timestamp(obj) return cls( timestamp=Timestamp.from_dict(d['timestamp']), offset=d['offset'], negative_utc=d['negative_utc']) + @classmethod + def from_datetime(cls, dt: datetime.datetime): + return cls.from_dict(dt) + + @classmethod + def from_iso8601(cls, s): + """Builds a TimestampWithTimezone from an ISO8601-formatted string. + """ + dt = iso8601.parse_date(s) + tstz = cls.from_datetime(dt) + if dt.tzname() == '-00:00': + tstz = attr.evolve(tstz, negative_utc=True) + return tstz + @attr.s(frozen=True) class Origin(BaseModel): diff --git a/swh/model/tests/test_model.py b/swh/model/tests/test_model.py --- a/swh/model/tests/test_model.py +++ b/swh/model/tests/test_model.py @@ -4,12 +4,16 @@ # See top-level LICENSE file for more information import copy +import datetime from hypothesis import given import pytest -from swh.model.model import Content, Directory, Revision, Release, Snapshot -from swh.model.model import MissingData +from swh.model.model import ( + Content, Directory, Revision, Release, Snapshot, + Timestamp, TimestampWithTimezone, + MissingData, +) from swh.model.hashutil import hash_to_bytes from swh.model.hypothesis_strategies import objects, origins, origin_visits from swh.model.identifiers import ( @@ -56,6 +60,53 @@ assert origin_visit == type(origin_visit).from_dict(obj) +def test_timestampwithtimezone_from_datetime(): + tz = datetime.timezone(datetime.timedelta(minutes=+60)) + date = datetime.datetime( + 2020, 2, 27, 14, 39, 19, tzinfo=tz) + + tstz = TimestampWithTimezone.from_datetime(date) + + assert tstz == TimestampWithTimezone( + timestamp=Timestamp( + seconds=1582810759, + microseconds=0, + ), + offset=60, + negative_utc=False, + ) + + +def test_timestampwithtimezone_from_iso8601(): + date = '2020-02-27 14:39:19.123456+0100' + + tstz = TimestampWithTimezone.from_iso8601(date) + + assert tstz == TimestampWithTimezone( + timestamp=Timestamp( + seconds=1582810759, + microseconds=123456, + ), + offset=60, + negative_utc=False, + ) + + +def test_timestampwithtimezone_from_iso8601_negative_utc(): + date = '2020-02-27 13:39:19-0000' + + tstz = TimestampWithTimezone.from_iso8601(date) + + assert tstz == TimestampWithTimezone( + timestamp=Timestamp( + seconds=1582810759, + microseconds=0, + ), + offset=0, + negative_utc=True, + ) + + def test_content_get_hash(): hashes = dict( sha1=b'foo', sha1_git=b'bar', sha256=b'baz', blake2s256=b'qux')