Read about our upcoming Code of Conduct on this issue

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)\]" ...@@ -95,6 +95,7 @@ COLORS = r"\[(BLACK|BLUE|CYAN|GREEN|MAGENTA|RED|WHITE|YELLOW)\]"
LITERAL_GROUP = r'".*?"' # anything in quotes LITERAL_GROUP = r'".*?"' # anything in quotes
LOCALE_GROUP = r'\[.+\]' # anything in square brackets, including colours LOCALE_GROUP = r'\[.+\]' # anything in square brackets, including colours
STRIP_RE = re.compile(f"{LITERAL_GROUP}|{LOCALE_GROUP}") 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 # Spec 18.8.31 numFmts
...@@ -108,6 +109,12 @@ def is_date_format(fmt): ...@@ -108,6 +109,12 @@ def is_date_format(fmt):
return re.search(r"[^\\][dmhysDMHYS]", fmt) is not None 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): def is_datetime(fmt):
""" """
Return date, time or datetime Return date, time or datetime
......
...@@ -25,6 +25,7 @@ from .numbers import ( ...@@ -25,6 +25,7 @@ from .numbers import (
BUILTIN_FORMATS_MAX_SIZE, BUILTIN_FORMATS_MAX_SIZE,
BUILTIN_FORMATS_REVERSE, BUILTIN_FORMATS_REVERSE,
is_date_format, is_date_format,
is_timedelta_format,
builtin_format_code builtin_format_code
) )
from .named_styles import ( from .named_styles import (
...@@ -156,6 +157,7 @@ class Stylesheet(Serialisable): ...@@ -156,6 +157,7 @@ class Stylesheet(Serialisable):
And index datetime formats And index datetime formats
""" """
date_formats = set() date_formats = set()
timedelta_formats = set()
custom = self.custom_formats custom = self.custom_formats
formats = self.number_formats formats = self.number_formats
for idx, style in enumerate(self.cell_styles): for idx, style in enumerate(self.cell_styles):
...@@ -170,7 +172,11 @@ class Stylesheet(Serialisable): ...@@ -170,7 +172,11 @@ class Stylesheet(Serialisable):
if is_date_format(fmt): if is_date_format(fmt):
# Create an index of which styles refer to datetimes # Create an index of which styles refer to datetimes
date_formats.add(idx) 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.date_formats = date_formats
self.timedelta_formats = timedelta_formats
def to_tree(self, tagname=None, idx=None, namespace=None): def to_tree(self, tagname=None, idx=None, namespace=None):
...@@ -204,6 +210,7 @@ def apply_stylesheet(archive, wb): ...@@ -204,6 +210,7 @@ def apply_stylesheet(archive, wb):
wb._cell_styles = stylesheet.cell_styles wb._cell_styles = stylesheet.cell_styles
wb._named_styles = stylesheet.named_styles wb._named_styles = stylesheet.named_styles
wb._date_formats = stylesheet.date_formats wb._date_formats = stylesheet.date_formats
wb._timedelta_formats = stylesheet.timedelta_formats
for ns in wb._named_styles: for ns in wb._named_styles:
ns.bind(wb) ns.bind(wb)
......
...@@ -84,6 +84,35 @@ def test_is_date_format(format, result): ...@@ -84,6 +84,35 @@ def test_is_date_format(format, result):
assert is_date_format(format) is 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", @pytest.mark.parametrize("fmt, typ",
[ [
(FORMAT_DATE_DATETIME, "datetime"), (FORMAT_DATE_DATETIME, "datetime"),
......
...@@ -88,10 +88,17 @@ def to_excel(dt, epoch=WINDOWS_EPOCH): ...@@ -88,10 +88,17 @@ def to_excel(dt, epoch=WINDOWS_EPOCH):
return days return days
def from_excel(value, epoch=WINDOWS_EPOCH): def from_excel(value, epoch=WINDOWS_EPOCH, timedelta=False):
"""Convert Excel serial to Python datetime""" """Convert Excel serial to Python datetime"""
if value is None: if value is None:
return 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) day, fraction = divmod(value, 1)
diff = datetime.timedelta(milliseconds=round(fraction * SECS_PER_DAY * 1000)) diff = datetime.timedelta(milliseconds=round(fraction * SECS_PER_DAY * 1000))
if 0 <= value < 1 and diff.days == 0: if 0 <= value < 1 and diff.days == 0:
......
...@@ -123,6 +123,26 @@ def test_from_excel(value, expected): ...@@ -123,6 +123,26 @@ def test_from_excel(value, expected):
assert FUT(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", @pytest.mark.parametrize("value, expected",
[ [
(39385, datetime(2011, 10, 31)), (39385, datetime(2011, 10, 31)),
......
...@@ -104,6 +104,7 @@ class Workbook(object): ...@@ -104,6 +104,7 @@ class Workbook(object):
self._number_formats = IndexedList() self._number_formats = IndexedList()
self._date_formats = {} self._date_formats = {}
self._timedelta_formats = {}
self._protections = IndexedList([Protection()]) self._protections = IndexedList([Protection()])
......
...@@ -85,7 +85,8 @@ def _cast_number(value): ...@@ -85,7 +85,8 @@ def _cast_number(value):
class WorkSheetParser(object): class WorkSheetParser(object):
def __init__(self, src, shared_strings, data_only=False, 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.min_row = self.min_col = None
self.epoch = epoch self.epoch = epoch
self.source = src self.source = src
...@@ -96,6 +97,7 @@ class WorkSheetParser(object): ...@@ -96,6 +97,7 @@ class WorkSheetParser(object):
self.row_counter = self.col_counter = 0 self.row_counter = self.col_counter = 0
self.tables = TablePartList() self.tables = TablePartList()
self.date_formats = date_formats self.date_formats = date_formats
self.timedelta_formats = timedelta_formats
self.row_dimensions = {} self.row_dimensions = {}
self.column_dimensions = {} self.column_dimensions = {}
self.number_formats = [] self.number_formats = []
...@@ -198,7 +200,10 @@ class WorkSheetParser(object): ...@@ -198,7 +200,10 @@ class WorkSheetParser(object):
elif value is not None: elif value is not None:
if data_type == 'n': if data_type == 'n':
value = _cast_number(value) 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' data_type = 'd'
try: try:
value = from_excel(value, self.epoch) value = from_excel(value, self.epoch)
...@@ -337,7 +342,9 @@ class WorksheetReader(object): ...@@ -337,7 +342,9 @@ class WorksheetReader(object):
def __init__(self, ws, xml_source, shared_strings, data_only): def __init__(self, ws, xml_source, shared_strings, data_only):
self.ws = ws 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 = [] self.tables = []
......
...@@ -63,6 +63,7 @@ def Workbook(): ...@@ -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._cell_styles.add(StyleArray([0,4,6,0,0,1,0,0,0])) #fillId=4, borderId=6, alignmentId=1))
self.sheetnames = [] self.sheetnames = []
self._date_formats = set() self._date_formats = set()
self._timedelta_formats = set()
def create_sheet(self, title): def create_sheet(self, title):
return Worksheet(self) return Worksheet(self)
...@@ -80,7 +81,8 @@ def WorkSheetParser(): ...@@ -80,7 +81,8 @@ def WorkSheetParser():
styles.add((StyleArray([i]*9))) 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)) 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]) 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 from warnings import simplefilter
simplefilter("always") simplefilter("always")
...@@ -288,6 +290,21 @@ class TestWorksheetParser: ...@@ -288,6 +290,21 @@ class TestWorksheetParser:
'style_id':0, 'value': datetime.datetime(2011, 12, 25, 14, 23, 55)} '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): def test_mac_date(self, WorkSheetParser):
parser = WorkSheetParser parser = WorkSheetParser
parser.epoch = CALENDAR_MAC_1904 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