From c2c769759157fc1be8859a3b93a925afe3e52dd1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 01:25:38 +0000 Subject: [PATCH 1/8] feat: add missing standard DateTime format specifiers for JS/TS and Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for these format specifiers in dateToStringWithKind and dateToStringWithOffset in both the TypeScript and Python runtime libraries: - R/r: RFC 1123 (e.g. "Mon, 01 Sep 2014 16:37:02 GMT") — always UTC - s: Sortable ISO 8601 (e.g. "2014-09-01T16:37:02") — no timezone - u: Universal sortable (e.g. "2014-09-01 16:37:02Z") — always UTC - F: Full date/time long (long date + long time) - f: Full date/time short (long date + short time) - G: General date/time long (short date + long time) - g: General date/time short (short date + short time) - M/m: Month/day (e.g. "September 1") - U: Universal full (full format in UTC) - Y/y: Year/month (e.g. "September 2014") Fixes #3976 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Fable.Cli/CHANGELOG.md | 4 + src/Fable.Compiler/CHANGELOG.md | 4 + src/fable-library-py/fable_library/date.py | 87 +++++++++++++++++++++- src/fable-library-ts/Date.ts | 77 ++++++++++++++++++- tests/Js/Main/DateTimeTests.fs | 56 +++++++++++++- tests/Python/TestDateTime.fs | 45 +++++++++++ 6 files changed, 267 insertions(+), 6 deletions(-) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 0c44a858c2..0069281e19 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +* [JS/TS/Python] Add missing standard DateTime format specifiers: `R`/`r` (RFC 1123), `s` (sortable), `u` (universal sortable), `F`, `f`, `G`, `g`, `M`/`m`, `U`, `Y`/`y` (fixes #3976) + ### Fixed * [Python] Fix derived classes of generic abstract classes not being instantiable due to mismatched mangled method names between abstract stubs and overrides (by @dbrattli) diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 9806792bb6..1b7ec8ec6d 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +* [JS/TS/Python] Add missing standard DateTime format specifiers: `R`/`r` (RFC 1123), `s` (sortable), `u` (universal sortable), `F`, `f`, `G`, `g`, `M`/`m`, `U`, `Y`/`y` (fixes #3976) + ### Fixed * [Python] Fix derived classes of generic abstract classes not being instantiable due to mismatched mangled method names between abstract stubs and overrides (by @dbrattli) diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index 319c11c934..9c6f8fde37 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -167,6 +167,39 @@ def to_long_time_string(date: datetime) -> str: return datetime.strftime(date, "%H:%M:%S") +def to_rfc1123_string(date: datetime) -> str: + """RFC 1123: "Thu, 01 Jan 2009 00:00:00 GMT" — always UTC""" + utc_date = date.astimezone(UTC) + return ( + short_days[day_of_week(utc_date)] + + ", " + + utc_date.strftime("%d ") + + short_months[utc_date.month - 1] + + utc_date.strftime(" %Y %H:%M:%S GMT") + ) + + +def to_sortable_string(date: datetime) -> str: + """Sortable ISO 8601, no timezone: "2009-06-15T13:45:30" """ + return date.strftime("%Y-%m-%dT%H:%M:%S") + + +def to_universal_sortable_string(date: datetime) -> str: + """Universal sortable: "2009-06-15 13:45:30Z" — always UTC""" + utc_date = date.astimezone(UTC) + return utc_date.strftime("%Y-%m-%d %H:%M:%SZ") + + +def to_month_day_string(date: datetime) -> str: + """Month/day: "June 15" """ + return long_months[date.month - 1] + " " + str(date.day) + + +def to_year_month_string(date: datetime) -> str: + """Year/month: "June 2009" """ + return long_months[date.month - 1] + " " + str(date.year) + + def parse_repeat_token(format: str, pos: int, pattern_char: str) -> int: token_length = 0 internal_pos = pos @@ -426,6 +459,35 @@ def date_to_string_with_offset(date: datetime, format: str | None = None) -> str return date.strftime("%Y-%m-%dT%H:%M:%S.%f%z") case "O" | "o": return date.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + case "D": + return to_long_date_string(date) + case "d": + return to_short_date_string(date) + case "F": + return to_long_date_string(date) + " " + to_long_time_string(date) + case "f": + return to_long_date_string(date) + " " + to_short_time_string(date) + case "G": + return to_short_date_string(date) + " " + to_long_time_string(date) + case "g": + return to_short_date_string(date) + " " + to_short_time_string(date) + case "M" | "m": + return to_month_day_string(date) + case "R" | "r": + return to_rfc1123_string(date) + case "s": + return to_sortable_string(date) + case "T": + return to_long_time_string(date) + case "t": + return to_short_time_string(date) + case "u": + return to_universal_sortable_string(date) + case "U": + utc_date = date.astimezone(UTC) + return to_long_date_string(utc_date) + " " + to_long_time_string(utc_date) + case "Y" | "y": + return to_year_month_string(date) case _ if len(format) == 1: raise Exception("Unrecognized Date print format") case _: @@ -442,12 +504,33 @@ def date_to_string_with_kind(date: datetime, format: str | None = None) -> str: return to_short_date_string(date) elif format == "D": return to_long_date_string(date) + elif format == "F": + return to_long_date_string(date) + " " + to_long_time_string(date) + elif format == "f": + return to_long_date_string(date) + " " + to_short_time_string(date) + elif format == "G": + return to_short_date_string(date) + " " + to_long_time_string(date) + elif format == "g": + return to_short_date_string(date) + " " + to_short_time_string(date) + elif format == "M" or format == "m": + return to_month_day_string(date) + elif format == "O" or format == "o": + return date.astimezone().isoformat(timespec="milliseconds") + elif format == "R" or format == "r": + return to_rfc1123_string(date) + elif format == "s": + return to_sortable_string(date) elif format == "T": return to_long_time_string(date) elif format == "t": return to_short_time_string(date) - elif format == "O" or format == "o": - return date.astimezone().isoformat(timespec="milliseconds") + elif format == "u": + return to_universal_sortable_string(date) + elif format == "U": + utc_date = date.astimezone(UTC) + return to_long_date_string(utc_date) + " " + to_long_time_string(utc_date) + elif format == "Y" or format == "y": + return to_year_month_string(date) else: raise Exception("Unrecognized Date print format") diff --git a/src/fable-library-ts/Date.ts b/src/fable-library-ts/Date.ts index 2970ba14fe..67700eaf91 100644 --- a/src/fable-library-ts/Date.ts +++ b/src/fable-library-ts/Date.ts @@ -410,9 +410,28 @@ function dateToStringWithOffset(date: IDateTimeOffset, format?: string) { switch (format) { case "D": return dateToString_D(d); case "d": return dateToString_d(d); + case "F": return dateToString_D(d) + " " + dateToString_T(d); + case "f": return dateToString_D(d) + " " + dateToString_t(d); + case "G": return dateToString_d(d) + " " + dateToString_T(d); + case "g": return dateToString_d(d) + " " + dateToString_t(d); + case "M": case "m": return dateToString_M(d); + case "O": case "o": return dateToISOStringWithOffset(d, (date.offset ?? 0)); + case "R": case "r": { + const utcDate = DateTime(date.getTime(), DateTimeKind.Utc); + return dateToString_R(utcDate); + } + case "s": return dateToString_s(d); case "T": return dateToString_T(toUniversalTime(d)); case "t": return dateToString_t(toUniversalTime(d)); - case "O": case "o": return dateToISOStringWithOffset(d, (date.offset ?? 0)); + case "u": { + const utcDate = DateTime(date.getTime(), DateTimeKind.Utc); + return dateToString_u(utcDate); + } + case "U": { + const utcDate = DateTime(date.getTime(), DateTimeKind.Utc); + return dateToString_D(utcDate) + " " + dateToString_T(utcDate); + } + case "Y": case "y": return dateToString_Y(d); default: throw new Exception("Unrecognized Date print format"); } } else { @@ -444,6 +463,49 @@ function dateToString_t(date: IDateTime) { + ":" + padWithZeros(minute(date), 2); } +// RFC 1123: "Thu, 01 Jan 2009 00:00:00 GMT" — always UTC +function dateToString_R(date: IDateTime) { + const utcDate = toUniversalTime(date); + return shortDays[dayOfWeek(utcDate)] + ", " + + padWithZeros(day(utcDate), 2) + " " + + shortMonths[month(utcDate) - 1] + " " + + year(utcDate) + " " + + padWithZeros(hour(utcDate), 2) + ":" + + padWithZeros(minute(utcDate), 2) + ":" + + padWithZeros(second(utcDate), 2) + " GMT"; +} + +// Sortable ISO 8601, no timezone: "2009-06-15T13:45:30" +function dateToString_s(date: IDateTime) { + return padWithZeros(year(date), 4) + "-" + + padWithZeros(month(date), 2) + "-" + + padWithZeros(day(date), 2) + "T" + + padWithZeros(hour(date), 2) + ":" + + padWithZeros(minute(date), 2) + ":" + + padWithZeros(second(date), 2); +} + +// Universal sortable: "2009-06-15 13:45:30Z" — always UTC +function dateToString_u(date: IDateTime) { + const utcDate = toUniversalTime(date); + return padWithZeros(year(utcDate), 4) + "-" + + padWithZeros(month(utcDate), 2) + "-" + + padWithZeros(day(utcDate), 2) + " " + + padWithZeros(hour(utcDate), 2) + ":" + + padWithZeros(minute(utcDate), 2) + ":" + + padWithZeros(second(utcDate), 2) + "Z"; +} + +// Month/day: "June 15" +function dateToString_M(date: IDateTime) { + return longMonths[month(date) - 1] + " " + day(date); +} + +// Year/month: "June 2009" +function dateToString_Y(date: IDateTime) { + return longMonths[month(date) - 1] + " " + year(date); +} + function dateToStringWithKind(date: IDateTime, format?: string) { const utc = date.kind === DateTimeKind.Utc; if (typeof format !== "string") { @@ -452,10 +514,19 @@ function dateToStringWithKind(date: IDateTime, format?: string) { switch (format) { case "D": return dateToString_D(date); case "d": return dateToString_d(date); + case "F": return dateToString_D(date) + " " + dateToString_T(date); + case "f": return dateToString_D(date) + " " + dateToString_t(date); + case "G": return dateToString_d(date) + " " + dateToString_T(date); + case "g": return dateToString_d(date) + " " + dateToString_t(date); + case "M": case "m": return dateToString_M(date); + case "O": case "o": return dateToISOString(date, utc); + case "R": case "r": return dateToString_R(date); + case "s": return dateToString_s(date); case "T": return dateToString_T(date); case "t": return dateToString_t(date); - case "O": case "o": - return dateToISOString(date, utc); + case "u": return dateToString_u(date); + case "U": return dateToString_D(toUniversalTime(date)) + " " + dateToString_T(toUniversalTime(date)); + case "Y": case "y": return dateToString_Y(date); default: throw new Exception("Unrecognized Date print format"); } diff --git a/tests/Js/Main/DateTimeTests.fs b/tests/Js/Main/DateTimeTests.fs index 3dd7a513a1..43dcca0bf9 100644 --- a/tests/Js/Main/DateTimeTests.fs +++ b/tests/Js/Main/DateTimeTests.fs @@ -503,7 +503,61 @@ let tests = // DateTime(2014, 9, 11, 16, 37, 2, DateTimeKind.Local).ToString("O") // |> equal "2014-09-11T16:37:02.000+02:00" // Here the time zone is Europe/Paris (GMT+2) - testCase "DateTime from Year 1 to 99 works" <| fun () -> + testCase "DateTime.ToString('R') works" <| fun _ -> + let format (d: DateTime) = d.ToString("R", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "Mon, 01 Sep 2014 16:37:02 GMT" + + testCase "DateTime.ToString('s') works" <| fun _ -> + let format (d: DateTime) = d.ToString("s", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "2014-09-01T16:37:02" + + testCase "DateTime.ToString('u') works" <| fun _ -> + let format (d: DateTime) = d.ToString("u", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "2014-09-01 16:37:02Z" + + testCase "DateTime.ToString('F') works" <| fun _ -> + let format (d: DateTime) = d.ToString("F", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "Monday, 01 September 2014 16:37:02" + + testCase "DateTime.ToString('f') works" <| fun _ -> + let format (d: DateTime) = d.ToString("f", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "Monday, 01 September 2014 16:37" + + testCase "DateTime.ToString('G') works" <| fun _ -> + let format (d: DateTime) = d.ToString("G", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "09/01/2014 16:37:02" + + testCase "DateTime.ToString('g') works" <| fun _ -> + let format (d: DateTime) = d.ToString("g", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "09/01/2014 16:37" + + testCase "DateTime.ToString('M') works" <| fun _ -> + let format (d: DateTime) = d.ToString("M", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "September 1" + + testCase "DateTime.ToString('Y') works" <| fun _ -> + let format (d: DateTime) = d.ToString("Y", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "September 2014" + + let date = DateTime(1, 1, 2) date.Year |> equal 1 let date = DateTime(99, 1, 2) diff --git a/tests/Python/TestDateTime.fs b/tests/Python/TestDateTime.fs index e250ec145f..d81c6bb21f 100644 --- a/tests/Python/TestDateTime.fs +++ b/tests/Python/TestDateTime.fs @@ -556,6 +556,51 @@ let ``test DateTime.ToString with Round-trip format works for Utc`` () = str.Replace("0000000Z", "000000Z") |> equal "2014-09-11T16:37:02.000000Z" +[] +let ``test DateTime.ToString("R") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("R", CultureInfo.InvariantCulture) + |> equal "Mon, 01 Sep 2014 16:37:02 GMT" + +[] +let ``test DateTime.ToString("s") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("s", CultureInfo.InvariantCulture) + |> equal "2014-09-01T16:37:02" + +[] +let ``test DateTime.ToString("u") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("u", CultureInfo.InvariantCulture) + |> equal "2014-09-01 16:37:02Z" + +[] +let ``test DateTime.ToString("F") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("F", CultureInfo.InvariantCulture) + |> equal "Monday, 01 September 2014 16:37:02" + +[] +let ``test DateTime.ToString("f") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("f", CultureInfo.InvariantCulture) + |> equal "Monday, 01 September 2014 16:37" + +[] +let ``test DateTime.ToString("G") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("G", CultureInfo.InvariantCulture) + |> equal "09/01/2014 16:37:02" + +[] +let ``test DateTime.ToString("g") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("g", CultureInfo.InvariantCulture) + |> equal "09/01/2014 16:37" + +[] +let ``test DateTime.ToString("M") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("M", CultureInfo.InvariantCulture) + |> equal "September 1" + +[] +let ``test DateTime.ToString("Y") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("Y", CultureInfo.InvariantCulture) + |> equal "September 2014" + [] let ``test DateTime from Year 1 to 99 works`` () = let date = DateTime(1, 1, 2) From ee5f4c9ec13599523c5e049f68cd96cff701b3a5 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 21 Apr 2026 00:24:32 +0200 Subject: [PATCH 2/8] fix: restore DateTime Year 1-99 testCase header lost during merge Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Js/Main/DateTimeTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Js/Main/DateTimeTests.fs b/tests/Js/Main/DateTimeTests.fs index 43dcca0bf9..4b2bf0c3a7 100644 --- a/tests/Js/Main/DateTimeTests.fs +++ b/tests/Js/Main/DateTimeTests.fs @@ -557,7 +557,7 @@ let tests = |> format |> equal "September 2014" - + testCase "DateTime from Year 1 to 99 works" <| fun () -> let date = DateTime(1, 1, 2) date.Year |> equal 1 let date = DateTime(99, 1, 2) From 7d525ca97ddd797611acafa76a47d85beaf40001 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 21 Apr 2026 00:28:19 +0200 Subject: [PATCH 3/8] test: cover DateTimeOffset R, u, s format specifiers Adds JS and Python tests for the R (RFC 1123, UTC), u (universal sortable, UTC), and s (sortable, offset-local) format specifiers applied to DateTimeOffset, exercising the UTC conversion paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Js/Main/DateTimeOffsetTests.fs | 15 +++++++++++++++ tests/Python/TestDateTimeOffset.fs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/tests/Js/Main/DateTimeOffsetTests.fs b/tests/Js/Main/DateTimeOffsetTests.fs index 7a00bdc98a..89c31edb1d 100644 --- a/tests/Js/Main/DateTimeOffsetTests.fs +++ b/tests/Js/Main/DateTimeOffsetTests.fs @@ -97,6 +97,21 @@ let tests = |> format |> equal "05:07" + testCase "DateTimeOffset.ToString('R') works" <| fun _ -> + // R always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("R", CultureInfo.InvariantCulture) + |> equal "Mon, 01 Sep 2014 14:37:02 GMT" + + testCase "DateTimeOffset.ToString('u') works" <| fun _ -> + // u always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("u", CultureInfo.InvariantCulture) + |> equal "2014-09-01 14:37:02Z" + + testCase "DateTimeOffset.ToString('s') works" <| fun _ -> + // s uses the offset-local time with no timezone designator + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.Zero).ToString("s", CultureInfo.InvariantCulture) + |> equal "2014-09-01T16:37:02" + testCase "DateTimeOffset from Year 1 to 99 works" <| fun () -> let date = DateTimeOffset(1, 1, 2, 0, 0, 0, TimeSpan.Zero) date.Year |> equal 1 diff --git a/tests/Python/TestDateTimeOffset.fs b/tests/Python/TestDateTimeOffset.fs index 4151101ae4..afb958b848 100644 --- a/tests/Python/TestDateTimeOffset.fs +++ b/tests/Python/TestDateTimeOffset.fs @@ -489,6 +489,24 @@ 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.ToString("R") works`` () = + // R always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("R", CultureInfo.InvariantCulture) + |> equal "Mon, 01 Sep 2014 14:37:02 GMT" + +[] +let ``test DateTimeOffset.ToString("u") works`` () = + // u always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("u", CultureInfo.InvariantCulture) + |> equal "2014-09-01 14:37:02Z" + +[] +let ``test DateTimeOffset.ToString("s") works`` () = + // s uses the offset-local time with no timezone designator + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.Zero).ToString("s", CultureInfo.InvariantCulture) + |> equal "2014-09-01T16:37:02" + [] let ``test DateTimeOffset.LocalDateTime works`` () = let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero) From 6083248f9a9bd7d6b0420827c073278d107c9e14 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 21 Apr 2026 00:33:10 +0200 Subject: [PATCH 4/8] test: rename F/f and G/g Python tests to avoid name collision Fable's Python mangler is case-insensitive for identifiers, so test names differing only by letter case collided when mangled. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Python/TestDateTime.fs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Python/TestDateTime.fs b/tests/Python/TestDateTime.fs index d81c6bb21f..b46da8f3bb 100644 --- a/tests/Python/TestDateTime.fs +++ b/tests/Python/TestDateTime.fs @@ -572,22 +572,22 @@ let ``test DateTime.ToString("u") works`` () = |> equal "2014-09-01 16:37:02Z" [] -let ``test DateTime.ToString("F") works`` () = +let ``test DateTime.ToString full date long time format works`` () = DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("F", CultureInfo.InvariantCulture) |> equal "Monday, 01 September 2014 16:37:02" [] -let ``test DateTime.ToString("f") works`` () = +let ``test DateTime.ToString full date short time format works`` () = DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("f", CultureInfo.InvariantCulture) |> equal "Monday, 01 September 2014 16:37" [] -let ``test DateTime.ToString("G") works`` () = +let ``test DateTime.ToString general long time format works`` () = DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("G", CultureInfo.InvariantCulture) |> equal "09/01/2014 16:37:02" [] -let ``test DateTime.ToString("g") works`` () = +let ``test DateTime.ToString general short time format works`` () = DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("g", CultureInfo.InvariantCulture) |> equal "09/01/2014 16:37" From 5fdca3cf259804a6d2cb7f734b55cbd4fc8cb515 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 21 Apr 2026 00:34:10 +0200 Subject: [PATCH 5/8] fix: correct M and Y format output to match .NET InvariantCulture .NET's InvariantCulture uses "MMMM dd" for M (padded day, e.g. "September 01") and "yyyy MMMM" for Y (year first, e.g. "2014 September"), not the en-US variants the PR assumed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fable-library-py/fable_library/date.py | 8 ++++---- src/fable-library-ts/Date.ts | 8 ++++---- tests/Js/Main/DateTimeTests.fs | 4 ++-- tests/Python/TestDateTime.fs | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index 9c6f8fde37..e650ac054a 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -191,13 +191,13 @@ def to_universal_sortable_string(date: datetime) -> str: def to_month_day_string(date: datetime) -> str: - """Month/day: "June 15" """ - return long_months[date.month - 1] + " " + str(date.day) + """Month/day (InvariantCulture "MMMM dd"): "June 15" """ + return long_months[date.month - 1] + " " + f"{date.day:02d}" def to_year_month_string(date: datetime) -> str: - """Year/month: "June 2009" """ - return long_months[date.month - 1] + " " + str(date.year) + """Year/month (InvariantCulture "yyyy MMMM"): "2009 June" """ + return str(date.year) + " " + long_months[date.month - 1] def parse_repeat_token(format: str, pos: int, pattern_char: str) -> int: diff --git a/src/fable-library-ts/Date.ts b/src/fable-library-ts/Date.ts index 67700eaf91..7dc81bece9 100644 --- a/src/fable-library-ts/Date.ts +++ b/src/fable-library-ts/Date.ts @@ -496,14 +496,14 @@ function dateToString_u(date: IDateTime) { + padWithZeros(second(utcDate), 2) + "Z"; } -// Month/day: "June 15" +// Month/day (InvariantCulture "MMMM dd"): "June 15" function dateToString_M(date: IDateTime) { - return longMonths[month(date) - 1] + " " + day(date); + return longMonths[month(date) - 1] + " " + padWithZeros(day(date), 2); } -// Year/month: "June 2009" +// Year/month (InvariantCulture "yyyy MMMM"): "2009 June" function dateToString_Y(date: IDateTime) { - return longMonths[month(date) - 1] + " " + year(date); + return year(date) + " " + longMonths[month(date) - 1]; } function dateToStringWithKind(date: IDateTime, format?: string) { diff --git a/tests/Js/Main/DateTimeTests.fs b/tests/Js/Main/DateTimeTests.fs index 4b2bf0c3a7..253830984b 100644 --- a/tests/Js/Main/DateTimeTests.fs +++ b/tests/Js/Main/DateTimeTests.fs @@ -549,13 +549,13 @@ let tests = let format (d: DateTime) = d.ToString("M", CultureInfo.InvariantCulture) DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) |> format - |> equal "September 1" + |> equal "September 01" testCase "DateTime.ToString('Y') works" <| fun _ -> let format (d: DateTime) = d.ToString("Y", CultureInfo.InvariantCulture) DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) |> format - |> equal "September 2014" + |> equal "2014 September" testCase "DateTime from Year 1 to 99 works" <| fun () -> let date = DateTime(1, 1, 2) diff --git a/tests/Python/TestDateTime.fs b/tests/Python/TestDateTime.fs index b46da8f3bb..19212924a9 100644 --- a/tests/Python/TestDateTime.fs +++ b/tests/Python/TestDateTime.fs @@ -594,12 +594,12 @@ let ``test DateTime.ToString general short time format works`` () = [] let ``test DateTime.ToString("M") works`` () = DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("M", CultureInfo.InvariantCulture) - |> equal "September 1" + |> equal "September 01" [] let ``test DateTime.ToString("Y") works`` () = DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("Y", CultureInfo.InvariantCulture) - |> equal "September 2014" + |> equal "2014 September" [] let ``test DateTime from Year 1 to 99 works`` () = From 2335eafe30a3d3ed46fcd016b993971f94db2328 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 21 Apr 2026 00:45:30 +0200 Subject: [PATCH 6/8] fix: [Python] strip DateTimeOffset wrapper before astimezone DateTimeOffset is a datetime subclass with a custom __new__ signature; calling astimezone() on it triggers Python's internal reconstruction via type(self).__new__(...) which fails. Rebuild a plain datetime from the fields before converting to UTC. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fable-library-py/fable_library/date.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index e650ac054a..6b0266e952 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -167,9 +167,18 @@ def to_long_time_string(date: datetime) -> str: return datetime.strftime(date, "%H:%M:%S") +def _to_plain_utc(date: datetime) -> datetime: + # Strip any datetime subclass (e.g. DateTimeOffset) so astimezone's internal + # reconstruction doesn't hit an incompatible __new__ signature. + plain = datetime( + date.year, date.month, date.day, date.hour, date.minute, date.second, date.microsecond, tzinfo=date.tzinfo + ) + return plain.astimezone(UTC) + + def to_rfc1123_string(date: datetime) -> str: """RFC 1123: "Thu, 01 Jan 2009 00:00:00 GMT" — always UTC""" - utc_date = date.astimezone(UTC) + utc_date = _to_plain_utc(date) return ( short_days[day_of_week(utc_date)] + ", " @@ -186,7 +195,7 @@ def to_sortable_string(date: datetime) -> str: def to_universal_sortable_string(date: datetime) -> str: """Universal sortable: "2009-06-15 13:45:30Z" — always UTC""" - utc_date = date.astimezone(UTC) + utc_date = _to_plain_utc(date) return utc_date.strftime("%Y-%m-%d %H:%M:%SZ") @@ -484,7 +493,7 @@ def date_to_string_with_offset(date: datetime, format: str | None = None) -> str case "u": return to_universal_sortable_string(date) case "U": - utc_date = date.astimezone(UTC) + utc_date = _to_plain_utc(date) return to_long_date_string(utc_date) + " " + to_long_time_string(utc_date) case "Y" | "y": return to_year_month_string(date) @@ -527,7 +536,7 @@ def date_to_string_with_kind(date: datetime, format: str | None = None) -> str: elif format == "u": return to_universal_sortable_string(date) elif format == "U": - utc_date = date.astimezone(UTC) + utc_date = _to_plain_utc(date) return to_long_date_string(utc_date) + " " + to_long_time_string(utc_date) elif format == "Y" or format == "y": return to_year_month_string(date) From f164afd2b9716e516dd4fb4686156302871e1d81 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Mon, 27 Apr 2026 20:53:19 +0200 Subject: [PATCH 7/8] refactor: [Python] rewrite date_to_string_with_kind as match statement Mirrors the structure of date_to_string_with_offset above it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fable-library-py/fable_library/date.py | 43 +++++++++++----------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index 6b0266e952..8f05a96c9d 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -506,45 +506,44 @@ def date_to_string_with_offset(date: datetime, format: str | None = None) -> str def date_to_string_with_kind(date: datetime, format: str | None = None) -> str: utc = date.tzinfo == UTC - if not format: - return date.isoformat() if utc else str(date) - elif len(format) == 1: - if format == "d": + match format: + case None | "": + return date.isoformat() if utc else str(date) + case "d": return to_short_date_string(date) - elif format == "D": + case "D": return to_long_date_string(date) - elif format == "F": + case "F": return to_long_date_string(date) + " " + to_long_time_string(date) - elif format == "f": + case "f": return to_long_date_string(date) + " " + to_short_time_string(date) - elif format == "G": + case "G": return to_short_date_string(date) + " " + to_long_time_string(date) - elif format == "g": + case "g": return to_short_date_string(date) + " " + to_short_time_string(date) - elif format == "M" or format == "m": + case "M" | "m": return to_month_day_string(date) - elif format == "O" or format == "o": + case "O" | "o": return date.astimezone().isoformat(timespec="milliseconds") - elif format == "R" or format == "r": + case "R" | "r": return to_rfc1123_string(date) - elif format == "s": + case "s": return to_sortable_string(date) - elif format == "T": + case "T": return to_long_time_string(date) - elif format == "t": + case "t": return to_short_time_string(date) - elif format == "u": + case "u": return to_universal_sortable_string(date) - elif format == "U": + case "U": utc_date = _to_plain_utc(date) return to_long_date_string(utc_date) + " " + to_long_time_string(utc_date) - elif format == "Y" or format == "y": + case "Y" | "y": return to_year_month_string(date) - else: + case _ if len(format) == 1: raise Exception("Unrecognized Date print format") - - else: - return date_to_string_with_custom_format(date, format, utc) + case _: + return date_to_string_with_custom_format(date, format, utc) def to_string(date: datetime, format: str | None = None, provider: Any | None = None) -> str: From e9f80ea2f6b9934361eb42c8d0e36e00c5c33ead Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Mon, 27 Apr 2026 20:53:24 +0200 Subject: [PATCH 8/8] revert: drop premature DateTime format specifiers changelog entry The Added entry was committed by an automated bot before the underlying work landed on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Fable.Cli/CHANGELOG.md | 4 ---- src/Fable.Compiler/CHANGELOG.md | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 0069281e19..0c44a858c2 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -### Added - -* [JS/TS/Python] Add missing standard DateTime format specifiers: `R`/`r` (RFC 1123), `s` (sortable), `u` (universal sortable), `F`, `f`, `G`, `g`, `M`/`m`, `U`, `Y`/`y` (fixes #3976) - ### Fixed * [Python] Fix derived classes of generic abstract classes not being instantiable due to mismatched mangled method names between abstract stubs and overrides (by @dbrattli) diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 1b7ec8ec6d..9806792bb6 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -### Added - -* [JS/TS/Python] Add missing standard DateTime format specifiers: `R`/`r` (RFC 1123), `s` (sortable), `u` (universal sortable), `F`, `f`, `G`, `g`, `M`/`m`, `U`, `Y`/`y` (fixes #3976) - ### Fixed * [Python] Fix derived classes of generic abstract classes not being instantiable due to mismatched mangled method names between abstract stubs and overrides (by @dbrattli)