From 625000c7402ff9dfe6b3c4c20170f587235eb6a5 Mon Sep 17 00:00:00 2001 From: "Matthew.Royle" Date: Wed, 28 Jan 2026 15:23:49 +0000 Subject: [PATCH 1/7] Add filetime to datetime conversion utility functions to Datetime.py --- nion/utils/DateTime.py | 29 +++++++++++++ nion/utils/test/DateTime_test.py | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 nion/utils/test/DateTime_test.py diff --git a/nion/utils/DateTime.py b/nion/utils/DateTime.py index 3561571..f2ea29b 100644 --- a/nion/utils/DateTime.py +++ b/nion/utils/DateTime.py @@ -20,6 +20,9 @@ last_time: float = 0.0 last_time_lock = threading.RLock() +FILETIME_TICKS_PER_MICROSECOND = 10 # Hundreds of nanoseconds in a microsecond +FILETIME_TICKS_PER_SECOND = 10000000 # Hundreds of nanoseconds (0.1 microseconds) in a second +FILETIME_EPOCH = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) class DateTimeUTC: @@ -62,3 +65,29 @@ def utcnow() -> datetime.datetime: def now() -> datetime.datetime: return datetime.datetime.now() + +def get_datetime_from_filetime(filetime: int) -> datetime.datetime: + """ + Converts a windows filetime to a datetime in UTC + Windows file time is: the time in hundreds of nanoseconds since January 1st 1601 UTC + """ + try: + total_microseconds = filetime // FILETIME_TICKS_PER_MICROSECOND + return FILETIME_EPOCH + datetime.timedelta(microseconds=total_microseconds) + except OverflowError: + if filetime < 0: + return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) + else: + return datetime.datetime.max.replace(tzinfo=datetime.timezone.utc) + + +def get_filetime_from_datetime(time_dt: datetime.datetime) -> int: + """ + Converts a datetime to a Windows file time. If the datetime's timezone is None it is assumed to be UTC. + """ + if time_dt.tzinfo is None: + time_dt = time_dt.replace(tzinfo=datetime.timezone.utc) + + delta = time_dt.astimezone(datetime.timezone.utc) - FILETIME_EPOCH + file_time_ticks = (delta.days * 24 * 3600 + delta.seconds) * FILETIME_TICKS_PER_SECOND + delta.microseconds * FILETIME_TICKS_PER_MICROSECOND + return file_time_ticks \ No newline at end of file diff --git a/nion/utils/test/DateTime_test.py b/nion/utils/test/DateTime_test.py new file mode 100644 index 0000000..492c1c5 --- /dev/null +++ b/nion/utils/test/DateTime_test.py @@ -0,0 +1,72 @@ +import unittest +import datetime +import zoneinfo +from nion.utils import DateTime + +class Test(unittest.TestCase): + def test_datetime_to_filetime(self): + test_datetimes = [ + (datetime.datetime(2025, 1, 1), 133801632000000000), + (datetime.datetime(2025, 1, 1, 12, 30, 45), 133802082450000000), + (datetime.datetime(2025, 12, 31, 23, 59, 59, 999999), 134116991999999990), # max microsecond + (datetime.datetime(1970, 1, 1, 0, 0, 0), 116444736000000000), # Unix epoch + (datetime.datetime(9999, 12, 31, 23, 59, 59, 999999), 2650467743999999990), # Python max datetime.datetime + (datetime.datetime(2024, 2, 29, 15, 0), 133536924000000000), # leap day + (datetime.datetime(2000, 2, 29, 23, 59, 59), 125963423990000000), # leap year divisible by 400 + (datetime.datetime(2025, 3, 30, 0, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London")), 133877682000000000), # before daylight savings + (datetime.datetime(2025, 3, 30, 1, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London")), 133877718000000000), # ambiguous transition + (datetime.datetime(2025, 10, 26, 1, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London")),134059122000000000), # repeated hour + (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=datetime.timezone.utc), 133932528000000000),# UTC aware + (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=5, minutes=30))),133932330000000000), # India Standard Time + (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York")), 133932672000000000), # US Eastern + (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=zoneinfo.ZoneInfo("Asia/Tokyo")), 133932204000000000), # Japan time + (datetime.datetime(1950, 5, 17, 8, 20, 0, tzinfo=datetime.timezone.utc), 110251020000000000), # negative time stamp + (datetime.datetime(1066, 1, 1, 0, 0, 0), -168829920000000000), # before windows filetime start + (datetime.datetime(2025, 7, 15, 10, 5, 30, 123456), 133970475301234560), # microseconds + (datetime.datetime(2025, 7, 15, 10, 5, 30, 0), 133970475300000000), # zero microseconds + (datetime.datetime(2035, 8, 1, 9, 0, tzinfo=datetime.timezone.utc), 137140452000000000), # 10 years ahead + (datetime.datetime(2100, 1, 1, 0, 0, 0),157469184000000000) # non‑leap century year + ] + for datetime_in, expected_filetime in test_datetimes: + with self.subTest(f"Datetime to filetime {datetime_in} expects {expected_filetime}"): + filetime = DateTime.get_filetime_from_datetime(datetime_in) + self.assertEqual(filetime, expected_filetime) + datetime_out = DateTime.get_datetime_from_filetime(filetime) + if datetime_in.tzinfo is None: + + time_in_utc = datetime_in.replace(tzinfo=datetime.timezone.utc) + else: + time_in_utc = datetime_in.astimezone(tz=datetime.timezone.utc) + self.assertEqual(time_in_utc, datetime_out) + + with self.subTest(f"Filetime to datetime {datetime_in} expects {expected_filetime}"): + datetime_out = DateTime.get_datetime_from_filetime(expected_filetime) + if datetime_in.tzinfo is None: + + time_in_utc = datetime_in.replace(tzinfo=datetime.timezone.utc) + else: + time_in_utc = datetime_in.astimezone(tz=datetime.timezone.utc) + self.assertEqual(time_in_utc, datetime_out) + filetime_out = DateTime.get_filetime_from_datetime(datetime_out) + self.assertEqual(filetime_out, expected_filetime) + + + def test_invalid_times(self): + test_filetimes = [ + (datetime.datetime.max, 2650467744999999990), # Above the max datetime + (datetime.datetime.max, 9223372036854775807), # Int max, + (datetime.datetime.min, -9223372036854775808), # int min + ] + for i, (expected_datetime, filetime_in) in enumerate(test_filetimes): + with self.subTest(f"Invalid filetime test: {expected_datetime} expects {filetime_in}"): + datetime_out = DateTime.get_datetime_from_filetime(filetime_in) + if expected_datetime.tzinfo is None: + + time_in_utc = expected_datetime.replace(tzinfo=datetime.timezone.utc) + else: + time_in_utc = expected_datetime.astimezone(tz=datetime.timezone.utc) + self.assertEqual(datetime_out, time_in_utc) + + +if __name__ == '__main__': + unittest.main() From 3b84801cac3196e1860f3415164dacfa2abced17 Mon Sep 17 00:00:00 2001 From: "Matthew.Royle" Date: Wed, 28 Jan 2026 15:34:20 +0000 Subject: [PATCH 2/7] Added None return type to Datetime tests --- nion/utils/test/DateTime_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nion/utils/test/DateTime_test.py b/nion/utils/test/DateTime_test.py index 492c1c5..2fa3bdf 100644 --- a/nion/utils/test/DateTime_test.py +++ b/nion/utils/test/DateTime_test.py @@ -3,8 +3,9 @@ import zoneinfo from nion.utils import DateTime + class Test(unittest.TestCase): - def test_datetime_to_filetime(self): + def test_datetime_to_filetime(self) -> None: test_datetimes = [ (datetime.datetime(2025, 1, 1), 133801632000000000), (datetime.datetime(2025, 1, 1, 12, 30, 45), 133802082450000000), @@ -50,8 +51,7 @@ def test_datetime_to_filetime(self): filetime_out = DateTime.get_filetime_from_datetime(datetime_out) self.assertEqual(filetime_out, expected_filetime) - - def test_invalid_times(self): + def test_invalid_times(self) -> None: test_filetimes = [ (datetime.datetime.max, 2650467744999999990), # Above the max datetime (datetime.datetime.max, 9223372036854775807), # Int max, From 464e7d7848a4383233ca4595646210fcf446dfde Mon Sep 17 00:00:00 2001 From: "Matthew.Royle" Date: Thu, 29 Jan 2026 09:40:41 +0000 Subject: [PATCH 3/7] added tzdata to the test requirements --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index d7e832c..77c59ba 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -3,4 +3,4 @@ # Use it like this: # pip install -r test-requirements.txt -# no requirements +tzdata From 8aefef10525d8cfbacefdf5972b96d9db1af8f89 Mon Sep 17 00:00:00 2001 From: "Matthew.Royle" Date: Thu, 29 Jan 2026 12:05:49 +0000 Subject: [PATCH 4/7] removed tzdata from the test requirements, changed doc string to the standard form, removed dependency on timezone library/ tzdata issue --- nion/utils/DateTime.py | 10 +++++----- nion/utils/test/DateTime_test.py | 18 ++++++++---------- test-requirements.txt | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/nion/utils/DateTime.py b/nion/utils/DateTime.py index f2ea29b..7ea7a11 100644 --- a/nion/utils/DateTime.py +++ b/nion/utils/DateTime.py @@ -67,12 +67,12 @@ def now() -> datetime.datetime: return datetime.datetime.now() def get_datetime_from_filetime(filetime: int) -> datetime.datetime: - """ - Converts a windows filetime to a datetime in UTC + """Converts a windows filetime to a datetime in UTC Windows file time is: the time in hundreds of nanoseconds since January 1st 1601 UTC + Since datetime objects only have 1 microsecond precision the exact filetime is not fully preserved. """ try: - total_microseconds = filetime // FILETIME_TICKS_PER_MICROSECOND + total_microseconds = filetime // FILETIME_TICKS_PER_MICROSECOND # Integer division is required to prevent floating point errors return FILETIME_EPOCH + datetime.timedelta(microseconds=total_microseconds) except OverflowError: if filetime < 0: @@ -82,8 +82,8 @@ def get_datetime_from_filetime(filetime: int) -> datetime.datetime: def get_filetime_from_datetime(time_dt: datetime.datetime) -> int: - """ - Converts a datetime to a Windows file time. If the datetime's timezone is None it is assumed to be UTC. + """ Converts a datetime to a Windows file time. + If the datetime's timezone is None it is assumed to be UTC. """ if time_dt.tzinfo is None: time_dt = time_dt.replace(tzinfo=datetime.timezone.utc) diff --git a/nion/utils/test/DateTime_test.py b/nion/utils/test/DateTime_test.py index 2fa3bdf..3dfe8ff 100644 --- a/nion/utils/test/DateTime_test.py +++ b/nion/utils/test/DateTime_test.py @@ -1,9 +1,7 @@ import unittest import datetime -import zoneinfo from nion.utils import DateTime - class Test(unittest.TestCase): def test_datetime_to_filetime(self) -> None: test_datetimes = [ @@ -14,23 +12,24 @@ def test_datetime_to_filetime(self) -> None: (datetime.datetime(9999, 12, 31, 23, 59, 59, 999999), 2650467743999999990), # Python max datetime.datetime (datetime.datetime(2024, 2, 29, 15, 0), 133536924000000000), # leap day (datetime.datetime(2000, 2, 29, 23, 59, 59), 125963423990000000), # leap year divisible by 400 - (datetime.datetime(2025, 3, 30, 0, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London")), 133877682000000000), # before daylight savings - (datetime.datetime(2025, 3, 30, 1, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London")), 133877718000000000), # ambiguous transition - (datetime.datetime(2025, 10, 26, 1, 30, tzinfo=zoneinfo.ZoneInfo("Europe/London")),134059122000000000), # repeated hour (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=datetime.timezone.utc), 133932528000000000),# UTC aware (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=5, minutes=30))),133932330000000000), # India Standard Time - (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York")), 133932672000000000), # US Eastern - (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=zoneinfo.ZoneInfo("Asia/Tokyo")), 133932204000000000), # Japan time (datetime.datetime(1950, 5, 17, 8, 20, 0, tzinfo=datetime.timezone.utc), 110251020000000000), # negative time stamp (datetime.datetime(1066, 1, 1, 0, 0, 0), -168829920000000000), # before windows filetime start (datetime.datetime(2025, 7, 15, 10, 5, 30, 123456), 133970475301234560), # microseconds (datetime.datetime(2025, 7, 15, 10, 5, 30, 0), 133970475300000000), # zero microseconds (datetime.datetime(2035, 8, 1, 9, 0, tzinfo=datetime.timezone.utc), 137140452000000000), # 10 years ahead - (datetime.datetime(2100, 1, 1, 0, 0, 0),157469184000000000) # non‑leap century year + (datetime.datetime(2100, 1, 1, 0, 0, 0),157469184000000000), # non‑leap century year + (datetime.datetime(2025, 3, 30, 0, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=-1))), 133877718000000000), # before daylight savings + (datetime.datetime(2025, 3, 30, 1, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=0))), 133877718000000000), # ambiguous transition + (datetime.datetime(2025, 10, 26, 1, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=0))), 134059158000000000), # repeated hour + (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=4))), 133932384000000000), # US Eastern + (datetime.datetime(2025, 6, 1, 12, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=9))), 133932204000000000), # Japan time ] for datetime_in, expected_filetime in test_datetimes: - with self.subTest(f"Datetime to filetime {datetime_in} expects {expected_filetime}"): + with self.subTest(f"Datetime to filetime {datetime_in.isoformat()} expects {expected_filetime}"): filetime = DateTime.get_filetime_from_datetime(datetime_in) + print(filetime) self.assertEqual(filetime, expected_filetime) datetime_out = DateTime.get_datetime_from_filetime(filetime) if datetime_in.tzinfo is None: @@ -67,6 +66,5 @@ def test_invalid_times(self) -> None: time_in_utc = expected_datetime.astimezone(tz=datetime.timezone.utc) self.assertEqual(datetime_out, time_in_utc) - if __name__ == '__main__': unittest.main() diff --git a/test-requirements.txt b/test-requirements.txt index 77c59ba..d7e832c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -3,4 +3,4 @@ # Use it like this: # pip install -r test-requirements.txt -tzdata +# no requirements From bc6ce02de4c64447e1fdf09ab280b5615ef85c71 Mon Sep 17 00:00:00 2001 From: "Matthew.Royle" Date: Thu, 29 Jan 2026 12:31:36 +0000 Subject: [PATCH 5/7] added rounding to the filetime conversion using divmod --- nion/utils/DateTime.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nion/utils/DateTime.py b/nion/utils/DateTime.py index 7ea7a11..0d98d1e 100644 --- a/nion/utils/DateTime.py +++ b/nion/utils/DateTime.py @@ -72,7 +72,8 @@ def get_datetime_from_filetime(filetime: int) -> datetime.datetime: Since datetime objects only have 1 microsecond precision the exact filetime is not fully preserved. """ try: - total_microseconds = filetime // FILETIME_TICKS_PER_MICROSECOND # Integer division is required to prevent floating point errors + total_microseconds, remainder = divmod(filetime, FILETIME_TICKS_PER_MICROSECOND) # regular division would introduce floating point issues + total_microseconds += 0 if remainder < 5 else 1 # we can manually do rounding to avoid the floating point issues return FILETIME_EPOCH + datetime.timedelta(microseconds=total_microseconds) except OverflowError: if filetime < 0: From fb4cbf08c2fbc6747b61d9fd2733d567e4753b17 Mon Sep 17 00:00:00 2001 From: "Matthew.Royle" Date: Thu, 29 Jan 2026 12:33:26 +0000 Subject: [PATCH 6/7] renamed the functions to specify it is windows filetime --- nion/utils/DateTime.py | 4 ++-- nion/utils/test/DateTime_test.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/nion/utils/DateTime.py b/nion/utils/DateTime.py index 0d98d1e..fcb323c 100644 --- a/nion/utils/DateTime.py +++ b/nion/utils/DateTime.py @@ -66,7 +66,7 @@ def utcnow() -> datetime.datetime: def now() -> datetime.datetime: return datetime.datetime.now() -def get_datetime_from_filetime(filetime: int) -> datetime.datetime: +def get_datetime_from_windows_filetime(filetime: int) -> datetime.datetime: """Converts a windows filetime to a datetime in UTC Windows file time is: the time in hundreds of nanoseconds since January 1st 1601 UTC Since datetime objects only have 1 microsecond precision the exact filetime is not fully preserved. @@ -82,7 +82,7 @@ def get_datetime_from_filetime(filetime: int) -> datetime.datetime: return datetime.datetime.max.replace(tzinfo=datetime.timezone.utc) -def get_filetime_from_datetime(time_dt: datetime.datetime) -> int: +def get_windows_filetime_from_datetime(time_dt: datetime.datetime) -> int: """ Converts a datetime to a Windows file time. If the datetime's timezone is None it is assumed to be UTC. """ diff --git a/nion/utils/test/DateTime_test.py b/nion/utils/test/DateTime_test.py index 3dfe8ff..6938b05 100644 --- a/nion/utils/test/DateTime_test.py +++ b/nion/utils/test/DateTime_test.py @@ -28,10 +28,10 @@ def test_datetime_to_filetime(self) -> None: ] for datetime_in, expected_filetime in test_datetimes: with self.subTest(f"Datetime to filetime {datetime_in.isoformat()} expects {expected_filetime}"): - filetime = DateTime.get_filetime_from_datetime(datetime_in) + filetime = DateTime.get_windows_filetime_from_datetime(datetime_in) print(filetime) self.assertEqual(filetime, expected_filetime) - datetime_out = DateTime.get_datetime_from_filetime(filetime) + datetime_out = DateTime.get_datetime_from_windows_filetime(filetime) if datetime_in.tzinfo is None: time_in_utc = datetime_in.replace(tzinfo=datetime.timezone.utc) @@ -40,14 +40,14 @@ def test_datetime_to_filetime(self) -> None: self.assertEqual(time_in_utc, datetime_out) with self.subTest(f"Filetime to datetime {datetime_in} expects {expected_filetime}"): - datetime_out = DateTime.get_datetime_from_filetime(expected_filetime) + datetime_out = DateTime.get_datetime_from_windows_filetime(expected_filetime) if datetime_in.tzinfo is None: time_in_utc = datetime_in.replace(tzinfo=datetime.timezone.utc) else: time_in_utc = datetime_in.astimezone(tz=datetime.timezone.utc) self.assertEqual(time_in_utc, datetime_out) - filetime_out = DateTime.get_filetime_from_datetime(datetime_out) + filetime_out = DateTime.get_windows_filetime_from_datetime(datetime_out) self.assertEqual(filetime_out, expected_filetime) def test_invalid_times(self) -> None: @@ -58,7 +58,7 @@ def test_invalid_times(self) -> None: ] for i, (expected_datetime, filetime_in) in enumerate(test_filetimes): with self.subTest(f"Invalid filetime test: {expected_datetime} expects {filetime_in}"): - datetime_out = DateTime.get_datetime_from_filetime(filetime_in) + datetime_out = DateTime.get_datetime_from_windows_filetime(filetime_in) if expected_datetime.tzinfo is None: time_in_utc = expected_datetime.replace(tzinfo=datetime.timezone.utc) From d747a745b8020fc1d82909caad8cd401cb5baec2 Mon Sep 17 00:00:00 2001 From: "Matthew.Royle" Date: Fri, 30 Jan 2026 09:46:54 +0000 Subject: [PATCH 7/7] fix the docstrings --- nion/utils/DateTime.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nion/utils/DateTime.py b/nion/utils/DateTime.py index fcb323c..57ac0f4 100644 --- a/nion/utils/DateTime.py +++ b/nion/utils/DateTime.py @@ -68,6 +68,7 @@ def now() -> datetime.datetime: def get_datetime_from_windows_filetime(filetime: int) -> datetime.datetime: """Converts a windows filetime to a datetime in UTC + Windows file time is: the time in hundreds of nanoseconds since January 1st 1601 UTC Since datetime objects only have 1 microsecond precision the exact filetime is not fully preserved. """ @@ -83,7 +84,8 @@ def get_datetime_from_windows_filetime(filetime: int) -> datetime.datetime: def get_windows_filetime_from_datetime(time_dt: datetime.datetime) -> int: - """ Converts a datetime to a Windows file time. + """Converts a datetime to a Windows file time. + If the datetime's timezone is None it is assumed to be UTC. """ if time_dt.tzinfo is None: