Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,4 @@ dmypy.json

# IDE stuff
.vscode
.idea
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-e .
pytest~=8.0
pandas[excel]~=2.0
numpy~=1.0
19 changes: 17 additions & 2 deletions src/types/cell.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::convert::From;

use calamine::DataType;
use chrono::Datelike;
use pyo3::prelude::*;

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -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();
Comment on lines +33 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What are datetime.MINYEAR and datetime.MAXYEAR in Python 3's datetime module?

💡 Result:

In Python's datetime module, MINYEAR and MAXYEAR are constants that define the supported range for year values in date and datetime objects [1][2]. - datetime.MINYEAR is 1 [1][2]. - datetime.MAXYEAR is 9999 [1][2]. Any year value provided to a date or datetime constructor or method must fall within the range [MINYEAR, MAXYEAR] inclusive [1][2]. Attempting to use a year outside this range will typically raise a ValueError [3].

Citations:


🏁 Script executed:

cat -n src/types/cell.rs | sed -n '30,45p'

Repository: dimastbk/python-calamine

Length of output: 997


Change year bounds check to match Python's datetime.MINYEAR (1).

Lines 33 and 41 incorrectly reject years in the range [1, 1000] using v.year() <= 1000, which downgrades valid Python dates to string representation. Python's datetime module allows years [1, 9999] inclusive. Change the condition to v.year() < 1 to align with datetime.MINYEAR.

Proposed fix
-                if v.year() > 9999 || v.year() <= 1000 {
+                if v.year() > 9999 || v.year() < 1 {
                     let formatted = v.format("%Y-%m-%d").to_string();
                     Ok(formatted.into_pyobject(py)?.into_any())
                 } else {
                     Ok(v.into_pyobject(py)?.into_any())
                 }
@@
-                if v.year() > 9999 || v.year() <= 1000 {
+                if v.year() > 9999 || v.year() < 1 {
                     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())
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/types/cell.rs` around lines 33 - 34, The year bounds check incorrectly
treats years 1..=1000 as invalid; in the code around the date formatting where
v.year() is checked (the branch that sets formatted =
v.format("%Y-%m-%d").to_string()), change the condition from v.year() > 9999 ||
v.year() <= 1000 to use v.year() > 9999 || v.year() < 1 so that years in the
valid Python datetime range [1,9999] are preserved instead of being downgraded
to string form.

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()),
}
Expand Down
Binary file added tests/data/out_of_range_dates.ods
Binary file not shown.
Binary file added tests/data/out_of_range_dates.xlsx
Binary file not shown.
91 changes: 91 additions & 0 deletions tests/test_out_of_range_dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
Tests verifying that date/datetime cells whose year falls outside Python's
datetime range are returned as ISO 8601 strings rather than raising an error.

Boundary: year <= 1000 or year > 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}"