From 88b62bfb5f497ab63b09fa20f2bacfd9d38eb2e5 Mon Sep 17 00:00:00 2001 From: Phil Robinson Date: Tue, 19 May 2026 16:01:35 +0100 Subject: [PATCH 1/5] Return out-of-range dates as ISO 8601 strings instead of crashing --- src/types/cell.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/types/cell.rs b/src/types/cell.rs index df84d87..47fc947 100644 --- a/src/types/cell.rs +++ b/src/types/cell.rs @@ -1,6 +1,7 @@ use std::convert::From; use calamine::DataType; +use chrono::Datelike; use pyo3::prelude::*; #[derive(Debug, Clone)] @@ -28,8 +29,22 @@ impl<'py> IntoPyObject<'py> for CellValue { CellValue::String(v) => Ok(v.into_pyobject(py)?.into_any()), CellValue::Bool(v) => Ok(v.into_pyobject(py)?.to_owned().into_any()), CellValue::Time(v) => Ok(v.into_pyobject(py)?.into_any()), - CellValue::Date(v) => Ok(v.into_pyobject(py)?.into_any()), - CellValue::DateTime(v) => Ok(v.into_pyobject(py)?.into_any()), + CellValue::Date(v) => { + if v.year() > 9999 || v.year() <= 1000 { + let formatted = v.format("%Y-%m-%d").to_string(); + Ok(formatted.into_pyobject(py)?.into_any()) + } else { + Ok(v.into_pyobject(py)?.into_any()) + } + } + CellValue::DateTime(v) => { + if v.year() > 9999 || v.year() <= 1000 { + let formatted = v.format("%Y-%m-%dT%H:%M:%S%.f").to_string(); + Ok(formatted.into_pyobject(py)?.into_any()) + } else { + Ok(v.into_pyobject(py)?.into_any()) + } + } CellValue::Timedelta(v) => Ok(v.into_pyobject(py)?.into_any()), CellValue::Empty => Ok("".into_pyobject(py)?.into_any()), } From d265bea20dc9e2665e34d8e8d25ae05416847087 Mon Sep 17 00:00:00 2001 From: Phil Robinson Date: Wed, 20 May 2026 11:04:46 +0100 Subject: [PATCH 2/5] tests: add out-of-range date fixtures and tests --- tests/data/out_of_range_dates.ods | Bin 0 -> 962 bytes tests/data/out_of_range_dates.xlsx | Bin 0 -> 5349 bytes tests/test_out_of_range_dates.py | 91 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 tests/data/out_of_range_dates.ods create mode 100644 tests/data/out_of_range_dates.xlsx create mode 100644 tests/test_out_of_range_dates.py diff --git a/tests/data/out_of_range_dates.ods b/tests/data/out_of_range_dates.ods new file mode 100644 index 0000000000000000000000000000000000000000..c0416cad76783d6840bba5ec2d84b5bc664bd297 GIT binary patch literal 962 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 9999 → str; otherwise → date/datetime. + +Fixtures: + out_of_range_dates.xlsx — two sheets with future date serials (year 10000) + out_of_range_dates.ods — two sheets with past ISO dates (year 500) +""" + +from pathlib import Path + +from python_calamine import CalamineWorkbook + +PATH = Path(__file__).parent / "data" +XLSX = PATH / "out_of_range_dates.xlsx" +ODS = PATH / "out_of_range_dates.ods" + + +# --------------------------------------------------------------------------- +# xlsx — future dates (serial 2958466+ maps to year 10000) +# --------------------------------------------------------------------------- + + +def test_xlsx_future_date_returns_string(): + sheet = CalamineWorkbook.from_object(XLSX).get_sheet_by_name("future_date") + rows = sheet.to_python() + cell = rows[0][0] + assert isinstance(cell, str), f"expected str for out-of-range date, got {type(cell)}: {cell!r}" + + +def test_xlsx_future_date_string_is_iso_format(): + sheet = CalamineWorkbook.from_object(XLSX).get_sheet_by_name("future_date") + cell = sheet.to_python()[0][0] + # chrono formats with %Y-%m-%d; year 10000 gives "10000-01-01" or similar + parts = cell.split("-") + assert len(parts) == 3, f"expected YYYY-MM-DD, got {cell!r}" + assert int(parts[0]) > 9999, f"expected year > 9999, got {cell!r}" + + +def test_xlsx_future_datetime_returns_string(): + sheet = CalamineWorkbook.from_object(XLSX).get_sheet_by_name("future_datetime") + rows = sheet.to_python() + cell = rows[0][0] + assert isinstance(cell, str), f"expected str for out-of-range datetime, got {type(cell)}: {cell!r}" + + +def test_xlsx_future_datetime_string_contains_time_component(): + sheet = CalamineWorkbook.from_object(XLSX).get_sheet_by_name("future_datetime") + cell = sheet.to_python()[0][0] + assert "T" in cell, f"expected ISO datetime with 'T' separator, got {cell!r}" + date_part = cell.split("T")[0] + year = int(date_part.split("-")[0]) + assert year > 9999, f"expected year > 9999 in {cell!r}" + + +# --------------------------------------------------------------------------- +# ODS — past dates (ISO value "0500-06-15", year 500) +# --------------------------------------------------------------------------- + + +def test_ods_past_date_returns_string(): + sheet = CalamineWorkbook.from_object(ODS).get_sheet_by_name("past_date") + rows = sheet.to_python() + cell = rows[0][0] + assert isinstance(cell, str), f"expected str for out-of-range date, got {type(cell)}: {cell!r}" + + +def test_ods_past_date_string_is_iso_format(): + sheet = CalamineWorkbook.from_object(ODS).get_sheet_by_name("past_date") + cell = sheet.to_python()[0][0] + parts = cell.split("-") + assert len(parts) == 3, f"expected YYYY-MM-DD, got {cell!r}" + assert int(parts[0]) <= 1000, f"expected year <= 1000, got {cell!r}" + + +def test_ods_past_datetime_returns_string(): + sheet = CalamineWorkbook.from_object(ODS).get_sheet_by_name("past_datetime") + rows = sheet.to_python() + cell = rows[0][0] + assert isinstance(cell, str), f"expected str for out-of-range datetime, got {type(cell)}: {cell!r}" + + +def test_ods_past_datetime_string_contains_time_component(): + sheet = CalamineWorkbook.from_object(ODS).get_sheet_by_name("past_datetime") + cell = sheet.to_python()[0][0] + assert "T" in cell, f"expected ISO datetime with 'T' separator, got {cell!r}" + date_part = cell.split("T")[0] + year = int(date_part.split("-")[0]) + assert year <= 1000, f"expected year <= 1000 in {cell!r}" From 67639036378a23a5ae6b33003bf9b97d06384512 Mon Sep 17 00:00:00 2001 From: Phil Robinson Date: Wed, 20 May 2026 11:14:40 +0100 Subject: [PATCH 3/5] tests: add requirements.txt to install local build for testing --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1378af1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +-e . +pytest~=8.0 +pandas[excel]~=2.0 +numpy~=1.0 From 20ab146c06dfa4887d2cb088ea718a911182b356 Mon Sep 17 00:00:00 2001 From: Phil Robinson Date: Wed, 20 May 2026 11:23:00 +0100 Subject: [PATCH 4/5] tests: regenerate ODS fixture with odfpy to fix whitespace parse error --- tests/data/out_of_range_dates.ods | Bin 962 -> 1745 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/data/out_of_range_dates.ods b/tests/data/out_of_range_dates.ods index c0416cad76783d6840bba5ec2d84b5bc664bd297..cba1fc4759181f9b1ba6ff0e22368db1c0718377 100644 GIT binary patch literal 1745 zcmZ`)2T+qq7!4&fLm34!Afg_i7$8Jxa=2Nd; zl|J+i7LZN=0SiSO4}<_GDEEWja5CQR?0ma3`@Y$)ytf#0E^blC&SQZ z#eDP0x9hWJn0c=a^yR||fe3(>LZ#uc6l5R)AD5^j)PjQ111??$76$bgn8A#=LrokL zgN6x`ylO~9RY9YC)A^3kzk(A!rzb}dqj>$VNc7~WY8r4Ps#av%OFJshZHOkfBUR8j zvN5bKCmCP--(%T=MrkEBOxkFGEX)QAjWklQ5ug~ej?EOcZ&+7jjn zzi2Xh&76K+tGi zYe7GQ7bxahMPG!*lB?Sbb@es@l%lwgyM772or7sZ?S>8H7kid!>EolJtTKIV7PQyM zEAgnVv86`Grc@LxXU#h}H0tAJ?_o)~v>B+s&SJ=1%_G@}D!DL8dz8Mjr|PKrgT)vi za3;e7XkvNh`2MhZF`XUt!kUYJ`-1Qe#Jr*Ti0qGMyZxleE3yMYP{ zmkzNY?y`Yd zEEz*vkx)+eF;)j&tNBfK*O-ZaR@PhP709L@)5-3Yo@^T7mWvoojEH*hj;y_SFG@qUp@T$Jrx&Az|sP_1UuxQlj3PSttFSts)2RGjNGgH-iAC z#8SVCpn#6d^IL{WFlVuAZZ%B0B0KJ6oyDmm6`IAjGn3+2=|jao=P2YsMFLqVH~TF! z2WNgeaW-|pbc?)d508ltCpphHdy(6DOw$Kingf>EP(oUPRFzPu2&Da?LU}jijoJry z&2H#;*+S*D_|hl+cA2z4HiLu}L=W1r@YP{u{t4QeYjCAnD}&Uz4qRPUjF zEWckd5J+yM6}a6K zFTWr)FC{;@G&eP`M6bA@C^a#qI3qQ+BminA2SaDXmY59HL#o?=X2mlyFo*(aU)K;v zT~9wZ{oKU7%(T?v61|GtoT(EG`3@QIxR(0~R^~HHcg@;V$YkL#MWSPhMd0JGg6bd@ z4GzN%H}8L|kAGKoUE)ZK^T$q;oLiH z^=OU+v)&Y-Ct2AT7`TBRP0r6N0fh|EdwWAp=QSG$?0K#|;l19nz+0!7ie7JA{w2op znCFBIy&EzQ-xs$x=wBtc>Q%MM zxvss(aASkcEXFO)9E;dzylJpMdS$z3w$*7L Date: Wed, 20 May 2026 11:26:11 +0100 Subject: [PATCH 5/5] Tests added --- .gitignore | 1 + Cargo.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bbdc098..b14c2c0 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,4 @@ dmypy.json # IDE stuff .vscode +.idea diff --git a/Cargo.lock b/Cargo.lock index 0115370..d28fa54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,7 +504,7 @@ dependencies = [ [[package]] name = "python-calamine" -version = "0.3.1" +version = "0.3.2" dependencies = [ "calamine", "chrono",