From 15705cf915062a52f0971ae5c47d4fdcaf934b27 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 21:58:54 +0200 Subject: [PATCH 01/14] [Python] Implement missing DateTimeOffset members (#4475) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Fable.Cli/CHANGELOG.md | 1 + src/Fable.Compiler/CHANGELOG.md | 1 + src/Fable.Transforms/Python/Replacements.fs | 61 ++- src/fable-library-py/fable_library/date.py | 11 +- .../fable_library/date_offset.py | 450 +++++++++++++++- tests/Python/TestDateTimeOffset.fs | 483 +++++++++++++++++- 6 files changed, 935 insertions(+), 72 deletions(-) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index ce1258499b..529f2ad4f5 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave) * [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave) +* [Python] Implement missing `DateTimeOffset` members: `MaxValue`, `FromTicks`, `FromDate`, `UtcDateTime`, `LocalDateTime`, `UtcTicks`, `Ticks`, `Date`, `DayOfWeek`, `DayOfYear`, `TimeOfDay`, `Add`, `AddYears`, `AddMonths`, `AddDays`, `AddHours`, `AddMinutes`, `AddSeconds`, `AddMilliseconds`, `AddTicks`, `ToUniversalTime`, `ToLocalTime`, `ToOffset`, `Subtract`, `op_Addition`, `Compare`, `CompareTo`, `EqualsExact`, `FromUnixTimeSeconds`, `FromUnixTimeMilliseconds`, `ToUnixTimeSeconds`, `ToUnixTimeMilliseconds`; fix equality/comparison to use UTC-normalized instants * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 1fe7753e52..8bfe90216a 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave) * [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave) +* [Python] Implement missing `DateTimeOffset` members: `MaxValue`, `FromTicks`, `FromDate`, `UtcDateTime`, `LocalDateTime`, `UtcTicks`, `Ticks`, `Date`, `DayOfWeek`, `DayOfYear`, `TimeOfDay`, `Add`, `AddYears`, `AddMonths`, `AddDays`, `AddHours`, `AddMinutes`, `AddSeconds`, `AddMilliseconds`, `AddTicks`, `ToUniversalTime`, `ToLocalTime`, `ToOffset`, `Subtract`, `op_Addition`, `Compare`, `CompareTo`, `EqualsExact`, `FromUnixTimeSeconds`, `FromUnixTimeMilliseconds`, `ToUnixTimeSeconds`, `ToUnixTimeMilliseconds`; fix equality/comparison to use UTC-normalized instants * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 2c74811a07..97412680bc 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -610,9 +610,12 @@ let rec equals (com: ICompiler) ctx r equal (left: Expr) (right: Expr) = BinaryUnequal makeBinOp r Boolean left right op - | Builtin(BclDateTime | BclDateTimeOffset) -> + | Builtin BclDateTime -> Helper.LibCall(com, "date", "equals", Boolean, [ left; right ], ?loc = r) |> is equal + | Builtin BclDateTimeOffset -> + Helper.LibCall(com, "date_offset", "equals", Boolean, [ left; right ], ?loc = r) + |> is equal | Builtin(FSharpSet _ | FSharpMap _) -> Helper.InstanceCall(left, "Equals", Boolean, [ right ]) |> is equal | DeclaredType _ -> Helper.LibCall(com, "util", "equals", Boolean, [ left; right ], ?loc = r) @@ -647,7 +650,8 @@ and compare (com: ICompiler) ctx r (left: Expr) (right: Expr) = | Char | String | Number _ -> Helper.LibCall(com, "util", "comparePrimitives", t, [ left; right ], ?loc = r) - | Builtin(BclDateTime | BclDateTimeOffset) -> Helper.LibCall(com, "date", "compare", t, [ left; right ], ?loc = r) + | Builtin BclDateTime -> Helper.LibCall(com, "date", "compare", t, [ left; right ], ?loc = r) + | Builtin BclDateTimeOffset -> Helper.LibCall(com, "date_offset", "compare", t, [ left; right ], ?loc = r) | DeclaredType _ -> Helper.LibCall(com, "util", "compare", t, [ left; right ], ?loc = r) | Array(genArg, _) -> let f = makeComparerFunction com ctx genArg @@ -2958,13 +2962,13 @@ let dates (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr optio |> getFieldWith r t thisArg.Value |> Some // DateTimeOffset - | "get_LocalDateTime" -> - Helper.LibCall(com, "date_offset", "to_local_time", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + | "get_LocalDateTime" when i.DeclaringEntityFullName = Types.datetimeOffset -> + Helper.LibCall(com, "date_offset", "localDateTime", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some - | "get_UtcDateTime" -> - Helper.LibCall(com, "date_offset", "to_universal_time", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + | "get_UtcDateTime" when i.DeclaringEntityFullName = Types.datetimeOffset -> + Helper.LibCall(com, "date_offset", "utcDateTime", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some - | "get_DateTime" -> + | "get_DateTime" when i.DeclaringEntityFullName = Types.datetimeOffset -> let kind = System.DateTimeKind.Unspecified |> int |> makeIntConst Helper.LibCall( @@ -2977,41 +2981,44 @@ let dates (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr optio ?loc = r ) |> Some - | "FromUnixTimeSeconds" - | "FromUnixTimeMilliseconds" -> + | "FromUnixTimeSeconds" -> let value = Helper.LibCall(com, "Long", "toNumber", Float64.Number, args, i.SignatureArgTypes) + Helper.LibCall(com, "DateOffset", "fromUnixTimeSeconds", t, [ value ], [ value.Type ], ?loc = r) + |> Some + | "FromUnixTimeMilliseconds" -> let value = - if i.CompiledName = "FromUnixTimeSeconds" then - makeBinOp r t value (makeIntConst 1000) BinaryMultiply - else - value + Helper.LibCall(com, "Long", "toNumber", Float64.Number, args, i.SignatureArgTypes) + Helper.LibCall(com, "DateOffset", "fromUnixTimeMilliseconds", t, [ value ], [ value.Type ], ?loc = r) + |> Some + | "ToUnixTimeSeconds" -> + Helper.LibCall(com, "DateOffset", "toUnixTimeSeconds", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + |> Some + | "ToUnixTimeMilliseconds" -> Helper.LibCall( com, "DateOffset", - "datetime.fromtimestamp", + "toUnixTimeMilliseconds", t, - [ value; makeIntConst 0 ], - [ value.Type; Int32.Number ], + [ thisArg.Value ], + [ thisArg.Value.Type ], ?loc = r ) |> Some - | "ToUnixTimeSeconds" - | "ToUnixTimeMilliseconds" -> - let ms = getTime thisArg.Value - - let args = - if i.CompiledName = "ToUnixTimeSeconds" then - [ makeBinOp r t ms (makeIntConst 1000) BinaryDivide ] - else - [ ms ] - - Helper.LibCall(com, "Long", "fromNumber", t, args, ?loc = r) |> Some | "get_UtcTicks" -> Helper.LibCall(com, "DateOffset", "getUtcTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some + | "EqualsExact" -> + Helper.LibCall(com, "DateOffset", "equalsExact", Boolean, [ thisArg.Value; args.Head ], ?loc = r) + |> Some + | "Compare" -> + Helper.LibCall(com, "DateOffset", "compare", t, args, i.SignatureArgTypes, ?loc = r) + |> Some + | "CompareTo" -> + Helper.LibCall(com, "DateOffset", "compareTo", t, [ thisArg.Value; args.Head ], ?loc = r) + |> Some | "TryParse" -> let args = ignoreFormatProvider com ctx r i.DeclaringEntityFullName i.CompiledName args diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index 52853d820e..ab8d29f512 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -729,10 +729,13 @@ def from_ticks(ticks: SupportsInt, kind: int32 | None = None) -> datetime: def from_date_time_offset(date_time_offset: Any, _kind: int = 0) -> datetime: - """Convert a DateTimeOffset to a DateTime""" - # DateTimeOffset is a datetime subclass, so we can just return it as a datetime - # The _kind parameter is ignored for now (it's used in .NET for DateTimeKind) - return date_time_offset + """Convert a DateTimeOffset to a DateTime. + + Returns a plain datetime with the same local date/time fields. + The _kind parameter maps to DateTimeKind (0=Unspecified, 1=UTC, 2=Local). + """ + d = date_time_offset + return datetime(d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond) __all__ = [ diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index a06c4219f2..e627464de5 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -1,10 +1,11 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta, timezone +from math import fmod from typing import Any, SupportsIndex from . import time_span -from .core import FSharpRef +from .core import FSharpRef, int64 from .time_span import TimeSpan @@ -36,9 +37,64 @@ def __str__(self): def __repr__(self): return f"DateTimeOffset({datetime.__repr__(self)}, offset={self.offset_ms})" - def getTime(self): - """Get time in milliseconds since epoch (like JavaScript Date.getTime())""" - return int(self.timestamp() * 1000) + def __eq__(self, other: object) -> bool: + """DateTimeOffset equality compares by UTC-normalized instant.""" + if not isinstance(other, DateTimeOffset): + return NotImplemented + return _to_utc_ms(self) == _to_utc_ms(other) + + def __ne__(self, other: object) -> bool: + if not isinstance(other, DateTimeOffset): + return NotImplemented + return _to_utc_ms(self) != _to_utc_ms(other) + + def __lt__(self, other: datetime) -> bool: + if not isinstance(other, DateTimeOffset): + return NotImplemented + return _to_utc_ms(self) < _to_utc_ms(other) + + def __le__(self, other: datetime) -> bool: + if not isinstance(other, DateTimeOffset): + return NotImplemented + return _to_utc_ms(self) <= _to_utc_ms(other) + + def __gt__(self, other: datetime) -> bool: + if not isinstance(other, DateTimeOffset): + return NotImplemented + return _to_utc_ms(self) > _to_utc_ms(other) + + def __ge__(self, other: datetime) -> bool: + if not isinstance(other, DateTimeOffset): + return NotImplemented + return _to_utc_ms(self) >= _to_utc_ms(other) + + def __hash__(self) -> int: + return hash(_to_utc_ms(self)) + + def __sub__(self, other: timedelta | datetime) -> DateTimeOffset | timedelta: + if isinstance(other, timedelta) and not isinstance(other, datetime): + # datetime - timedelta → new DateTimeOffset preserving offset + plain = datetime( + self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond, self.tzinfo + ) + new_dt = plain - other + return DateTimeOffset(new_dt, self.offset_ms) + # datetime - datetime → timedelta (use datetime's implementation) + return datetime.__sub__(self, other) # type: ignore + + def __add__(self, other: timedelta) -> DateTimeOffset: + plain = datetime( + self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond, self.tzinfo + ) + new_dt = plain + other + return DateTimeOffset(new_dt, self.offset_ms) + + def __radd__(self, other: timedelta) -> DateTimeOffset: + return self.__add__(other) + + def getTime(self) -> int: + """Get UTC time in milliseconds since epoch.""" + return _to_utc_ms(self) def replace( self, @@ -94,6 +150,30 @@ def replace( return DateTimeOffset(new_dt, new_offset) +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +# .NET epoch: 0001-01-01T00:00:00Z in microseconds from Unix epoch +_DOTNET_EPOCH_MICROSECONDS = -62135596800000000 + + +def _to_utc_ms(d: DateTimeOffset) -> int: + """Return UTC instant in milliseconds since Unix epoch.""" + # The datetime stores local time; subtract the offset to get UTC + local_ms = int(datetime.timestamp(d) * 1000) + return local_ms + + +def _ticks_per_millisecond() -> int: + return 10000 + + +# --------------------------------------------------------------------------- +# Constructors +# --------------------------------------------------------------------------- + + def timedelta_total_microseconds(td: timedelta) -> int: # timedelta doesn't expose total_microseconds # so we need to calculate it ourselves @@ -166,6 +246,45 @@ def create( return DateTimeOffset(dt, offset_ms) +def from_date(dt: datetime, offset_ts: TimeSpan | None = None) -> DateTimeOffset: + """Construct DateTimeOffset from a DateTime.""" + if offset_ts is not None: + offset_ms = int(time_span.total_microseconds(offset_ts) / 1000) + elif dt.tzinfo is not None and dt.utcoffset() is not None: + offset_td = dt.utcoffset() + assert offset_td is not None + offset_ms = int(offset_td.total_seconds() * 1000) + else: + # Unspecified kind — use local offset + local_dt = dt.astimezone() + offset_td = local_dt.utcoffset() + offset_ms = int(offset_td.total_seconds() * 1000) if offset_td else 0 + dt = local_dt + + python_offset = timedelta(milliseconds=offset_ms) + dt = dt.replace(tzinfo=timezone(python_offset)) + return DateTimeOffset(dt, offset_ms) + + +def from_ticks(ticks: int, offset_ts: TimeSpan) -> DateTimeOffset: + """Construct DateTimeOffset from ticks and offset.""" + offset_ms = int(time_span.total_microseconds(offset_ts) / 1000) + # Ticks are 100-nanosecond intervals since 0001-01-01 + # Convert to microseconds from Unix epoch + us = (ticks // 10) + _DOTNET_EPOCH_MICROSECONDS + python_offset = timedelta(milliseconds=offset_ms) + dt = datetime.fromtimestamp(us / 1_000_000, tz=UTC) + # Shift from UTC to the specified offset + dt = dt + python_offset + dt = dt.replace(tzinfo=timezone(python_offset)) + return DateTimeOffset(dt, offset_ms) + + +# --------------------------------------------------------------------------- +# Static fields +# --------------------------------------------------------------------------- + + def now() -> DateTimeOffset: dt = datetime.now().astimezone() offset_td = dt.utcoffset() @@ -183,6 +302,16 @@ def min_value() -> DateTimeOffset: return DateTimeOffset(dt, 0) +def max_value() -> DateTimeOffset: + dt = datetime.max.replace(tzinfo=UTC) + return DateTimeOffset(dt, 0) + + +# --------------------------------------------------------------------------- +# Properties (accessors) +# --------------------------------------------------------------------------- + + def year(d: DateTimeOffset) -> int: return d.year @@ -215,33 +344,326 @@ def microsecond(d: DateTimeOffset) -> int: return d.microsecond % 1000 +def day_of_week(d: DateTimeOffset) -> int: + return (d.weekday() + 1) % 7 + + +def day_of_year(d: DateTimeOffset) -> int: + return d.timetuple().tm_yday + + +def date(d: DateTimeOffset) -> datetime: + """Return the date component (time set to midnight) as a DateTime.""" + from . import date as date_mod + + return date_mod.create(d.year, d.month, d.day, 0, 0, 0, 0, 0, 0) + + +def time_of_day(d: DateTimeOffset) -> TimeSpan: + """Return the time of day as a TimeSpan.""" + return time_span.create( + 0.0, float(d.hour), float(d.minute), float(d.second), float(d.microsecond // 1000), float(d.microsecond % 1000) + ) + + +def ticks(d: DateTimeOffset) -> int64: + """Return ticks of the local date/time (not UTC-adjusted). + + .NET: DateTimeOffset.Ticks returns the local date/time component ticks, + independent of the offset. + """ + # Compute ticks from the local date/time fields + from . import date as date_mod + + local_dt = datetime(d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond, tzinfo=UTC) + us = int(local_dt.timestamp() * 1_000_000) + return date_mod.unix_epoch_microseconds_to_ticks(int64(us), int64(0)) + + +def get_utc_ticks(d: DateTimeOffset) -> int64: + """Return UTC ticks.""" + from . import date as date_mod + + utc_ms = _to_utc_ms(d) + us = utc_ms * 1000 + return date_mod.unix_epoch_microseconds_to_ticks(int64(us), int64(0)) + + +# --------------------------------------------------------------------------- +# Conversion +# --------------------------------------------------------------------------- + + +def utc_date_time(d: DateTimeOffset) -> datetime: + """Return UtcDateTime (DateTime with UTC kind). + + Fable Python convention: UTC DateTimes are naive (no tzinfo). + """ + utc_ms = _to_utc_ms(d) + return datetime.fromtimestamp(utc_ms / 1000, tz=UTC).replace(tzinfo=None) + + +def local_date_time(d: DateTimeOffset) -> datetime: + """Return LocalDateTime (DateTime with Local kind). + + Returns naive datetime in the local time zone to match + Fable Python's DateTime convention where comparisons must + work across different DateTimeKind values. + """ + utc_ms = _to_utc_ms(d) + utc_dt = datetime.fromtimestamp(utc_ms / 1000, tz=UTC) + local_dt = utc_dt.astimezone() + # Strip tzinfo so the datetime is naive (comparable with UTC datetimes) + return datetime( + local_dt.year, + local_dt.month, + local_dt.day, + local_dt.hour, + local_dt.minute, + local_dt.second, + local_dt.microsecond, + ) + + +def to_universal_time(d: DateTimeOffset) -> DateTimeOffset: + """Convert to UTC DateTimeOffset.""" + utc_ms = _to_utc_ms(d) + utc_dt = datetime.fromtimestamp(utc_ms / 1000, tz=UTC) + return DateTimeOffset(utc_dt, 0) + + +def to_local_time(d: DateTimeOffset) -> DateTimeOffset: + """Convert to local DateTimeOffset.""" + utc_ms = _to_utc_ms(d) + utc_dt = datetime.fromtimestamp(utc_ms / 1000, tz=UTC) + local_dt = utc_dt.astimezone() + offset_td = local_dt.utcoffset() + offset_ms = int(offset_td.total_seconds() * 1000) if offset_td else 0 + return DateTimeOffset(local_dt, offset_ms) + + +def to_offset(d: DateTimeOffset, offset_ts: TimeSpan) -> DateTimeOffset: + """Convert to a DateTimeOffset with the specified offset.""" + new_offset_ms = int(time_span.total_microseconds(offset_ts) / 1000) + utc_ms = _to_utc_ms(d) + new_python_offset = timedelta(milliseconds=new_offset_ms) + utc_dt = datetime.fromtimestamp(utc_ms / 1000, tz=UTC) + local_dt = utc_dt + new_python_offset + local_dt = local_dt.replace(tzinfo=timezone(new_python_offset)) + return DateTimeOffset(local_dt, new_offset_ms) + + +# --------------------------------------------------------------------------- +# Add methods +# --------------------------------------------------------------------------- + + +def _add_timedelta(d: DateTimeOffset, td: timedelta) -> DateTimeOffset: + """Add a timedelta, preserving offset. Returns a new DateTimeOffset.""" + # We can't use datetime.__add__ directly because it tries to construct + # a DateTimeOffset via __new__ with the wrong signature. + # Instead, construct a plain datetime, add the timedelta, then wrap. + plain = datetime(d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond, d.tzinfo) + new_dt = plain + td + return DateTimeOffset(new_dt, d.offset_ms) + + +def add(d: DateTimeOffset, ts: TimeSpan) -> DateTimeOffset: + """Add a TimeSpan to a DateTimeOffset.""" + us = time_span.total_microseconds(ts) + return _add_timedelta(d, timedelta(microseconds=float(us))) + + +def add_years(d: DateTimeOffset, v: int) -> DateTimeOffset: + new_month = d.month + new_year = d.year + v + _days_in_month = _get_days_in_month(new_year, new_month) + new_day = min(_days_in_month, d.day) + return create(new_year, new_month, new_day, d.hour, d.minute, d.second, d.microsecond // 1000, d.offset) + + +def add_months(d: DateTimeOffset, v: int) -> DateTimeOffset: + new_month = d.month + v + new_month_ = 0 + year_offset = 0 + if new_month > 12: + new_month_ = int(fmod(new_month, 12)) + year_offset = new_month // 12 + new_month = new_month_ + elif new_month < 1: + new_month_ = 12 + int(fmod(new_month, 12)) + year_offset = new_month // 12 + (-1 if new_month_ == 12 else 0) + new_month = new_month_ + new_year = d.year + year_offset + _days_in_month = _get_days_in_month(new_year, new_month) + new_day = min(_days_in_month, d.day) + return create(new_year, new_month, new_day, d.hour, d.minute, d.second, d.microsecond // 1000, d.offset) + + +def add_days(d: DateTimeOffset, v: float) -> DateTimeOffset: + return _add_timedelta(d, timedelta(days=float(v))) + + +def add_hours(d: DateTimeOffset, v: float) -> DateTimeOffset: + return _add_timedelta(d, timedelta(hours=float(v))) + + +def add_minutes(d: DateTimeOffset, v: float) -> DateTimeOffset: + return _add_timedelta(d, timedelta(minutes=float(v))) + + +def add_seconds(d: DateTimeOffset, v: float) -> DateTimeOffset: + return _add_timedelta(d, timedelta(seconds=float(v))) + + +def add_milliseconds(d: DateTimeOffset, v: float) -> DateTimeOffset: + return _add_timedelta(d, timedelta(milliseconds=float(v))) + + +def add_microseconds(d: DateTimeOffset, v: float) -> DateTimeOffset: + return _add_timedelta(d, timedelta(microseconds=float(v))) + + +def add_ticks(d: DateTimeOffset, v: int) -> DateTimeOffset: + ms = v / 10000 + return add_milliseconds(d, ms) + + +# --------------------------------------------------------------------------- +# Subtract / Operators +# --------------------------------------------------------------------------- + + +def subtract(x: DateTimeOffset, y: DateTimeOffset | TimeSpan) -> DateTimeOffset | TimeSpan: + if isinstance(y, DateTimeOffset): + # Subtracting two DateTimeOffsets: compare UTC instants + diff_ms = _to_utc_ms(x) - _to_utc_ms(y) + return time_span.from_milliseconds(diff_ms) + # TimeSpan subtraction (TimeSpan is an int subclass, not timedelta) + us = time_span.total_microseconds(y) + return _add_timedelta(x, timedelta(microseconds=float(-us))) + + def op_subtraction(x: DateTimeOffset, y: DateTimeOffset | TimeSpan) -> DateTimeOffset | TimeSpan: - if isinstance(y, TimeSpan): - # Subtract TimeSpan from DateTimeOffset - new_dt = x - timedelta(microseconds=float(time_span.total_microseconds(y))) - # Create new DateTimeOffset preserving the offset - return DateTimeOffset(new_dt, x.offset_ms) + return subtract(x, y) + + +def op_addition(x: DateTimeOffset, y: TimeSpan) -> DateTimeOffset: + return add(x, y) - # When subtracting two DateTimeOffset objects, return TimeSpan - time_diff = datetime.__sub__(x, y) # This will be a timedelta - return time_span.from_microseconds(timedelta_total_microseconds(time_diff)) + +# --------------------------------------------------------------------------- +# Comparison / Equality +# --------------------------------------------------------------------------- + + +def equals(d1: DateTimeOffset, d2: DateTimeOffset) -> bool: + return _to_utc_ms(d1) == _to_utc_ms(d2) + + +def equals_exact(d1: DateTimeOffset, d2: DateTimeOffset) -> bool: + return _to_utc_ms(d1) == _to_utc_ms(d2) and d1.offset_ms == d2.offset_ms + + +def compare(d1: DateTimeOffset, d2: DateTimeOffset) -> int: + a = _to_utc_ms(d1) + b = _to_utc_ms(d2) + if a < b: + return -1 + if a > b: + return 1 + return 0 + + +def compare_to(d1: DateTimeOffset, d2: DateTimeOffset) -> int: + return compare(d1, d2) + + +# --------------------------------------------------------------------------- +# Unix time +# --------------------------------------------------------------------------- + + +def from_unix_time_milliseconds(ms: int) -> DateTimeOffset: + dt = datetime.fromtimestamp(ms / 1000, tz=UTC) + return DateTimeOffset(dt, 0) + + +def from_unix_time_seconds(s: int) -> DateTimeOffset: + dt = datetime.fromtimestamp(s, tz=UTC) + return DateTimeOffset(dt, 0) + + +def to_unix_time_milliseconds(d: DateTimeOffset) -> int64: + return int64(_to_utc_ms(d)) + + +def to_unix_time_seconds(d: DateTimeOffset) -> int64: + return int64(_to_utc_ms(d) // 1000) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_days_in_month(year: int, month: int) -> int: + if month == 2: + return 29 if (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)) else 28 + if month in (4, 6, 9, 11): + return 30 + return 31 __all__ = [ "DateTimeOffset", + "add", + "add_days", + "add_hours", + "add_microseconds", + "add_milliseconds", + "add_minutes", + "add_months", + "add_seconds", + "add_ticks", + "add_years", + "compare", + "compare_to", "create", + "date", "day", + "day_of_week", + "day_of_year", + "equals", + "equals_exact", + "from_date", + "from_ticks", + "from_unix_time_milliseconds", + "from_unix_time_seconds", + "get_utc_ticks", "hour", + "local_date_time", + "max_value", + "microsecond", + "millisecond", "min_value", "minute", "month", "now", + "op_addition", "op_subtraction", "parse", "second", - "millisecond", - "microsecond", + "subtract", + "ticks", + "time_of_day", + "to_local_time", + "to_offset", + "to_universal_time", + "to_unix_time_milliseconds", + "to_unix_time_seconds", "try_parse", + "utc_date_time", "utc_now", "year", ] diff --git a/tests/Python/TestDateTimeOffset.fs b/tests/Python/TestDateTimeOffset.fs index 59a59b3394..a846b81287 100644 --- a/tests/Python/TestDateTimeOffset.fs +++ b/tests/Python/TestDateTimeOffset.fs @@ -4,50 +4,145 @@ open System open Util.Testing open System.Globalization +let thatYearSeconds (dt: DateTimeOffset) = + (dt - DateTimeOffset(dt.Year, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalSeconds + +let thatYearMilliseconds (dt: DateTimeOffset) = + (dt - DateTimeOffset(dt.Year, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalMilliseconds + +// --------------------------------------------------------------------------- +// Constructors +// --------------------------------------------------------------------------- + [] -let ``test DateTimeOffset.TryParse works`` () = - let f (d: string) = - match DateTimeOffset.TryParse(d) with - | true, _ -> true - | false, _ -> false - f "foo" |> equal false - f "9/10/2014 1:50:34 PM" |> equal true - f "9/10/2014 1:50:34" |> equal true - f "2014-09-10T13:50:34" |> equal true +let ``test DateTimeOffset constructors work`` () = + let d3 = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) + let d5 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.Zero) + d3.Second + d5.Millisecond + |> equal 530 [] -let ``test DateTimeOffset.ToString() default works`` () = - let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) - let str = d.ToString() - // Just verify it doesn't throw NotImplementedError and returns a string - str.Length > 0 |> equal true +let ``test DateTimeOffset from Year 1 to 99 works`` () = + let date = DateTimeOffset(1, 1, 2, 0, 0, 0, TimeSpan.Zero) + date.Year |> equal 1 + let date = DateTimeOffset(99, 1, 2, 0, 0, 0, TimeSpan.Zero) + date.Year |> equal 99 [] -let ``test DateTimeOffset.ToString with custom format works`` () = - DateTimeOffset(2014, 9, 11, 16, 37, 0, TimeSpan.Zero).ToString("HH:mm", CultureInfo.InvariantCulture) - |> equal "16:37" +let ``test DateTimeOffset constructor from Ticks works`` () = + let d = DateTimeOffset(624059424000000000L, TimeSpan.Zero) + equal 1978 d.Year + equal 7 d.Month + equal 27 d.Day -// Basic constructor and property access - tests the DateTimeOffset class we created [] -let ``test DateTimeOffset constructors work`` () = +let ``test DateTimeOffset constructor from DateTime works`` () = + let d1 = DateTime(2014, 10, 9, 13, 23, 30, DateTimeKind.Local) + let d1' = DateTimeOffset(d1) + equal d1.Year d1'.Year + equal d1.Day d1'.Day + equal d1.Hour d1'.Hour + let d2 = DateTime(2014, 10, 9, 13, 23, 30, DateTimeKind.Utc) + let d2' = DateTimeOffset(d2) + equal d2.Year d2'.Year + equal d2.Day d2'.Day + equal d2.Hour d2'.Hour + +// --------------------------------------------------------------------------- +// Static fields +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset.MaxValue works`` () = + let d1 = DateTimeOffset.Now + let d2 = DateTimeOffset.MaxValue + d1 < d2 |> equal true + +[] +let ``test DateTimeOffset.MinValue works`` () = + let d1 = DateTimeOffset.Now + let d2 = DateTimeOffset.MinValue + d1 < d2 |> equal false + +[] +let ``test DateTimeOffset.MinValue works in pattern match`` () = + let d1 = Some DateTimeOffset.Now + match d1 with + | Some date when date <> DateTimeOffset.MinValue -> () + | _ -> failwith "expected pattern match above" + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset.Now works`` () = + let d = DateTimeOffset.Now + d > DateTimeOffset.MinValue |> equal true + +[] +let ``test DateTimeOffset.UtcNow works`` () = + let d = DateTimeOffset.UtcNow + d > DateTimeOffset.MinValue |> equal true + +[] +let ``test DateTimeOffset.Hour works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) + d.Hour |> equal 13 + +[] +let ``test DateTimeOffset.Year works`` () = let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) d.Year |> equal 2014 + +[] +let ``test DateTimeOffset.Month works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) d.Month |> equal 10 + +[] +let ``test DateTimeOffset.Day works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) d.Day |> equal 9 - d.Hour |> equal 13 + +[] +let ``test DateTimeOffset.Minute works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) d.Minute |> equal 23 + +[] +let ``test DateTimeOffset.Second works`` () = + let d = DateTimeOffset(2014,9,12,0,0,30,TimeSpan.Zero) d.Second |> equal 30 - let d2 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.Zero) - d2.Second + d2.Millisecond |> equal 530 + +[] +let ``test DateTimeOffset.Millisecond works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, 999, TimeSpan.Zero) + d.Millisecond |> equal 999 [] let ``test DateTimeOffset.Offset works`` () = let d1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) d1.Offset |> equal TimeSpan.Zero - let d2 = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.FromHours(3.0)) d2.Offset |> equal (TimeSpan.FromHours(3.0)) +[] +let ``test DateTimeOffset.DayOfWeek works`` () = + let d = DateTimeOffset(2014, 10, 9, 0, 0, 0, TimeSpan.Zero) + d.DayOfWeek |> equal DayOfWeek.Thursday + +[] +let ``test DateTimeOffset.DayOfYear works`` () = + let d = DateTimeOffset(2014, 10, 9, 0, 0, 0, TimeSpan.Zero) + d.DayOfYear |> equal 282 + +[] +let ``test DateTimeOffset.Date works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) + d.Date.Hour |> equal 0 + d.Date.Day |> equal 9 + [] let ``test DateTimeOffset.DateTime works`` () = let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) @@ -57,8 +152,342 @@ let ``test DateTimeOffset.DateTime works`` () = dt.Day |> equal 9 [] -let ``test DateTimeOffset is datetime compatible`` () = +let ``test DateTimeOffset.TimeOfDay works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, 1, TimeSpan.Zero) + let t = d.TimeOfDay + t |> equal (TimeSpan(0, 13, 23, 30, 1)) + +[] +let ``test DateTimeOffset.Ticks does not care about offset`` () = + let d1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.Zero) + let d2 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.FromHours 1.0) + let d3 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.FromHours -5.0) + equal d1.Ticks d2.Ticks + equal d1.Ticks d3.Ticks + equal d2.Ticks d3.Ticks + +[] +let ``test DateTimeOffset.Ticks works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, 999, TimeSpan.Zero) + d.Ticks |> equal 635484578109990000L + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, 999, TimeSpan.FromMinutes(-150.)) + d.Ticks |> equal 635484578109990000L + +[] +let ``test DateTimeOffset.UtcTicks works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, 999, TimeSpan.Zero) + d.UtcTicks |> equal 635484578109990000L + +// --------------------------------------------------------------------------- +// Equality / Comparison +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset equality cares about offset`` () = + let d1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.Zero) + let d2 = DateTimeOffset(2014, 10, 9, 14, 23, 30, 500, TimeSpan.FromHours 1.0) + equal d1 d2 + +[] +let ``test DateTimeOffset Equality works`` () = + let test (ms: float) expected = + let dt1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 234, TimeSpan.Zero) + let dt2 = dt1.AddMilliseconds(ms) + dt1 = dt2 |> equal expected + test 1000. false + test -1000. false + test 0. true + +[] +let ``test DateTimeOffset Inequality works`` () = + let test (ms: float) expected = + let dt1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 234, TimeSpan.Zero) + let dt2 = dt1.AddMilliseconds(ms) + dt1 <> dt2 |> equal expected + test 1000. true + test -1000. true + test 0. false + +[] +let ``test DateTimeOffset GreaterThan works`` () = + let test (ms: float) expected = + let dt1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 234, TimeSpan.Zero) + let dt2 = dt1.AddMilliseconds(ms) + dt1 > dt2 |> equal expected + test 1000. false + test -1000. true + test 0. false + +[] +let ``test DateTimeOffset LessThan works`` () = + let test (ms: float) expected = + let dt1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 234, TimeSpan.Zero) + let dt2 = dt1.AddMilliseconds(ms) + dt1 < dt2 |> equal expected + test 1000. true + test -1000. false + test 0. false + +[] +let ``test DateTimeOffset Comparison works`` () = + let test (ms: float) expected = + let dt1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 234, TimeSpan.Zero) + let dt2 = dt1.AddMilliseconds(ms) + let res1 = compare dt1 dt2 + let res2 = dt1.CompareTo(dt2) + let res3 = DateTimeOffset.Compare(dt1, dt2) + equal true (res1 = res2 && res2 = res3) + equal expected res1 + test 1000. -1 + test -1000. 1 + test 0. 0 + +[] +let ``test DateTimeOffset.EqualsExact works`` () = + let d1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 234, TimeSpan.Zero) + let d2 = DateTimeOffset(2014, 10, 9, 14, 23, 30, 234, TimeSpan.FromHours(1.)) + d1.Equals(d2) |> equal true + d1.EqualsExact(d2) |> equal false + +// --------------------------------------------------------------------------- +// Conversion +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset.ToLocalTime works`` () = + let d = DateTimeOffset.UtcNow + let localTime = d.ToLocalTime() + let utcTime = d.ToUniversalTime() + localTime.ToUniversalTime() |> equal utcTime + +[] +let ``test DateTimeOffset.ToUniversalTime works`` () = + let d = DateTimeOffset.Now + let localTime = d.ToLocalTime() + let utcTime = d.ToUniversalTime() + localTime.ToUniversalTime() |> equal utcTime + +[] +let ``test DateTimeOffset DateTime - Offset - UtcDateTime relationship`` () = + let dto = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, TimeSpan.FromHours -5.0) + let date = dto.DateTime + let utcDate = dto.UtcDateTime + let offset = dto.Offset + let compound = date - offset + equal compound utcDate + +[] +let ``test DateTimeOffset UtcDateTime - LocalDateTime relationship`` () = + let dto = DateTimeOffset.Now + let localOffset = dto.Offset + let localDate = dto.LocalDateTime + let utcDate = dto.UtcDateTime + let compound = localDate - localOffset + equal compound utcDate + +// --------------------------------------------------------------------------- +// Unix time +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset.FromUnixTimeSeconds work`` () = + let d = DateTimeOffset.FromUnixTimeSeconds(1510310885L) + equal 2017 d.Year + equal 11 d.Month + equal 10 d.Day + +[] +let ``test DateTimeOffset.FromUnixTimeMilliseconds work`` () = + let d = DateTimeOffset.FromUnixTimeMilliseconds(1510310885000L) + equal 2017 d.Year + equal 11 d.Month + equal 10 d.Day + +[] +let ``test DateTimeOffset.ToUnixTimeSeconds works`` () = + let d = DateTimeOffset(2017, 11, 10, 0, 0, 0, TimeSpan.Zero) + d.ToUnixTimeSeconds() |> equal 1510272000L + +[] +let ``test DateTimeOffset.ToUnixTimeMilliseconds works`` () = + let d = DateTimeOffset(2017, 11, 10, 0, 0, 0, TimeSpan.Zero) + d.ToUnixTimeMilliseconds() |> equal 1510272000000L + +// --------------------------------------------------------------------------- +// Add methods +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset.AddYears works`` () = + let test v expected = + let dt = DateTimeOffset(2016,2,29,0,0,0,TimeSpan.Zero).AddYears(v) + equal expected (dt.Month + dt.Day) + test 100 31 + test 1 30 + test -1 30 + test -100 31 + test 0 31 + +[] +let ``test DateTimeOffset.AddMonths works`` () = + let test v expected = + let dt = DateTimeOffset(2016,1,31,0,0,0,TimeSpan.Zero).AddMonths(v) + dt.Year + dt.Month + dt.Day + |> equal expected + test 100 2060 + test 20 2056 + test 6 2054 + test 5 2052 + test 1 2047 + test 0 2048 + test -1 2058 + test -5 2054 + test -20 2050 + test -100 2046 + +[] +let ``test DateTimeOffset.AddDays works`` () = + let test v expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero).AddDays(v) + thatYearSeconds dt + |> equal expected + test 100. 30585600.0 + test -100. 13305600.0 + test 0. 21945600.0 + +[] +let ``test DateTimeOffset.AddHours works`` () = + let test v expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero).AddHours(v) + thatYearSeconds dt + |> equal expected + test 100. 22305600.0 + test -100. 21585600.0 + test 0. 21945600.0 + +[] +let ``test DateTimeOffset.AddMinutes works`` () = + let test v expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero).AddMinutes(v) + thatYearSeconds dt + |> equal expected + test 100. 21951600.0 + test -100. 21939600.0 + test 0. 21945600.0 + +[] +let ``test DateTimeOffset.AddSeconds works`` () = + let test v expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero).AddSeconds(v) + thatYearSeconds dt + |> equal expected + test 100. 21945700.0 + test -100. 21945500.0 + test 0. 21945600.0 + +[] +let ``test DateTimeOffset.AddMilliseconds works`` () = + let test v expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero).AddMilliseconds(v) + thatYearMilliseconds dt + |> equal expected + test 100. 2.19456001e+10 + test -100. 2.19455999e+10 + test 0. 2.19456e+10 + +// NOTE: Doesn't work for values between 10000L (TimeSpan.TicksPerMillisecond) and -10000L, except 0L +[] +let ``test DateTimeOffset.AddTicks works`` () = + let test v expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero).AddTicks(v) + dt.Ticks + |> equal expected + let ticks = 635460768000000000L + test 100000L (ticks + 100000L) + test -100000L (ticks - 100000L) + test 0L ticks + +[] +let ``test DateTimeOffset Addition works`` () = + let test (ms: float) expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero) + let ts = TimeSpan.FromMilliseconds(ms) + let res1 = dt.Add(ts) |> thatYearSeconds + let res2 = (dt + ts) |> thatYearSeconds + equal true (res1 = res2) + equal expected res1 + test 1000. 21945601.0 + test -1000. 21945599.0 + test 0. 21945600.0 + +// --------------------------------------------------------------------------- +// Subtract +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset Subtraction with TimeSpan works`` () = + let test (ms: float) expected = + let dt = DateTimeOffset(2014,9,12,0,0,0,TimeSpan.Zero) + let ts = TimeSpan.FromMilliseconds(ms) + let res1 = dt.Subtract(ts) |> thatYearSeconds + let res2 = (dt - ts) |> thatYearSeconds + equal true (res1 = res2) + equal expected res1 + test 1000. 21945599.0 + test -1000. 21945601.0 + test 0. 21945600.0 + +[] +let ``test DateTimeOffset Subtraction with DateTimeOffset works`` () = + let test (ms: float) expected = + let dt1 = DateTimeOffset(2014, 10, 9, 13, 23, 30, 234, TimeSpan.Zero) + let dt2 = dt1.AddMilliseconds(ms) + let res1 = dt1.Subtract(dt2).TotalSeconds + let res2 = (dt1 - dt2).TotalSeconds + equal true (res1 = res2) + equal expected res1 + test 1000. -1.0 + test -1000. 1.0 + test 0. 0.0 + +// --------------------------------------------------------------------------- +// Parse / ToString +// --------------------------------------------------------------------------- + +[] +let ``test DateTimeOffset.TryParse works`` () = + let f (d: string) = + match DateTimeOffset.TryParse(d) with + | true, _ -> true + | false, _ -> false + f "foo" |> equal false + f "9/10/2014 1:50:34 PM" |> equal true + f "9/10/2014 1:50:34" |> equal true + f "2014-09-10T13:50:34" |> equal true + +[] +let ``test DateTimeOffset.ToString() default works`` () = let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) - // This tests that our DateTimeOffset can be used where datetime is expected - d.Year |> equal 2014 - d.Month |> equal 10 \ No newline at end of file + let str = d.ToString() + str.Length > 0 |> equal true + +[] +let ``test DateTimeOffset.ToString with custom format works`` () = + DateTimeOffset(2014, 9, 11, 16, 37, 0, TimeSpan.Zero).ToString("HH:mm", CultureInfo.InvariantCulture) + |> equal "16:37" + +[] +let ``test DateTimeOffset.LocalDateTime works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) + let d2 = d.LocalDateTime + equal d.Year d2.Year + equal d.Month d2.Month + equal d.Day d2.Day + +[] +let ``test DateTimeOffset.UtcDateTime works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) + let d2 = d.UtcDateTime + equal d.Year d2.Year + equal d.Month d2.Month + equal d.Day d2.Day From 72bde3e8da2784e04552f10c1afb5c7f2351ca75 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 22:03:57 +0200 Subject: [PATCH 02/14] Shorten changelog entries Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Fable.Cli/CHANGELOG.md | 2 +- src/Fable.Compiler/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 529f2ad4f5..de38e3d322 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave) * [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave) -* [Python] Implement missing `DateTimeOffset` members: `MaxValue`, `FromTicks`, `FromDate`, `UtcDateTime`, `LocalDateTime`, `UtcTicks`, `Ticks`, `Date`, `DayOfWeek`, `DayOfYear`, `TimeOfDay`, `Add`, `AddYears`, `AddMonths`, `AddDays`, `AddHours`, `AddMinutes`, `AddSeconds`, `AddMilliseconds`, `AddTicks`, `ToUniversalTime`, `ToLocalTime`, `ToOffset`, `Subtract`, `op_Addition`, `Compare`, `CompareTo`, `EqualsExact`, `FromUnixTimeSeconds`, `FromUnixTimeMilliseconds`, `ToUnixTimeSeconds`, `ToUnixTimeMilliseconds`; fix equality/comparison to use UTC-normalized instants +* [Python] Implement missing `DateTimeOffset` members and fix equality/comparison to use UTC-normalized instants (by @dbrattli) * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 8bfe90216a..5a1dc094e0 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave) * [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave) -* [Python] Implement missing `DateTimeOffset` members: `MaxValue`, `FromTicks`, `FromDate`, `UtcDateTime`, `LocalDateTime`, `UtcTicks`, `Ticks`, `Date`, `DayOfWeek`, `DayOfYear`, `TimeOfDay`, `Add`, `AddYears`, `AddMonths`, `AddDays`, `AddHours`, `AddMinutes`, `AddSeconds`, `AddMilliseconds`, `AddTicks`, `ToUniversalTime`, `ToLocalTime`, `ToOffset`, `Subtract`, `op_Addition`, `Compare`, `CompareTo`, `EqualsExact`, `FromUnixTimeSeconds`, `FromUnixTimeMilliseconds`, `ToUnixTimeSeconds`, `ToUnixTimeMilliseconds`; fix equality/comparison to use UTC-normalized instants +* [Python] Implement missing `DateTimeOffset` members and fix equality/comparison to use UTC-normalized instants (by @dbrattli) * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index From c381d7cb24d6cfb4a516d50af191bef5af3922f4 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 22:08:52 +0200 Subject: [PATCH 03/14] Fix Pyright type error on DateTimeOffset.__sub__ override Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fable-library-py/fable_library/date_offset.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index e627464de5..96d568b8cc 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta, timezone from math import fmod -from typing import Any, SupportsIndex +from typing import Any, SupportsIndex, overload from . import time_span from .core import FSharpRef, int64 @@ -71,6 +71,11 @@ def __ge__(self, other: datetime) -> bool: def __hash__(self) -> int: return hash(_to_utc_ms(self)) + @overload + def __sub__(self, other: timedelta) -> DateTimeOffset: ... + @overload + def __sub__(self, other: datetime) -> timedelta: ... + def __sub__(self, other: timedelta | datetime) -> DateTimeOffset | timedelta: if isinstance(other, timedelta) and not isinstance(other, datetime): # datetime - timedelta → new DateTimeOffset preserving offset @@ -80,7 +85,7 @@ def __sub__(self, other: timedelta | datetime) -> DateTimeOffset | timedelta: new_dt = plain - other return DateTimeOffset(new_dt, self.offset_ms) # datetime - datetime → timedelta (use datetime's implementation) - return datetime.__sub__(self, other) # type: ignore + return datetime.__sub__(self, other) # type: ignore[return-value] def __add__(self, other: timedelta) -> DateTimeOffset: plain = datetime( From 30f1a24655acb6890ac207396eb450bf54a85d34 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 22:11:31 +0200 Subject: [PATCH 04/14] Fix Pyright type errors in date_offset.py Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fable-library-py/fable_library/date_offset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index 96d568b8cc..ce37332844 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -361,7 +361,7 @@ def date(d: DateTimeOffset) -> datetime: """Return the date component (time set to midnight) as a DateTime.""" from . import date as date_mod - return date_mod.create(d.year, d.month, d.day, 0, 0, 0, 0, 0, 0) + return date_mod.create(d.year, d.month, d.day) def time_of_day(d: DateTimeOffset) -> TimeSpan: From 6b8513ead09f074fa660fa5df07844a8cecddaf3 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 22:32:03 +0200 Subject: [PATCH 05/14] Fix Pyright errors and analyzer warnings - Use SupportsInt/SupportsFloat for Fable numeric type parameters - Add @overload for op_subtraction/subtract/__sub__ - Return int32 from compare/compare_to - Use Int64 constant for AddTicks divisor - Replace unsafe thisArg.Value with pattern matching Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Fable.Transforms/Python/Replacements.fs | 89 +++++++++++-------- .../fable_library/date_offset.py | 76 ++++++++++------ 2 files changed, 101 insertions(+), 64 deletions(-) diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 97412680bc..02afe5ae5a 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -2963,24 +2963,33 @@ let dates (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr optio |> Some // DateTimeOffset | "get_LocalDateTime" when i.DeclaringEntityFullName = Types.datetimeOffset -> - Helper.LibCall(com, "date_offset", "localDateTime", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) - |> Some + match thisArg with + | Some thisArg -> + Helper.LibCall(com, "date_offset", "localDateTime", t, [ thisArg ], [ thisArg.Type ], ?loc = r) + |> Some + | None -> None | "get_UtcDateTime" when i.DeclaringEntityFullName = Types.datetimeOffset -> - Helper.LibCall(com, "date_offset", "utcDateTime", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) - |> Some + match thisArg with + | Some thisArg -> + Helper.LibCall(com, "date_offset", "utcDateTime", t, [ thisArg ], [ thisArg.Type ], ?loc = r) + |> Some + | None -> None | "get_DateTime" when i.DeclaringEntityFullName = Types.datetimeOffset -> - let kind = System.DateTimeKind.Unspecified |> int |> makeIntConst + match thisArg with + | Some thisArg -> + let kind = System.DateTimeKind.Unspecified |> int |> makeIntConst - Helper.LibCall( - com, - "Date", - "fromDateTimeOffset", - t, - [ thisArg.Value; kind ], - [ thisArg.Value.Type; kind.Type ], - ?loc = r - ) - |> Some + Helper.LibCall( + com, + "Date", + "fromDateTimeOffset", + t, + [ thisArg; kind ], + [ thisArg.Type; kind.Type ], + ?loc = r + ) + |> Some + | None -> None | "FromUnixTimeSeconds" -> let value = Helper.LibCall(com, "Long", "toNumber", Float64.Number, args, i.SignatureArgTypes) @@ -2994,31 +3003,38 @@ let dates (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr optio Helper.LibCall(com, "DateOffset", "fromUnixTimeMilliseconds", t, [ value ], [ value.Type ], ?loc = r) |> Some | "ToUnixTimeSeconds" -> - Helper.LibCall(com, "DateOffset", "toUnixTimeSeconds", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) - |> Some + match thisArg with + | Some thisArg -> + Helper.LibCall(com, "DateOffset", "toUnixTimeSeconds", t, [ thisArg ], [ thisArg.Type ], ?loc = r) + |> Some + | None -> None | "ToUnixTimeMilliseconds" -> - Helper.LibCall( - com, - "DateOffset", - "toUnixTimeMilliseconds", - t, - [ thisArg.Value ], - [ thisArg.Value.Type ], - ?loc = r - ) - |> Some + match thisArg with + | Some thisArg -> + Helper.LibCall(com, "DateOffset", "toUnixTimeMilliseconds", t, [ thisArg ], [ thisArg.Type ], ?loc = r) + |> Some + | None -> None | "get_UtcTicks" -> - Helper.LibCall(com, "DateOffset", "getUtcTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) - |> Some + match thisArg with + | Some thisArg -> + Helper.LibCall(com, "DateOffset", "getUtcTicks", t, [ thisArg ], [ thisArg.Type ], ?loc = r) + |> Some + | None -> None | "EqualsExact" -> - Helper.LibCall(com, "DateOffset", "equalsExact", Boolean, [ thisArg.Value; args.Head ], ?loc = r) - |> Some + match thisArg with + | Some thisArg -> + Helper.LibCall(com, "DateOffset", "equalsExact", Boolean, [ thisArg; args.Head ], ?loc = r) + |> Some + | None -> None | "Compare" -> Helper.LibCall(com, "DateOffset", "compare", t, args, i.SignatureArgTypes, ?loc = r) |> Some | "CompareTo" -> - Helper.LibCall(com, "DateOffset", "compareTo", t, [ thisArg.Value; args.Head ], ?loc = r) - |> Some + match thisArg with + | Some thisArg -> + Helper.LibCall(com, "DateOffset", "compareTo", t, [ thisArg; args.Head ], ?loc = r) + |> Some + | None -> None | "TryParse" -> let args = ignoreFormatProvider com ctx r i.DeclaringEntityFullName i.CompiledName args @@ -3028,14 +3044,17 @@ let dates (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr optio | "AddTicks" -> match thisArg, args with | Some c, [ ticks ] -> + let divisor = + NumberConstant(NumberValue.Int64 10000L, NumberInfo.Empty) |> makeValue None + let ms = Helper.LibCall( com, "long", "op_Division", i.SignatureArgTypes.Head, - [ ticks; makeIntConst 10000 ], - [ ticks.Type; Int32.Number ] + [ ticks; divisor ], + [ ticks.Type; Int64.Number ] ) let ms = diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index ce37332844..01eb3453c4 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -2,10 +2,10 @@ from datetime import UTC, datetime, timedelta, timezone from math import fmod -from typing import Any, SupportsIndex, overload +from typing import Any, SupportsFloat, SupportsIndex, SupportsInt, overload from . import time_span -from .core import FSharpRef, int64 +from .core import FSharpRef, int32, int64 from .time_span import TimeSpan @@ -71,10 +71,10 @@ def __ge__(self, other: datetime) -> bool: def __hash__(self) -> int: return hash(_to_utc_ms(self)) - @overload - def __sub__(self, other: timedelta) -> DateTimeOffset: ... @overload def __sub__(self, other: datetime) -> timedelta: ... + @overload + def __sub__(self, other: timedelta) -> DateTimeOffset: ... def __sub__(self, other: timedelta | datetime) -> DateTimeOffset | timedelta: if isinstance(other, timedelta) and not isinstance(other, datetime): @@ -84,8 +84,14 @@ def __sub__(self, other: timedelta | datetime) -> DateTimeOffset | timedelta: ) new_dt = plain - other return DateTimeOffset(new_dt, self.offset_ms) - # datetime - datetime → timedelta (use datetime's implementation) - return datetime.__sub__(self, other) # type: ignore[return-value] + # datetime - datetime → timedelta + plain_self = datetime( + self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond, self.tzinfo + ) + plain_other = datetime( + other.year, other.month, other.day, other.hour, other.minute, other.second, other.microsecond, other.tzinfo + ) + return plain_self - plain_other def __add__(self, other: timedelta) -> DateTimeOffset: plain = datetime( @@ -271,12 +277,12 @@ def from_date(dt: datetime, offset_ts: TimeSpan | None = None) -> DateTimeOffset return DateTimeOffset(dt, offset_ms) -def from_ticks(ticks: int, offset_ts: TimeSpan) -> DateTimeOffset: +def from_ticks(ticks: SupportsInt, offset_ts: TimeSpan) -> DateTimeOffset: """Construct DateTimeOffset from ticks and offset.""" offset_ms = int(time_span.total_microseconds(offset_ts) / 1000) # Ticks are 100-nanosecond intervals since 0001-01-01 # Convert to microseconds from Unix epoch - us = (ticks // 10) + _DOTNET_EPOCH_MICROSECONDS + us = (int(ticks) // 10) + _DOTNET_EPOCH_MICROSECONDS python_offset = timedelta(milliseconds=offset_ms) dt = datetime.fromtimestamp(us / 1_000_000, tz=UTC) # Shift from UTC to the specified offset @@ -479,16 +485,16 @@ def add(d: DateTimeOffset, ts: TimeSpan) -> DateTimeOffset: return _add_timedelta(d, timedelta(microseconds=float(us))) -def add_years(d: DateTimeOffset, v: int) -> DateTimeOffset: +def add_years(d: DateTimeOffset, v: SupportsInt) -> DateTimeOffset: new_month = d.month - new_year = d.year + v + new_year = d.year + int(v) _days_in_month = _get_days_in_month(new_year, new_month) new_day = min(_days_in_month, d.day) return create(new_year, new_month, new_day, d.hour, d.minute, d.second, d.microsecond // 1000, d.offset) -def add_months(d: DateTimeOffset, v: int) -> DateTimeOffset: - new_month = d.month + v +def add_months(d: DateTimeOffset, v: SupportsInt) -> DateTimeOffset: + new_month = d.month + int(v) new_month_ = 0 year_offset = 0 if new_month > 12: @@ -505,32 +511,32 @@ def add_months(d: DateTimeOffset, v: int) -> DateTimeOffset: return create(new_year, new_month, new_day, d.hour, d.minute, d.second, d.microsecond // 1000, d.offset) -def add_days(d: DateTimeOffset, v: float) -> DateTimeOffset: +def add_days(d: DateTimeOffset, v: SupportsFloat) -> DateTimeOffset: return _add_timedelta(d, timedelta(days=float(v))) -def add_hours(d: DateTimeOffset, v: float) -> DateTimeOffset: +def add_hours(d: DateTimeOffset, v: SupportsFloat) -> DateTimeOffset: return _add_timedelta(d, timedelta(hours=float(v))) -def add_minutes(d: DateTimeOffset, v: float) -> DateTimeOffset: +def add_minutes(d: DateTimeOffset, v: SupportsFloat) -> DateTimeOffset: return _add_timedelta(d, timedelta(minutes=float(v))) -def add_seconds(d: DateTimeOffset, v: float) -> DateTimeOffset: +def add_seconds(d: DateTimeOffset, v: SupportsFloat) -> DateTimeOffset: return _add_timedelta(d, timedelta(seconds=float(v))) -def add_milliseconds(d: DateTimeOffset, v: float) -> DateTimeOffset: +def add_milliseconds(d: DateTimeOffset, v: SupportsFloat) -> DateTimeOffset: return _add_timedelta(d, timedelta(milliseconds=float(v))) -def add_microseconds(d: DateTimeOffset, v: float) -> DateTimeOffset: +def add_microseconds(d: DateTimeOffset, v: SupportsFloat) -> DateTimeOffset: return _add_timedelta(d, timedelta(microseconds=float(v))) -def add_ticks(d: DateTimeOffset, v: int) -> DateTimeOffset: - ms = v / 10000 +def add_ticks(d: DateTimeOffset, v: SupportsFloat) -> DateTimeOffset: + ms = float(v) / 10000 return add_milliseconds(d, ms) @@ -539,6 +545,12 @@ def add_ticks(d: DateTimeOffset, v: int) -> DateTimeOffset: # --------------------------------------------------------------------------- +@overload +def subtract(x: DateTimeOffset, y: DateTimeOffset) -> TimeSpan: ... +@overload +def subtract(x: DateTimeOffset, y: TimeSpan) -> DateTimeOffset: ... + + def subtract(x: DateTimeOffset, y: DateTimeOffset | TimeSpan) -> DateTimeOffset | TimeSpan: if isinstance(y, DateTimeOffset): # Subtracting two DateTimeOffsets: compare UTC instants @@ -549,6 +561,12 @@ def subtract(x: DateTimeOffset, y: DateTimeOffset | TimeSpan) -> DateTimeOffset return _add_timedelta(x, timedelta(microseconds=float(-us))) +@overload +def op_subtraction(x: DateTimeOffset, y: DateTimeOffset) -> TimeSpan: ... +@overload +def op_subtraction(x: DateTimeOffset, y: TimeSpan) -> DateTimeOffset: ... + + def op_subtraction(x: DateTimeOffset, y: DateTimeOffset | TimeSpan) -> DateTimeOffset | TimeSpan: return subtract(x, y) @@ -570,17 +588,17 @@ def equals_exact(d1: DateTimeOffset, d2: DateTimeOffset) -> bool: return _to_utc_ms(d1) == _to_utc_ms(d2) and d1.offset_ms == d2.offset_ms -def compare(d1: DateTimeOffset, d2: DateTimeOffset) -> int: +def compare(d1: DateTimeOffset, d2: DateTimeOffset) -> int32: a = _to_utc_ms(d1) b = _to_utc_ms(d2) if a < b: - return -1 + return int32(-1) if a > b: - return 1 - return 0 + return int32(1) + return int32(0) -def compare_to(d1: DateTimeOffset, d2: DateTimeOffset) -> int: +def compare_to(d1: DateTimeOffset, d2: DateTimeOffset) -> int32: return compare(d1, d2) @@ -589,13 +607,13 @@ def compare_to(d1: DateTimeOffset, d2: DateTimeOffset) -> int: # --------------------------------------------------------------------------- -def from_unix_time_milliseconds(ms: int) -> DateTimeOffset: - dt = datetime.fromtimestamp(ms / 1000, tz=UTC) +def from_unix_time_milliseconds(ms: SupportsFloat) -> DateTimeOffset: + dt = datetime.fromtimestamp(float(ms) / 1000, tz=UTC) return DateTimeOffset(dt, 0) -def from_unix_time_seconds(s: int) -> DateTimeOffset: - dt = datetime.fromtimestamp(s, tz=UTC) +def from_unix_time_seconds(s: SupportsFloat) -> DateTimeOffset: + dt = datetime.fromtimestamp(float(s), tz=UTC) return DateTimeOffset(dt, 0) From 29b5593d061398755a87a733dcdcda955e86ad63 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 22:59:53 +0200 Subject: [PATCH 06/14] Add DateOnly, TimeOnly support and remaining DateTimeOffset members - Add date_only.py: create, min/max, day/month/year, dayOfWeek, dayOfYear, addDays/Months/Years, toDateTime, toString, parse - Add time_only.py: create, fromTicks, fromTimeSpan, fromDateTime, add, addHours/Minutes, isBetween, toString, parse - Add DateTimeOffset: UnixEpoch, TotalOffsetMinutes, DateTimeOffset(DateOnly, TimeOnly, TimeSpan) constructor - Wire up dateOnly/timeOnly handlers in Python Replacements.fs - Add 21 new tests (10 DateOnly + 8 TimeOnly + 3 DateTimeOffset) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Fable.Cli/CHANGELOG.md | 1 + src/Fable.Compiler/CHANGELOG.md | 1 + src/Fable.Transforms/Python/Replacements.fs | 137 +++++++++++- .../fable_library/date_offset.py | 30 +++ .../fable_library/date_only.py | 194 +++++++++++++++++ .../fable_library/time_only.py | 198 ++++++++++++++++++ tests/Python/Fable.Tests.Python.fsproj | 2 + tests/Python/TestDateOnly.fs | 70 +++++++ tests/Python/TestDateTimeOffset.fs | 27 +++ tests/Python/TestTimeOnly.fs | 61 ++++++ 10 files changed, 715 insertions(+), 6 deletions(-) create mode 100644 src/fable-library-py/fable_library/date_only.py create mode 100644 src/fable-library-py/fable_library/time_only.py create mode 100644 tests/Python/TestDateOnly.fs create mode 100644 tests/Python/TestTimeOnly.fs diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index de38e3d322..0c279fab08 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave) * [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave) * [Python] Implement missing `DateTimeOffset` members and fix equality/comparison to use UTC-normalized instants (by @dbrattli) +* [Python] Add `DateOnly` and `TimeOnly` support (by @dbrattli) * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 5a1dc094e0..7e6a980808 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [JS/TS] Fix `Guid` to use cryptographically strong random values (by @ncave) * [Python] Fix `DateTimeOffset` millisecond constructor and property (by @ncave) * [Python] Implement missing `DateTimeOffset` members and fix equality/comparison to use UTC-normalized instants (by @dbrattli) +* [Python] Add `DateOnly` and `TimeOnly` support (by @dbrattli) * [Python] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument emitting it as a start-index instead of a compile error (by @repo-assist) * [Beam] Fix `String.IndexOf`/`LastIndexOf` with `StringComparison` argument incorrectly treating the enum value as a start index diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 02afe5ae5a..7085016807 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -89,9 +89,9 @@ let coreModFor = | FSharpReference _ -> "core" | BclHashSet _ -> "mutable_set" | BclDictionary _ -> "mutable_map" - | BclKeyValuePair _ - | BclDateOnly - | BclTimeOnly -> FableError "Cannot decide core module" |> raise + | BclDateOnly -> "date_only" + | BclTimeOnly -> "time_only" + | BclKeyValuePair _ -> FableError "Cannot decide core module" |> raise let makeDecimal com r t (x: decimal) = let str = x.ToString(System.Globalization.CultureInfo.InvariantCulture) @@ -809,7 +809,9 @@ and private getZero (com: ICompiler) ctx (t: Type) = | Char -> makeCharConst '\u0000' | String -> makeStrConst "" // TODO: Use null for string? | Builtin BclTimeSpan -> Helper.LibCall(com, "time_span", "create", t, [ makeIntConst 0 ]) + | Builtin BclTimeOnly -> makeIntConst 0 | Builtin BclDateTime as t -> Helper.LibCall(com, "date", "minValue", t, []) + | Builtin BclDateOnly as t -> Helper.LibCall(com, "DateOnly", "minValue", t, []) | Builtin BclDateTimeOffset as t -> Helper.LibCall(com, "DateOffset", "minValue", t, []) | Builtin(FSharpSet genArg) as t -> makeSet com ctx None t "Empty" [] genArg | Builtin(BclKeyValuePair(k, v)) -> makeTuple None true [ getZero com ctx k; getZero com ctx v ] @@ -905,7 +907,9 @@ let rec defaultof com ctx r t = | Char | Number _ | Builtin BclTimeSpan + | Builtin BclTimeOnly | Builtin BclDateTime + | Builtin BclDateOnly | Builtin BclDateTimeOffset -> getZero com ctx t | Builtin BclGuid -> emptyGuid com t | DeclaredType(ent, _) -> @@ -2897,6 +2901,119 @@ let private ignoreFormatProvider com (ctx: Context) r (moduleName: string) meth [ input; defVal ] | _ -> args +let dateOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + match i.CompiledName with + | ".ctor" when args.Length = 4 -> + "DateOnly constructor with the calendar parameter is not supported." + |> addError com ctx.InlinePath r + + None + | ".ctor" -> + Helper.LibCall(com, "DateOnly", "create", t, args, i.SignatureArgTypes, ?loc = r) + |> Some + | "ToString" -> + match args with + | [ ExprType String ] + | [ StringConst _ ] -> + "DateOnly.ToString without CultureInfo is not supported, please add CultureInfo.InvariantCulture" + |> addError com ctx.InlinePath r + + None + | [ StringConst("d" | "o" | "O"); _ ] -> + Helper.LibCall(com, "DateOnly", "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | [ StringConst _; _ ] -> + "DateOnly.ToString doesn't support custom format. It only handles \"d\", \"o\", \"O\" format, with CultureInfo.InvariantCulture." + |> addError com ctx.InlinePath r + + None + | [ _ ] -> + Helper.LibCall( + com, + "DateOnly", + "toString", + t, + makeStrConst "d" :: args, + i.SignatureArgTypes, + ?thisArg = thisArg, + ?loc = r + ) + |> Some + | _ -> None + | "AddDays" + | "AddMonths" + | "AddYears" -> + let meth = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + Helper.LibCall(com, "DateOnly", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | meth -> + let args = ignoreFormatProvider com ctx r i.DeclaringEntityFullName meth args + let meth = Naming.removeGetSetPrefix meth |> Naming.lowerFirst + + Helper.LibCall(com, "DateOnly", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + +let timeOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + match i.CompiledName with + | ".ctor" -> + match args with + | [ ExprType(Number(Int64, _)) ] -> + Helper.LibCall(com, "TimeOnly", "fromTicks", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | _ -> + Helper.LibCall(com, "TimeOnly", "create", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | "get_MinValue" -> makeIntConst 0 |> Some + | "ToTimeSpan" -> thisArg + | "get_Hour" + | "get_Minute" + | "get_Second" + | "get_Millisecond" -> + // Delegate to TimeSpan module with plural property names (hours, minutes, etc.) + let meth = (Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst) + "s" + + Helper.LibCall(com, "time_span", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | "get_Ticks" -> + Helper.LibCall(com, "time_span", "ticks", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | "ToString" -> + match args with + | [ ExprType String ] + | [ StringConst _ ] -> + "TimeOnly.ToString without CultureInfo is not supported, please add CultureInfo.InvariantCulture" + |> addError com ctx.InlinePath r + + None + | [ StringConst("r" | "R" | "o" | "O" | "t" | "T"); _ ] -> + Helper.LibCall(com, "TimeOnly", "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | [ StringConst _; _ ] -> + "TimeOnly.ToString doesn't support custom format. It only handles \"r\", \"R\", \"o\", \"O\", \"t\", \"T\" format, with CultureInfo.InvariantCulture." + |> addError com ctx.InlinePath r + + None + | [ _ ] -> + Helper.LibCall( + com, + "TimeOnly", + "toString", + t, + makeStrConst "t" :: args, + i.SignatureArgTypes, + ?thisArg = thisArg, + ?loc = r + ) + |> Some + | _ -> None + | meth -> + let args = ignoreFormatProvider com ctx r i.DeclaringEntityFullName meth args + let meth = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + Helper.LibCall(com, "TimeOnly", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + let dates (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = let getTime (e: Expr) = Helper.InstanceCall(e, "getTime", t, []) @@ -2931,6 +3048,9 @@ let dates (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr optio | ExprType(DeclaredType(e, [])) :: _ when e.FullName = Types.datetime -> Helper.LibCall(com, "DateOffset", "fromDate", t, args, i.SignatureArgTypes, ?loc = r) |> Some + | ExprType(DeclaredType(e, [])) :: _ when e.FullName = Types.dateOnly -> + Helper.LibCall(com, "DateOffset", "fromDateTime", t, args, i.SignatureArgTypes, ?loc = r) + |> Some | _ -> let last = List.last args @@ -3819,9 +3939,12 @@ let tryField com returnTyp ownerTyp fieldName = | Builtin BclDateTime, ("MaxValue" | "MinValue") -> Helper.LibCall(com, coreModFor BclDateTime, Naming.lowerFirst fieldName, returnTyp, []) |> Some - | Builtin BclDateTimeOffset, ("MaxValue" | "MinValue") -> + | Builtin BclDateTimeOffset, ("MaxValue" | "MinValue" | "UnixEpoch") -> Helper.LibCall(com, coreModFor BclDateTimeOffset, Naming.lowerFirst fieldName, returnTyp, []) |> Some + | Builtin BclDateOnly, ("MaxValue" | "MinValue") -> + Helper.LibCall(com, coreModFor BclDateOnly, Naming.lowerFirst fieldName, returnTyp, []) + |> Some | DeclaredType(ent, genArgs), fieldName -> match ent.FullName with | "System.BitConverter" -> @@ -3926,6 +4049,8 @@ let private replacedModules = "System.Diagnostics.Stopwatch", stopwatch Types.datetime, dates Types.datetimeOffset, dates + Types.dateOnly, dateOnly + Types.timeOnly, timeOnly Types.timespan, timeSpans "System.Timers.Timer", timers "System.Environment", systemEnv @@ -4098,6 +4223,6 @@ let tryType typ = | FSharpResult(genArg1, genArg2) -> Some(Types.result, results, [ genArg1; genArg2 ]) | FSharpChoice genArgs -> Some($"{Types.choiceNonGeneric}`{List.length genArgs}", results, genArgs) | FSharpReference genArg -> Some(Types.refCell, refCells, [ genArg ]) - | BclDateOnly - | BclTimeOnly -> None + | BclDateOnly -> Some(Types.dateOnly, dateOnly, []) + | BclTimeOnly -> Some(Types.timeOnly, timeOnly, []) | _ -> None diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index 01eb3453c4..fae7e673c2 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -291,6 +291,24 @@ def from_ticks(ticks: SupportsInt, offset_ts: TimeSpan) -> DateTimeOffset: return DateTimeOffset(dt, offset_ms) +def from_date_time(date_only: datetime, time_only: TimeSpan, offset_ts: TimeSpan) -> DateTimeOffset: + """Construct DateTimeOffset from DateOnly, TimeOnly, and offset TimeSpan.""" + from .time_span import hours, microseconds, milliseconds, minutes, seconds + + h = int(hours(time_only)) + m = int(minutes(time_only)) + s = int(seconds(time_only)) + ms = int(milliseconds(time_only)) + mc = int(microseconds(time_only)) + + offset_ms = int(time_span.total_microseconds(offset_ts) / 1000) + python_offset = timedelta(milliseconds=offset_ms) + dt = datetime( + date_only.year, date_only.month, date_only.day, h, m, s, ms * 1000 + mc, tzinfo=timezone(python_offset) + ) + return DateTimeOffset(dt, offset_ms) + + # --------------------------------------------------------------------------- # Static fields # --------------------------------------------------------------------------- @@ -318,6 +336,11 @@ def max_value() -> DateTimeOffset: return DateTimeOffset(dt, 0) +def unix_epoch() -> DateTimeOffset: + dt = datetime(1970, 1, 1, tzinfo=UTC) + return DateTimeOffset(dt, 0) + + # --------------------------------------------------------------------------- # Properties (accessors) # --------------------------------------------------------------------------- @@ -363,6 +386,10 @@ def day_of_year(d: DateTimeOffset) -> int: return d.timetuple().tm_yday +def total_offset_minutes(d: DateTimeOffset) -> int: + return d.offset_ms // (60 * 1000) + + def date(d: DateTimeOffset) -> datetime: """Return the date component (time set to midnight) as a DateTime.""" from . import date as date_mod @@ -660,6 +687,7 @@ def _get_days_in_month(year: int, month: int) -> int: "equals", "equals_exact", "from_date", + "from_date_time", "from_ticks", "from_unix_time_milliseconds", "from_unix_time_seconds", @@ -685,7 +713,9 @@ def _get_days_in_month(year: int, month: int) -> int: "to_universal_time", "to_unix_time_milliseconds", "to_unix_time_seconds", + "total_offset_minutes", "try_parse", + "unix_epoch", "utc_date_time", "utc_now", "year", diff --git a/src/fable-library-py/fable_library/date_only.py b/src/fable-library-py/fable_library/date_only.py new file mode 100644 index 0000000000..61dbe6ddd5 --- /dev/null +++ b/src/fable-library-py/fable_library/date_only.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from .core import FSharpRef, int32, int64 +from .date import days_in_month +from .time_span import TimeSpan + + +def create(year: int, month: int, day: int) -> datetime: + """Create a DateOnly value represented as a UTC datetime at midnight.""" + return datetime(year, month, day) + + +def max_value() -> datetime: + return datetime(9999, 12, 31) + + +def min_value() -> datetime: + return datetime(1, 1, 1) + + +def day_number(d: datetime) -> int: + """Return the number of days since 0001-01-01.""" + ticks = _to_ticks(d) + return int(ticks // 864000000000) + + +def from_day_number(day_num: int) -> datetime: + """Create a DateOnly from a day number (days since 0001-01-01).""" + ticks = day_num * 864000000000 + us = (ticks // 10) + _DOTNET_EPOCH_MICROSECONDS + from datetime import UTC + + dt = datetime.fromtimestamp(us / 1_000_000, tz=UTC) + return datetime(dt.year, dt.month, dt.day) + + +def from_date_time(d: datetime) -> datetime: + """Create a DateOnly from a DateTime.""" + return datetime(d.year, d.month, d.day) + + +def year(d: datetime) -> int32: + return int32(d.year) + + +def month(d: datetime) -> int32: + return int32(d.month) + + +def day(d: datetime) -> int32: + return int32(d.day) + + +def day_of_week(d: datetime) -> int: + return (d.weekday() + 1) % 7 + + +def day_of_year(d: datetime) -> int: + return d.timetuple().tm_yday + + +def add_days(d: datetime, v: int) -> datetime: + from datetime import timedelta + + new_dt = d + timedelta(days=int(v)) + return datetime(new_dt.year, new_dt.month, new_dt.day) + + +def add_months(d: datetime, v: int) -> datetime: + from .date import add_months as date_add_months + + result = date_add_months(d, v) + return datetime(result.year, result.month, result.day) + + +def add_years(d: datetime, v: int) -> datetime: + from .date import add_years as date_add_years + + result = date_add_years(d, v) + return datetime(result.year, result.month, result.day) + + +def to_date_time(d: datetime, time: TimeSpan, kind: int = 0) -> datetime: + """Convert DateOnly + TimeOnly to DateTime. + + time is a TimeOnly value (ticks since midnight). + kind: 0=Unspecified, 1=UTC, 2=Local + """ + from datetime import UTC as _UTC + + from .singleton_local_time_zone import local_time_zone + from .time_span import hours, microseconds, milliseconds, minutes, seconds + + h = int(hours(time)) + m = int(minutes(time)) + s = int(seconds(time)) + ms = int(milliseconds(time)) + mc = int(microseconds(time)) + + if kind == 1: # UTC + return datetime(d.year, d.month, d.day, h, m, s, ms * 1000 + mc, tzinfo=_UTC) + elif kind == 2: # Local + return datetime(d.year, d.month, d.day, h, m, s, ms * 1000 + mc, tzinfo=local_time_zone) + else: + return datetime(d.year, d.month, d.day, h, m, s, ms * 1000 + mc) + + +def to_string(d: datetime, format: str = "d", _provider: Any = None) -> str: + y = str(d.year).zfill(4) + m = str(d.month).zfill(2) + dd = str(d.day).zfill(2) + + if format == "d": + return f"{m}/{dd}/{y}" + else: # "o" or "O" + return f"{y}-{m}-{dd}" + + +def parse(string: str) -> datetime: + """Parse a string into a DateOnly value.""" + import re + + # yyyy-mm-dd or yyyy/mm/dd + match = re.match(r"^\s*(\d{4})\s*[-/.,]\s*(\d{1,2})\s*[-/.,]\s*(\d{1,2})\s*$", string) + if match: + y, m, d = int(match.group(1)), int(match.group(2)), int(match.group(3)) + if 1 <= m <= 12 and 1 <= d <= days_in_month(y, m): + return create(y, m, d) + + # mm/dd/yyyy + match = re.match(r"^\s*(\d{1,2})\s*[-/.,]\s*(\d{1,2})\s*[-/.,]\s*(\d{4})\s*$", string) + if match: + m, d, y = int(match.group(1)), int(match.group(2)), int(match.group(3)) + if 1 <= m <= 12 and 1 <= d <= days_in_month(y, m): + return create(y, m, d) + + # mm/dd (current year) + match = re.match(r"^\s*(\d{1,2})\s*[-/.,]\s*(\d{1,2})\s*$", string) + if match: + m, d = int(match.group(1)), int(match.group(2)) + y = datetime.now().year + if 1 <= m <= 12 and 1 <= d <= days_in_month(y, m): + return create(y, m, d) + + raise Exception(f"String '{string}' was not recognized as a valid DateOnly.") + + +def try_parse(v: str, def_value: FSharpRef[datetime]) -> bool: + try: + def_value.contents = parse(v) + return True + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_DOTNET_EPOCH_MICROSECONDS = -62135596800000000 + + +def _to_ticks(d: datetime) -> int: + """Convert a naive datetime to .NET ticks.""" + from datetime import UTC as _UTC + + utc_dt = datetime(d.year, d.month, d.day, tzinfo=_UTC) + us = int(utc_dt.timestamp() * 1_000_000) + return (us - _DOTNET_EPOCH_MICROSECONDS) * 10 + + +__all__ = [ + "add_days", + "add_months", + "add_years", + "create", + "day", + "day_number", + "day_of_week", + "day_of_year", + "from_date_time", + "from_day_number", + "max_value", + "min_value", + "month", + "parse", + "to_date_time", + "to_string", + "try_parse", + "year", +] diff --git a/src/fable-library-py/fable_library/time_only.py b/src/fable-library-py/fable_library/time_only.py new file mode 100644 index 0000000000..a9d9ec0709 --- /dev/null +++ b/src/fable-library-py/fable_library/time_only.py @@ -0,0 +1,198 @@ +"""TimeOnly implementation for Fable Python. + +TimeOnly is represented as ticks since midnight (same unit as TimeSpan). +This is consistent with the Python TimeSpan representation which stores ticks. +""" + +from __future__ import annotations + +from typing import Any + +from .core import FSharpRef, int32 +from .time_span import ( + TimeSpan, + hours, + microseconds, + milliseconds, + minutes, + seconds, + ticks, +) +from .util import pad_with_zeros + +_TICKS_PER_MILLISECOND = 10000 +_TICKS_PER_SECOND = 10000000 +_TICKS_PER_MINUTE = 600000000 +_TICKS_PER_HOUR = 36000000000 +_TICKS_PER_DAY = 864000000000 + + +def create(h: int = 0, m: int = 0, s: int = 0, ms: int = 0) -> TimeSpan: + """Create a TimeOnly value from hours, minutes, seconds, milliseconds. + + Returns a TimeSpan (ticks since midnight). + """ + if h < 0 or m < 0 or s < 0 or ms < 0: + raise Exception("The parameters describe an unrepresentable TimeOnly.") + + return TimeSpan( + int(h) * _TICKS_PER_HOUR + + int(m) * _TICKS_PER_MINUTE + + int(s) * _TICKS_PER_SECOND + + int(ms) * _TICKS_PER_MILLISECOND + ) + + +def from_ticks(t: int) -> TimeSpan: + """Create a TimeOnly from ticks.""" + return TimeSpan(int(t)) + + +def from_time_span(ts: TimeSpan) -> TimeSpan: + """Create a TimeOnly from a TimeSpan.""" + t = int(ts) + if t < 0 or t >= _TICKS_PER_DAY: + raise Exception("The TimeSpan describes an unrepresentable TimeOnly.") + return TimeSpan(t) + + +def from_date_time(d: Any) -> TimeSpan: + """Extract TimeOnly from a DateTime.""" + return create(d.hour, d.minute, d.second, d.microsecond // 1000) + + +def max_value() -> TimeSpan: + """23:59:59.9999999""" + return TimeSpan(_TICKS_PER_DAY - 1) + + +def min_value() -> TimeSpan: + return TimeSpan(0) + + +def add(t: TimeSpan, ts: TimeSpan, wrapped_days: FSharpRef[int] | None = None) -> TimeSpan: + """Add a TimeSpan to a TimeOnly, wrapping around midnight.""" + t_val = int(t) + ts_val = int(ts) + + if wrapped_days is None: + t2 = (t_val + ts_val) % _TICKS_PER_DAY + return TimeSpan(t2 if t2 >= 0 else _TICKS_PER_DAY + t2) + + wrapped_days.contents = ts_val // _TICKS_PER_DAY + new_ticks = t_val + ts_val % _TICKS_PER_DAY + + if new_ticks < 0: + wrapped_days.contents -= 1 + new_ticks += _TICKS_PER_DAY + elif new_ticks >= _TICKS_PER_DAY: + wrapped_days.contents += 1 + new_ticks -= _TICKS_PER_DAY + + return TimeSpan(new_ticks) + + +def add_hours(t: TimeSpan, h: float) -> TimeSpan: + return add(t, TimeSpan(int(float(h) * _TICKS_PER_HOUR))) + + +def add_minutes(t: TimeSpan, m: float) -> TimeSpan: + return add(t, TimeSpan(int(float(m) * _TICKS_PER_MINUTE))) + + +def is_between(t: TimeSpan, start: TimeSpan, end: TimeSpan) -> bool: + t_val, s_val, e_val = int(t), int(start), int(end) + if s_val <= e_val: + return s_val <= t_val and e_val > t_val + else: + return s_val <= t_val or e_val > t_val + + +def to_string(t: TimeSpan, format: str = "t", _provider: Any = None) -> str: + if format not in ("r", "R", "o", "O", "t", "T"): + raise Exception("Custom formats are not supported") + + h = pad_with_zeros(int(hours(t)), 2) + m = pad_with_zeros(int(minutes(t)), 2) + base = f"{h}:{m}" + + if format == "t": + return base + + s = pad_with_zeros(int(seconds(t)), 2) + if format in ("o", "O"): + ms = pad_with_zeros(int(milliseconds(t)), 3) + return f"{base}:{s}.{ms}0000" + else: + return f"{base}:{s}" + + +def parse(string: str) -> TimeSpan: + """Parse a time string into a TimeOnly value.""" + import re + + match = re.match( + r"^\s*([0-1]?\d|2[0-3])\s*:\s*([0-5]?\d)(\s*:\s*([0-5]?\d)(\.(\d+))?)?\s*$", + string, + ) + if match and match.group(1) is not None and match.group(2) is not None: + h = int(match.group(1)) + m = int(match.group(2)) + s = 0 + ms = 0 + + if match.group(4) is not None: + s = int(match.group(4)) + + if match.group(6) is not None: + frac = match.group(6) + length = len(frac) + val = int(frac) + if length == 1: + ms = val * 100 + elif length == 2: + ms = val * 10 + elif length == 3: + ms = val + elif length == 4: + ms = val // 10 + elif length == 5: + ms = val // 100 + elif length == 6: + ms = val // 1000 + else: + ms = int(frac[:7]) // 10000 + + return create(h, m, s, ms) + + raise Exception(f"String '{string}' was not recognized as a valid TimeOnly.") + + +def try_parse(v: str, def_value: FSharpRef[TimeSpan]) -> bool: + try: + def_value.contents = parse(v) + return True + except Exception: + return False + + +def op_subtraction(left: TimeSpan, right: TimeSpan) -> TimeSpan: + return add(left, TimeSpan(-int(right))) + + +__all__ = [ + "add", + "add_hours", + "add_minutes", + "create", + "from_date_time", + "from_ticks", + "from_time_span", + "is_between", + "max_value", + "min_value", + "op_subtraction", + "parse", + "to_string", + "try_parse", +] diff --git a/tests/Python/Fable.Tests.Python.fsproj b/tests/Python/Fable.Tests.Python.fsproj index bc9cfdc216..b555e53a20 100644 --- a/tests/Python/Fable.Tests.Python.fsproj +++ b/tests/Python/Fable.Tests.Python.fsproj @@ -39,7 +39,9 @@ + + diff --git a/tests/Python/TestDateOnly.fs b/tests/Python/TestDateOnly.fs new file mode 100644 index 0000000000..77032cf5eb --- /dev/null +++ b/tests/Python/TestDateOnly.fs @@ -0,0 +1,70 @@ +module Fable.Tests.DateOnly + +open System +open Util.Testing + +[] +let ``test DateOnly constructors work`` () = + let d = DateOnly(2014, 10, 9) + d.Year |> equal 2014 + d.Month |> equal 10 + d.Day |> equal 9 + +[] +let ``test DateOnly.MinValue works`` () = + let d = DateOnly.MinValue + d.Year |> equal 1 + d.Month |> equal 1 + d.Day |> equal 1 + +[] +let ``test DateOnly.MaxValue works`` () = + let d = DateOnly.MaxValue + d.Year |> equal 9999 + d.Month |> equal 12 + d.Day |> equal 31 + +[] +let ``test DateOnly.DayOfWeek works`` () = + let d = DateOnly(2014, 10, 9) + d.DayOfWeek |> equal DayOfWeek.Thursday + +[] +let ``test DateOnly.DayOfYear works`` () = + let d = DateOnly(2014, 10, 9) + d.DayOfYear |> equal 282 + +[] +let ``test DateOnly.AddDays works`` () = + let d = DateOnly(2014, 10, 9) + let d2 = d.AddDays(10) + d2.Day |> equal 19 + d2.Month |> equal 10 + +[] +let ``test DateOnly.AddMonths works`` () = + let d = DateOnly(2014, 1, 31) + let d2 = d.AddMonths(1) + d2.Month |> equal 2 + d2.Day |> equal 28 + +[] +let ``test DateOnly.AddYears works`` () = + let d = DateOnly(2016, 2, 29) + let d2 = d.AddYears(1) + d2.Year |> equal 2017 + d2.Month |> equal 2 + d2.Day |> equal 28 + +[] +let ``test DateOnly.ToString works`` () = + let d = DateOnly(2014, 10, 9) + d.ToString("o", System.Globalization.CultureInfo.InvariantCulture) |> equal "2014-10-09" + +[] +let ``test DateOnly.FromDateTime works`` () = + let dt = DateTime(2014, 10, 9, 13, 23, 30) + let d = DateOnly.FromDateTime(dt) + d.Year |> equal 2014 + d.Month |> equal 10 + d.Day |> equal 9 diff --git a/tests/Python/TestDateTimeOffset.fs b/tests/Python/TestDateTimeOffset.fs index a846b81287..9fedb44e4d 100644 --- a/tests/Python/TestDateTimeOffset.fs +++ b/tests/Python/TestDateTimeOffset.fs @@ -491,3 +491,30 @@ let ``test DateTimeOffset.UtcDateTime works`` () = equal d.Year d2.Year equal d.Month d2.Month equal d.Day d2.Day + +[] +let ``test DateTimeOffset.UnixEpoch works`` () = + let epoch = DateTimeOffset.UnixEpoch + equal 1970 epoch.Year + equal 1 epoch.Month + equal 1 epoch.Day + +[] +let ``test DateTimeOffset.TotalOffsetMinutes works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.FromHours(5.0)) + d.TotalOffsetMinutes |> equal 300 + +[] +let ``test DateTimeOffset constructor from DateOnly and TimeOnly works`` () = + let d = DateOnly(2014, 10, 9) + let t = TimeOnly(13, 23, 30, 500) + let o = TimeSpan.FromHours 5.0 + let dt = DateTimeOffset(d, t, o) + let dt_exp = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, o) + dt.Year |> equal dt_exp.Year + dt.Month |> equal dt_exp.Month + dt.Day |> equal dt_exp.Day + dt.Hour |> equal dt_exp.Hour + dt.Minute |> equal dt_exp.Minute + dt.Second |> equal dt_exp.Second + dt.Millisecond |> equal dt_exp.Millisecond diff --git a/tests/Python/TestTimeOnly.fs b/tests/Python/TestTimeOnly.fs new file mode 100644 index 0000000000..958e7cc57f --- /dev/null +++ b/tests/Python/TestTimeOnly.fs @@ -0,0 +1,61 @@ +module Fable.Tests.TimeOnly + +open System +open Util.Testing + +[] +let ``test TimeOnly constructors work`` () = + let t = TimeOnly(13, 23, 30) + t.Hour |> equal 13 + t.Minute |> equal 23 + t.Second |> equal 30 + +[] +let ``test TimeOnly constructor with milliseconds works`` () = + let t = TimeOnly(13, 23, 30, 500) + t.Hour |> equal 13 + t.Minute |> equal 23 + t.Second |> equal 30 + t.Millisecond |> equal 500 + +[] +let ``test TimeOnly.MinValue works`` () = + let t = TimeOnly.MinValue + t.Hour |> equal 0 + t.Minute |> equal 0 + t.Second |> equal 0 + +[] +let ``test TimeOnly.ToTimeSpan works`` () = + let t = TimeOnly(13, 23, 30) + let ts = t.ToTimeSpan() + ts.Hours |> equal 13 + ts.Minutes |> equal 23 + ts.Seconds |> equal 30 + +[] +let ``test TimeOnly.AddHours works`` () = + let t = TimeOnly(13, 0, 0) + let t2 = t.AddHours(2.0) + t2.Hour |> equal 15 + +[] +let ``test TimeOnly.AddMinutes works`` () = + let t = TimeOnly(13, 30, 0) + let t2 = t.AddMinutes(45.0) + t2.Hour |> equal 14 + t2.Minute |> equal 15 + +[] +let ``test TimeOnly.IsBetween works`` () = + let t = TimeOnly(13, 0, 0) + let start = TimeOnly(12, 0, 0) + let finish = TimeOnly(14, 0, 0) + t.IsBetween(start, finish) |> equal true + t.IsBetween(finish, start) |> equal false + +[] +let ``test TimeOnly.ToString with format works`` () = + let t = TimeOnly(13, 23, 30) + t.ToString("T", System.Globalization.CultureInfo.InvariantCulture) |> equal "13:23:30" + t.ToString("t", System.Globalization.CultureInfo.InvariantCulture) |> equal "13:23" From 1a76110656151d23a698725de386cca491bdfeb0 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 23:12:58 +0200 Subject: [PATCH 07/14] Remove unused Rust datetime_offset stubs, fix Pyright errors - Remove datetime_offset.rs and .pyi stub (pure Python approach) - Return int32 from DateTimeOffset property accessors - Add TimeOnly property functions (hour, minute, second, millisecond, ticks) - Use SupportsFloat for TimeOnly add_hours/add_minutes - Route TimeOnly.MinValue through library call Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Fable.Transforms/Python/Replacements.fs | 13 +-- .../fable_library/core/datetime_offset.pyi | 44 -------- .../fable_library/date_offset.py | 32 +++--- .../fable_library/time_only.py | 28 ++++- src/fable-library-py/src/datetime_offset.rs | 105 ------------------ src/fable-library-py/src/lib.rs | 3 - 6 files changed, 45 insertions(+), 180 deletions(-) delete mode 100644 src/fable-library-py/fable_library/core/datetime_offset.pyi delete mode 100644 src/fable-library-py/src/datetime_offset.rs diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 7085016807..7ac0d5029d 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -2964,19 +2964,16 @@ let timeOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op | _ -> Helper.LibCall(com, "TimeOnly", "create", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some - | "get_MinValue" -> makeIntConst 0 |> Some + | "get_MinValue" -> Helper.LibCall(com, "TimeOnly", "minValue", t, [], [], ?loc = r) |> Some | "ToTimeSpan" -> thisArg | "get_Hour" | "get_Minute" | "get_Second" - | "get_Millisecond" -> - // Delegate to TimeSpan module with plural property names (hours, minutes, etc.) - let meth = (Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst) + "s" - - Helper.LibCall(com, "time_span", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) - |> Some + | "get_Millisecond" | "get_Ticks" -> - Helper.LibCall(com, "time_span", "ticks", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + let meth = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + Helper.LibCall(com, "TimeOnly", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | "ToString" -> match args with diff --git a/src/fable-library-py/fable_library/core/datetime_offset.pyi b/src/fable-library-py/fable_library/core/datetime_offset.pyi deleted file mode 100644 index 4be3afb238..0000000000 --- a/src/fable-library-py/fable_library/core/datetime_offset.pyi +++ /dev/null @@ -1,44 +0,0 @@ -"""Stub file for the DateTimeOffset module. - -This is only needed so that the static type checker can find the types for the extension -methods we have written in Rust. The file will never be used by Python at runtime. -""" - -from datetime import datetime - -# Only include the now function for now -def now() -> datetime: ... - -# Commented out functions -# def add(d: datetime, ts: timedelta) -> datetime: ... -# def parse(string: str, detect_utc: bool = False) -> datetime: ... -# def try_parse(string: str, style: int, unsigned: bool, bitsize: int, def_value: FSharpRef[datetime]) -> bool: ... -# @overload -# def create( -# year: int, -# month: int, -# day: int, -# h: int, -# m: int, -# s: int, -# ms: int, -# offset: timedelta | None = None, -# ) -> datetime: ... -# @overload -# def create( -# year: int, -# month: int, -# day: int, -# h: int, -# m: int, -# s: int, -# ms: int, -# offset: int | None = None, -# ) -> datetime: ... -# def utc_now() -> datetime: ... -# def op_addition(x: datetime, y: timedelta) -> datetime: ... -# @overload -# def op_subtraction(x: datetime, y: datetime) -> int: ... # Returns TimeSpan as ticks -# @overload -# def op_subtraction(x: datetime, y: int) -> datetime: ... # TimeSpan as ticks -# def min_value() -> datetime: ... diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index fae7e673c2..433cdd75e4 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -346,36 +346,36 @@ def unix_epoch() -> DateTimeOffset: # --------------------------------------------------------------------------- -def year(d: DateTimeOffset) -> int: - return d.year +def year(d: DateTimeOffset) -> int32: + return int32(d.year) -def month(d: DateTimeOffset) -> int: - return d.month +def month(d: DateTimeOffset) -> int32: + return int32(d.month) -def day(d: DateTimeOffset) -> int: - return d.day +def day(d: DateTimeOffset) -> int32: + return int32(d.day) -def hour(d: DateTimeOffset) -> int: - return d.hour +def hour(d: DateTimeOffset) -> int32: + return int32(d.hour) -def minute(d: DateTimeOffset) -> int: - return d.minute +def minute(d: DateTimeOffset) -> int32: + return int32(d.minute) -def second(d: DateTimeOffset) -> int: - return d.second +def second(d: DateTimeOffset) -> int32: + return int32(d.second) -def millisecond(d: DateTimeOffset) -> int: - return d.microsecond // 1000 +def millisecond(d: DateTimeOffset) -> int32: + return int32(d.microsecond // 1000) -def microsecond(d: DateTimeOffset) -> int: - return d.microsecond % 1000 +def microsecond(d: DateTimeOffset) -> int32: + return int32(d.microsecond % 1000) def day_of_week(d: DateTimeOffset) -> int: diff --git a/src/fable-library-py/fable_library/time_only.py b/src/fable-library-py/fable_library/time_only.py index a9d9ec0709..621c42facf 100644 --- a/src/fable-library-py/fable_library/time_only.py +++ b/src/fable-library-py/fable_library/time_only.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, SupportsFloat from .core import FSharpRef, int32 from .time_span import ( @@ -16,7 +16,7 @@ milliseconds, minutes, seconds, - ticks, + ticks as _ts_ticks, ) from .util import pad_with_zeros @@ -70,6 +70,26 @@ def min_value() -> TimeSpan: return TimeSpan(0) +def hour(t: TimeSpan) -> int32: + return int32(hours(t)) + + +def minute(t: TimeSpan) -> int32: + return int32(minutes(t)) + + +def second(t: TimeSpan) -> int32: + return int32(seconds(t)) + + +def millisecond(t: TimeSpan) -> int32: + return int32(milliseconds(t)) + + +def ticks(t: TimeSpan) -> int: + return int(_ts_ticks(t)) + + def add(t: TimeSpan, ts: TimeSpan, wrapped_days: FSharpRef[int] | None = None) -> TimeSpan: """Add a TimeSpan to a TimeOnly, wrapping around midnight.""" t_val = int(t) @@ -92,11 +112,11 @@ def add(t: TimeSpan, ts: TimeSpan, wrapped_days: FSharpRef[int] | None = None) - return TimeSpan(new_ticks) -def add_hours(t: TimeSpan, h: float) -> TimeSpan: +def add_hours(t: TimeSpan, h: SupportsFloat) -> TimeSpan: return add(t, TimeSpan(int(float(h) * _TICKS_PER_HOUR))) -def add_minutes(t: TimeSpan, m: float) -> TimeSpan: +def add_minutes(t: TimeSpan, m: SupportsFloat) -> TimeSpan: return add(t, TimeSpan(int(float(m) * _TICKS_PER_MINUTE))) diff --git a/src/fable-library-py/src/datetime_offset.rs b/src/fable-library-py/src/datetime_offset.rs deleted file mode 100644 index 0e7eb47c3a..0000000000 --- a/src/fable-library-py/src/datetime_offset.rs +++ /dev/null @@ -1,105 +0,0 @@ -use chrono::Utc; -use pyo3::prelude::*; - -/// A module for datetime offset operations -pub fn register_datetime_offset_module(parent_module: &Bound<'_, PyModule>) -> PyResult<()> { - let m = PyModule::new(parent_module.py(), "datetime_offset")?; - - // Add functions to the module - m.add_function(wrap_pyfunction!(now, &m)?)?; - - // Commented out functions for now - // m.add_function(wrap_pyfunction!(add, m)?)?; - // m.add_function(wrap_pyfunction!(parse, m)?)?; - // m.add_function(wrap_pyfunction!(try_parse, m)?)?; - // m.add_function(wrap_pyfunction!(create, m)?)?; - // m.add_function(wrap_pyfunction!(utc_now, m)?)?; - // m.add_function(wrap_pyfunction!(op_addition, m)?)?; - // m.add_function(wrap_pyfunction!(op_subtraction, m)?)?; - // m.add_function(wrap_pyfunction!(min_value, m)?)?; - - parent_module.add_submodule(&m) -} - -/// Get current datetime -/// -/// Returns the current local date and time as a Python datetime object. -#[pyfunction] -fn now() -> PyResult> { - // Get the current local time using chrono - // Convert to Python datetime using PyO3's chrono feature - // pyo3::prepare_freethreaded_python(); - Python::attach(|py| { - let now = Utc::now(); - let py_now = now.into_pyobject(py)?; - Ok(py_now.into()) - }) -} - -// /// Add a timedelta to a datetime -// #[pyfunction] -// fn add(py: Python<'_>, d: DateTime, ts: Duration) -> DateTime { -// // Add the duration to the datetime -// d + ts -// } -// -// /// Parse a string into a datetime -// #[pyfunction] -// #[pyo3(signature = (string, detect_utc=false))] -// fn parse(py: Python<'_>, string: &str, detect_utc: bool) -> PyResult> { -// // TODO: Implement using chrono -// unimplemented!() -// } -// -// /// Try to parse a string into a datetime -// #[pyfunction] -// fn try_parse(py: Python<'_>, string: &str, style: i32, unsigned: bool, bitsize: i32, def_value: &PyAny) -> PyResult { -// // TODO: Implement using chrono -// unimplemented!() -// } -// -// /// Create a datetime with timezone -// #[pyfunction] -// #[pyo3(signature = (year, month, day, h, m, s, ms, offset=None))] -// fn create( -// py: Python<'_>, -// year: i32, -// month: i32, -// day: i32, -// h: i32, -// m: i32, -// s: i32, -// ms: i64, -// offset: Option, -// ) -> PyResult> { -// // TODO: Implement using chrono -// unimplemented!() -// } -// -// /// Get current UTC datetime -// #[pyfunction] -// fn utc_now(py: Python<'_>) -> DateTime { -// // Get the current UTC time using chrono -// Utc::now() -// } -// -// /// Add a timedelta to a datetime -// #[pyfunction] -// fn op_addition(py: Python<'_>, x: DateTime, y: Duration) -> DateTime { -// // Add the duration to the datetime -// x + y -// } -// -// /// Subtract a datetime or timedelta from a datetime -// #[pyfunction] -// fn op_subtraction(py: Python<'_>, x: DateTime, y: DateTime) -> Duration { -// // Subtract the datetimes to get a duration -// x - y -// } -// -// /// Get minimum datetime value -// #[pyfunction] -// fn min_value(py: Python<'_>) -> DateTime { -// // Create a datetime for year 1, month 1, day 1 -// Utc.with_ymd_and_hms(1, 1, 1, 0, 0, 0).unwrap() -// } diff --git a/src/fable-library-py/src/lib.rs b/src/fable-library-py/src/lib.rs index 76740ae481..9cf067209d 100644 --- a/src/fable-library-py/src/lib.rs +++ b/src/fable-library-py/src/lib.rs @@ -1,7 +1,6 @@ use pyo3::prelude::*; mod array; -mod datetime_offset; mod floats; mod ints; mod native_array; @@ -11,7 +10,6 @@ mod types; mod util; use crate::array::register_array_module; -use crate::datetime_offset::register_datetime_offset_module; use crate::floats::*; use crate::ints::*; use crate::options::register_option_module; @@ -31,7 +29,6 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; register_array_module(m)?; - register_datetime_offset_module(m)?; register_option_module(m)?; register_float_module(m)?; register_string_module(m)?; From 99ca294f08e40c44a717204463ec50a37402ad35 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 23:14:05 +0200 Subject: [PATCH 08/14] Remove assert from from_date, use walrus operator instead Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fable-library-py/fable_library/date_offset.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index 433cdd75e4..c0a320620c 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -261,9 +261,7 @@ def from_date(dt: datetime, offset_ts: TimeSpan | None = None) -> DateTimeOffset """Construct DateTimeOffset from a DateTime.""" if offset_ts is not None: offset_ms = int(time_span.total_microseconds(offset_ts) / 1000) - elif dt.tzinfo is not None and dt.utcoffset() is not None: - offset_td = dt.utcoffset() - assert offset_td is not None + elif dt.tzinfo is not None and (offset_td := dt.utcoffset()) is not None: offset_ms = int(offset_td.total_seconds() * 1000) else: # Unspecified kind — use local offset From 4a36d2f4421ece75e7ddfa805f9812d04d68e038 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 23:27:46 +0200 Subject: [PATCH 09/14] Clean up unused imports in date_only.py and time_only.py Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fable-library-py/fable_library/date_only.py | 2 +- src/fable-library-py/fable_library/time_only.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/fable-library-py/fable_library/date_only.py b/src/fable-library-py/fable_library/date_only.py index 61dbe6ddd5..7a5d1aa034 100644 --- a/src/fable-library-py/fable_library/date_only.py +++ b/src/fable-library-py/fable_library/date_only.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Any -from .core import FSharpRef, int32, int64 +from .core import FSharpRef, int32 from .date import days_in_month from .time_span import TimeSpan diff --git a/src/fable-library-py/fable_library/time_only.py b/src/fable-library-py/fable_library/time_only.py index 621c42facf..e22977e77d 100644 --- a/src/fable-library-py/fable_library/time_only.py +++ b/src/fable-library-py/fable_library/time_only.py @@ -12,14 +12,16 @@ from .time_span import ( TimeSpan, hours, - microseconds, milliseconds, minutes, seconds, +) +from .time_span import ( ticks as _ts_ticks, ) from .util import pad_with_zeros + _TICKS_PER_MILLISECOND = 10000 _TICKS_PER_SECOND = 10000000 _TICKS_PER_MINUTE = 600000000 From 3c595304b17a98ec0e75d2c49abd3fc997eccde0 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 23:36:24 +0200 Subject: [PATCH 10/14] Move all imports to top of file Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fable_library/date_offset.py | 11 ++----- .../fable_library/date_only.py | 29 +++++-------------- .../fable_library/time_only.py | 3 +- 3 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index c0a320620c..ed9c629832 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -4,9 +4,10 @@ from math import fmod from typing import Any, SupportsFloat, SupportsIndex, SupportsInt, overload +from . import date as date_mod from . import time_span from .core import FSharpRef, int32, int64 -from .time_span import TimeSpan +from .time_span import TimeSpan, hours, microseconds, milliseconds, minutes, seconds class DateTimeOffset(datetime): @@ -291,8 +292,6 @@ def from_ticks(ticks: SupportsInt, offset_ts: TimeSpan) -> DateTimeOffset: def from_date_time(date_only: datetime, time_only: TimeSpan, offset_ts: TimeSpan) -> DateTimeOffset: """Construct DateTimeOffset from DateOnly, TimeOnly, and offset TimeSpan.""" - from .time_span import hours, microseconds, milliseconds, minutes, seconds - h = int(hours(time_only)) m = int(minutes(time_only)) s = int(seconds(time_only)) @@ -390,8 +389,6 @@ def total_offset_minutes(d: DateTimeOffset) -> int: def date(d: DateTimeOffset) -> datetime: """Return the date component (time set to midnight) as a DateTime.""" - from . import date as date_mod - return date_mod.create(d.year, d.month, d.day) @@ -409,8 +406,6 @@ def ticks(d: DateTimeOffset) -> int64: independent of the offset. """ # Compute ticks from the local date/time fields - from . import date as date_mod - local_dt = datetime(d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond, tzinfo=UTC) us = int(local_dt.timestamp() * 1_000_000) return date_mod.unix_epoch_microseconds_to_ticks(int64(us), int64(0)) @@ -418,8 +413,6 @@ def ticks(d: DateTimeOffset) -> int64: def get_utc_ticks(d: DateTimeOffset) -> int64: """Return UTC ticks.""" - from . import date as date_mod - utc_ms = _to_utc_ms(d) us = utc_ms * 1000 return date_mod.unix_epoch_microseconds_to_ticks(int64(us), int64(0)) diff --git a/src/fable-library-py/fable_library/date_only.py b/src/fable-library-py/fable_library/date_only.py index 7a5d1aa034..920bea6701 100644 --- a/src/fable-library-py/fable_library/date_only.py +++ b/src/fable-library-py/fable_library/date_only.py @@ -1,11 +1,15 @@ from __future__ import annotations -from datetime import datetime +import re +from datetime import UTC, datetime, timedelta from typing import Any from .core import FSharpRef, int32 +from .date import add_months as date_add_months +from .date import add_years as date_add_years from .date import days_in_month -from .time_span import TimeSpan +from .singleton_local_time_zone import local_time_zone +from .time_span import TimeSpan, hours, microseconds, milliseconds, minutes, seconds def create(year: int, month: int, day: int) -> datetime: @@ -31,8 +35,6 @@ def from_day_number(day_num: int) -> datetime: """Create a DateOnly from a day number (days since 0001-01-01).""" ticks = day_num * 864000000000 us = (ticks // 10) + _DOTNET_EPOCH_MICROSECONDS - from datetime import UTC - dt = datetime.fromtimestamp(us / 1_000_000, tz=UTC) return datetime(dt.year, dt.month, dt.day) @@ -63,22 +65,16 @@ def day_of_year(d: datetime) -> int: def add_days(d: datetime, v: int) -> datetime: - from datetime import timedelta - new_dt = d + timedelta(days=int(v)) return datetime(new_dt.year, new_dt.month, new_dt.day) def add_months(d: datetime, v: int) -> datetime: - from .date import add_months as date_add_months - result = date_add_months(d, v) return datetime(result.year, result.month, result.day) def add_years(d: datetime, v: int) -> datetime: - from .date import add_years as date_add_years - result = date_add_years(d, v) return datetime(result.year, result.month, result.day) @@ -89,11 +85,6 @@ def to_date_time(d: datetime, time: TimeSpan, kind: int = 0) -> datetime: time is a TimeOnly value (ticks since midnight). kind: 0=Unspecified, 1=UTC, 2=Local """ - from datetime import UTC as _UTC - - from .singleton_local_time_zone import local_time_zone - from .time_span import hours, microseconds, milliseconds, minutes, seconds - h = int(hours(time)) m = int(minutes(time)) s = int(seconds(time)) @@ -101,7 +92,7 @@ def to_date_time(d: datetime, time: TimeSpan, kind: int = 0) -> datetime: mc = int(microseconds(time)) if kind == 1: # UTC - return datetime(d.year, d.month, d.day, h, m, s, ms * 1000 + mc, tzinfo=_UTC) + return datetime(d.year, d.month, d.day, h, m, s, ms * 1000 + mc, tzinfo=UTC) elif kind == 2: # Local return datetime(d.year, d.month, d.day, h, m, s, ms * 1000 + mc, tzinfo=local_time_zone) else: @@ -121,8 +112,6 @@ def to_string(d: datetime, format: str = "d", _provider: Any = None) -> str: def parse(string: str) -> datetime: """Parse a string into a DateOnly value.""" - import re - # yyyy-mm-dd or yyyy/mm/dd match = re.match(r"^\s*(\d{4})\s*[-/.,]\s*(\d{1,2})\s*[-/.,]\s*(\d{1,2})\s*$", string) if match: @@ -165,9 +154,7 @@ def try_parse(v: str, def_value: FSharpRef[datetime]) -> bool: def _to_ticks(d: datetime) -> int: """Convert a naive datetime to .NET ticks.""" - from datetime import UTC as _UTC - - utc_dt = datetime(d.year, d.month, d.day, tzinfo=_UTC) + utc_dt = datetime(d.year, d.month, d.day, tzinfo=UTC) us = int(utc_dt.timestamp() * 1_000_000) return (us - _DOTNET_EPOCH_MICROSECONDS) * 10 diff --git a/src/fable-library-py/fable_library/time_only.py b/src/fable-library-py/fable_library/time_only.py index e22977e77d..19d5fcd133 100644 --- a/src/fable-library-py/fable_library/time_only.py +++ b/src/fable-library-py/fable_library/time_only.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re from typing import Any, SupportsFloat from .core import FSharpRef, int32 @@ -151,8 +152,6 @@ def to_string(t: TimeSpan, format: str = "t", _provider: Any = None) -> str: def parse(string: str) -> TimeSpan: """Parse a time string into a TimeOnly value.""" - import re - match = re.match( r"^\s*([0-1]?\d|2[0-3])\s*:\s*([0-5]?\d)(\s*:\s*([0-5]?\d)(\.(\d+))?)?\s*$", string, From 9b05da46fdc72ec603eb4f9f19a61b39300a7ed4 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 23:38:43 +0200 Subject: [PATCH 11/14] Add ruff rule PLC0415 to disallow inline imports Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 4 +++- src/fable-library-py/fable_library/date_offset.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 34b72ac5ed..7aa068cbdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,9 @@ respect-gitignore = false include =["*.py"] [tool.ruff.lint] -select = ["E", "W", "F", "I", "T", "RUF", "TID", "UP"] +select = ["E", "W", "F", "I", "T", "RUF", "TID", "UP", "PLC0415"] +preview = true +explicit-preview-rules = true ignore = [ "F841" # Ignore unused variable warnings ] diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index ed9c629832..5bf8f4e248 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -193,7 +193,7 @@ def timedelta_total_microseconds(td: timedelta) -> int: def parse(string: str, detectUTC: bool = False) -> DateTimeOffset: - from dateutil import parser # Imported here to avoid top-level dependency if not used + from dateutil import parser # noqa: PLC0415 - lazy import to avoid top-level dependency parsed_dt = parser.parse(string) From e879b57519daee7ac2de91682ed1e612e07fbf22 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 2 Apr 2026 23:51:54 +0200 Subject: [PATCH 12/14] Address PR review feedback - Fix misleading comment in _to_utc_ms - Remove unused _ticks_per_millisecond - Fix precision loss in ticks() using integer arithmetic - Add docstring to from_date_time clarifying representations - Add missing __all__ entries in time_only.py Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fable_library/date_offset.py | 20 ++++++++++--------- .../fable_library/time_only.py | 5 +++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index 5bf8f4e248..9988270dec 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -171,14 +171,11 @@ def replace( def _to_utc_ms(d: DateTimeOffset) -> int: - """Return UTC instant in milliseconds since Unix epoch.""" - # The datetime stores local time; subtract the offset to get UTC - local_ms = int(datetime.timestamp(d) * 1000) - return local_ms + """Return UTC instant in milliseconds since Unix epoch. - -def _ticks_per_millisecond() -> int: - return 10000 + datetime.timestamp() already accounts for tzinfo offset. + """ + return int(datetime.timestamp(d) * 1000) # --------------------------------------------------------------------------- @@ -291,7 +288,11 @@ def from_ticks(ticks: SupportsInt, offset_ts: TimeSpan) -> DateTimeOffset: def from_date_time(date_only: datetime, time_only: TimeSpan, offset_ts: TimeSpan) -> DateTimeOffset: - """Construct DateTimeOffset from DateOnly, TimeOnly, and offset TimeSpan.""" + """Construct DateTimeOffset from DateOnly, TimeOnly, and offset TimeSpan. + + DateOnly is represented as a naive datetime (midnight). + TimeOnly is represented as a TimeSpan (ticks since midnight). + """ h = int(hours(time_only)) m = int(minutes(time_only)) s = int(seconds(time_only)) @@ -406,8 +407,9 @@ def ticks(d: DateTimeOffset) -> int64: independent of the offset. """ # Compute ticks from the local date/time fields + # Avoid float multiplication to prevent precision loss for large timestamps local_dt = datetime(d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond, tzinfo=UTC) - us = int(local_dt.timestamp() * 1_000_000) + us = int(local_dt.timestamp()) * 1_000_000 + local_dt.microsecond return date_mod.unix_epoch_microseconds_to_ticks(int64(us), int64(0)) diff --git a/src/fable-library-py/fable_library/time_only.py b/src/fable-library-py/fable_library/time_only.py index 19d5fcd133..8cc64d78de 100644 --- a/src/fable-library-py/fable_library/time_only.py +++ b/src/fable-library-py/fable_library/time_only.py @@ -209,11 +209,16 @@ def op_subtraction(left: TimeSpan, right: TimeSpan) -> TimeSpan: "from_date_time", "from_ticks", "from_time_span", + "hour", "is_between", "max_value", + "millisecond", "min_value", + "minute", "op_subtraction", "parse", + "second", + "ticks", "to_string", "try_parse", ] From 9dfdc1d98a631e134699bfe0b16d1cc670428190 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Fri, 3 Apr 2026 00:04:24 +0200 Subject: [PATCH 13/14] Add microsecond constructor support for DateTimeOffset Refactor create() to use isinstance checks for TimeSpan offset detection, supporting all constructor arities: 7-arg (with offset), 8-arg (with ms and offset), and 9-arg (with ms, mc, and offset). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fable_library/date_offset.py | 61 ++++++++++++------- tests/Python/TestDateTimeOffset.fs | 6 ++ 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/fable-library-py/fable_library/date_offset.py b/src/fable-library-py/fable_library/date_offset.py index 9988270dec..6081e5598b 100644 --- a/src/fable-library-py/fable_library/date_offset.py +++ b/src/fable-library-py/fable_library/date_offset.py @@ -219,6 +219,17 @@ def try_parse(string: str, def_value: FSharpRef[DateTimeOffset]) -> bool: return False +def _offset_to_ms_and_tz(offset_ts: TimeSpan) -> tuple[int, timezone]: + """Convert a TimeSpan offset to (offset_ms, timezone).""" + offset_ms = int(time_span.total_microseconds(offset_ts) / 1000) + return offset_ms, timezone(timedelta(microseconds=float(time_span.total_microseconds(offset_ts)))) + + +def _local_offset_ms(dt: datetime) -> int: + offset_td = dt.utcoffset() + return int(offset_td.total_seconds() * 1000) if offset_td else 0 + + def create( year: int, month: int, @@ -227,32 +238,36 @@ def create( m: int, s: int, ms: int | TimeSpan, + mc: int | TimeSpan | None = None, offset: TimeSpan | None = None, ) -> DateTimeOffset: - python_offset: timedelta | None = None - offset_ms = 0 - - if isinstance(ms, TimeSpan): - python_offset = timedelta(microseconds=float(time_span.total_microseconds(ms))) - offset_ms = int(time_span.total_microseconds(ms) / 1000) - ms = 0 - - if python_offset is None: - dt = datetime(year, month, day, h, m, s, ms * 1000) - if offset is not None: - offset_ms = int(time_span.total_microseconds(offset) / 1000) - python_offset = timedelta(microseconds=float(time_span.total_microseconds(offset))) - dt = dt.replace(tzinfo=timezone(python_offset)) - else: - # Default to local timezone + # Normalize: detect when ms or mc is actually a TimeSpan offset + # 7-arg: create(y, mo, d, h, mi, s, offset) + if isinstance(ms, TimeSpan) and mc is None and offset is None: + offset_ms, tz = _offset_to_ms_and_tz(ms) + dt = datetime(year, month, day, h, m, s, 0, tzinfo=tz) + return DateTimeOffset(dt, offset_ms) + + # 8-arg: create(y, mo, d, h, mi, s, ms, offset) + if isinstance(mc, TimeSpan) and offset is None: + offset_ms, tz = _offset_to_ms_and_tz(mc) + dt = datetime(year, month, day, h, m, s, int(ms) * 1000, tzinfo=tz) + return DateTimeOffset(dt, offset_ms) + + mc_val = int(mc) if mc is not None else 0 + + match offset: + # 9-arg: create(y, mo, d, h, mi, s, ms, mc, offset) + case TimeSpan() as offset_ts: + offset_ms, tz = _offset_to_ms_and_tz(offset_ts) + dt = datetime(year, month, day, h, m, s, int(ms) * 1000 + mc_val, tzinfo=tz) + return DateTimeOffset(dt, offset_ms) + + # No offset — default to local timezone + case _: + dt = datetime(year, month, day, h, m, s, int(ms) * 1000 + mc_val) dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo) - offset_td = dt.utcoffset() - offset_ms = int(offset_td.total_seconds() * 1000) if offset_td else 0 - else: - tzinfo = timezone(python_offset) - dt = datetime(year, month, day, h, m, s, ms, tzinfo=tzinfo) - - return DateTimeOffset(dt, offset_ms) + return DateTimeOffset(dt, _local_offset_ms(dt)) def from_date(dt: datetime, offset_ts: TimeSpan | None = None) -> DateTimeOffset: diff --git a/tests/Python/TestDateTimeOffset.fs b/tests/Python/TestDateTimeOffset.fs index 9fedb44e4d..223a8cceff 100644 --- a/tests/Python/TestDateTimeOffset.fs +++ b/tests/Python/TestDateTimeOffset.fs @@ -21,6 +21,12 @@ let ``test DateTimeOffset constructors work`` () = d3.Second + d5.Millisecond |> equal 530 +[] +let ``test DateTimeOffset constructor with microseconds works`` () = + let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, 500, 123, TimeSpan.Zero) + d.Millisecond |> equal 500 + d.Microsecond |> equal 123 + [] let ``test DateTimeOffset from Year 1 to 99 works`` () = let date = DateTimeOffset(1, 1, 2, 0, 0, 0, TimeSpan.Zero) From 9a6b71ef340af3aa41ea23c3720a67c3f8a2f7ca Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Fri, 3 Apr 2026 00:29:54 +0200 Subject: [PATCH 14/14] Fix time_only.create: use SupportsInt, match statement, avoid double int() Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fable_library/time_only.py | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/src/fable-library-py/fable_library/time_only.py b/src/fable-library-py/fable_library/time_only.py index 8cc64d78de..c0147bc8f0 100644 --- a/src/fable-library-py/fable_library/time_only.py +++ b/src/fable-library-py/fable_library/time_only.py @@ -7,7 +7,7 @@ from __future__ import annotations import re -from typing import Any, SupportsFloat +from typing import Any, SupportsFloat, SupportsInt from .core import FSharpRef, int32 from .time_span import ( @@ -30,19 +30,17 @@ _TICKS_PER_DAY = 864000000000 -def create(h: int = 0, m: int = 0, s: int = 0, ms: int = 0) -> TimeSpan: +def create(h: SupportsInt = 0, m: SupportsInt = 0, s: SupportsInt = 0, ms: SupportsInt = 0) -> TimeSpan: """Create a TimeOnly value from hours, minutes, seconds, milliseconds. Returns a TimeSpan (ticks since midnight). """ - if h < 0 or m < 0 or s < 0 or ms < 0: + _h, _m, _s, _ms = int(h), int(m), int(s), int(ms) + if _h < 0 or _m < 0 or _s < 0 or _ms < 0: raise Exception("The parameters describe an unrepresentable TimeOnly.") return TimeSpan( - int(h) * _TICKS_PER_HOUR - + int(m) * _TICKS_PER_MINUTE - + int(s) * _TICKS_PER_SECOND - + int(ms) * _TICKS_PER_MILLISECOND + _h * _TICKS_PER_HOUR + _m * _TICKS_PER_MINUTE + _s * _TICKS_PER_SECOND + _ms * _TICKS_PER_MILLISECOND ) @@ -167,22 +165,22 @@ def parse(string: str) -> TimeSpan: if match.group(6) is not None: frac = match.group(6) - length = len(frac) val = int(frac) - if length == 1: - ms = val * 100 - elif length == 2: - ms = val * 10 - elif length == 3: - ms = val - elif length == 4: - ms = val // 10 - elif length == 5: - ms = val // 100 - elif length == 6: - ms = val // 1000 - else: - ms = int(frac[:7]) // 10000 + match len(frac): + case 1: + ms = val * 100 + case 2: + ms = val * 10 + case 3: + ms = val + case 4: + ms = val // 10 + case 5: + ms = val // 100 + case 6: + ms = val // 1000 + case _: + ms = int(frac[:7]) // 10000 return create(h, m, s, ms)