diff --git a/koala/excellib.py b/koala/excellib.py index 7ca17317..6b8350e3 100644 --- a/koala/excellib.py +++ b/koala/excellib.py @@ -9,9 +9,11 @@ from __future__ import absolute_import, division import numpy as np -from datetime import datetime, date +import datetime from math import log, ceil from decimal import Decimal, ROUND_UP, ROUND_HALF_UP +from calendar import monthrange +from dateutil.relativedelta import relativedelta from openpyxl.compat import unicode @@ -99,11 +101,14 @@ "ROUNDUP", "POWER", "SQRT", - "TODAY" + "TODAY", + "YEAR", + "MONTH", + "EOMONTH", ] CELL_CHARACTER_LIMIT = 32767 -EXCEL_EPOCH = datetime.strptime("1900-01-01", '%Y-%m-%d').date() +EXCEL_EPOCH = datetime.datetime.strptime("1900-01-01", '%Y-%m-%d').date() ###################################################################################### # List of excel equivalent functions @@ -419,6 +424,48 @@ def mod(nb, q): # Excel Reference: https://support.office.com/en-us/article/MOD- return nb % q +def eomonth(start_date, months): # Excel reference: https://support.office.com/en-us/article/eomonth-function-7314ffa1-2bc9-4005-9d66-f49db127d628 + if not is_number(start_date): + return ExcelError('#VALUE!', 'start_date %s must be a number' % str(start_date)) + if start_date < 0: + return ExcelError('#VALUE!', 'start_date %s must be positive' % str(start_date)) + + if not is_number(months): + return ExcelError('#VALUE!', 'months %s must be a number' % str(months)) + + y1, m1, d1 = date_from_int(start_date) + start_date_d = datetime.date(year=y1, month=m1, day=d1) + end_date_d = start_date_d + relativedelta(months=months) + y2 = end_date_d.year + m2 = end_date_d.month + d2 = monthrange(y2, m2)[1] + res = int(int_from_date(datetime.date(y2, m2, d2))) + + return res + + +def year(serial_number): # Excel reference: https://support.office.com/en-us/article/year-function-c64f017a-1354-490d-981f-578e8ec8d3b9 + if not is_number(serial_number): + return ExcelError('#VALUE!', 'start_date %s must be a number' % str(serial_number)) + if serial_number < 0: + return ExcelError('#VALUE!', 'start_date %s must be positive' % str(serial_number)) + + y1, m1, d1 = date_from_int(serial_number) + + return y1 + + +def month(serial_number): # Excel reference: https://support.office.com/en-us/article/month-function-579a2881-199b-48b2-ab90-ddba0eba86e8 + if not is_number(serial_number): + return ExcelError('#VALUE!', 'start_date %s must be a number' % str(serial_number)) + if serial_number < 0: + return ExcelError('#VALUE!', 'start_date %s must be positive' % str(serial_number)) + + y1, m1, d1 = date_from_int(serial_number) + + return m1 + + def count(*args): # Excel reference: https://support.office.com/en-us/article/COUNT-function-a59cd7fc-b623-4d93-87a4-d23bf411294c l = list(args) @@ -590,10 +637,10 @@ def date(year, month, day): # Excel reference: https://support.office.com/en-us/ year, month, day = normalize_year(year, month, day) # taking into account negative month and day values - date_0 = datetime(1900, 1, 1) - date = datetime(year, month, day) + date_0 = datetime.datetime(1900, 1, 1) + date = datetime.datetime(year, month, day) - result = (datetime(year, month, day) - date_0).days + 2 + result = (datetime.datetime(year, month, day) - date_0).days + 2 if result <= 0: return ExcelError('#VALUE!', 'Date result is negative') @@ -980,7 +1027,7 @@ def sqrt(number): # https://support.office.com/en-ie/article/today-function-5eb3078d-a82c-4736-8930-2f51a028fdd9 def today(): - reference_date = datetime.today().date() + reference_date = datetime.datetime.today().date() days_since_epoch = reference_date - EXCEL_EPOCH # why +2 ? # 1 based from 1900-01-01 diff --git a/koala/utils.py b/koala/utils.py index b53e08e7..3137f7fe 100644 --- a/koala/utils.py +++ b/koala/utils.py @@ -5,6 +5,7 @@ import collections import numbers import re +import datetime as dt from six import string_types from openpyxl.compat import unicode @@ -131,7 +132,7 @@ def resolve_range(rng, should_flatten = False, sheet=''): start_row = start end_col = "XFD" end_row = end - else: + else: sh, start_col, start_row = split_address(start) sh, end_col, end_row = split_address(end) @@ -407,11 +408,17 @@ def date_from_int(nb): if nb > max_days: nb -= max_days else: - current_day = nb + current_day = int(nb) nb = 0 return (current_year, current_month, current_day) +def int_from_date(date): + temp = dt.date(1899, 12, 30) # Note, not 31st Dec but 30th! + delta = date - temp + + return float(delta.days) + (float(delta.seconds) / 86400) + def criteria_parser(criteria): if is_number(criteria): diff --git a/requirements.txt b/requirements.txt index f82bea12..7d1b27ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ numpy==1.14.2 Cython==0.28.2 lxml==4.1.1 six==1.11.0 +python-dateutil==2.8.0 \ No newline at end of file diff --git a/tests/excel/test_functions.py b/tests/excel/test_functions.py index 0f573f7f..e0d8cb27 100644 --- a/tests/excel/test_functions.py +++ b/tests/excel/test_functions.py @@ -719,11 +719,14 @@ def test_first_argument_validity(self): def test_positive_integers(self): self.assertEqual(sqrt(16), 4) + def test_float(self): + self.assertEqual(sqrt(.25), .5) + class Test_Today(unittest.TestCase): - EXCEL_EPOCH = datetime.strptime("1900-01-01", '%Y-%m-%d').date() - reference_date = datetime.today().date() + EXCEL_EPOCH = datetime.datetime.strptime("1900-01-01", '%Y-%m-%d').date() + reference_date = datetime.datetime.today().date() days_since_epoch = reference_date - EXCEL_EPOCH todays_ordinal = days_since_epoch.days + 2 @@ -740,3 +743,29 @@ def test_first_argument_validity(self): def test_concatenate(self): self.assertEqual(concatenate("Hello", " ", "World!"), "Hello World!") + + +class Test_Year(unittest.TestCase): + + def test_results(self): + self.assertEqual(year(43566), 2019) # 11/04/2019 + self.assertEqual(year(43831), 2020) # 01/01/2020 + self.assertEqual(year(36525), 1999) # 31/12/1999 + + +class Test_Month(unittest.TestCase): + + def test_results(self): + self.assertEqual(month(43566), 4) # 11/04/2019 + self.assertEqual(month(43831), 1) # 01/01/2020 + self.assertEqual(month(36525), 12) # 31/12/1999 + + +class Test_Eomonth(unittest.TestCase): + + def test_results(self): + self.assertEqual(eomonth(43566, 2), 43646) # 11/04/2019, add 2 months + self.assertEqual(eomonth(43831, 5), 44012) # 01/01/2020, add 5 months + self.assertEqual(eomonth(36525, 1), 36556) # 31/12/1999, add 1 month + self.assertEqual(eomonth(36525, 15), 36981) # 31/12/1999, add 15 month + self.assertNotEqual(eomonth(36525, 15), 36980) # 31/12/1999, add 15 month