Skip to content

Commit b3ec8b6

Browse files
dbrattliclaude
andauthored
fix(python): derive DateTime "O" format from the value, not the host timezone (#4869)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bb3c86d commit b3ec8b6

4 files changed

Lines changed: 124 additions & 15 deletions

File tree

src/fable-library-py/fable_library/date.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,52 @@ def date_to_string_with_custom_format(date: datetime, format: str, utc: bool) ->
456456
return result
457457

458458

459+
def _is_date_time_offset(date: datetime) -> bool:
460+
# DateTimeOffset sets this marker on itself. It is checked by attribute
461+
# rather than isinstance because date_offset imports this module, so
462+
# importing it back here would be a cycle.
463+
return getattr(date, "is_offset_value", False) is True
464+
465+
466+
def _offset_designator(offset: timedelta) -> str:
467+
"""Format a UTC offset the way .NET's "zzz" does: "+02:00", "-05:30"."""
468+
total_minutes = round(offset.total_seconds() / 60)
469+
sign = "-" if total_minutes < 0 else "+"
470+
total_minutes = abs(total_minutes)
471+
return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
472+
473+
474+
def _roundtrip_zone_designator(date: datetime) -> str:
475+
"""Zone designator of the round-trip format, derived from the value alone.
476+
477+
Kind = Unspecified has none, Kind = Utc is "Z", and Kind = Local carries its
478+
own offset. A DateTimeOffset always prints a numeric offset, even "+00:00".
479+
"""
480+
offset = date.utcoffset()
481+
if offset is None:
482+
return ""
483+
484+
if date.tzinfo == UTC and not _is_date_time_offset(date):
485+
return "Z"
486+
487+
return _offset_designator(offset)
488+
489+
490+
def _to_roundtrip_string(date: datetime) -> str:
491+
"""Round-trip ("O"/"o") format: "yyyy-MM-ddTHH:mm:ss.fffffff" plus a zone
492+
designator.
493+
494+
The result depends only on the value, never on the host timezone. Python's
495+
datetime only carries microseconds, so the seventh fractional digit is
496+
always 0 rather than truncating the fraction to six digits.
497+
"""
498+
return (
499+
f"{date.year:04d}-{date.month:02d}-{date.day:02d}"
500+
f"T{date.hour:02d}:{date.minute:02d}:{date.second:02d}"
501+
f".{date.microsecond:06d}0{_roundtrip_zone_designator(date)}"
502+
)
503+
504+
459505
def date_to_string_with_offset(date: datetime, format: str | None = None) -> str:
460506
utc = date.tzinfo == UTC
461507

@@ -467,7 +513,7 @@ def date_to_string_with_offset(date: datetime, format: str | None = None) -> str
467513
else:
468514
return date.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
469515
case "O" | "o":
470-
return date.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
516+
return _to_roundtrip_string(date)
471517
case "D":
472518
return to_long_date_string(date)
473519
case "d":
@@ -524,7 +570,7 @@ def date_to_string_with_kind(date: datetime, format: str | None = None) -> str:
524570
case "M" | "m":
525571
return to_month_day_string(date)
526572
case "O" | "o":
527-
return date.astimezone().isoformat(timespec="milliseconds")
573+
return _to_roundtrip_string(date)
528574
case "R" | "r":
529575
return to_rfc1123_string(date)
530576
case "s":

src/fable-library-py/fable_library/date_offset.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
class DateTimeOffset(datetime):
1515
"""A datetime subclass with an explicit offset, similar to .NET DateTimeOffset"""
1616

17+
# Lets the formatters in `date` tell a DateTimeOffset apart from a DateTime
18+
# with Kind = Utc — a zero offset gives both the same tzinfo, but only the
19+
# latter renders as "Z" in the round-trip format.
20+
is_offset_value: bool = True
21+
1722
def __new__(cls, dt: datetime, offset_milliseconds: int = 0):
1823
# Create new datetime instance using the values from the input datetime
1924
instance = super().__new__(

tests/Python/TestDateTime.fs

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -542,19 +542,57 @@ let ``test DateTime.ToString("t") (lower) works`` () =
542542

543543
[<Fact>]
544544
let ``test DateTime.ToString with Round-trip format works for Utc`` () =
545-
let str = DateTime(2014, 9, 11, 16, 37, 2, DateTimeKind.Utc).ToString("O")
546-
// FIXME: missing regex module
547-
// System.Text.RegularExpressions.Regex.Replace(str, "0{3,}", "000")
548-
// Hardcode the replace string so we can test that "O" format is supported
549-
str.Replace("0000000Z", "000000Z")
550-
|> equal "2014-09-11T16:37:02.000000Z"
551-
552-
let str = DateTime(2014, 9, 11, 16, 37, 2, DateTimeKind.Utc).ToString("o")
553-
// FIXME: missing regex module
554-
// System.Text.RegularExpressions.Regex.Replace(str, "0{3,}", "000")
555-
// Hardcode the replace string so we can test that "O" format is supported
556-
str.Replace("0000000Z", "000000Z")
557-
|> equal "2014-09-11T16:37:02.000000Z"
545+
DateTime(2014, 9, 11, 16, 37, 2, DateTimeKind.Utc).ToString("O")
546+
|> equal "2014-09-11T16:37:02.0000000Z"
547+
548+
DateTime(2014, 9, 11, 16, 37, 2, DateTimeKind.Utc).ToString("o")
549+
|> equal "2014-09-11T16:37:02.0000000Z"
550+
551+
[<Fact>]
552+
let ``test DateTime.ToString with Round-trip format works for Unspecified`` () =
553+
// The round-trip format must depend only on the value: an Unspecified
554+
// DateTime carries no offset, so none is printed whatever the host timezone.
555+
DateTime(2014, 9, 11, 16, 37, 2).ToString("O")
556+
|> equal "2014-09-11T16:37:02.0000000"
557+
558+
DateTime(2014, 9, 11, 16, 37, 2, 345).ToString("o")
559+
|> equal "2014-09-11T16:37:02.3450000"
560+
561+
[<Fact>]
562+
let ``test DateTime.ToString with Round-trip format works for Local`` () =
563+
// A Local DateTime prints its own offset, which follows the host timezone,
564+
// so only the machine-independent part can be asserted literally.
565+
let str = DateTime(2014, 9, 11, 16, 37, 2, DateTimeKind.Local).ToString("O")
566+
str.Substring(0, 27) |> equal "2014-09-11T16:37:02.0000000"
567+
str.Length |> equal 33
568+
let sign = str.[27]
569+
(sign = '+' || sign = '-') |> equal true
570+
str.[30] |> equal ':'
571+
572+
[<Fact>]
573+
let ``test DateTime.ToString with Round-trip format pads the fraction to 7 digits`` () =
574+
// Sub-millisecond digits are padded on the right, not truncated.
575+
DateTime(2014, 9, 11, 16, 37, 2, 345, DateTimeKind.Utc).ToString("O")
576+
|> equal "2014-09-11T16:37:02.3450000Z"
577+
578+
DateTime(2014, 9, 11, 16, 37, 2, 345, 678, DateTimeKind.Utc).ToString("O")
579+
|> equal "2014-09-11T16:37:02.3456780Z"
580+
581+
[<Fact>]
582+
let ``test DateTime.ToString with Round-trip format round-trips the instant`` () =
583+
let roundtrips (d: DateTime) =
584+
let parsed = DateTime.Parse(d.ToString("O"), CultureInfo.InvariantCulture)
585+
parsed.ToUniversalTime() |> equal (d.ToUniversalTime())
586+
587+
roundtrips (DateTime(2014, 9, 11, 16, 37, 2, 345))
588+
roundtrips (DateTime(2014, 9, 11, 16, 37, 2, 345, DateTimeKind.Utc))
589+
roundtrips (DateTime(2014, 9, 11, 16, 37, 2, 345, DateTimeKind.Local))
590+
591+
// An Unspecified value has no offset to interpret, so its Kind survives too
592+
let unspecified = DateTime(2014, 9, 11, 16, 37, 2, 345)
593+
let parsed = DateTime.Parse(unspecified.ToString("O"), CultureInfo.InvariantCulture)
594+
parsed.Kind |> equal DateTimeKind.Unspecified
595+
parsed |> equal unspecified
558596

559597
[<Fact>]
560598
let ``test DateTime.ToString("R") works`` () =

tests/Python/TestDateTimeOffset.fs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,26 @@ let ``test DateTimeOffset.ToString with custom format works`` () =
489489
DateTimeOffset(2014, 9, 11, 16, 37, 0, TimeSpan.Zero).ToString("HH:mm", CultureInfo.InvariantCulture)
490490
|> equal "16:37"
491491

492+
[<Fact>]
493+
let ``test DateTimeOffset.ToString with Round-trip format works`` () =
494+
// A DateTimeOffset always prints its own numeric offset — even "+00:00",
495+
// where a DateTime with Kind = Utc would print "Z" instead.
496+
DateTimeOffset(2014, 9, 11, 16, 37, 2, TimeSpan.FromHours 2).ToString("O", CultureInfo.InvariantCulture)
497+
|> equal "2014-09-11T16:37:02.0000000+02:00"
498+
499+
DateTimeOffset(2014, 9, 11, 16, 37, 2, TimeSpan.Zero).ToString("o", CultureInfo.InvariantCulture)
500+
|> equal "2014-09-11T16:37:02.0000000+00:00"
501+
502+
DateTimeOffset(2014, 9, 11, 16, 37, 2, 345, TimeSpan.FromMinutes -330.0).ToString("O", CultureInfo.InvariantCulture)
503+
|> equal "2014-09-11T16:37:02.3450000-05:30"
504+
505+
[<Fact>]
506+
let ``test DateTimeOffset.ToString with Round-trip format round-trips`` () =
507+
let d = DateTimeOffset(2014, 9, 11, 16, 37, 2, 345, TimeSpan.FromHours 2)
508+
let parsed = DateTimeOffset.Parse(d.ToString("O", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture)
509+
parsed |> equal d
510+
parsed.Offset |> equal d.Offset
511+
492512
[<Fact>]
493513
let ``test DateTimeOffset.ToString("R") works`` () =
494514
// R always formats in UTC

0 commit comments

Comments
 (0)