Skip to content

fix(python): derive DateTime "O" format from the value, not the host timezone - #4869

Merged
dbrattli merged 1 commit into
mainfrom
fix/python-datetime-roundtrip-format
Aug 3, 2026
Merged

fix(python): derive DateTime "O" format from the value, not the host timezone#4869
dbrattli merged 1 commit into
mainfrom
fix/python-datetime-roundtrip-format

Conversation

@dbrattli

@dbrattli dbrattli commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

"O" is the round-trip specifier: its contract is that the string identifies the same value on the way back, and that it depends only on the value. On Python it depended on the machine's timezone.

DateTime(2026, 8, 3, 14, 30, 15).ToString("O")   // Kind = Unspecified
// .NET   : "2026-08-03T14:30:15.0000000"
// Python : "2026-08-03T14:30:15.000+02:00"      // <-- host offset, 3-digit fraction

astimezone() on the naive datetime backing Kind = Unspecified assumes local time and attaches the local offset, so the same value printed differently on a developer machine and on CI.

Investigating turned up three more failures on the same path:

Value Before .NET / after
Unspecified …15.000+02:00 …15.0000000
Utc …15.000000Z …15.0000000Z
Local …15.000000Z …15.0000000+02:00
DateTimeOffset(+02:00) …15.000000Z …15.0000000+02:00
DateTimeOffset(-05:30) …15.000000Z …15.0000000-05:30
Year 1 ValueError crash 0001-01-01T00:00:00.0000000

The Local and DateTimeOffset rows are the serious ones: the old code appended a literal Z to the offset-local wall clock, so the string named a different instant than the value — silent corruption rather than cosmetic drift. The fractional width was also inconsistent between the two paths (3 digits on one, 6 on the other; .NET always emits 7).

Fix

A single _to_roundtrip_string in date.py, used by both the with_kind and with_offset dispatchers. The zone designator comes from the value alone — none for Unspecified, Z for Utc, the value's own offset for Local — and the fraction is always 7 digits, padding the seventh rather than truncating.

Fields are formatted directly instead of via strftime, which does not zero-pad years < 1000 on glibc.

One wrinkle: timezone(timedelta(0)) is datetime.UTC in CPython, so a DateTimeOffset at +00:00 cannot be told apart from Kind = Utc by tzinfo alone — yet .NET renders them differently (+00:00 vs Z). Hence the is_offset_value marker on DateTimeOffset, checked by attribute rather than isinstance because date_offset imports date and importing back would trip reportImportCycles: true.

Verification

  • 2458 Python tests pass; the 9 new/updated tests pass on .NET first, all under TZ=Europe/Oslo.
  • Expected strings were captured from dotnet fsi rather than assumed; Python now matches literally.
  • TZ-invariance checked across Europe/Oslo, UTC and America/New_York.
  • Round-trip preserves the instant for every kind. Kind survives only for Unspecified — confirmed correct, .NET's default DateTime.Parse converts both Z and +hh:mm to Local.
  • No new Pyright errors (126 pre-existing, all in array/float files) and no new Ruff errors (4 pre-existing long lines).

The existing Utc test worked around the 6-digit output with str.Replace("0000000Z", "000000Z"); that is now an exact literal, so it actually pins the format.

Notes

Found while adding DateTime support to Fable.TypedJson, which guarantees a value serializes identically across Beam, Python, JS and .NET. "O" was the one construct that broke the guarantee.

Two related items deliberately left out of scope:

  • TimeSpan renders as raw ticks under %A/%O/str() (72000000000 instead of 02:00:00), while .ToString() is correct — printf lives in the Rust core and falls back to Python's str(), which TimeSpan(int) does not override. The obvious fix (__str__/__format__) is blocked on TimeOnly being the same Python type, so one __str__ cannot serve both (.NET wants 02:00:00 vs 14:30). Needs a design call.
  • Beam's format_roundtrip emits no designator for Kind = Local (fable_date.erl:435-444) where .NET emits +02:00. Beam's DateTimeOffset path is already correct.

🤖 Generated with Claude Code

…timezone

The round-trip specifier is meant to identify the same value on the way
back and to depend only on that value. On Python it depended on the
machine's timezone instead:

  DateTime(2026, 8, 3, 14, 30, 15).ToString("O")
  // .NET   : "2026-08-03T14:30:15.0000000"
  // Python : "2026-08-03T14:30:15.000+02:00"

`astimezone()` on the naive datetime backing Kind = Unspecified assumes
local time and attaches the local offset, so the same value printed
differently on a developer machine and on CI.

The DateTimeOffset path was worse: it appended a literal "Z" to the
offset-local wall clock, discarding the offset and naming a different
instant. A Local DateTime hit the same path.

Format the three kinds from the value alone — no designator for
Unspecified, "Z" for Utc, and the value's own offset for Local — and use
a constant 7-digit fraction on every path (it was 3 on one and 6 on the
other). A DateTimeOffset keeps a numeric offset even at +00:00, which is
what distinguishes it from Kind = Utc.

Also fixes a crash on year < 1000, where astimezone() could underflow
past year 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Python Type Checking Results (Pyright)

Metric Value
Total errors 34
Files with errors 4
Excluded files 4
New errors ✅ No
Excluded files with errors (4 files)

These files have known type errors and are excluded from CI. Remove from pyrightconfig.ci.json as errors are fixed.

File Errors Status
temp/tests/Python/test_hash_set.py 18 Excluded
temp/tests/Python/test_applicative.py 12 Excluded
temp/tests/Python/test_nested_and_recursive_pattern.py 2 Excluded
temp/tests/Python/fable_modules/thoth_json_python/encode.py 2 Excluded

@dbrattli
dbrattli merged commit b3ec8b6 into main Aug 3, 2026
42 checks passed
@dbrattli
dbrattli deleted the fix/python-datetime-roundtrip-format branch August 3, 2026 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant