From 71e18e031d30e8258e631510d03a087ddbaa0dd5 Mon Sep 17 00:00:00 2001 From: David Straub Date: Thu, 13 Aug 2026 21:13:02 +0200 Subject: [PATCH 1/2] Fix fraction dropping leading zeros --- gedcom7/cast.py | 3 ++- gedcom7/types.py | 5 ++++- test/test_cast.py | 16 ++++++++++++++-- test/test_util.py | 17 +++++++++++++---- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/gedcom7/cast.py b/gedcom7/cast.py index d657968..6b64c61 100644 --- a/gedcom7/cast.py +++ b/gedcom7/cast.py @@ -79,7 +79,8 @@ def _cast_time(value: str) -> types.Time: hour=int(match.group("hour")), minute=int(match.group("minute")), second=int(match.group("second")) if match.group("second") else None, - fraction=int(match.group("fraction")) if match.group("fraction") else None, + # kept as a string: see the note on Time.fraction + fraction=match.group("fraction"), ) diff --git a/gedcom7/types.py b/gedcom7/types.py index c0c11da..8594dc2 100644 --- a/gedcom7/types.py +++ b/gedcom7/types.py @@ -83,7 +83,10 @@ class Time: hour: int minute: int second: int | None = None - fraction: int | None = None + # The digits after the decimal point, kept verbatim: their width is + # significant, so ".05" and ".5" are different instants. Parsing them as an + # integer would map both to 5. + fraction: str | None = None tz: Literal["Z"] | None = None diff --git a/test/test_cast.py b/test/test_cast.py index 515fc2a..3f845cc 100644 --- a/test/test_cast.py +++ b/test/test_cast.py @@ -88,7 +88,7 @@ def test_cast_time() -> None: hour=13, minute=15, second=12, - fraction=246, + fraction="246", tz=None, ) assert cast._cast_time("13:15Z") == types.Time( @@ -102,13 +102,25 @@ def test_cast_time() -> None: hour=13, minute=15, second=12, - fraction=246, + fraction="246", tz="Z", ) with pytest.raises(ValueError): cast._cast_time("13:15A") # Invalid timezone, should be Z +def test_cast_time_preserves_fraction_width() -> None: + """The fraction is kept verbatim so distinct instants stay distinct. + + Casting to an integer would collapse ".05" and ".5" onto 5, and neither the + datetime conversion nor a serializer could tell them apart afterwards. + """ + assert cast._cast_time("13:15:12.05").fraction == "05" + assert cast._cast_time("13:15:12.5").fraction == "5" + assert cast._cast_time("13:15:12.500").fraction == "500" + assert cast._cast_time("13:15:12.05") != cast._cast_time("13:15:12.5") + + def test_cast_age() -> None: # Testing basic combinations assert cast._cast_age("3y") == types.Age( diff --git a/test/test_util.py b/test/test_util.py index 8ea03dd..8e4e9d3 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -126,20 +126,29 @@ def test_time_with_seconds() -> None: def test_time_with_fraction() -> None: """Test time conversion with fractional seconds.""" - gedcom_time = types.Time(hour=14, minute=30, second=45, fraction=123456) + gedcom_time = types.Time(hour=14, minute=30, second=45, fraction="123456") python_time = util.time_to_python_time(gedcom_time) assert python_time.second == 45 assert python_time.microsecond == 123456 # Test with different fraction lengths - gedcom_time = types.Time(hour=14, minute=30, second=45, fraction=5) + gedcom_time = types.Time(hour=14, minute=30, second=45, fraction="5") python_time = util.time_to_python_time(gedcom_time) assert python_time.second == 45 assert python_time.microsecond == 500000 +def test_time_fraction_leading_zero_is_significant() -> None: + """".05" is 50 ms, not 500 ms; the width of the fraction carries meaning.""" + gedcom_time = types.Time(hour=14, minute=30, second=45, fraction="05") + assert util.time_to_python_time(gedcom_time).microsecond == 50000 + + gedcom_time = types.Time(hour=14, minute=30, second=45, fraction="5") + assert util.time_to_python_time(gedcom_time).microsecond == 500000 + + def test_time_without_fraction_or_seconds() -> None: """Test time conversion without fraction or seconds.""" gedcom_time = types.Time(hour=14, minute=30, second=None, fraction=None) @@ -168,7 +177,7 @@ def test_date_only() -> None: def test_date_and_time() -> None: """Test conversion with both date and time.""" gedcom_date = types.DateExact(day=15, month="JAN", year=2023) - gedcom_time = types.Time(hour=14, minute=30, second=45, fraction=123) + gedcom_time = types.Time(hour=14, minute=30, second=45, fraction="123") python_datetime = util.date_exact_and_time_to_python_datetime( gedcom_date, gedcom_time @@ -197,7 +206,7 @@ def test_integration_with_other_functions() -> None: ) gedcom_date = types.DateExact(day=15, month="JAN", year=2023) - gedcom_time = types.Time(hour=14, minute=30, second=45, fraction=123) + gedcom_time = types.Time(hour=14, minute=30, second=45, fraction="123") util.date_exact_and_time_to_python_datetime(gedcom_date, gedcom_time) From 706e025cc899badaf0dd8cbc718d8ef40a085718 Mon Sep 17 00:00:00 2001 From: David Straub Date: Thu, 13 Aug 2026 22:18:53 +0200 Subject: [PATCH 2/2] Address comments --- gedcom7/types.py | 7 ++++--- test/test_cast.py | 7 ++++--- test/test_util.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/gedcom7/types.py b/gedcom7/types.py index 8594dc2..d7a04d5 100644 --- a/gedcom7/types.py +++ b/gedcom7/types.py @@ -83,9 +83,10 @@ class Time: hour: int minute: int second: int | None = None - # The digits after the decimal point, kept verbatim: their width is - # significant, so ".05" and ".5" are different instants. Parsing them as an - # integer would map both to 5. + # The digits after the decimal point, kept verbatim. Leading zeros are part + # of the value: ".05" and ".5" are different instants. Trailing zeros are + # not, but they belong to the payload, so keeping the digits as written + # preserves those too. fraction: str | None = None tz: Literal["Z"] | None = None diff --git a/test/test_cast.py b/test/test_cast.py index 3f845cc..9b54bf7 100644 --- a/test/test_cast.py +++ b/test/test_cast.py @@ -110,10 +110,11 @@ def test_cast_time() -> None: def test_cast_time_preserves_fraction_width() -> None: - """The fraction is kept verbatim so distinct instants stay distinct. + """The fraction is kept verbatim, digits as written. - Casting to an integer would collapse ".05" and ".5" onto 5, and neither the - datetime conversion nor a serializer could tell them apart afterwards. + Leading zeros are part of the value, so ".05" and ".5" are different + instants. Trailing zeros do not change the value but do belong to the + payload, so they are preserved as well. """ assert cast._cast_time("13:15:12.05").fraction == "05" assert cast._cast_time("13:15:12.5").fraction == "5" diff --git a/test/test_util.py b/test/test_util.py index 8e4e9d3..6597a1b 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -141,7 +141,7 @@ def test_time_with_fraction() -> None: def test_time_fraction_leading_zero_is_significant() -> None: - """".05" is 50 ms, not 500 ms; the width of the fraction carries meaning.""" + """A leading zero scales the fraction: ".05" is 50 ms, not 500 ms.""" gedcom_time = types.Time(hour=14, minute=30, second=45, fraction="05") assert util.time_to_python_time(gedcom_time).microsecond == 50000