Commit 0bb18c55 authored by nmartensen's avatar nmartensen
Browse files

add heuristic for reading timedelta

Since dates, times, and elapsed time are all usually stored as simple
numbers in XLSX, openpyxl uses a heuristic to recognize the values as
date/time representation and returns datetime.datetime or datetime.time
objects for them. The heuristic is based on inspecting the number format
style string (is this a date format?) and the stored value itself (to
distinguish times from datetimes).

This commit refines the heuristic by adding support for recognizing and
returning time interval durations as datetime.timedelta.

--HG--
branch : 3.0
parent 299bf67d9fb2
......@@ -95,6 +95,7 @@ COLORS = r"\[(BLACK|BLUE|CYAN|GREEN|MAGENTA|RED|WHITE|YELLOW)\]"
LITERAL_GROUP = r'".*?"' # anything in quotes
LOCALE_GROUP = r'\[.+\]' # anything in square brackets, including colours
STRIP_RE = re.compile(f"{LITERAL_GROUP}|{LOCALE_GROUP}")
TIMEDELTA_RE = re.compile(r'\[hh?\](:mm(:ss(\.0*)?)?)?|\[mm?\]:ss(\.0*)?|\[ss?\](\.0*)?', re.I)
# Spec 18.8.31 numFmts
......@@ -108,6 +109,12 @@ def is_date_format(fmt):
return re.search(r"[^\\][dmhysDMHYS]", fmt) is not None
def is_timedelta_format(fmt):
if fmt is None:
return False
return TIMEDELTA_RE.match(fmt) is not None
def is_datetime(fmt):
"""
Return date, time or datetime
......
......@@ -25,6 +25,7 @@ from .numbers import (
BUILTIN_FORMATS_MAX_SIZE,
BUILTIN_FORMATS_REVERSE,
is_date_format,
is_timedelta_format,
builtin_format_code
)
from .named_styles import (
......@@ -156,6 +157,7 @@ class Stylesheet(Serialisable):
And index datetime formats
"""
date_formats = set()
timedelta_formats = set()
custom = self.custom_formats
formats = self.number_formats
for idx, style in enumerate(self.cell_styles):
......@@ -170,7 +172,11 @@ class Stylesheet(Serialisable):
if is_date_format(fmt):
# Create an index of which styles refer to datetimes
date_formats.add(idx)
if is_timedelta_format(fmt):
# Create an index of which styles refer to timedeltas
timedelta_formats.add(idx)
self.date_formats = date_formats
self.timedelta_formats = timedelta_formats
def to_tree(self, tagname=None, idx=None, namespace=None):
......@@ -204,6 +210,7 @@ def apply_stylesheet(archive, wb):
wb._cell_styles = stylesheet.cell_styles
wb._named_styles = stylesheet.named_styles
wb._date_formats = stylesheet.date_formats
wb._timedelta_formats = stylesheet.timedelta_formats
for ns in wb._named_styles:
ns.bind(wb)
......
......@@ -84,6 +84,35 @@ def test_is_date_format(format, result):
assert is_date_format(format) is result
@pytest.mark.parametrize("format, result",
[
('m:ss', False),
('[h]', True),
('[hh]', True),
('[h]:mm:ss', True),
('[hh]:mm:ss', True),
('[h]:mm:ss.000', True),
('[hh]:mm:ss.0', True),
('[h]:mm', True),
('[hh]:mm', True),
('[m]:ss', True),
('[mm]:ss', True),
('[m]:ss.000', True),
('[mm]:ss.0', True),
('[s]', True),
('[ss]', True),
('[s].000', True),
('[ss].0', True),
('[m]', False),
('[mm]', False),
('h:mm', False),
]
)
def test_is_timedelta_format(format, result):
from ..numbers import is_timedelta_format
assert is_timedelta_format(format) is result
@pytest.mark.parametrize("fmt, typ",
[
(FORMAT_DATE_DATETIME, "datetime"),
......
......@@ -88,10 +88,17 @@ def to_excel(dt, epoch=WINDOWS_EPOCH):
return days
def from_excel(value, epoch=WINDOWS_EPOCH):
def from_excel(value, epoch=WINDOWS_EPOCH, timedelta=False):
"""Convert Excel serial to Python datetime"""
if value is None:
return
if timedelta:
td = datetime.timedelta(days=value)
if td.microseconds:
# round to millisecond precision
td += datetime.timedelta(microseconds=-td.microseconds +
round(td.microseconds/1000)*1000)
return td
day, fraction = divmod(value, 1)
diff = datetime.timedelta(milliseconds=round(fraction * SECS_PER_DAY * 1000))
if 0 <= value < 1 and diff.days == 0:
......
......@@ -123,6 +123,26 @@ def test_from_excel(value, expected):
assert FUT(value) == expected
@pytest.mark.parametrize("value, expected",
[
(0, timedelta(hours=0)),
(0.5, timedelta(hours=12)),
(-0.5, timedelta(hours=-12)),
(1.25, timedelta(hours=30)),
(-1.25, timedelta(hours=-30)),
(59.5, timedelta(days=59, hours=12)),
(60.5, timedelta(days=60, hours=12)),
(61.5, timedelta(days=61, hours=12)),
(0.9999999995, timedelta(days=1)),
(1.0000000005, timedelta(days=1)),
(None, None),
])
def test_from_excel_timedelta(value, expected):
from ..datetime import from_excel
FUT = from_excel
assert FUT(value, timedelta=True) == expected
@pytest.mark.parametrize("value, expected",
[
(39385, datetime(2011, 10, 31)),
......
......@@ -104,6 +104,7 @@ class Workbook(object):
self._number_formats = IndexedList()
self._date_formats = {}
self._timedelta_formats = {}
self._protections = IndexedList([Protection()])
......
......@@ -85,7 +85,8 @@ def _cast_number(value):
class WorkSheetParser(object):
def __init__(self, src, shared_strings, data_only=False,
epoch=WINDOWS_EPOCH, date_formats=set()):
epoch=WINDOWS_EPOCH, date_formats=set(),
timedelta_formats=set()):
self.min_row = self.min_col = None
self.epoch = epoch
self.source = src
......@@ -96,6 +97,7 @@ class WorkSheetParser(object):
self.row_counter = self.col_counter = 0
self.tables = TablePartList()
self.date_formats = date_formats
self.timedelta_formats = timedelta_formats
self.row_dimensions = {}
self.column_dimensions = {}
self.number_formats = []
......@@ -198,7 +200,10 @@ class WorkSheetParser(object):
elif value is not None:
if data_type == 'n':
value = _cast_number(value)
if style_id in self.date_formats:
if style_id in self.timedelta_formats:
data_type = 'd'
value = from_excel(value, self.epoch, timedelta=True)
elif style_id in self.date_formats:
data_type = 'd'
try:
value = from_excel(value, self.epoch)
......@@ -337,7 +342,9 @@ class WorksheetReader(object):
def __init__(self, ws, xml_source, shared_strings, data_only):
self.ws = ws
self.parser = WorkSheetParser(xml_source, shared_strings, data_only, ws.parent.epoch, ws.parent._date_formats)
self.parser = WorkSheetParser(xml_source, shared_strings,
data_only, ws.parent.epoch, ws.parent._date_formats,
ws.parent._timedelta_formats)
self.tables = []
......
......@@ -63,6 +63,7 @@ def Workbook():
self._cell_styles.add(StyleArray([0,4,6,0,0,1,0,0,0])) #fillId=4, borderId=6, alignmentId=1))
self.sheetnames = []
self._date_formats = set()
self._timedelta_formats = set()
def create_sheet(self, title):
return Worksheet(self)
......@@ -80,7 +81,8 @@ def WorkSheetParser():
styles.add((StyleArray([i]*9)))
styles.add(StyleArray([0,4,6,14,0,1,0,0,0])) #fillId=4, borderId=6, number_format=14 alignmentId=1))
date_formats = set([1, 29])
return WorkSheetParser(None, {0:'a'}, date_formats=date_formats)
timedelta_formats = set([30])
return WorkSheetParser(None, {0:'a'}, date_formats=date_formats, timedelta_formats=timedelta_formats)
from warnings import simplefilter
simplefilter("always")
......@@ -288,6 +290,21 @@ class TestWorksheetParser:
'style_id':0, 'value': datetime.datetime(2011, 12, 25, 14, 23, 55)}
def test_timedelta(self, WorkSheetParser):
parser = WorkSheetParser
src = """
<c r="A1" t="n" s="30" xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<v>1.25</v>
</c>
"""
element = fromstring(src)
cell = parser.parse_cell(element)
assert cell == {'column': 1, 'data_type': 'd', 'row': 1,
'style_id':30, 'value':datetime.timedelta(days=1, hours=6)}
def test_mac_date(self, WorkSheetParser):
parser = WorkSheetParser
parser.epoch = CALENDAR_MAC_1904
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment