Skip to content

Commit 11579ec

Browse files
github-actions[bot]Copilotdbrattliclaude
authored
[Repo Assist] fix(python): DateTime.TryParse preserves Unspecified kind for naive strings; use fromisoformat in DateTimeOffset.parse (#4491)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dag Brattli <dag@brattli.net> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8db5354 commit 11579ec

10 files changed

Lines changed: 250 additions & 261 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ authors = [
99
requires-python = ">= 3.12, < 4.0"
1010
readme = "README.md"
1111
license = "MIT"
12-
dependencies = [
13-
"python-dateutil>=2.9.0,<3",
14-
]
12+
dependencies = []
1513

1614
[project.urls]
1715
Homepage = "https://fable.io"

src/Fable.Cli/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Fixed
2222

23+
* [Python] Fix `DateTime.TryParse` incorrectly assigning `DateTimeKind.Local` to naive datetime strings (should be `DateTimeKind.Unspecified`) (fixes #3654)
24+
* [Python] Remove `python-dateutil` dependency from fable-library; use stdlib `datetime.fromisoformat` with `strptime` fallback
2325
* [All] Fix unnecessary object allocations during AST traversal when visiting `Import` expressions (by Repo Assist)
2426
* [Beam] Fix `System.Random.Next(0)` implementation (by @ncave)
2527
* [Python] Fix `System.Random` seeded implementation (by @ncave)

src/Fable.Compiler/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Fixed
2222

23+
* [Python] Fix `DateTime.TryParse` incorrectly assigning `DateTimeKind.Local` to naive datetime strings (should be `DateTimeKind.Unspecified`) (fixes #3654)
24+
* [Python] Remove `python-dateutil` dependency from fable-library; use stdlib `datetime.fromisoformat` with `strptime` fallback
2325
* [All] Fix unnecessary object allocations during AST traversal when visiting `Import` expressions (by Repo Assist)
2426
* [Beam] Fix `System.Random.Next(0)` implementation (by @ncave)
2527
* [Python] Fix `System.Random` seeded implementation (by @ncave)

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,12 @@ def add(x: datetime, y: TimeSpan) -> datetime:
510510

511511
def parse(string: str) -> datetime:
512512
try:
513-
return datetime.fromisoformat(string).astimezone()
513+
parsed = datetime.fromisoformat(string)
514+
# Only convert to local time when the string had an explicit timezone.
515+
# Naive strings represent DateTimeKind.Unspecified and must stay naive.
516+
if parsed.tzinfo is not None:
517+
return parsed.astimezone()
518+
return parsed
514519
except ValueError:
515520
pass
516521

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import re
34
from datetime import UTC, datetime, timedelta, timezone
45
from math import fmod
56
from typing import Any, SupportsFloat, SupportsIndex, SupportsInt, overload
@@ -189,10 +190,26 @@ def timedelta_total_microseconds(td: timedelta) -> int:
189190
return td.days * (24 * 3600) + td.seconds * 10**6 + td.microseconds
190191

191192

192-
def parse(string: str, detectUTC: bool = False) -> DateTimeOffset:
193-
from dateutil import parser # noqa: PLC0415 - lazy import to avoid top-level dependency
193+
_NON_ISO_FORMATS: dict[str, str] = {
194+
# 9/10/2014 1:50:34 PM
195+
r"^(0?[1-9]|1[0-2])\/(0?[1-9]|1[0-2])\/\d{4} ([0-9]|(0|1)[0-9]|2[0-4]):([0-5][0-9]|0?[0-9]):([0-5][0-9]|0?[0-9]) [AP]M$": "%m/%d/%Y %I:%M:%S %p",
196+
# 9/10/2014 1:50:34
197+
r"^(0?[1-9]|1[0-2])\/(0?[1-9]|1[0-2])\/\d{4} ([0-9]|(0|1)[0-9]|2[0-4]):([0-5][0-9]|0?[0-9]):([0-5][0-9]|0?[0-9])$": "%m/%d/%Y %H:%M:%S",
198+
}
199+
200+
201+
def _parse_non_iso(string: str) -> datetime:
202+
for pattern, fmt in _NON_ISO_FORMATS.items():
203+
if re.fullmatch(pattern, string):
204+
return datetime.strptime(string, fmt)
205+
raise ValueError(f"Unsupported format by Fable: {string}")
194206

195-
parsed_dt = parser.parse(string)
207+
208+
def parse(string: str, detectUTC: bool = False) -> DateTimeOffset:
209+
try:
210+
parsed_dt = datetime.fromisoformat(string)
211+
except ValueError:
212+
parsed_dt = _parse_non_iso(string)
196213

197214
# Calculate offset in milliseconds
198215
if parsed_dt.tzinfo is not None:

src/fable-library-py/pyproject.toml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ authors = [
88
requires-python = ">= 3.10, < 4.0"
99
readme = "README.md"
1010
license = "MIT License"
11-
dependencies = [
12-
"python-dateutil>=2.9.0,<3"
13-
]
11+
dependencies = []
1412

1513
[project.urls]
1614
Homepage = "https://fable.io"
@@ -23,7 +21,6 @@ dev = [
2321
"pytest-benchmark>=5.1.0,<6",
2422
"pyright>=1.1.401,<2",
2523
"pydantic>=2.12.5",
26-
"python-dateutil>=2.9.0,<3",
2724
]
2825

2926
[tool.hatch.build.targets.sdist]

src/fable-library-py/uv.lock

Lines changed: 199 additions & 225 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/Python/TestDateTime.fs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,18 @@ let ``test DateTime.TryParse works`` () =
787787
dateTime.Year + dateTime.Month + dateTime.Day + dateTime.Hour + dateTime.Minute + dateTime.Second |> equal 2130
788788

789789

790+
[<Fact>]
791+
let ``test DateTime.TryParse preserves Unspecified kind for naive strings`` () =
792+
let (isSuccess, dt) = DateTime.TryParse("2018-10-01T11:12:55")
793+
isSuccess |> equal true
794+
dt.Kind |> equal DateTimeKind.Unspecified
795+
dt.Year |> equal 2018
796+
dt.Month |> equal 10
797+
dt.Day |> equal 1
798+
dt.Hour |> equal 11
799+
dt.Minute |> equal 12
800+
dt.Second |> equal 55
801+
790802
[<Fact>]
791803
let ``test "Parsing doesn't succeed for invalid dates`` () =
792804
let invalidAmericanDate = "13/1/2020"

tests/Python/TestDateTimeOffset.fs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,13 @@ let ``test DateTimeOffset.TryParse works`` () =
471471
f "9/10/2014 1:50:34" |> equal true
472472
f "2014-09-10T13:50:34" |> equal true
473473

474+
[<Fact>]
475+
let ``test DateTimeOffset.TryParse with timezone offset returns correct value`` () =
476+
let expected = DateTimeOffset(2018, 7, 2, 12, 23, 45, 0, TimeSpan.FromHours(2.))
477+
let (isSuccess, actual) = DateTimeOffset.TryParse("2018-07-02T12:23:45+02:00")
478+
isSuccess |> equal true
479+
actual |> equal expected
480+
474481
[<Fact>]
475482
let ``test DateTimeOffset.ToString() default works`` () =
476483
let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero)

uv.lock

Lines changed: 0 additions & 25 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)