diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 48f2c0c369..8c292941a8 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fix `DateTime(..., DateTimeKind.Local).ToString("O")` (by @MangelMaxime) * Fix calling `value.ToString(CultureInfo.InvariantCulture)` (by @MangelMaxime) * Fix #3605: Fix record equality comparison to works with optional fields (by @MangelMaxime & @dbrattli) +* PR #3608: Rewrite `time_span.py` allowing for better precision by using a number representation intead of native `timedelta`. (by @MangelMaxime) ## 4.5.0 - 2023-11-07 diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index fa46c12cc2..31fd08804d 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -5229,46 +5229,16 @@ let timeSpans (args: Expr list) = // let callee = match i.callee with Some c -> c | None -> i.args.Head + match i.CompiledName with | ".ctor" -> - let meth = - match args with - | [ ticks ] -> "fromTicks" - | _ -> "create" - - Helper.LibCall( - com, - "time_span", - meth, - t, - args, - i.SignatureArgTypes, - ?loc = r - ) - |> Some - | "FromMilliseconds" -> - //TypeCast(args.Head, t) |> Some Helper.LibCall( com, "time_span", - "from_milliseconds", - t, - args, - i.SignatureArgTypes, - ?thisArg = thisArg, - ?loc = r - ) - |> Some - | "get_TotalMilliseconds" -> - //TypeCast(thisArg.Value, t) |> Some - Helper.LibCall( - com, - "time_span", - "to_milliseconds", + "create", t, args, i.SignatureArgTypes, - ?thisArg = thisArg, ?loc = r ) |> Some @@ -5298,6 +5268,8 @@ let timeSpans |> addError com ctx.InlinePath r None + | "get_Nanoseconds" + | "get_TotalNanoseconds" -> None | meth -> let meth = Naming.removeGetSetPrefix meth |> Naming.lowerFirst diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index 7995e84b13..dbdc88dc35 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -7,13 +7,18 @@ from .types import FSharpRef from .util import DateKind +from .time_span import TimeSpan, create as create_time_span formatRegExp = re.compile(r"(\w)\1*") -def op_subtraction(x: datetime, y: datetime) -> timedelta: - return x - y +def op_subtraction(x: datetime, y: datetime) -> TimeSpan: + delta = x - y + # ts.microseconds only contains the microseconds provided to the constructor + # so we need to calculate the total microseconds ourselves + delta_microseconds = delta.days * (24*3600) + delta.seconds * 10**6 + delta.microseconds + return create_time_span(0,0,0,0,0,delta_microseconds) def create( diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index 2c5522dd15..c5b63e495c 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -2,8 +2,14 @@ from datetime import datetime, timedelta, timezone +from . import time_span +from .time_span import TimeSpan from .types import FSharpRef +def timedelta_total_microseconds(td: timedelta) -> int: + # timedelta doesn't expose total_microseconds + # so we need to calculate it ourselves + return td.days * (24*3600) + td.seconds * 10**6 + td.microseconds def add(d: datetime, ts: timedelta) -> datetime: return d + ts @@ -30,17 +36,18 @@ def create( h: int, m: int, s: int, - ms: int | timedelta, - offset: timedelta | None = None, + ms: int | TimeSpan, + offset: TimeSpan | None = None, ) -> datetime: - if isinstance(ms, timedelta): - offset = ms + python_offset: timedelta | None = None + if isinstance(ms, TimeSpan): + python_offset = timedelta(microseconds=time_span.total_microseconds(ms)) ms = 0 - if offset is None: + if python_offset is None: return datetime(year, month, day, h, m, s, ms) - tzinfo = timezone(offset) + tzinfo = timezone(python_offset) return datetime(year, month, day, h, m, s, ms, tzinfo=tzinfo) @@ -56,11 +63,11 @@ def op_addition(x: datetime, y: timedelta) -> datetime: return x + y -def op_subtraction(x: datetime, y: datetime | timedelta) -> datetime | timedelta: - if isinstance(y, timedelta): - return x - y +def op_subtraction(x: datetime, y: datetime | TimeSpan) -> datetime | TimeSpan: + if isinstance(y, TimeSpan): + return x - timedelta(microseconds=time_span.total_microseconds(y)) - return x - y + return time_span.create(0,0,0,0,0,timedelta_total_microseconds(x - y)) def min_value() -> datetime: diff --git a/src/fable-library-py/fable_library/time_span.py b/src/fable-library-py/fable_library/time_span.py index 11397644a4..c168412415 100644 --- a/src/fable-library-py/fable_library/time_span.py +++ b/src/fable-library-py/fable_library/time_span.py @@ -1,132 +1,169 @@ from __future__ import annotations -from datetime import timedelta from typing import Any from .util import pad_left_and_right_with_zeros, pad_with_zeros +from math import fmod, ceil, floor -def total_seconds(ts: timedelta) -> float: - return ts.total_seconds() +# TimeSpan is represented as an int which is the Tick value +# We can recompute everything from this value +class TimeSpan(int): + pass -def total_days(ts: timedelta) -> float: - return total_seconds(ts) / 86400 +def create( + days: float = 0, + hours: float | None = None, + minutes: float | None = None, + seconds: float | None = None, + milliseconds: float | None = None, + microseconds: float | None = None, +) -> TimeSpan: + match (days, hours, minutes, seconds, milliseconds, microseconds): + # ticks constructor + case (_, None, None, None, None, None): + return TimeSpan(days) + # hours, minutes, seconds constructor + case (_, _, _, None, None, None): + seconds = minutes + minutes = hours + hours = days + days = 0 + # others constructor follows the correct order of arguments + case _: + pass + + return TimeSpan( + days * 864000000000 + + (hours or 0) * 36000000000 + + (minutes or 0) * 600000000 + + (seconds or 0) * 10000000 + + (milliseconds or 0) * 10000 + + (microseconds or 0) * 10 + ) -def total_minutes(ts: timedelta) -> float: - return total_seconds(ts) / 60 +def total_nanoseconds(ts: TimeSpan) -> float: + # We store timespan as the Tick value so nanoseconds step is 100 + return ts * 100 -def total_hours(ts: timedelta) -> float: - return total_seconds(ts) / 3600 +def total_microseconds(ts: TimeSpan) -> float: + return ts / 10 -def from_milliseconds(msecs: int) -> timedelta: - return timedelta(milliseconds=msecs) +def total_milliseconds(ts: TimeSpan) -> float: + return ts / 10000 -def from_ticks(ticks: int) -> timedelta: - return timedelta(microseconds=ticks // 10) +def total_seconds(ts: TimeSpan) -> float: + return ts / 10000000 -def from_seconds(s: int) -> timedelta: - return timedelta(seconds=s) +def total_minutes(ts: TimeSpan) -> float: + return ts / 600000000 -def from_minutes(m: int) -> timedelta: - return timedelta(minutes=m) +def total_hours(ts: TimeSpan) -> float: + return ts / 36000000000 -def from_hours(h: int) -> timedelta: - return timedelta(hours=h) +def total_days(ts: TimeSpan) -> float: + return ts / 864000000000 +def from_microseconds(micros: float) -> TimeSpan: + return create(0,0,0,0,0,micros) -def from_days(d: int) -> timedelta: - return timedelta(days=d) +def from_milliseconds(msecs: int) -> TimeSpan: + return create(0,0,0,0,msecs) -def to_milliseconds(td: timedelta) -> int: - return int(td.total_seconds() * 1000) +def from_ticks(ticks: int) -> TimeSpan: + return create(ticks) -def days(ts: timedelta) -> int: - return int(total_seconds(ts) / 86400) +def from_seconds(s: int) -> TimeSpan: + return create(0, 0, s) -def hours(ts: timedelta) -> int: - return int(abs(total_seconds(ts)) % 86400 / 3600) +def from_minutes(m: int) -> TimeSpan: + return create(0, m, 0) -def minutes(ts: timedelta) -> int: - return int(abs(total_seconds(ts)) % 3600 / 60) +def from_hours(h: int) -> TimeSpan: + return create(h, 0, 0) -def seconds(ts: timedelta) -> int: - return int(abs(total_seconds(ts)) % 60) +def from_days(d: int) -> TimeSpan: + return create(d, 0, 0, 0) -def milliseconds(ts: timedelta) -> int: - return int(total_seconds(ts) % 1 * 1000) +def ticks(ts: TimeSpan) -> int: + return int(ts) -def ticks(ts: timedelta) -> int: - return int(total_seconds(ts) * 10000000) +def microseconds(ts: TimeSpan) -> int: + return int(fmod(ts, 10000) / 10) -def negate(ts: timedelta) -> timedelta: - return -ts +def milliseconds(ts: TimeSpan) -> int: + return int(fmod(ts, 10000000) / 10000) -def duration(ts: timedelta) -> timedelta: - if ts < timedelta(0): - return -ts +def seconds(ts: TimeSpan) -> int: + return int(fmod(ts, 600000000) / 10000000) - return ts +def minutes(ts: TimeSpan) -> int: + return int(fmod(ts, 36000000000) / 600000000) -def add(ts: timedelta, other: timedelta) -> timedelta: - return ts + other +def hours(ts: TimeSpan) -> int: + return int(fmod(ts, 864000000000) / 36000000000) -def subtract(ts: timedelta, other: timedelta) -> timedelta: - return ts - other +def days(ts: TimeSpan) -> int: + res = ts / 864000000000 + return ceil(res) if res < 0 else floor(res) -def multiply(ts: timedelta, factor: int) -> timedelta: - return ts * factor +def negate(ts: TimeSpan) -> TimeSpan: + return TimeSpan(-ts) -def divide(ts: timedelta, divisor: int) -> timedelta: - return ts / divisor +def duration(ts: TimeSpan) -> TimeSpan: + return TimeSpan(abs(int(ts))) -def create( - d: int = 0, - h: int | None = None, - m: int | None = None, - s: int | None = None, - ms: int | None = None, -) -> timedelta: - if h is None and m is None and s is None and ms is None: - return from_ticks(d) - elif s is None and ms is None: - return timedelta(hours=d or 0, minutes=h or 0, seconds=m or 0) +def add(ts: TimeSpan, other: TimeSpan) -> TimeSpan: + return TimeSpan(ts + other) + + +def subtract(ts: TimeSpan, other: TimeSpan) -> TimeSpan: + return TimeSpan(ts - other) + - return timedelta(days=d, hours=h or 0, minutes=m or 0, seconds=s or 0, milliseconds=ms or 0) +def multiply(ts: TimeSpan, factor: float) -> TimeSpan: + # We represents TimeSpan as a Tick which can't be a float + # This also allows us + return TimeSpan(int(ts * factor)) -def to_string(ts: timedelta, format: str = "c", _provider: Any | None = None) -> str: +def divide(ts: TimeSpan, divisor: float) -> TimeSpan: + return TimeSpan(int(ts / divisor)) + + +def to_string(ts: TimeSpan, format: str = "c", _provider: Any | None = None) -> str: if format not in ["c", "g", "G"]: raise ValueError("Custom formats are not supported") d = abs(days(ts)) - h = hours(ts) - m = minutes(ts) - s = seconds(ts) + h = abs(hours(ts)) + m = abs(minutes(ts)) + s = abs(seconds(ts)) ms = abs(milliseconds(ts)) - sign: str = "-" if ts < timedelta(0) else "" + sign: str = "-" if ts < 0 else "" ms_str = ( "" @@ -143,9 +180,14 @@ def to_string(ts: timedelta, format: str = "c", _provider: Any | None = None) -> __all__ = [ "create", - "to_milliseconds", - "to_string", + "total_microseconds", + "total_milliseconds", + "total_seconds", + "total_minutes", + "total_hours", + "total_days", "from_ticks", + "from_microseconds", "from_milliseconds", "from_hours", "from_minutes", diff --git a/src/quicktest-py/quicktest.fsx b/src/quicktest-py/quicktest.fsx index 8185ef42dd..974b9a53f7 100644 --- a/src/quicktest-py/quicktest.fsx +++ b/src/quicktest-py/quicktest.fsx @@ -1,8 +1,14 @@ #r "nuget:Fable.Python" open Fable.Core +open Fable.Core.Testing open Fable.Core.PyInterop open Fable.Python.Builtins +open System + +let equal expected actual = + // According the console log arguments are reversed + Assert.AreEqual(actual, expected) [] let main argv = diff --git a/tests/Python/Fable.Tests.Python.fsproj b/tests/Python/Fable.Tests.Python.fsproj index e564631241..cac5e1cb5a 100644 --- a/tests/Python/Fable.Tests.Python.fsproj +++ b/tests/Python/Fable.Tests.Python.fsproj @@ -1,7 +1,7 @@ Exe - net6.0 + net8.0 Major false false diff --git a/tests/Python/TestTimeSpan.fs b/tests/Python/TestTimeSpan.fs index 13fdb727ab..172e72cb59 100644 --- a/tests/Python/TestTimeSpan.fs +++ b/tests/Python/TestTimeSpan.fs @@ -11,12 +11,12 @@ let ``test TimeSpan.ToString() works`` () = TimeSpan.FromSeconds(12345.).ToString() |> equal "03:25:45" TimeSpan.FromDays(18.).ToString() |> equal "18.00:00:00" TimeSpan.FromMilliseconds(25.).ToString() |> equal "00:00:00.0250000" - + [] let ``test TimeSpan.ToString() works for negative TimeSpan`` () = TimeSpan.FromSeconds(-5.).ToString() |> equal "-00:00:05" TimeSpan.FromDays(-5.23).ToString() |> equal "-5.05:31:12" - + [] let ``test TimeSpan.ToString(\"c\", CultureInfo.InvariantCulture) works`` () = TimeSpan(0L).ToString("c", CultureInfo.InvariantCulture) |> equal "00:00:00" @@ -37,7 +37,7 @@ let ``test TimeSpan.ToString(\"General long\", CultureInfo.InvariantCulture) wor TimeSpan.FromSeconds(12345.).ToString("G", CultureInfo.InvariantCulture) |> equal "0:03:25:45.0000000" TimeSpan.FromDays(18.).ToString("G", CultureInfo.InvariantCulture) |> equal "18:00:00:00.0000000" TimeSpan.FromMilliseconds(25.).ToString("G", CultureInfo.InvariantCulture) |> equal "0:00:00:00.0250000" - + [] let ``test TimeSpan constructors work`` () = let t1 = TimeSpan(20000L) @@ -124,6 +124,53 @@ let ``test TimeSpan Addition works`` () = test -2000. 0. -2000. test 0. 0. 0. +[] +let ``test TimeSpan implementation coherence`` () = + TimeSpan.FromTicks(1L).Ticks |> equal 1L + TimeSpan.FromMilliseconds(1).Milliseconds |> equal 1 + TimeSpan.FromMilliseconds(1).TotalMilliseconds |> equal 1. + TimeSpan.FromSeconds(1).Seconds |> equal 1 + TimeSpan.FromSeconds(1).TotalSeconds |> equal 1. + TimeSpan.FromMinutes(1).Minutes |> equal 1 + TimeSpan.FromMinutes(1).TotalMinutes |> equal 1. + TimeSpan.FromHours(1).Hours |> equal 1 + TimeSpan.FromHours(1).TotalHours |> equal 1. + TimeSpan.FromDays(1).Days |> equal 1 + TimeSpan.FromDays(1).TotalDays |> equal 1. + + TimeSpan.FromMilliseconds(-1).Milliseconds |> equal -1 + TimeSpan.FromMilliseconds(-1).TotalMilliseconds |> equal -1. + TimeSpan.FromSeconds(-1).Seconds |> equal -1 + TimeSpan.FromSeconds(-1).TotalSeconds |> equal -1. + TimeSpan.FromMinutes(-1).Minutes |> equal -1 + TimeSpan.FromMinutes(-1).TotalMinutes |> equal -1. + TimeSpan.FromHours(-1).Hours |> equal -1 + TimeSpan.FromHours(-1).TotalHours |> equal -1. + TimeSpan.FromDays(-1).Days |> equal -1 + TimeSpan.FromDays(-1).TotalDays |> equal -1. + + TimeSpan(49, 0, 0).Days |> equal 2 + TimeSpan(0, 200, 0).Hours |> equal 3 + TimeSpan(0, 0, 300).Minutes |> equal 5 + TimeSpan(0, 0, 0, 0, 5089).Seconds |> equal 5 + TimeSpan(0, 0, 0, 0, 0, 5999).Milliseconds |> equal 5 + + let t1 = TimeSpan(10, 20, 39, 42, 57, 589) + + t1.Days |> equal 10 + t1.Hours |> equal 20 + t1.Minutes |> equal 39 + t1.Seconds |> equal 42 + t1.Milliseconds |> equal 57 + t1.Ticks |> equal 9383820575890L + t1.TotalDays |> equal 10.86090344431713 + t1.TotalHours |> equal 260.6616826636111 + t1.TotalMinutes |> equal 15639.700959816666 + t1.TotalSeconds |> equal 938382.057589 + t1.TotalMilliseconds |> equal 938382057.589 + t1.TotalMicroseconds |> equal 938382057589.0 + + //[] //[] //[] @@ -139,7 +186,7 @@ let ``test TimeSpan Addition works`` () = // let res2 = (t1 - t2).TotalMilliseconds // equal true (res1 = res2) // equal expected res1 -// +// //[] //[] //[] @@ -154,7 +201,7 @@ let ``test TimeSpan Addition works`` () = // equal res1 res2 // equal true (res1 = res2) // equal expected res1 - + //[] //let ``test TimeSpan Division works" [ @@ -185,4 +232,4 @@ let ``test TimeSpan Addition works`` () = // test_float 1000. -1. -1000. // test_float 2200. 11. 200. // test_float -3000. 1.5 -2000. -// test_float 0. 1000. 0. \ No newline at end of file +// test_float 0. 1000. 0.