(core) move data engine code to core

Summary:
this moves sandbox/grist to core, and adds a requirements.txt
file for reconstructing the content of sandbox/thirdparty.

Test Plan:
existing tests pass.
Tested core functionality manually.  Tested docker build manually.

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2563
This commit is contained in:
Paul Fitzpatrick
2020-07-27 14:57:36 -04:00
parent 2399baaca2
commit b82eec714a
97 changed files with 29551 additions and 2 deletions

View File

@@ -0,0 +1,12 @@
# pylint: disable=wildcard-import
from date import *
from info import *
from logical import *
from lookup import *
from math import *
from stats import *
from text import *
from schedule import *
# Export all uppercase names, for use with `from functions import *`.
__all__ = [k for k in dir() if not k.startswith('_') and k.isupper()]

View File

@@ -0,0 +1,773 @@
import calendar
import datetime
import dateutil.parser
import moment
import docmodel
# pylint: disable=no-member
_excel_date_zero = datetime.datetime(1899, 12, 30)
def _make_datetime(value):
if isinstance(value, datetime.datetime):
return value
elif isinstance(value, datetime.date):
return datetime.datetime.combine(value, datetime.time())
elif isinstance(value, datetime.time):
return datetime.datetime.combine(datetime.date.today(), value)
elif isinstance(value, basestring):
return dateutil.parser.parse(value)
else:
raise ValueError('Invalid date %r' % (value,))
def _get_global_tz():
if docmodel.global_docmodel:
return docmodel.global_docmodel.doc_info.lookupOne(id=1).tzinfo
return moment.TZ_UTC
def _get_tzinfo(zonelabel):
"""
A helper that returns a `datetime.tzinfo` instance for zonelabel. Returns the global
document timezone if zonelabel is None.
"""
return moment.tzinfo(zonelabel) if zonelabel else _get_global_tz()
def DTIME(value, tz=None):
"""
Returns the value converted to a python `datetime` object. The value may be a
`string`, `date` (interpreted as midnight on that day), `time` (interpreted as a
time-of-day today), or an existing `datetime`.
The returned `datetime` will have its timezone set to the `tz` argument, or the
document's default timezone when `tz` is omitted or None. If the input is itself a
`datetime` with the timezone set, it is returned unchanged (no changes to its timezone).
>>> DTIME(datetime.date(2017, 1, 1))
datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DTIME(datetime.date(2017, 1, 1), 'Europe/Paris')
datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('Europe/Paris'))
>>> DTIME(datetime.datetime(2017, 1, 1))
datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DTIME(datetime.datetime(2017, 1, 1, tzinfo=moment.tzinfo('UTC')))
datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('UTC'))
>>> DTIME(datetime.datetime(2017, 1, 1, tzinfo=moment.tzinfo('UTC')), 'Europe/Paris')
datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('UTC'))
>>> DTIME("1/1/2008")
datetime.datetime(2008, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
"""
value = _make_datetime(value)
return value if value.tzinfo else value.replace(tzinfo=_get_tzinfo(tz))
def XL_TO_DATE(value, tz=None):
"""
Converts a provided Excel serial number representing a date into a `datetime` object.
Value is interpreted as the number of days since December 30, 1899.
(This corresponds to Google Sheets interpretation. Excel starts with Dec. 31, 1899 but wrongly
considers 1900 to be a leap year. Excel for Mac should be configured to use 1900 date system,
i.e. uncheck "Use the 1904 date system" option.)
The returned `datetime` will have its timezone set to the `tz` argument, or the
document's default timezone when `tz` is omitted or None.
>>> XL_TO_DATE(41100.1875)
datetime.datetime(2012, 7, 10, 4, 30, tzinfo=moment.tzinfo('America/New_York'))
>>> XL_TO_DATE(39448)
datetime.datetime(2008, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> XL_TO_DATE(40982.0625)
datetime.datetime(2012, 3, 14, 1, 30, tzinfo=moment.tzinfo('America/New_York'))
More tests:
>>> XL_TO_DATE(0)
datetime.datetime(1899, 12, 30, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> XL_TO_DATE(-1)
datetime.datetime(1899, 12, 29, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> XL_TO_DATE(1)
datetime.datetime(1899, 12, 31, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> XL_TO_DATE(1.5)
datetime.datetime(1899, 12, 31, 12, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> XL_TO_DATE(61.0)
datetime.datetime(1900, 3, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
"""
return DTIME(_excel_date_zero, tz) + datetime.timedelta(days=value)
def DATE_TO_XL(date_value):
"""
Converts a Python `date` or `datetime` object to the serial number as used by
Excel, with December 30, 1899 as serial number 1.
See XL_TO_DATE for more explanation.
>>> DATE_TO_XL(datetime.date(2008, 1, 1))
39448.0
>>> DATE_TO_XL(datetime.date(2012, 3, 14))
40982.0
>>> DATE_TO_XL(datetime.datetime(2012, 3, 14, 1, 30))
40982.0625
More tests:
>>> DATE_TO_XL(datetime.date(1900, 1, 1))
2.0
>>> DATE_TO_XL(datetime.datetime(1900, 1, 1))
2.0
>>> DATE_TO_XL(datetime.datetime(1900, 1, 1, 12, 0))
2.5
>>> DATE_TO_XL(datetime.datetime(1900, 1, 1, 12, 0, tzinfo=moment.tzinfo('America/New_York')))
2.5
>>> DATE_TO_XL(datetime.date(1900, 3, 1))
61.0
>>> DATE_TO_XL(datetime.datetime(2008, 1, 1))
39448.0
>>> DATE_TO_XL(XL_TO_DATE(39488))
39488.0
>>> dt_ny = XL_TO_DATE(39488)
>>> dt_paris = moment.tz(dt_ny, 'America/New_York').tz('Europe/Paris').datetime()
>>> DATE_TO_XL(dt_paris)
39488.0
"""
# If date_value is `naive` it's ok to pass tz to both DTIME as it won't affect the
# result.
return (DTIME(date_value) - DTIME(_excel_date_zero)).total_seconds() / 86400.
def DATE(year, month, day):
"""
Returns the `datetime.datetime` object that represents a particular date.
The DATE function is most useful in formulas where year, month, and day are formulas, not
constants.
If year is between 0 and 1899 (inclusive), adds 1900 to calculate the year.
>>> DATE(108, 1, 2)
datetime.date(2008, 1, 2)
>>> DATE(2008, 1, 2)
datetime.date(2008, 1, 2)
If month is greater than 12, rolls into the following year.
>>> DATE(2008, 14, 2)
datetime.date(2009, 2, 2)
If month is less than 1, subtracts that many months plus 1, from the first month in the year.
>>> DATE(2008, -3, 2)
datetime.date(2007, 9, 2)
If day is greater than the number of days in the given month, rolls into the following months.
>>> DATE(2008, 1, 35)
datetime.date(2008, 2, 4)
If day is less than 1, subtracts that many days plus 1, from the first day of the given month.
>>> DATE(2008, 1, -15)
datetime.date(2007, 12, 16)
More tests:
>>> DATE(1900, 1, 1)
datetime.date(1900, 1, 1)
>>> DATE(1900, 0, 0)
datetime.date(1899, 11, 30)
"""
if year < 1900:
year += 1900
norm_month = (month - 1) % 12 + 1
norm_year = year + (month - 1) // 12
return datetime.date(norm_year, norm_month, 1) + datetime.timedelta(days=day - 1)
def DATEDIF(start_date, end_date, unit):
"""
Calculates the number of days, months, or years between two dates.
Unit indicates the type of information that you want returned:
- "Y": The number of complete years in the period.
- "M": The number of complete months in the period.
- "D": The number of days in the period.
- "MD": The difference between the days in start_date and end_date. The months and years of the
dates are ignored.
- "YM": The difference between the months in start_date and end_date. The days and years of the
dates are ignored.
- "YD": The difference between the days of start_date and end_date. The years of the dates are
ignored.
Two complete years in the period (2)
>>> DATEDIF(DATE(2001, 1, 1), DATE(2003, 1, 1), "Y")
2
440 days between June 1, 2001, and August 15, 2002 (440)
>>> DATEDIF(DATE(2001, 6, 1), DATE(2002, 8, 15), "D")
440
75 days between June 1 and August 15, ignoring the years of the dates (75)
>>> DATEDIF(DATE(2001, 6, 1), DATE(2012, 8, 15), "YD")
75
The difference between 1 and 15, ignoring the months and the years of the dates (14)
>>> DATEDIF(DATE(2001, 6, 1), DATE(2002, 8, 15), "MD")
14
More tests:
>>> DATEDIF(DATE(1969, 7, 16), DATE(1969, 7, 24), "D")
8
>>> DATEDIF(DATE(2014, 1, 1), DATE(2015, 1, 1), "M")
12
>>> DATEDIF(DATE(2014, 1, 2), DATE(2015, 1, 1), "M")
11
>>> DATEDIF(DATE(2014, 1, 1), DATE(2024, 1, 1), "Y")
10
>>> DATEDIF(DATE(2014, 1, 2), DATE(2024, 1, 1), "Y")
9
>>> DATEDIF(DATE(1906, 10, 16), DATE(2004, 2, 3), "YM")
3
>>> DATEDIF(DATE(2016, 2, 14), DATE(2016, 3, 14), "YM")
1
>>> DATEDIF(DATE(2016, 2, 14), DATE(2016, 3, 13), "YM")
0
>>> DATEDIF(DATE(2008, 10, 16), DATE(2019, 12, 3), "MD")
17
>>> DATEDIF(DATE(2008, 11, 16), DATE(2019, 1, 3), "MD")
18
>>> DATEDIF(DATE(2016, 2, 29), DATE(2017, 2, 28), "Y")
0
>>> DATEDIF(DATE(2016, 2, 29), DATE(2017, 2, 29), "Y")
1
"""
if isinstance(start_date, datetime.datetime):
start_date = start_date.date()
if isinstance(end_date, datetime.datetime):
end_date = end_date.date()
if unit == 'D':
return (end_date - start_date).days
elif unit == 'M':
months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)
month_delta = 0 if start_date.day <= end_date.day else 1
return months - month_delta
elif unit == 'Y':
years = end_date.year - start_date.year
year_delta = 0 if (start_date.month, start_date.day) <= (end_date.month, end_date.day) else 1
return years - year_delta
elif unit == 'MD':
month_delta = 0 if start_date.day <= end_date.day else 1
return (end_date - DATE(end_date.year, end_date.month - month_delta, start_date.day)).days
elif unit == 'YM':
month_delta = 0 if start_date.day <= end_date.day else 1
return (end_date.month - start_date.month - month_delta) % 12
elif unit == 'YD':
year_delta = 0 if (start_date.month, start_date.day) <= (end_date.month, end_date.day) else 1
return (end_date - DATE(end_date.year - year_delta, start_date.month, start_date.day)).days
else:
raise ValueError('Invalid unit %s' % (unit,))
def DATEVALUE(date_string, tz=None):
"""
Converts a date that is stored as text to a `datetime` object.
>>> DATEVALUE("1/1/2008")
datetime.datetime(2008, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DATEVALUE("30-Jan-2008")
datetime.datetime(2008, 1, 30, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DATEVALUE("2008-12-11")
datetime.datetime(2008, 12, 11, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DATEVALUE("5-JUL").replace(year=2000)
datetime.datetime(2000, 7, 5, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
In case of ambiguity, prefer M/D/Y format.
>>> DATEVALUE("1/2/3")
datetime.datetime(2003, 1, 2, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
More tests:
>>> DATEVALUE("8/22/2011")
datetime.datetime(2011, 8, 22, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DATEVALUE("22-MAY-2011")
datetime.datetime(2011, 5, 22, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DATEVALUE("2011/02/23")
datetime.datetime(2011, 2, 23, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DATEVALUE("11/3/2011")
datetime.datetime(2011, 11, 3, 0, 0, tzinfo=moment.tzinfo('America/New_York'))
>>> DATE_TO_XL(DATEVALUE("11/3/2011"))
40850.0
>>> DATEVALUE("asdf")
Traceback (most recent call last):
...
ValueError: Unknown string format
"""
return dateutil.parser.parse(date_string).replace(tzinfo=_get_tzinfo(tz))
def DAY(date):
"""
Returns the day of a date, as an integer ranging from 1 to 31. Same as `date.day`.
>>> DAY(DATE(2011, 4, 15))
15
>>> DAY("5/31/2012")
31
>>> DAY(datetime.datetime(1900, 1, 1))
1
"""
return _make_datetime(date).day
def DAYS(end_date, start_date):
"""
Returns the number of days between two dates. Same as `(end_date - start_date).days`.
>>> DAYS("3/15/11","2/1/11")
42
>>> DAYS(DATE(2011, 12, 31), DATE(2011, 1, 1))
364
>>> DAYS("2/1/11", "3/15/11")
-42
"""
return (_make_datetime(end_date) - _make_datetime(start_date)).days
def EDATE(start_date, months):
"""
Returns the date that is the given number of months before or after `start_date`. Use
EDATE to calculate maturity dates or due dates that fall on the same day of the month as the
date of issue.
>>> EDATE(DATE(2011, 1, 15), 1)
datetime.date(2011, 2, 15)
>>> EDATE(DATE(2011, 1, 15), -1)
datetime.date(2010, 12, 15)
>>> EDATE(DATE(2011, 1, 15), 2)
datetime.date(2011, 3, 15)
>>> EDATE(DATE(2012, 3, 1), 10)
datetime.date(2013, 1, 1)
>>> EDATE(DATE(2012, 5, 1), -2)
datetime.date(2012, 3, 1)
"""
return DATE(start_date.year, start_date.month + months, start_date.day)
def DATEADD(start_date, days=0, months=0, years=0, weeks=0):
"""
Returns the date a given number of days, months, years, or weeks away from `start_date`. You may
specify arguments in any order if you specify argument names. Use negative values to subtract.
For example, `DATEADD(date, 1)` is the same as `DATEADD(date, days=1)`, ands adds one day to
`date`. `DATEADD(date, years=1, days=-1)` adds one year minus one day.
>>> DATEADD(DATE(2011, 1, 15), 1)
datetime.date(2011, 1, 16)
>>> DATEADD(DATE(2011, 1, 15), months=1, days=-1)
datetime.date(2011, 2, 14)
>>> DATEADD(DATE(2011, 1, 15), years=-2, months=1, days=3, weeks=2)
datetime.date(2009, 3, 4)
>>> DATEADD(DATE(1975, 4, 30), years=50, weeks=-5)
datetime.date(2025, 3, 26)
"""
return DATE(start_date.year + years, start_date.month + months,
start_date.day + days + weeks * 7)
def EOMONTH(start_date, months):
"""
Returns the date for the last day of the month that is the indicated number of months before or
after start_date. Use EOMONTH to calculate maturity dates or due dates that fall on the last day
of the month.
>>> EOMONTH(DATE(2011, 1, 1), 1)
datetime.date(2011, 2, 28)
>>> EOMONTH(DATE(2011, 1, 15), -3)
datetime.date(2010, 10, 31)
>>> EOMONTH(DATE(2012, 3, 1), 10)
datetime.date(2013, 1, 31)
>>> EOMONTH(DATE(2012, 5, 1), -2)
datetime.date(2012, 3, 31)
"""
return DATE(start_date.year, start_date.month + months + 1, 1) - datetime.timedelta(days=1)
def HOUR(time):
"""
Returns the hour of a `datetime`, as an integer from 0 (12:00 A.M.) to 23 (11:00 P.M.).
Same as `time.hour`.
>>> HOUR(XL_TO_DATE(0.75))
18
>>> HOUR("7/18/2011 7:45")
7
>>> HOUR("4/21/2012")
0
"""
return _make_datetime(time).hour
def ISOWEEKNUM(date):
"""
Returns the ISO week number of the year for a given date.
>>> ISOWEEKNUM("3/9/2012")
10
>>> [ISOWEEKNUM(DATE(2000 + y, 1, 1)) for y in [0,1,2,3,4,5,6,7,8]]
[52, 1, 1, 1, 1, 53, 52, 1, 1]
"""
return _make_datetime(date).isocalendar()[1]
def MINUTE(time):
"""
Returns the minutes of `datetime`, as an integer from 0 to 59.
Same as `time.minute`.
>>> MINUTE(XL_TO_DATE(0.75))
0
>>> MINUTE("7/18/2011 7:45")
45
>>> MINUTE("12:59:00 PM")
59
>>> MINUTE(datetime.time(12, 58, 59))
58
"""
return _make_datetime(time).minute
def MONTH(date):
"""
Returns the month of a date represented, as an integer from from 1 (January) to 12 (December).
Same as `date.month`.
>>> MONTH(DATE(2011, 4, 15))
4
>>> MONTH("5/31/2012")
5
>>> MONTH(datetime.datetime(1900, 1, 1))
1
"""
return _make_datetime(date).month
def NOW(tz=None):
"""
Returns the `datetime` object for the current time.
"""
return datetime.datetime.now(_get_tzinfo(tz))
def SECOND(time):
"""
Returns the seconds of `datetime`, as an integer from 0 to 59.
Same as `time.second`.
>>> SECOND(XL_TO_DATE(0.75))
0
>>> SECOND("7/18/2011 7:45:13")
13
>>> SECOND(datetime.time(12, 58, 59))
59
"""
return _make_datetime(time).second
def TODAY():
"""
Returns the `date` object for the current date.
"""
return datetime.date.today()
_weekday_type_map = {
# type: (first day of week (according to date.weekday()), number to return for it)
1: (6, 1),
2: (0, 1),
3: (0, 0),
11: (0, 1),
12: (1, 1),
13: (2, 1),
14: (3, 1),
15: (4, 1),
16: (5, 1),
17: (6, 1),
}
def WEEKDAY(date, return_type=1):
"""
Returns the day of the week corresponding to a date. The day is given as an integer, ranging
from 1 (Sunday) to 7 (Saturday), by default.
Return_type determines the type of the returned value.
- 1 (default) - Returns 1 (Sunday) through 7 (Saturday).
- 2 - Returns 1 (Monday) through 7 (Sunday).
- 3 - Returns 0 (Monday) through 6 (Sunday).
- 11 - Returns 1 (Monday) through 7 (Sunday).
- 12 - Returns 1 (Tuesday) through 7 (Monday).
- 13 - Returns 1 (Wednesday) through 7 (Tuesday).
- 14 - Returns 1 (Thursday) through 7 (Wednesday).
- 15 - Returns 1 (Friday) through 7 (Thursday).
- 16 - Returns 1 (Saturday) through 7 (Friday).
- 17 - Returns 1 (Sunday) through 7 (Saturday).
>>> WEEKDAY(DATE(2008, 2, 14))
5
>>> WEEKDAY(DATE(2012, 3, 1))
5
>>> WEEKDAY(DATE(2012, 3, 1), 1)
5
>>> WEEKDAY(DATE(2012, 3, 1), 2)
4
>>> WEEKDAY("3/1/2012", 3)
3
More tests:
>>> WEEKDAY(XL_TO_DATE(10000), 1)
4
>>> WEEKDAY(DATE(1901, 1, 1))
3
>>> WEEKDAY(DATE(1901, 1, 1), 2)
2
>>> [WEEKDAY(DATE(2008, 2, d)) for d in [10, 11, 12, 13, 14, 15, 16, 17]]
[1, 2, 3, 4, 5, 6, 7, 1]
>>> [WEEKDAY(DATE(2008, 2, d), 1) for d in [10, 11, 12, 13, 14, 15, 16, 17]]
[1, 2, 3, 4, 5, 6, 7, 1]
>>> [WEEKDAY(DATE(2008, 2, d), 17) for d in [10, 11, 12, 13, 14, 15, 16, 17]]
[1, 2, 3, 4, 5, 6, 7, 1]
>>> [WEEKDAY(DATE(2008, 2, d), 2) for d in [10, 11, 12, 13, 14, 15, 16, 17]]
[7, 1, 2, 3, 4, 5, 6, 7]
>>> [WEEKDAY(DATE(2008, 2, d), 3) for d in [10, 11, 12, 13, 14, 15, 16, 17]]
[6, 0, 1, 2, 3, 4, 5, 6]
"""
if return_type not in _weekday_type_map:
raise ValueError("Invalid return type %s" % (return_type,))
(first, index) = _weekday_type_map[return_type]
return (_make_datetime(date).weekday() - first) % 7 + index
def WEEKNUM(date, return_type=1):
"""
Returns the week number of a specific date. For example, the week containing January 1 is the
first week of the year, and is numbered week 1.
Return_type determines which week is considered the first week of the year.
- 1 (default) - Week 1 is the first week starting Sunday that contains January 1.
- 2 - Week 1 is the first week starting Monday that contains January 1.
- 11 - Week 1 is the first week starting Monday that contains January 1.
- 12 - Week 1 is the first week starting Tuesday that contains January 1.
- 13 - Week 1 is the first week starting Wednesday that contains January 1.
- 14 - Week 1 is the first week starting Thursday that contains January 1.
- 15 - Week 1 is the first week starting Friday that contains January 1.
- 16 - Week 1 is the first week starting Saturday that contains January 1.
- 17 - Week 1 is the first week starting Sunday that contains January 1.
- 21 - ISO 8601 Approach: Week 1 is the first week starting Monday that contains January 4.
Equivalently, it is the week that contains the first Thursday of the year.
>>> WEEKNUM(DATE(2012, 3, 9))
10
>>> WEEKNUM(DATE(2012, 3, 9), 2)
11
>>> WEEKNUM('1/1/1900')
1
>>> WEEKNUM('2/1/1900')
5
More tests:
>>> WEEKNUM('2/1/1909', 2)
6
>>> WEEKNUM('1/1/1901', 21)
1
>>> [WEEKNUM(DATE(2012, 3, 9), t) for t in [1,2,11,12,13,14,15,16,17,21]]
[10, 11, 11, 11, 11, 11, 11, 10, 10, 10]
"""
if return_type == 21:
return ISOWEEKNUM(date)
if return_type not in _weekday_type_map:
raise ValueError("Invalid return type %s" % (return_type,))
(first, index) = _weekday_type_map[return_type]
date = _make_datetime(date)
jan1 = datetime.datetime(date.year, 1, 1)
week1_start = jan1 - datetime.timedelta(days=(jan1.weekday() - first) % 7)
return (date - week1_start).days // 7 + 1
def YEAR(date):
"""
Returns the year corresponding to a date as an integer.
Same as `date.year`.
>>> YEAR(DATE(2011, 4, 15))
2011
>>> YEAR("5/31/2030")
2030
>>> YEAR(datetime.datetime(1900, 1, 1))
1900
"""
return _make_datetime(date).year
def _date_360(y, m, d):
return y * 360 + m * 30 + d
def _last_of_feb(date):
return date.month == 2 and (date + datetime.timedelta(days=1)).month == 3
def YEARFRAC(start_date, end_date, basis=0):
"""
Calculates the fraction of the year represented by the number of whole days between two dates.
Basis is the type of day count basis to use.
* `0` (default) - US (NASD) 30/360
* `1` - Actual/actual
* `2` - Actual/360
* `3` - Actual/365
* `4` - European 30/360
* `-1` - Actual/actual (Google Sheets variation)
This function is useful for financial calculations. For compatibility with Excel, it defaults to
using the NASD standard calendar. For use in non-financial settings, option `-1` is
likely the best choice.
See <https://en.wikipedia.org/wiki/360-day_calendar> for explanation of
the US 30/360 and European 30/360 methods. See <http://www.dwheeler.com/yearfrac/> for analysis of
Excel's particular implementation.
Basis `-1` is similar to `1`, but differs from Excel when dates span both leap and non-leap years.
It matches the calculation in Google Sheets, counting the days in each year as a fraction of
that year's length.
Fraction of the year between 1/1/2012 and 7/30/12, omitting the Basis argument.
>>> "%.8f" % YEARFRAC(DATE(2012, 1, 1), DATE(2012, 7, 30))
'0.58055556'
Fraction between same dates, using the Actual/Actual basis argument. Because 2012 is a Leap
year, it has a 366 day basis.
>>> "%.8f" % YEARFRAC(DATE(2012, 1, 1), DATE(2012, 7, 30), 1)
'0.57650273'
Fraction between same dates, using the Actual/365 basis argument. Uses a 365 day basis.
>>> "%.8f" % YEARFRAC(DATE(2012, 1, 1), DATE(2012, 7, 30), 3)
'0.57808219'
More tests:
>>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30)), 10)
0.4972222222
>>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 0), 10)
0.4972222222
>>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 1), 10)
0.4945355191
>>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 2), 10)
0.5027777778
>>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 3), 10)
0.495890411
>>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 4), 10)
0.4972222222
>>> [YEARFRAC(DATE(2012, 1, 1), DATE(2012, 1, 1), t) for t in [0, 1, -1, 2, 3, 4]]
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
>>> [round(YEARFRAC(DATE(1985, 3, 15), DATE(2016, 2, 29), t), 6) for t in [0, 1, -1, 2, 3, 4]]
[30.955556, 30.959617, 30.961202, 31.411111, 30.980822, 30.955556]
>>> [round(YEARFRAC(DATE(2001, 2, 28), DATE(2016, 3, 31), t), 6) for t in [0, 1, -1, 2, 3, 4]]
[15.086111, 15.085558, 15.086998, 15.305556, 15.09589, 15.088889]
>>> [round(YEARFRAC(DATE(1968, 4, 7), DATE(2011, 2, 14), t), 6) for t in [0, 1, -1, 2, 3, 4]]
[42.852778, 42.855578, 42.855521, 43.480556, 42.884932, 42.852778]
Here we test "basis 1" on leap and non-leap years.
>>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2015, 3, 1), t), 6) for t in [1, -1]]
[0.161644, 0.161644]
>>> [round(YEARFRAC(DATE(2016, 1, 1), DATE(2016, 3, 1), t), 6) for t in [1, -1]]
[0.163934, 0.163934]
>>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2016, 1, 1), t), 6) for t in [1, -1]]
[1.0, 1.0]
>>> [round(YEARFRAC(DATE(2016, 1, 1), DATE(2017, 1, 1), t), 6) for t in [1, -1]]
[1.0, 1.0]
>>> [round(YEARFRAC(DATE(2016, 2, 29), DATE(2017, 1, 1), t), 6) for t in [1, -1]]
[0.838798, 0.838798]
>>> [round(YEARFRAC(DATE(2014, 12, 15), DATE(2015, 3, 15), t), 6) for t in [1, -1]]
[0.246575, 0.246575]
For these examples, Google Sheets differs from Excel, and we match Excel here.
>>> [round(YEARFRAC(DATE(2015, 12, 15), DATE(2016, 3, 15), t), 6) for t in [1, -1]]
[0.248634, 0.248761]
>>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2016, 2, 29), t), 6) for t in [1, -1]]
[1.160055, 1.161202]
>>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2016, 2, 28), t), 6) for t in [1, -1]]
[1.157319, 1.15847]
>>> [round(YEARFRAC(DATE(2015, 3, 1), DATE(2016, 2, 29), t), 6) for t in [1, -1]]
[0.997268, 0.999558]
>>> [round(YEARFRAC(DATE(2015, 3, 1), DATE(2016, 2, 28), t), 6) for t in [1, -1]]
[0.99726, 0.996826]
>>> [round(YEARFRAC(DATE(2016, 3, 1), DATE(2017, 1, 1), t), 6) for t in [1, -1]]
[0.838356, 0.836066]
>>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2017, 1, 1), t), 6) for t in [1, -1]]
[2.000912, 2.0]
"""
# pylint: disable=too-many-return-statements
# This function is actually completely crazy. The rules are strange too. We'll follow the logic
# in http://www.dwheeler.com/yearfrac/excel-ooxml-yearfrac.pdf
if start_date == end_date:
return 0.0
if start_date > end_date:
start_date, end_date = end_date, start_date
d1, m1, y1 = start_date.day, start_date.month, start_date.year
d2, m2, y2 = end_date.day, end_date.month, end_date.year
if basis == 0:
if d1 == 31:
d1 = 30
if d1 == 30 and d2 == 31:
d2 = 30
if _last_of_feb(start_date):
d1 = 30
if _last_of_feb(end_date):
d2 = 30
return (_date_360(y2, m2, d2) - _date_360(y1, m1, d1)) / 360.0
elif basis == 1:
# This implements Excel's convoluted logic.
if (y1 + 1, m1, d1) >= (y2, m2, d2):
# Less than or equal to one year.
if y1 == y2 and calendar.isleap(y1):
year_length = 366.0
elif (y1, m1, d1) < (y2, 2, 29) <= (y2, m2, d2) and calendar.isleap(y2):
year_length = 366.0
elif (y1, m1, d1) <= (y1, 2, 29) < (y2, m2, d2) and calendar.isleap(y1):
year_length = 366.0
else:
year_length = 365.0
else:
year_length = (datetime.date(y2 + 1, 1, 1) - datetime.date(y1, 1, 1)).days / (y2 + 1.0 - y1)
return (end_date - start_date).days / year_length
elif basis == -1:
# This is Google Sheets implementation. Call it an overkill, but I think it's more sensible.
#
# Excel's logic has the unfortunate property that YEARFRAC(a, b) + YEARFRAC(b, c) is not
# always equal to YEARFRAC(a, c). Google Sheets implements a variation that does have this
# property, counting the days in each year as a fraction of that year's length (as if each day
# is counted as 1/365 or 1/366 depending on the year).
#
# The one redeeming quality of Excel's logic is that YEARFRAC for two days that differ by
# exactly one year is 1.0 (not always true for GS). But in GS version, YEARFRAC between any
# two Jan 1 is always a whole number (not always true in Excel).
if y1 == y2:
return _one_year_frac(start_date, end_date)
return (
+ _one_year_frac(start_date, datetime.date(y1 + 1, 1, 1))
+ (y2 - y1 - 1)
+ _one_year_frac(datetime.date(y2, 1, 1), end_date)
)
elif basis == 2:
return (end_date - start_date).days / 360.0
elif basis == 3:
return (end_date - start_date).days / 365.0
elif basis == 4:
if d1 == 31:
d1 = 30
if d2 == 31:
d2 = 30
return (_date_360(y2, m2, d2) - _date_360(y1, m1, d1)) / 360.0
raise ValueError('Invalid basis argument %r' % (basis,))
def _one_year_frac(start_date, end_date):
year_length = 366.0 if calendar.isleap(start_date.year) else 365.0
return (end_date - start_date).days / year_length

View File

@@ -0,0 +1,520 @@
# -*- coding: UTF-8 -*-
# pylint: disable=unused-argument
from __future__ import absolute_import
import datetime
import math
import numbers
import re
from functions import date # pylint: disable=import-error
from usertypes import AltText # pylint: disable=import-error
from records import Record # pylint: disable=import-error
def ISBLANK(value):
"""
Returns whether a value refers to an empty cell. It isn't implemented in Grist. To check for an
empty string, use `value == ""`.
"""
raise NotImplementedError()
def ISERR(value):
"""
Checks whether a value is an error. In other words, it returns true
if using `value` directly would raise an exception.
NOTE: Grist implements this by automatically wrapping the argument to use lazy evaluation.
A more Pythonic approach to checking for errors is:
```
try:
... value ...
except Exception, err:
... do something about the error ...
```
For example:
>>> ISERR("Hello")
False
More tests:
>>> ISERR(lambda: (1/0.1))
False
>>> ISERR(lambda: (1/0.0))
True
>>> ISERR(lambda: "test".bar())
True
>>> ISERR(lambda: "test".upper())
False
>>> ISERR(lambda: AltText("A"))
False
>>> ISERR(lambda: float('nan'))
False
>>> ISERR(lambda: None)
False
"""
return lazy_value_or_error(value) is _error_sentinel
def ISERROR(value):
"""
Checks whether a value is an error or an invalid value. It is similar to `ISERR`, but also
returns true for an invalid value such as NaN or a text value in a Numeric column.
NOTE: Grist implements this by automatically wrapping the argument to use lazy evaluation.
>>> ISERROR("Hello")
False
>>> ISERROR(AltText("fail"))
True
>>> ISERROR(float('nan'))
True
More tests:
>>> ISERROR(AltText(""))
True
>>> [ISERROR(v) for v in [0, None, "", "Test", 17.0]]
[False, False, False, False, False]
>>> ISERROR(lambda: (1/0.1))
False
>>> ISERROR(lambda: (1/0.0))
True
>>> ISERROR(lambda: "test".bar())
True
>>> ISERROR(lambda: "test".upper())
False
>>> ISERROR(lambda: AltText("A"))
True
>>> ISERROR(lambda: float('nan'))
True
>>> ISERROR(lambda: None)
False
"""
return is_error(lazy_value_or_error(value))
def ISLOGICAL(value):
"""
Checks whether a value is `True` or `False`.
>>> ISLOGICAL(True)
True
>>> ISLOGICAL(False)
True
>>> ISLOGICAL(0)
False
>>> ISLOGICAL(None)
False
>>> ISLOGICAL("Test")
False
"""
return isinstance(value, bool)
def ISNA(value):
"""
Checks whether a value is the error `#N/A`.
>>> ISNA(float('nan'))
True
>>> ISNA(0.0)
False
>>> ISNA('text')
False
>>> ISNA(float('-inf'))
False
"""
return isinstance(value, float) and math.isnan(value)
def ISNONTEXT(value):
"""
Checks whether a value is non-textual.
>>> ISNONTEXT("asdf")
False
>>> ISNONTEXT("")
False
>>> ISNONTEXT(AltText("text"))
False
>>> ISNONTEXT(17.0)
True
>>> ISNONTEXT(None)
True
>>> ISNONTEXT(datetime.date(2011, 1, 1))
True
"""
return not ISTEXT(value)
def ISNUMBER(value):
"""
Checks whether a value is a number.
>>> ISNUMBER(17)
True
>>> ISNUMBER(-123.123423)
True
>>> ISNUMBER(False)
True
>>> ISNUMBER(float('nan'))
True
>>> ISNUMBER(float('inf'))
True
>>> ISNUMBER('17')
False
>>> ISNUMBER(None)
False
>>> ISNUMBER(datetime.date(2011, 1, 1))
False
More tests:
>>> ISNUMBER(AltText("text"))
False
>>> ISNUMBER('')
False
"""
return isinstance(value, numbers.Number)
def ISREF(value):
"""
Checks whether a value is a table record.
For example, if a column person is of type Reference to the People table, then ISREF($person)
is True.
Similarly, ISREF(People.lookupOne(name=$name)) is True. For any other type of value,
ISREF() would evaluate to False.
>>> ISREF(17)
False
>>> ISREF("Roger")
False
"""
return isinstance(value, Record)
def ISTEXT(value):
"""
Checks whether a value is text.
>>> ISTEXT("asdf")
True
>>> ISTEXT("")
True
>>> ISTEXT(AltText("text"))
True
>>> ISTEXT(17.0)
False
>>> ISTEXT(None)
False
>>> ISTEXT(datetime.date(2011, 1, 1))
False
"""
return isinstance(value, (basestring, AltText))
# Regexp for matching email. See ISEMAIL for justification.
_email_regexp = re.compile(
r"""
^\w # Start with an alphanumeric character
[\w%+/='-]* (\.[\w%+/='-]+)* # Elsewhere allow also a few other special characters
# But no two consecutive periods
@
([A-Za-z0-9] # Each part of hostname must start with alphanumeric
([A-Za-z0-9-]*[A-Za-z0-9])?\. # May have dashes inside, but end in alphanumeric
)+
[A-Za-z]{2,6}$ # Restrict top-level domain to length {2,6}. Google seems
# to use a whitelist for TLDs longer than 2 characters.
""", re.UNICODE | re.VERBOSE)
# Regexp for matching hostname part of URLs (see also ISURL). Duplicates part of _email_regexp.
_hostname_regexp = re.compile(
r"""^
([A-Za-z0-9] # Each part of hostname must start with alphanumeric
([A-Za-z0-9-]*[A-Za-z0-9])?\. # May have dashes inside, but end in alphanumeric
)+
[A-Za-z]{2,6}$ # Restrict top-level domain to length {2,6}. Google seems
""", re.VERBOSE)
def ISEMAIL(value):
u"""
Returns whether a value is a valid email address.
Note that checking email validity is not an exact science. The technical standard considers many
email addresses valid that are not used in practice, and would not be considered valid by most
users. Instead, we follow Google Sheets implementation, with some differences, noted below.
>>> ISEMAIL("Abc.123@example.com")
True
>>> ISEMAIL("Bob_O-Reilly+tag@example.com")
True
>>> ISEMAIL("John Doe")
False
>>> ISEMAIL("john@aol...com")
False
More tests:
>>> ISEMAIL("Abc@example.com") # True, True
True
>>> ISEMAIL("Abc.123@example.com") # True, True
True
>>> ISEMAIL("foo@bar.com") # True, True
True
>>> ISEMAIL("asdf@com.zt") # True, True
True
>>> ISEMAIL("Bob_O-Reilly+tag@example.com") # True, True
True
>>> ISEMAIL("john@server.department.company.com") # True, True
True
>>> ISEMAIL("asdf@mail.ru") # True, True
True
>>> ISEMAIL("fabio@foo.qwer.COM") # True, True
True
>>> ISEMAIL("user+mailbox/department=shipping@example.com") # False, True
True
>>> ISEMAIL(u"user+mailbox/department=shipping@example.com") # False, True
True
>>> ISEMAIL("customer/department=shipping@example.com") # False, True
True
>>> ISEMAIL("Bob_O'Reilly+tag@example.com") # False, True
True
>>> ISEMAIL(u"фыва@mail.ru") # False, True
True
>>> ISEMAIL("my@baddash.-.com") # True, False
False
>>> ISEMAIL("my@baddash.-a.com") # True, False
False
>>> ISEMAIL("my@baddash.b-.com") # True, False
False
>>> ISEMAIL("john@-.com") # True, False
False
>>> ISEMAIL("fabio@disapproved.solutions") # False, False
False
>>> ISEMAIL("!def!xyz%abc@example.com") # False, False
False
>>> ISEMAIL("!#$%&'*+-/=?^_`.{|}~@example.com") # False, False
False
>>> ISEMAIL(u"伊昭傑@郵件.商務") # False, False
False
>>> ISEMAIL(u"राम@मोहन.ईन्फो") # False, Fale
False
>>> ISEMAIL(u"юзер@екзампл.ком") # False, False
False
>>> ISEMAIL(u"θσερ@εχαμπλε.ψομ") # False, False
False
>>> ISEMAIL(u"葉士豪@臺網中心.tw") # False, False
False
>>> ISEMAIL(u"jeff@臺網中心.tw") # False, False
False
>>> ISEMAIL(u"葉士豪@臺網中心.台灣") # False, False
False
>>> ISEMAIL(u"jeff葉@臺網中心.tw") # False, False
False
>>> ISEMAIL("myname@domain.com") # False, False
False
>>> ISEMAIL("my.name@domaincom") # False, False
False
>>> ISEMAIL("my@.leadingdot.com") # False, False
False
>>> ISEMAIL("my@leadingfwdot.com") # False, False
False
>>> ISEMAIL("my@..twodots.com") # False, False
False
>>> ISEMAIL("my@twodots..com") # False, False
False
>>> ISEMAIL(".leadingdot@domain.com") # False, False
False
>>> ISEMAIL("..twodots@domain.com") # False, False
False
>>> ISEMAIL("twodots..here@domain.com") # False, False
False
>>> ISEMAIL("me@⒈wouldbeinvalid.com") # False, False
False
>>> ISEMAIL("Foo Bar <a+2asdf@qwer.bar.com>") # False, False
False
>>> ISEMAIL("Abc\\@def@example.com") # False, False
False
>>> ISEMAIL("foo@bar@google.com") # False, False
False
>>> ISEMAIL("john@aol...com") # False, False
False
>>> ISEMAIL("x@ทีเอชนิค.ไทย") # False, False
False
>>> ISEMAIL("asdf@mail") # False, False
False
>>> ISEMAIL("example@良好Mail.中国") # False, False
False
"""
return bool(_email_regexp.match(value))
_url_regexp = re.compile(
r"""^
((ftp|http|https|gopher|mailto|news|telnet|aim)://)?
(\w+@)? # Allow 'user@' part, esp. useful for mailto: URLs.
([A-Za-z0-9] # Each part of hostname must start with alphanumeric
([A-Za-z0-9-]*[A-Za-z0-9])?\. # May have dashes inside, but end in alphanumeric
)+
[A-Za-z]{2,6} # Restrict top-level domain to length {2,6}. Google seems
# to use a whitelist for TLDs longer than 2 characters.
([/?][-\w!#$%&'()*+,./:;=?@~]*)?$ # Notably, this excludes <, >, and ".
""", re.VERBOSE)
def ISURL(value):
"""
Checks whether a value is a valid URL. It does not need to be fully qualified, or to include
"http://" and "www". It does not follow a standard, but attempts to work similarly to ISURL in
Google Sheets, and to return True for text that is likely a URL.
Valid protocols include ftp, http, https, gopher, mailto, news, telnet, and aim.
>>> ISURL("http://www.getgrist.com")
True
>>> ISURL("https://foo.com/test_(wikipedia)#cite-1")
True
>>> ISURL("mailto://user@example.com")
True
>>> ISURL("http:///a")
False
More tests:
>>> ISURL("http://www.google.com")
True
>>> ISURL("www.google.com/")
True
>>> ISURL("google.com")
True
>>> ISURL("http://a.b-c.de")
True
>>> ISURL("a.b-c.de")
True
>>> ISURL("http://j.mp/---")
True
>>> ISURL("ftp://foo.bar/baz")
True
>>> ISURL("https://foo.com/blah_(wikipedia)#cite-1")
True
>>> ISURL("mailto://user@google.com")
True
>>> ISURL("http://user@www.google.com")
True
>>> ISURL("http://foo.com/!#$%25&'()*+,-./=?@_~")
True
>>> ISURL("http://../")
False
>>> ISURL("http://??/")
False
>>> ISURL("a.-b.cd")
False
>>> ISURL("http://foo.bar?q=Spaces should be encoded ")
False
>>> ISURL("//")
False
>>> ISURL("///a")
False
>>> ISURL("http:///a")
False
>>> ISURL("bar://www.google.com")
False
>>> ISURL("http:// shouldfail.com")
False
>>> ISURL("ftps://foo.bar/")
False
>>> ISURL("http://-error-.invalid/")
False
>>> ISURL("http://0.0.0.0")
False
>>> ISURL("http://.www.foo.bar/")
False
>>> ISURL("http://.www.foo.bar./")
False
>>> ISURL("example.com/file[/].html")
False
>>> ISURL("http://example.com/file[/].html")
False
>>> ISURL("http://mw1.google.com/kml-samples/gp/seattle/gigapxl/$[level]/r$[y]_c$[x].jpg")
False
>>> ISURL("http://foo.com/>")
False
"""
value = value.strip()
if ' ' in value: # Disallow spaces inside value.
return False
return bool(_url_regexp.match(value))
def N(value):
"""
Returns the value converted to a number. True/False are converted to 1/0. A date is converted to
Excel-style serial number of the date. Anything else is converted to 0.
>>> N(7)
7
>>> N(7.1)
7.1
>>> N("Even")
0
>>> N("7")
0
>>> N(True)
1
>>> N(datetime.datetime(2011, 4, 17))
40650.0
"""
if ISNUMBER(value):
return value
if isinstance(value, datetime.date):
return date.DATE_TO_XL(value)
return 0
def NA():
"""
Returns the "value not available" error, `#N/A`.
>>> math.isnan(NA())
True
"""
return float('nan')
def TYPE(value):
"""
Returns a number associated with the type of data passed into the function. This is not
implemented in Grist. Use `isinstance(value, type)` or `type(value)`.
"""
raise NotImplementedError()
def CELL(info_type, reference):
"""
Returns the requested information about the specified cell. This is not implemented in Grist
"""
raise NotImplementedError()
# Unique sentinel value to represent that a lazy value evaluates with an exception.
_error_sentinel = object()
def lazy_value_or_error(value):
"""
Evaluates a value like lazy_value(), but returns _error_sentinel on exception.
"""
try:
return value() if callable(value) else value
except Exception:
return _error_sentinel
def is_error(value):
"""
Checks whether a value is an invalid value or _error_sentinel.
"""
return ((value is _error_sentinel)
or isinstance(value, AltText)
or (isinstance(value, float) and math.isnan(value)))

View File

@@ -0,0 +1,165 @@
from info import lazy_value_or_error, is_error
from usertypes import AltText # pylint: disable=unused-import,import-error
def AND(logical_expression, *logical_expressions):
"""
Returns True if all of the arguments are logically true, and False if any are false.
Same as `all([value1, value2, ...])`.
>>> AND(1)
True
>>> AND(0)
False
>>> AND(1, 1)
True
>>> AND(1,2,3,4)
True
>>> AND(1,2,3,4,0)
False
"""
return all((logical_expression,) + logical_expressions)
def FALSE():
"""
Returns the logical value `False`. You may also use the value `False` directly. This
function is provided primarily for compatibility with other spreadsheet programs.
>>> FALSE()
False
"""
return False
def IF(logical_expression, value_if_true, value_if_false):
"""
Returns one value if a logical expression is `True` and another if it is `False`.
The equivalent Python expression is:
```
value_if_true if logical_expression else value_if_false
```
Since Grist supports multi-line formulas, you may also use Python blocks such as:
```
if logical_expression:
return value_if_true
else:
return value_if_false
```
NOTE: Grist follows Excel model by only evaluating one of the value expressions, by
automatically wrapping the expressions to use lazy evaluation. This allows `IF(False, 1/0, 1)`
to evaluate to `1` rather than raise an exception.
>>> IF(12, "Yes", "No")
'Yes'
>>> IF(None, "Yes", "No")
'No'
>>> IF(True, 0.85, 0.0)
0.85
>>> IF(False, 0.85, 0.0)
0.0
More tests:
>>> IF(True, lambda: (1/0), lambda: (17))
Traceback (most recent call last):
...
ZeroDivisionError: integer division or modulo by zero
>>> IF(False, lambda: (1/0), lambda: (17))
17
"""
return lazy_value(value_if_true) if logical_expression else lazy_value(value_if_false)
def IFERROR(value, value_if_error=""):
"""
Returns the first argument if it is not an error value, otherwise returns the second argument if
present, or a blank if the second argument is absent.
NOTE: Grist handles values that raise an exception by wrapping them to use lazy evaluation.
>>> IFERROR(float('nan'), "**NAN**")
'**NAN**'
>>> IFERROR(17.17, "**NAN**")
17.17
>>> IFERROR("Text")
'Text'
>>> IFERROR(AltText("hello"))
''
More tests:
>>> IFERROR(lambda: (1/0.1), "X")
10.0
>>> IFERROR(lambda: (1/0.0), "X")
'X'
>>> IFERROR(lambda: AltText("A"), "err")
'err'
>>> IFERROR(lambda: None, "err")
>>> IFERROR(lambda: foo.bar, 123)
123
>>> IFERROR(lambda: "test".bar(), 123)
123
>>> IFERROR(lambda: "test".bar())
''
>>> IFERROR(lambda: "test".upper(), 123)
'TEST'
"""
value = lazy_value_or_error(value)
return value if not is_error(value) else value_if_error
def NOT(logical_expression):
"""
Returns the opposite of a logical value: `NOT(True)` returns `False`; `NOT(False)` returns
`True`. Same as `not logical_expression`.
>>> NOT(123)
False
>>> NOT(0)
True
"""
return not logical_expression
def OR(logical_expression, *logical_expressions):
"""
Returns True if any of the arguments is logically true, and false if all of the
arguments are false.
Same as `any([value1, value2, ...])`.
>>> OR(1)
True
>>> OR(0)
False
>>> OR(1, 1)
True
>>> OR(0, 1)
True
>>> OR(0, 0)
False
>>> OR(0,False,0.0,"",None)
False
>>> OR(0,None,3,0)
True
"""
return any((logical_expression,) + logical_expressions)
def TRUE():
"""
Returns the logical value `True`. You may also use the value `True` directly. This
function is provided primarily for compatibility with other spreadsheet programs.
>>> TRUE()
True
"""
return True
def lazy_value(value):
"""
Evaluates a lazy value by calling it when it's a callable, or returns it unchanged otherwise.
"""
return value() if callable(value) else value

View File

@@ -0,0 +1,80 @@
# pylint: disable=redefined-builtin, line-too-long
def ADDRESS(row, column, absolute_relative_mode, use_a1_notation, sheet):
"""Returns a cell reference as a string."""
raise NotImplementedError()
def CHOOSE(index, choice1, choice2):
"""Returns an element from a list of choices based on index."""
raise NotImplementedError()
def COLUMN(cell_reference=None):
"""Returns the column number of a specified cell, with `A=1`."""
raise NotImplementedError()
def COLUMNS(range):
"""Returns the number of columns in a specified array or range."""
raise NotImplementedError()
def GETPIVOTDATA(value_name, any_pivot_table_cell, original_column_1, pivot_item_1=None, *args):
"""Extracts an aggregated value from a pivot table that corresponds to the specified row and column headings."""
raise NotImplementedError()
def HLOOKUP(search_key, range, index, is_sorted):
"""Horizontal lookup. Searches across the first row of a range for a key and returns the value of a specified cell in the column found."""
raise NotImplementedError()
def HYPERLINK(url, link_label):
"""Creates a hyperlink inside a cell."""
raise NotImplementedError()
def INDEX(reference, row, column):
"""Returns the content of a cell, specified by row and column offset."""
raise NotImplementedError()
def INDIRECT(cell_reference_as_string):
"""Returns a cell reference specified by a string."""
raise NotImplementedError()
def LOOKUP(search_key, search_range_or_search_result_array, result_range=None):
"""Looks through a row or column for a key and returns the value of the cell in a result range located in the same position as the search row or column."""
raise NotImplementedError()
def MATCH(search_key, range, search_type):
"""Returns the relative position of an item in a range that matches a specified value."""
raise NotImplementedError()
def OFFSET(cell_reference, offset_rows, offset_columns, height, width):
"""Returns a range reference shifted a specified number of rows and columns from a starting cell reference."""
raise NotImplementedError()
def ROW(cell_reference):
"""Returns the row number of a specified cell."""
raise NotImplementedError()
def ROWS(range):
"""Returns the number of rows in a specified array or range."""
raise NotImplementedError()
def VLOOKUP(table, **field_value_pairs):
"""
Vertical lookup. Searches the given table for a record matching the given `field=value`
arguments. If multiple records match, returns one of them. If none match, returns the special
empty record.
The returned object is a record whose fields are available using `.field` syntax. For example,
`VLOOKUP(Employees, EmployeeID=$EmpID).Salary`.
Note that `VLOOKUP` isn't commonly needed in Grist, since [Reference columns](col-refs) are the
best way to link data between tables, and allow simple efficient usage such as `$Person.Age`.
`VLOOKUP` is exactly quivalent to `table.lookupOne(**field_value_pairs)`. See
[lookupOne](#lookupone).
For example:
```
VLOOKUP(People, First_Name="Lewis", Last_Name="Carroll")
VLOOKUP(People, First_Name="Lewis", Last_Name="Carroll").Age
```
"""
return table.lookupOne(**field_value_pairs)

View File

@@ -0,0 +1,830 @@
# pylint: disable=unused-argument
from __future__ import absolute_import
import itertools
import math as _math
import operator
import random
from functions.info import ISNUMBER, ISLOGICAL
import roman
# Iterates through elements of iterable arguments, or through individual args when not iterable.
def _chain(*values_or_iterables):
for v in values_or_iterables:
try:
for x in v:
yield x
except TypeError:
yield v
# Iterates through iterable or other arguments, skipping non-numeric ones.
def _chain_numeric(*values_or_iterables):
for v in _chain(*values_or_iterables):
if ISNUMBER(v) and not ISLOGICAL(v):
yield v
# Iterates through iterable or other arguments, replacing non-numeric ones with 0 (or True with 1).
def _chain_numeric_a(*values_or_iterables):
for v in _chain(*values_or_iterables):
yield int(v) if ISLOGICAL(v) else v if ISNUMBER(v) else 0
def _round_toward_zero(value):
return _math.floor(value) if value >= 0 else _math.ceil(value)
def _round_away_from_zero(value):
return _math.ceil(value) if value >= 0 else _math.floor(value)
def ABS(value):
"""
Returns the absolute value of a number.
>>> ABS(2)
2
>>> ABS(-2)
2
>>> ABS(-4)
4
"""
return abs(value)
def ACOS(value):
"""
Returns the inverse cosine of a value, in radians.
>>> round(ACOS(-0.5), 9)
2.094395102
>>> round(ACOS(-0.5)*180/PI(), 10)
120.0
"""
return _math.acos(value)
def ACOSH(value):
"""
Returns the inverse hyperbolic cosine of a number.
>>> ACOSH(1)
0.0
>>> round(ACOSH(10), 7)
2.9932228
"""
return _math.acosh(value)
def ARABIC(roman_numeral):
"""
Computes the value of a Roman numeral.
>>> ARABIC("LVII")
57
>>> ARABIC('mcmxii')
1912
"""
return roman.fromRoman(roman_numeral.upper())
def ASIN(value):
"""
Returns the inverse sine of a value, in radians.
>>> round(ASIN(-0.5), 9)
-0.523598776
>>> round(ASIN(-0.5)*180/PI(), 10)
-30.0
>>> round(DEGREES(ASIN(-0.5)), 10)
-30.0
"""
return _math.asin(value)
def ASINH(value):
"""
Returns the inverse hyperbolic sine of a number.
>>> round(ASINH(-2.5), 9)
-1.647231146
>>> round(ASINH(10), 9)
2.99822295
"""
return _math.asinh(value)
def ATAN(value):
"""
Returns the inverse tangent of a value, in radians.
>>> round(ATAN(1), 9)
0.785398163
>>> ATAN(1)*180/PI()
45.0
>>> DEGREES(ATAN(1))
45.0
"""
return _math.atan(value)
def ATAN2(x, y):
"""
Returns the angle between the x-axis and a line segment from the origin (0,0) to specified
coordinate pair (`x`,`y`), in radians.
>>> round(ATAN2(1, 1), 9)
0.785398163
>>> round(ATAN2(-1, -1), 9)
-2.35619449
>>> ATAN2(-1, -1)*180/PI()
-135.0
>>> DEGREES(ATAN2(-1, -1))
-135.0
>>> round(ATAN2(1,2), 9)
1.107148718
"""
return _math.atan2(y, x)
def ATANH(value):
"""
Returns the inverse hyperbolic tangent of a number.
>>> round(ATANH(0.76159416), 9)
1.00000001
>>> round(ATANH(-0.1), 9)
-0.100335348
"""
return _math.atanh(value)
def CEILING(value, factor=1):
"""
Rounds a number up to the nearest multiple of factor, or the nearest integer if the factor is
omitted or 1.
>>> CEILING(2.5, 1)
3
>>> CEILING(-2.5, -2)
-4
>>> CEILING(-2.5, 2)
-2
>>> CEILING(1.5, 0.1)
1.5
>>> CEILING(0.234, 0.01)
0.24
"""
return int(_math.ceil(float(value) / factor)) * factor
def COMBIN(n, k):
"""
Returns the number of ways to choose some number of objects from a pool of a given size of
objects.
>>> COMBIN(8,2)
28
>>> COMBIN(4,2)
6
>>> COMBIN(10,7)
120
"""
# From http://stackoverflow.com/a/4941932/328565
k = min(k, n-k)
if k == 0:
return 1
numer = reduce(operator.mul, xrange(n, n-k, -1))
denom = reduce(operator.mul, xrange(1, k+1))
return numer//denom
def COS(angle):
"""
Returns the cosine of an angle provided in radians.
>>> round(COS(1.047), 7)
0.5001711
>>> round(COS(60*PI()/180), 10)
0.5
>>> round(COS(RADIANS(60)), 10)
0.5
"""
return _math.cos(angle)
def COSH(value):
"""
Returns the hyperbolic cosine of any real number.
>>> round(COSH(4), 6)
27.308233
>>> round(COSH(EXP(1)), 7)
7.6101251
"""
return _math.cosh(value)
def DEGREES(angle):
"""
Converts an angle value in radians to degrees.
>>> round(DEGREES(ACOS(-0.5)), 10)
120.0
>>> DEGREES(PI())
180.0
"""
return _math.degrees(angle)
def EVEN(value):
"""
Rounds a number up to the nearest even integer, rounding away from zero.
>>> EVEN(1.5)
2
>>> EVEN(3)
4
>>> EVEN(2)
2
>>> EVEN(-1)
-2
"""
return int(_round_away_from_zero(float(value) / 2)) * 2
def EXP(exponent):
"""
Returns Euler's number, e (~2.718) raised to a power.
>>> round(EXP(1), 8)
2.71828183
>>> round(EXP(2), 7)
7.3890561
"""
return _math.exp(exponent)
def FACT(value):
"""
Returns the factorial of a number.
>>> FACT(5)
120
>>> FACT(1.9)
1
>>> FACT(0)
1
>>> FACT(1)
1
>>> FACT(-1)
Traceback (most recent call last):
...
ValueError: factorial() not defined for negative values
"""
return _math.factorial(int(value))
def FACTDOUBLE(value):
"""
Returns the "double factorial" of a number.
>>> FACTDOUBLE(6)
48
>>> FACTDOUBLE(7)
105
>>> FACTDOUBLE(3)
3
>>> FACTDOUBLE(4)
8
"""
return reduce(operator.mul, xrange(value, 1, -2))
def FLOOR(value, factor=1):
"""
Rounds a number down to the nearest integer multiple of specified significance.
>>> FLOOR(3.7,2)
2
>>> FLOOR(-2.5,-2)
-2
>>> FLOOR(2.5,-2)
Traceback (most recent call last):
...
ValueError: factor argument invalid
>>> FLOOR(1.58,0.1)
1.5
>>> FLOOR(0.234,0.01)
0.23
"""
if (factor < 0) != (value < 0):
raise ValueError("factor argument invalid")
return int(_math.floor(float(value) / factor)) * factor
def _gcd(a, b):
while a != 0:
if a > b:
a, b = b, a
a, b = b % a, a
return b
def GCD(value1, *more_values):
"""
Returns the greatest common divisor of one or more integers.
>>> GCD(5, 2)
1
>>> GCD(24, 36)
12
>>> GCD(7, 1)
1
>>> GCD(5, 0)
5
>>> GCD(0, 5)
5
>>> GCD(5)
5
>>> GCD(14, 42, 21)
7
"""
values = [v for v in (value1,) + more_values if v]
if not values:
return 0
if any(v < 0 for v in values):
raise ValueError("gcd requires non-negative values")
return reduce(_gcd, map(int, values))
def INT(value):
"""
Rounds a number down to the nearest integer that is less than or equal to it.
>>> INT(8.9)
8
>>> INT(-8.9)
-9
>>> 19.5-INT(19.5)
0.5
"""
return int(_math.floor(value))
def _lcm(a, b):
return a * b / _gcd(a, b)
def LCM(value1, *more_values):
"""
Returns the least common multiple of one or more integers.
>>> LCM(5, 2)
10
>>> LCM(24, 36)
72
>>> LCM(0, 5)
0
>>> LCM(5)
5
>>> LCM(10, 100)
100
>>> LCM(12, 18)
36
>>> LCM(12, 18, 24)
72
"""
values = (value1,) + more_values
if any(v < 0 for v in values):
raise ValueError("gcd requires non-negative values")
if any(v == 0 for v in values):
return 0
return reduce(_lcm, map(int, values))
def LN(value):
"""
Returns the the logarithm of a number, base e (Euler's number).
>>> round(LN(86), 7)
4.4543473
>>> round(LN(2.7182818), 7)
1.0
>>> round(LN(EXP(3)), 10)
3.0
"""
return _math.log(value)
def LOG(value, base=10):
"""
Returns the the logarithm of a number given a base.
>>> LOG(10)
1.0
>>> LOG(8, 2)
3.0
>>> round(LOG(86, 2.7182818), 7)
4.4543473
"""
return _math.log(value, base)
def LOG10(value):
"""
Returns the the logarithm of a number, base 10.
>>> round(LOG10(86), 9)
1.934498451
>>> LOG10(10)
1.0
>>> LOG10(100000)
5.0
>>> LOG10(10**5)
5.0
"""
return _math.log10(value)
def MOD(dividend, divisor):
"""
Returns the result of the modulo operator, the remainder after a division operation.
>>> MOD(3, 2)
1
>>> MOD(-3, 2)
1
>>> MOD(3, -2)
-1
>>> MOD(-3, -2)
-1
"""
return dividend % divisor
def MROUND(value, factor):
"""
Rounds one number to the nearest integer multiple of another.
>>> MROUND(10, 3)
9
>>> MROUND(-10, -3)
-9
>>> round(MROUND(1.3, 0.2), 10)
1.4
>>> MROUND(5, -2)
Traceback (most recent call last):
...
ValueError: factor argument invalid
"""
if (factor < 0) != (value < 0):
raise ValueError("factor argument invalid")
return int(_round_toward_zero(float(value) / factor + 0.5)) * factor
def MULTINOMIAL(value1, *more_values):
"""
Returns the factorial of the sum of values divided by the product of the values' factorials.
>>> MULTINOMIAL(2, 3, 4)
1260
>>> MULTINOMIAL(3)
1
>>> MULTINOMIAL(1,2,3)
60
>>> MULTINOMIAL(0,2,4,6)
13860
"""
s = value1
res = 1
for v in more_values:
s += v
res *= COMBIN(s, v)
return res
def ODD(value):
"""
Rounds a number up to the nearest odd integer.
>>> ODD(1.5)
3
>>> ODD(3)
3
>>> ODD(2)
3
>>> ODD(-1)
-1
>>> ODD(-2)
-3
"""
return int(_round_away_from_zero(float(value + 1) / 2)) * 2 - 1
def PI():
"""
Returns the value of Pi to 14 decimal places.
>>> round(PI(), 9)
3.141592654
>>> round(PI()/2, 9)
1.570796327
>>> round(PI()*9, 8)
28.27433388
"""
return _math.pi
def POWER(base, exponent):
"""
Returns a number raised to a power.
>>> POWER(5,2)
25.0
>>> round(POWER(98.6,3.2), 3)
2401077.222
>>> round(POWER(4,5.0/4), 9)
5.656854249
"""
return _math.pow(base, exponent)
def PRODUCT(factor1, *more_factors):
"""
Returns the result of multiplying a series of numbers together. Each argument may be a number or
an array.
>>> PRODUCT([5,15,30])
2250
>>> PRODUCT([5,15,30], 2)
4500
>>> PRODUCT(5,15,[30],[2])
4500
More tests:
>>> PRODUCT([2, True, None, "", False, "0", 5])
10
>>> PRODUCT([2, True, None, "", False, 0, 5])
0
"""
return reduce(operator.mul, _chain_numeric(factor1, *more_factors))
def QUOTIENT(dividend, divisor):
"""
Returns one number divided by another.
>>> QUOTIENT(5, 2)
2
>>> QUOTIENT(4.5, 3.1)
1
>>> QUOTIENT(-10, 3)
-3
"""
return TRUNC(float(dividend) / divisor)
def RADIANS(angle):
"""
Converts an angle value in degrees to radians.
>>> round(RADIANS(270), 6)
4.712389
"""
return _math.radians(angle)
def RAND():
"""
Returns a random number between 0 inclusive and 1 exclusive.
"""
return random.random()
def RANDBETWEEN(low, high):
"""
Returns a uniformly random integer between two values, inclusive.
"""
return random.randrange(low, high + 1)
def ROMAN(number, form_unused=None):
"""
Formats a number in Roman numerals. The second argument is ignored in this implementation.
>>> ROMAN(499,0)
'CDXCIX'
>>> ROMAN(499.2,0)
'CDXCIX'
>>> ROMAN(57)
'LVII'
>>> ROMAN(1912)
'MCMXII'
"""
# TODO: Maybe we should support the second argument.
return roman.toRoman(int(number))
def ROUND(value, places=0):
"""
Rounds a number to a certain number of decimal places according to standard rules.
>>> ROUND(2.15, 1) # Excel actually gives the more correct 2.2
2.1
>>> ROUND(2.149, 1)
2.1
>>> ROUND(-1.475, 2)
-1.48
>>> ROUND(21.5, -1)
20.0
>>> ROUND(626.3,-3)
1000.0
>>> ROUND(1.98,-1)
0.0
>>> ROUND(-50.55,-2)
-100.0
"""
# TODO: Excel manages to round 2.15 to 2.2, but Python sees 2.149999... and rounds to 2.1
# (see Python notes in documentation of `round()`).
return round(value, places)
def ROUNDDOWN(value, places=0):
"""
Rounds a number to a certain number of decimal places, always rounding down towards zero.
>>> ROUNDDOWN(3.2, 0)
3
>>> ROUNDDOWN(76.9,0)
76
>>> ROUNDDOWN(3.14159, 3)
3.141
>>> ROUNDDOWN(-3.14159, 1)
-3.1
>>> ROUNDDOWN(31415.92654, -2)
31400
"""
factor = 10**-places
return int(_round_toward_zero(float(value) / factor)) * factor
def ROUNDUP(value, places=0):
"""
Rounds a number to a certain number of decimal places, always rounding up away from zero.
>>> ROUNDUP(3.2,0)
4
>>> ROUNDUP(76.9,0)
77
>>> ROUNDUP(3.14159, 3)
3.142
>>> ROUNDUP(-3.14159, 1)
-3.2
>>> ROUNDUP(31415.92654, -2)
31500
"""
factor = 10**-places
return int(_round_away_from_zero(float(value) / factor)) * factor
def SERIESSUM(x, n, m, a):
"""
Given parameters x, n, m, and a, returns the power series sum a_1*x^n + a_2*x^(n+m)
+ ... + a_i*x^(n+(i-1)m), where i is the number of entries in range `a`.
>>> SERIESSUM(1,0,1,1)
1
>>> SERIESSUM(2,1,0,[1,2,3])
12
>>> SERIESSUM(-3,1,1,[2,4,6])
-132
>>> round(SERIESSUM(PI()/4,0,2,[1,-1./FACT(2),1./FACT(4),-1./FACT(6)]), 6)
0.707103
"""
return sum(coef*pow(x, n+i*m) for i, coef in enumerate(_chain(a)))
def SIGN(value):
"""
Given an input number, returns `-1` if it is negative, `1` if positive, and `0` if it is zero.
>>> SIGN(10)
1
>>> SIGN(4.0-4.0)
0
>>> SIGN(-0.00001)
-1
"""
return 0 if value == 0 else int(_math.copysign(1, value))
def SIN(angle):
"""
Returns the sine of an angle provided in radians.
>>> round(SIN(PI()), 10)
0.0
>>> SIN(PI()/2)
1.0
>>> round(SIN(30*PI()/180), 10)
0.5
>>> round(SIN(RADIANS(30)), 10)
0.5
"""
return _math.sin(angle)
def SINH(value):
"""
Returns the hyperbolic sine of any real number.
>>> round(2.868*SINH(0.0342*1.03), 7)
0.1010491
"""
return _math.sinh(value)
def SQRT(value):
"""
Returns the positive square root of a positive number.
>>> SQRT(16)
4.0
>>> SQRT(-16)
Traceback (most recent call last):
...
ValueError: math domain error
>>> SQRT(ABS(-16))
4.0
"""
return _math.sqrt(value)
def SQRTPI(value):
"""
Returns the positive square root of the product of Pi and the given positive number.
>>> round(SQRTPI(1), 6)
1.772454
>>> round(SQRTPI(2), 6)
2.506628
"""
return _math.sqrt(_math.pi * value)
def SUBTOTAL(function_code, range1, range2):
"""
Returns a subtotal for a vertical range of cells using a specified aggregation function.
"""
raise NotImplementedError()
def SUM(value1, *more_values):
"""
Returns the sum of a series of numbers. Each argument may be a number or an array.
Non-numeric values are ignored.
>>> SUM([5,15,30])
50
>>> SUM([5.,15,30], 2)
52.0
>>> SUM(5,15,[30],[2])
52
More tests:
>>> SUM([10.25, None, "", False, "other", 20.5])
30.75
>>> SUM([True, "3", 4], True)
6
"""
return sum(_chain_numeric_a(value1, *more_values))
def SUMIF(records, criterion, sum_range):
"""
Returns a conditional sum across a range.
"""
raise NotImplementedError()
def SUMIFS(sum_range, criteria_range1, criterion1, *args):
"""
Returns the sum of a range depending on multiple criteria.
"""
raise NotImplementedError()
def SUMPRODUCT(array1, *more_arrays):
"""
Multiplies corresponding components in the given arrays, and returns the sum of those products.
>>> SUMPRODUCT([3,8,1,4,6,9], [2,6,5,7,7,3])
156
>>> SUMPRODUCT([], [], [])
0
>>> SUMPRODUCT([-0.25], [-2], [-3])
-1.5
>>> SUMPRODUCT([-0.25, -0.25], [-2, -2], [-3, -3])
-3.0
"""
return sum(reduce(operator.mul, values) for values in itertools.izip(array1, *more_arrays))
def SUMSQ(value1, value2):
"""
Returns the sum of the squares of a series of numbers and/or cells.
"""
raise NotImplementedError()
def TAN(angle):
"""
Returns the tangent of an angle provided in radians.
>>> round(TAN(0.785), 8)
0.99920399
>>> round(TAN(45*PI()/180), 10)
1.0
>>> round(TAN(RADIANS(45)), 10)
1.0
"""
return _math.tan(angle)
def TANH(value):
"""
Returns the hyperbolic tangent of any real number.
>>> round(TANH(-2), 6)
-0.964028
>>> TANH(0)
0.0
>>> round(TANH(0.5), 6)
0.462117
"""
return _math.tanh(value)
def TRUNC(value, places=0):
"""
Truncates a number to a certain number of significant digits by omitting less significant
digits.
>>> TRUNC(8.9)
8
>>> TRUNC(-8.9)
-8
>>> TRUNC(0.45)
0
"""
# TRUNC seems indistinguishable from ROUNDDOWN.
return ROUNDDOWN(value, places)

View File

@@ -0,0 +1,329 @@
from datetime import datetime, timedelta
import re
from date import DATEADD, NOW, DTIME
from moment_parse import MONTH_NAMES, DAY_NAMES
# Limit exports to schedule, so that upper-case constants like MONTH_NAMES, DAY_NAMES don't end up
# exposed as if Excel-style functions (or break docs generation).
__all__ = ['SCHEDULE']
def SCHEDULE(schedule, start=None, count=10, end=None):
"""
Returns the list of `datetime` objects generated according to the `schedule` string. Starts at
`start`, which defaults to NOW(). Generates at most `count` results (10 by default). If `end` is
given, stops there.
The schedule has the format "INTERVAL: SLOTS, ...". For example:
annual: Jan-15, Apr-15, Jul-15 -- Three times a year on given dates at midnight.
annual: 1/15, 4/15, 7/15 -- Same as above.
monthly: /1 2pm, /15 2pm -- The 1st and the 15th of each month, at 2pm.
3-months: /10, +1m /20 -- Every 3 months on the 10th of month 1, 20th of month 2.
weekly: Mo 9am, Tu 9am, Fr 2pm -- Three times a week at specified times.
2-weeks: Mo, +1w Tu -- Every 2 weeks on Monday of week 1, Tuesday of week 2.
daily: 07:30, 21:00 -- Twice a day at specified times.
2-day: 12am, 4pm, +1d 8am -- Three times every two days, evenly spaced.
hourly: :15, :45 -- 15 minutes before and after each hour.
4-hour: :00, 1:20, 2:40 -- Three times every 4 hours, evenly spaced.
10-minute: +0s -- Every 10 minutes on the minute.
INTERVAL must be either of the form `N-unit` where `N` is a number and `unit` is one of `year`,
`month`, `week`, `day`, `hour`; or one of the aliases: `annual`, `monthly`, `weekly`, `daily`,
`hourly`, which mean `1-year`, `1-month`, etc.
SLOTS support the following units:
`Jan-15` or `1/15` -- Month and day of the month; available when INTERVAL is year-based.
`/15` -- Day of the month, available when INTERVAL is month-based.
`Mon`, `Mo`, `Friday` -- Day of the week (or abbreviation), when INTERVAL is week-based.
10am, 1:30pm, 15:45 -- Time of day, available for day-based or longer intervals.
:45, :00 -- Minutes of the hour, available when INTERVAL is hour-based.
+1d, +15d -- How many days to add to start of INTERVAL.
+1w -- How many weeks to add to start of INTERVAL.
+1m -- How many months to add to start of INTERVAL.
The SLOTS are always relative to the INTERVAL rather than to `start`. Week-based intervals start
on Sunday. E.g. `weekly: +1d, +4d` is the same as `weekly: Mon, Thu`, and generates times on
Mondays and Thursdays regardless of `start`.
The first generated time is determined by the *unit* of the INTERVAL without regard to the
multiple. E.g. both "2-week: Mon" and "3-week: Mon" start on the first Monday after `start`, and
then generate either every second or every third Monday after that. Similarly, `24-hour: :00`
starts with the first top-of-the-hour after `start` (not with midnight), and then repeats every
24 hours. To start with the midnight after `start`, use `daily: 0:00`.
For interval units of a day or longer, if time-of-day is not specified, it defaults to midnight.
The time zone of `start` determines the time zone of the generated times.
>>> def show(dates): return [d.strftime("%Y-%m-%d %H:%M") for d in dates]
>>> start = datetime(2018, 9, 4, 14, 0); # 2pm on Tue, Sep 4 2018.
>>> show(SCHEDULE('annual: Jan-15, Apr-15, Jul-15, Oct-15', start=start, count=4))
['2018-10-15 00:00', '2019-01-15 00:00', '2019-04-15 00:00', '2019-07-15 00:00']
>>> show(SCHEDULE('annual: 1/15, 4/15, 7/15', start=start, count=4))
['2019-01-15 00:00', '2019-04-15 00:00', '2019-07-15 00:00', '2020-01-15 00:00']
>>> show(SCHEDULE('monthly: /1 2pm, /15 5pm', start=start, count=4))
['2018-09-15 17:00', '2018-10-01 14:00', '2018-10-15 17:00', '2018-11-01 14:00']
>>> show(SCHEDULE('3-months: /10, +1m /20', start=start, count=4))
['2018-09-10 00:00', '2018-10-20 00:00', '2018-12-10 00:00', '2019-01-20 00:00']
>>> show(SCHEDULE('weekly: Mo 9am, Tu 9am, Fr 2pm', start=start, count=4))
['2018-09-07 14:00', '2018-09-10 09:00', '2018-09-11 09:00', '2018-09-14 14:00']
>>> show(SCHEDULE('2-weeks: Mo, +1w Tu', start=start, count=4))
['2018-09-11 00:00', '2018-09-17 00:00', '2018-09-25 00:00', '2018-10-01 00:00']
>>> show(SCHEDULE('daily: 07:30, 21:00', start=start, count=4))
['2018-09-04 21:00', '2018-09-05 07:30', '2018-09-05 21:00', '2018-09-06 07:30']
>>> show(SCHEDULE('2-day: 12am, 4pm, +1d 8am', start=start, count=4))
['2018-09-04 16:00', '2018-09-05 08:00', '2018-09-06 00:00', '2018-09-06 16:00']
>>> show(SCHEDULE('hourly: :15, :45', start=start, count=4))
['2018-09-04 14:15', '2018-09-04 14:45', '2018-09-04 15:15', '2018-09-04 15:45']
>>> show(SCHEDULE('4-hour: :00, +1H :20, +2H :40', start=start, count=4))
['2018-09-04 14:00', '2018-09-04 15:20', '2018-09-04 16:40', '2018-09-04 18:00']
"""
return Schedule(schedule).series(start or NOW(), end, count=count)
class Delta(object):
"""
Similar to timedelta, keeps intervals by unit. Specifically, this is needed for months
and years, since those can't be represented exactly with a timedelta.
"""
def __init__(self):
self._timedelta = timedelta(0)
self._months = 0
def add_interval(self, number, unit):
if unit == 'months':
self._months += number
elif unit == 'years':
self._months += number * 12
else:
self._timedelta += timedelta(**{unit: number})
return self
def add_to(self, dtime):
return datetime.combine(DATEADD(dtime, months=self._months), dtime.timetz()) + self._timedelta
class Schedule(object):
"""
Schedule parses a schedule spec into an interval and slots in the constructor. Then the series()
method applies it to any start/end dates.
"""
def __init__(self, spec_string):
parts = spec_string.split(":", 1)
if len(parts) != 2:
raise ValueError("schedule must have the form INTERVAL: SLOTS, ...")
count, unit = _parse_interval(parts[0].strip())
self._interval_unit = unit
self._interval = Delta().add_interval(count, unit)
self._slots = [_parse_slot(t, self._interval_unit) for t in parts[1].split(",")]
def series(self, start_dtime, end_dtime, count=10):
# Start with a preceding unit boundary, then check the slots within that unit and start with
# the first one that's at start_dtime or later.
start_dtime = DTIME(start_dtime)
end_dtime = end_dtime and DTIME(end_dtime)
dtime = _round_down_to_unit(start_dtime, self._interval_unit)
while True:
for slot in self._slots:
if count <= 0:
return
out = slot.add_to(dtime)
if out < start_dtime:
continue
if end_dtime is not None and out > end_dtime:
return
yield out
count -= 1
dtime = self._interval.add_to(dtime)
def _fail(message):
raise ValueError(message)
def _round_down_to_unit(dtime, unit):
"""
Rounds datetime down to the given unit. Weeks are rounded to start of Sunday.
"""
tz = dtime.tzinfo
return ( datetime(dtime.year, 1, 1, tzinfo=tz) if unit == 'years'
else datetime(dtime.year, dtime.month, 1, tzinfo=tz) if unit == 'months'
else (dtime - timedelta(days=dtime.isoweekday() % 7))
.replace(hour=0, minute=0, second=0, microsecond=0) if unit == 'weeks'
else dtime.replace(hour=0, minute=0, second=0, microsecond=0) if unit == 'days'
else dtime.replace(minute=0, second=0, microsecond=0) if unit == 'hours'
else dtime.replace(second=0, microsecond=0) if unit == 'minutes'
else dtime.replace(microsecond=0) if unit == 'seconds'
else _fail("Invalid unit %s" % unit)
)
_UNITS = ('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds')
_VALID_UNITS = set(_UNITS)
_SINGULAR_UNITS = dict(zip(('year', 'month', 'week', 'day', 'hour', 'minute', 'second'), _UNITS))
_SHORT_UNITS = dict(zip(('y', 'm', 'w', 'd', 'H', 'M', 'S'), _UNITS))
_INTERVAL_ALIASES = {
'annual': (1, 'years'),
'monthly': (1, 'months'),
'weekly': (1, 'weeks'),
'daily': (1, 'days'),
'hourly': (1, 'hours'),
}
_INTERVAL_RE = re.compile(r'^(?P<num>\d+)[-\s]+(?P<unit>[a-z]+)$', re.I)
# Maps weekday names, including 2- and 3-letter abbreviations, to numbers 0 through 6.
WEEKDAY_OFFSETS = {}
for (i, name) in enumerate(DAY_NAMES):
WEEKDAY_OFFSETS[name] = i
WEEKDAY_OFFSETS[name[:3]] = i
WEEKDAY_OFFSETS[name[:2]] = i
# Maps month names, including 3-letter abbreviations, to numbers 0 through 11.
MONTH_OFFSETS = {}
for (i, name) in enumerate(MONTH_NAMES):
MONTH_OFFSETS[name] = i
MONTH_OFFSETS[name[:3]] = i
def _parse_interval(interval_str):
"""
Given a spec like "daily" or "3-week", returns (N, unit), such as (1, "days") or (3, "weeks").
"""
interval_str = interval_str.lower()
if interval_str in _INTERVAL_ALIASES:
return _INTERVAL_ALIASES[interval_str]
m = _INTERVAL_RE.match(interval_str)
if not m:
raise ValueError("Not a valid interval '%s'" % interval_str)
num = int(m.group("num"))
unit = m.group("unit")
unit = _SINGULAR_UNITS.get(unit, unit)
if unit not in _VALID_UNITS:
raise ValueError("Unknown unit '%s' in interval '%s'" % (unit, interval_str))
return (num, unit)
def _parse_slot(slot_str, parent_unit):
"""
Parses a slot in one of several recognized formats. Allowed formats depend on parent_unit, e.g.
'Jan-15' is valid when parent_unit is 'years', but not when it is 'hours'. We also disallow
using the same unit more than once, which is confusing, e.g. "+1d +2d" or "9:30am +2H".
Returns a Delta object.
"""
parts = slot_str.split()
if not parts:
raise ValueError("At least one slot must be specified")
delta = Delta()
seen_units = set()
allowed_slot_types = _ALLOWED_SLOTS_BY_UNIT.get(parent_unit) or ('delta',)
# Slot parts go through parts like "Jan-15 16pm", collecting the offsets into a single Delta.
for part in parts:
m = _SLOT_RE.match(part)
if not m:
raise ValueError("Invalid slot '%s'" % part)
for slot_type in allowed_slot_types:
if m.group(slot_type):
# If there is a group for one slot type, that's the only group. We find and use the
# corresponding parser, then move on to the next slot part.
for count, unit in _SLOT_PARSERS[slot_type](m):
delta.add_interval(count, unit)
if unit in seen_units:
raise ValueError("Duplicate unit %s in '%s'" % (unit, slot_str))
seen_units.add(unit)
break
else:
# If none of the allowed slot types was found, it must be a disallowed one.
raise ValueError("Invalid slot '%s' for unit '%s'" % (part, parent_unit))
return delta
# We parse all slot types using one big regex. The constants below define one part of the regex
# for each slot type (e.g. to match "Jan-15" or "5:30am" or "+1d"). Note that all group names
# (defined with (?P<NAME>...)) must be distinct.
_DATE_RE = r'(?:(?P<month_name>[a-z]+)-|(?P<month_num>\d+)/)(?P<month_day>\d+)'
_MDAY_RE = r'/(?P<month_day2>\d+)'
_WDAY_RE = r'(?P<weekday>[a-z]+)'
_TIME_RE = r'(?P<hours>\d+)(?:\:(?P<minutes>\d{2})(?P<ampm1>am|pm)?|(?P<ampm2>am|pm))'
_MINS_RE = r':(?P<minutes2>\d{2})'
_DELTA_RE = r'\+(?P<count>\d+)(?P<unit>[a-z]+)'
# The regex parts are combined and compiled here. Only one group will match, corresponding to one
# slot type. Different slot types depend on the unit of the overall interval.
_SLOT_RE = re.compile(
r'^(?:(?P<date>%s)|(?P<mday>%s)|(?P<wday>%s)|(?P<time>%s)|(?P<mins>%s)|(?P<delta>%s))$' %
(_DATE_RE, _MDAY_RE, _WDAY_RE, _TIME_RE, _MINS_RE, _DELTA_RE), re.IGNORECASE)
# Slot types that make sense for each unit of overall interval. If not listed (e.g. "minutes")
# then only "delta" slot type is allowed.
_ALLOWED_SLOTS_BY_UNIT = {
'years': ('date', 'time', 'delta'),
'months': ('mday', 'time', 'delta'),
'weeks': ('wday', 'time', 'delta'),
'days': ('time', 'delta'),
'hours': ('mins', 'delta'),
}
# The helper methods below parse one slot type each, given a regex match that matched that slot
# type. These are combined and used via the _SLOT_PARSERS dict below.
def _parse_slot_date(m):
mday = int(m.group("month_day"))
month_name = m.group("month_name")
month_num = m.group("month_num")
if month_name:
name = month_name.lower()
if name not in MONTH_OFFSETS:
raise ValueError("Unknown month '%s'" % month_name)
mnum = MONTH_OFFSETS[name]
else:
mnum = int(month_num) - 1
return [(mnum, 'months'), (mday - 1, 'days')]
def _parse_slot_mday(m):
mday = int(m.group("month_day2"))
return [(mday - 1, 'days')]
def _parse_slot_wday(m):
wday = m.group("weekday").lower()
if wday not in WEEKDAY_OFFSETS:
raise ValueError("Unknown day of the week '%s'" % wday)
return [(WEEKDAY_OFFSETS[wday], "days")]
def _parse_slot_time(m):
hours = int(m.group("hours"))
minutes = int(m.group("minutes") or 0)
ampm = m.group("ampm1") or m.group("ampm2")
if ampm:
hours = (hours % 12) + (12 if ampm.lower() == "pm" else 0)
return [(hours, 'hours'), (minutes, 'minutes')]
def _parse_slot_mins(m):
minutes = int(m.group("minutes2"))
return [(minutes, 'minutes')]
def _parse_slot_delta(m):
count = int(m.group("count"))
unit = m.group("unit")
if unit not in _SHORT_UNITS:
raise ValueError("Unknown unit '%s' in interval '%s'" % (unit, m.group()))
return [(count, _SHORT_UNITS[unit])]
_SLOT_PARSERS = {
'date': _parse_slot_date,
'mday': _parse_slot_mday,
'wday': _parse_slot_wday,
'time': _parse_slot_time,
'mins': _parse_slot_mins,
'delta': _parse_slot_delta,
}

View File

@@ -0,0 +1,615 @@
# pylint: disable=redefined-builtin, line-too-long, unused-argument
from math import _chain, _chain_numeric, _chain_numeric_a
from info import ISNUMBER, ISLOGICAL
from date import DATE # pylint: disable=unused-import
def _average(iterable):
total, count = 0.0, 0
for value in iterable:
total += value
count += 1
return total / count
def _default_if_empty(iterable, default):
"""
Yields all values from iterable, except when it is empty, yields just the single default value.
"""
empty = True
for value in iterable:
empty = False
yield value
if empty:
yield default
def AVEDEV(value1, value2):
"""Calculates the average of the magnitudes of deviations of data from a dataset's mean."""
raise NotImplementedError()
def AVERAGE(value, *more_values):
"""
Returns the numerical average value in a dataset, ignoring non-numerical values.
Each argument may be a value or an array. Values that are not numbers, including logical
and blank values, and text representations of numbers, are ignored.
>>> AVERAGE([2, -1.0, 11])
4.0
>>> AVERAGE([2, -1, 11, "Hello"])
4.0
>>> AVERAGE([2, -1, "Hello", DATE(2015,1,1)], True, [False, "123", "", 11])
4.0
>>> AVERAGE(False, True)
Traceback (most recent call last):
...
ZeroDivisionError: float division by zero
"""
return _average(_chain_numeric(value, *more_values))
def AVERAGEA(value, *more_values):
"""
Returns the numerical average value in a dataset, counting non-numerical values as 0.
Each argument may be a value of an array. Values that are not numbers, including dates and text
representations of numbers, are counted as 0 (zero). Logical value of True is counted as 1, and
False as 0.
>>> AVERAGEA([2, -1.0, 11])
4.0
>>> AVERAGEA([2, -1, 11, "Hello"])
3.0
>>> AVERAGEA([2, -1, "Hello", DATE(2015,1,1)], True, [False, "123", "", 11.5])
1.5
>>> AVERAGEA(False, True)
0.5
"""
return _average(_chain_numeric_a(value, *more_values))
# Note that Google Sheets offers a similar function, called AVERAGE.WEIGHTED
# (https://support.google.com/docs/answer/9084098?hl=en)
def AVERAGE_WEIGHTED(pairs):
"""
Given a list of (value, weight) pairs, finds the average of the values weighted by the
corresponding weights. Ignores any pairs with a non-numerical value or weight.
If you have two lists, of values and weights, use the Python built-in zip() function to create a
list of pairs.
>>> AVERAGE_WEIGHTED(((95, .25), (90, .1), ("X", .5), (85, .15), (88, .2), (82, .3), (70, None)))
87.7
>>> AVERAGE_WEIGHTED(zip([95, 90, "X", 85, 88, 82, 70], [25, 10, 50, 15, 20, 30, None]))
87.7
>>> AVERAGE_WEIGHTED(zip([95, 90, False, 85, 88, 82, 70], [.25, .1, .5, .15, .2, .3, True]))
87.7
"""
sum_value, sum_weight = 0.0, 0.0
for value, weight in pairs:
# The type-checking here is the same as used by _chain_numeric.
if ISNUMBER(value) and not ISLOGICAL(value) and ISNUMBER(weight) and not ISLOGICAL(weight):
sum_value += value * weight
sum_weight += weight
return sum_value / sum_weight
def AVERAGEIF(criteria_range, criterion, average_range=None):
"""Returns the average of a range depending on criteria."""
raise NotImplementedError()
def AVERAGEIFS(average_range, criteria_range1, criterion1, *args):
"""Returns the average of a range depending on multiple criteria."""
raise NotImplementedError()
def BINOMDIST(num_successes, num_trials, prob_success, cumulative):
"""
Calculates the probability of drawing a certain number of successes (or a maximum number of
successes) in a certain number of tries given a population of a certain size containing a
certain number of successes, with replacement of draws.
"""
raise NotImplementedError()
def CONFIDENCE(alpha, standard_deviation, pop_size):
"""Calculates the width of half the confidence interval for a normal distribution."""
raise NotImplementedError()
def CORREL(data_y, data_x):
"""Calculates r, the Pearson product-moment correlation coefficient of a dataset."""
raise NotImplementedError()
def COUNT(value, *more_values):
"""
Returns the count of numerical values in a dataset, ignoring non-numerical values.
Each argument may be a value or an array. Values that are not numbers, including logical
and blank values, and text representations of numbers, are ignored.
>>> COUNT([2, -1.0, 11])
3
>>> COUNT([2, -1, 11, "Hello"])
3
>>> COUNT([2, -1, "Hello", DATE(2015,1,1)], True, [False, "123", "", 11.5])
3
>>> COUNT(False, True)
0
"""
return sum(1 for v in _chain_numeric(value, *more_values))
def COUNTA(value, *more_values):
"""
Returns the count of all values in a dataset, including non-numerical values.
Each argument may be a value or an array.
>>> COUNTA([2, -1.0, 11])
3
>>> COUNTA([2, -1, 11, "Hello"])
4
>>> COUNTA([2, -1, "Hello", DATE(2015,1,1)], True, [False, "123", "", 11.5])
9
>>> COUNTA(False, True)
2
"""
return sum(1 for v in _chain(value, *more_values))
def COVAR(data_y, data_x):
"""Calculates the covariance of a dataset."""
raise NotImplementedError()
def CRITBINOM(num_trials, prob_success, target_prob):
"""Calculates the smallest value for which the cumulative binomial distribution is greater than or equal to a specified criteria."""
raise NotImplementedError()
def DEVSQ(value1, value2):
"""Calculates the sum of squares of deviations based on a sample."""
raise NotImplementedError()
def EXPONDIST(x, lambda_, cumulative):
"""Returns the value of the exponential distribution function with a specified lambda at a specified value."""
raise NotImplementedError()
def F_DIST(x, degrees_freedom1, degrees_freedom2, cumulative):
"""
Calculates the left-tailed F probability distribution (degree of diversity) for two data sets
with given input x. Alternately called Fisher-Snedecor distribution or Snedecor's F
distribution.
"""
raise NotImplementedError()
def F_DIST_RT(x, degrees_freedom1, degrees_freedom2):
"""
Calculates the right-tailed F probability distribution (degree of diversity) for two data sets
with given input x. Alternately called Fisher-Snedecor distribution or Snedecor's F
distribution.
"""
raise NotImplementedError()
def FDIST(x, degrees_freedom1, degrees_freedom2):
"""
Calculates the right-tailed F probability distribution (degree of diversity) for two data sets
with given input x. Alternately called Fisher-Snedecor distribution or Snedecor's F
distribution.
"""
raise NotImplementedError()
def FISHER(value):
"""Returns the Fisher transformation of a specified value."""
raise NotImplementedError()
def FISHERINV(value):
"""Returns the inverse Fisher transformation of a specified value."""
raise NotImplementedError()
def FORECAST(x, data_y, data_x):
"""Calculates the expected y-value for a specified x based on a linear regression of a dataset."""
raise NotImplementedError()
def GEOMEAN(value1, value2):
"""Calculates the geometric mean of a dataset."""
raise NotImplementedError()
def HARMEAN(value1, value2):
"""Calculates the harmonic mean of a dataset."""
raise NotImplementedError()
def HYPGEOMDIST(num_successes, num_draws, successes_in_pop, pop_size):
"""Calculates the probability of drawing a certain number of successes in a certain number of tries given a population of a certain size containing a certain number of successes, without replacement of draws."""
raise NotImplementedError()
def INTERCEPT(data_y, data_x):
"""Calculates the y-value at which the line resulting from linear regression of a dataset will intersect the y-axis (x=0)."""
raise NotImplementedError()
def KURT(value1, value2):
"""Calculates the kurtosis of a dataset, which describes the shape, and in particular the "peakedness" of that dataset."""
raise NotImplementedError()
def LARGE(data, n):
"""Returns the nth largest element from a data set, where n is user-defined."""
raise NotImplementedError()
def LOGINV(x, mean, standard_deviation):
"""Returns the value of the inverse log-normal cumulative distribution with given mean and standard deviation at a specified value."""
raise NotImplementedError()
def LOGNORMDIST(x, mean, standard_deviation):
"""Returns the value of the log-normal cumulative distribution with given mean and standard deviation at a specified value."""
raise NotImplementedError()
def MAX(value, *more_values):
"""
Returns the maximum value in a dataset, ignoring non-numerical values.
Each argument may be a value or an array. Values that are not numbers, including logical
and blank values, and text representations of numbers, are ignored. Returns 0 if the arguments
contain no numbers.
>>> MAX([2, -1.5, 11.5])
11.5
>>> MAX([2, -1.5, "Hello", DATE(2015, 1, 1)], True, [False, "123", "", 11.5])
11.5
>>> MAX(True, -123)
-123
>>> MAX("123", -123)
-123
>>> MAX("Hello", "123", DATE(2015, 1, 1))
0
"""
return max(_default_if_empty(_chain_numeric(value, *more_values), 0))
def MAXA(value, *more_values):
"""
Returns the maximum numeric value in a dataset.
Each argument may be a value of an array. Values that are not numbers, including dates and text
representations of numbers, are counted as 0 (zero). Logical value of True is counted as 1, and
False as 0. Returns 0 if the arguments contain no numbers.
>>> MAXA([2, -1.5, 11.5])
11.5
>>> MAXA([2, -1.5, "Hello", DATE(2015, 1, 1)], True, [False, "123", "", 11.5])
11.5
>>> MAXA(True, -123)
1
>>> MAXA("123", -123)
0
>>> MAXA("Hello", "123", DATE(2015, 1, 1))
0
"""
return max(_default_if_empty(_chain_numeric_a(value, *more_values), 0))
def MEDIAN(value, *more_values):
"""
Returns the median value in a numeric dataset, ignoring non-numerical values.
Each argument may be a value or an array. Values that are not numbers, including logical
and blank values, and text representations of numbers, are ignored.
Produces an error if the arguments contain no numbers.
The median is the middle number when all values are sorted. So half of the values in the dataset
are less than the median, and half of the values are greater. If there is an even number of
values in the dataset, returns the average of the two numbers in the middle.
>>> MEDIAN(1, 2, 3, 4, 5)
3
>>> MEDIAN(3, 5, 1, 4, 2)
3
>>> MEDIAN(xrange(10))
4.5
>>> MEDIAN("Hello", "123", DATE(2015, 1, 1), 12.3)
12.3
>>> MEDIAN("Hello", "123", DATE(2015, 1, 1))
Traceback (most recent call last):
...
ValueError: MEDIAN requires at least one number
"""
values = sorted(_chain_numeric(value, *more_values))
if not values:
raise ValueError("MEDIAN requires at least one number")
count = len(values)
if count % 2 == 0:
return (values[count / 2 - 1] + values[count / 2]) / 2.0
else:
return values[(count - 1) / 2]
def MIN(value, *more_values):
"""
Returns the minimum value in a dataset, ignoring non-numerical values.
Each argument may be a value or an array. Values that are not numbers, including logical
and blank values, and text representations of numbers, are ignored. Returns 0 if the arguments
contain no numbers.
>>> MIN([2, -1.5, 11.5])
-1.5
>>> MIN([2, -1.5, "Hello", DATE(2015, 1, 1)], True, [False, "123", "", 11.5])
-1.5
>>> MIN(True, 123)
123
>>> MIN("-123", 123)
123
>>> MIN("Hello", "123", DATE(2015, 1, 1))
0
"""
return min(_default_if_empty(_chain_numeric(value, *more_values), 0))
def MINA(value, *more_values):
"""
Returns the minimum numeric value in a dataset.
Each argument may be a value of an array. Values that are not numbers, including dates and text
representations of numbers, are counted as 0 (zero). Logical value of True is counted as 1, and
False as 0. Returns 0 if the arguments contain no numbers.
>>> MINA([2, -1.5, 11.5])
-1.5
>>> MINA([2, -1.5, "Hello", DATE(2015, 1, 1)], True, [False, "123", "", 11.5])
-1.5
>>> MINA(True, 123)
1
>>> MINA("-123", 123)
0
>>> MINA("Hello", "123", DATE(2015, 1, 1))
0
"""
return min(_default_if_empty(_chain_numeric_a(value, *more_values), 0))
def MODE(value1, value2):
"""Returns the most commonly occurring value in a dataset."""
raise NotImplementedError()
def NEGBINOMDIST(num_failures, num_successes, prob_success):
"""Calculates the probability of drawing a certain number of failures before a certain number of successes given a probability of success in independent trials."""
raise NotImplementedError()
def NORMDIST(x, mean, standard_deviation, cumulative):
"""
Returns the value of the normal distribution function (or normal cumulative distribution
function) for a specified value, mean, and standard deviation.
"""
raise NotImplementedError()
def NORMINV(x, mean, standard_deviation):
"""Returns the value of the inverse normal distribution function for a specified value, mean, and standard deviation."""
raise NotImplementedError()
def NORMSDIST(x):
"""Returns the value of the standard normal cumulative distribution function for a specified value."""
raise NotImplementedError()
def NORMSINV(x):
"""Returns the value of the inverse standard normal distribution function for a specified value."""
raise NotImplementedError()
def PEARSON(data_y, data_x):
"""Calculates r, the Pearson product-moment correlation coefficient of a dataset."""
raise NotImplementedError()
def PERCENTILE(data, percentile):
"""Returns the value at a given percentile of a dataset."""
raise NotImplementedError()
def PERCENTRANK(data, value, significant_digits=None):
"""Returns the percentage rank (percentile) of a specified value in a dataset."""
raise NotImplementedError()
def PERCENTRANK_EXC(data, value, significant_digits=None):
"""Returns the percentage rank (percentile) from 0 to 1 exclusive of a specified value in a dataset."""
raise NotImplementedError()
def PERCENTRANK_INC(data, value, significant_digits=None):
"""Returns the percentage rank (percentile) from 0 to 1 inclusive of a specified value in a dataset."""
raise NotImplementedError()
def PERMUT(n, k):
"""Returns the number of ways to choose some number of objects from a pool of a given size of objects, considering order."""
raise NotImplementedError()
def POISSON(x, mean, cumulative):
"""
Returns the value of the Poisson distribution function (or Poisson cumulative distribution
function) for a specified value and mean.
"""
raise NotImplementedError()
def PROB(data, probabilities, low_limit, high_limit=None):
"""Given a set of values and corresponding probabilities, calculates the probability that a value chosen at random falls between two limits."""
raise NotImplementedError()
def QUARTILE(data, quartile_number):
"""Returns a value nearest to a specified quartile of a dataset."""
raise NotImplementedError()
def RANK(value, data, is_ascending=None):
"""Returns the rank of a specified value in a dataset."""
raise NotImplementedError()
def RANK_AVG(value, data, is_ascending=None):
"""Returns the rank of a specified value in a dataset. If there is more than one entry of the same value in the dataset, the average rank of the entries will be returned."""
raise NotImplementedError()
def RANK_EQ(value, data, is_ascending=None):
"""Returns the rank of a specified value in a dataset. If there is more than one entry of the same value in the dataset, the top rank of the entries will be returned."""
raise NotImplementedError()
def RSQ(data_y, data_x):
"""Calculates the square of r, the Pearson product-moment correlation coefficient of a dataset."""
raise NotImplementedError()
def SKEW(value1, value2):
"""Calculates the skewness of a dataset, which describes the symmetry of that dataset about the mean."""
raise NotImplementedError()
def SLOPE(data_y, data_x):
"""Calculates the slope of the line resulting from linear regression of a dataset."""
raise NotImplementedError()
def SMALL(data, n):
"""Returns the nth smallest element from a data set, where n is user-defined."""
raise NotImplementedError()
def STANDARDIZE(value, mean, standard_deviation):
"""Calculates the normalized equivalent of a random variable given mean and standard deviation of the distribution."""
raise NotImplementedError()
# This should make us all cry a little. Because the sandbox does not do Python3 (which has
# statistics package), and because it does not do numpy (because it's native and hasn't been built
# for it), we have to implement simple stats functions by hand.
# TODO: switch to use the statistics package instead, once we upgrade to Python3.
#
# The following implementation of stdev is taken from https://stackoverflow.com/a/27758326/328565
def _mean(data):
return sum(data) / float(len(data))
def _ss(data):
"""Return sum of square deviations of sequence data."""
c = _mean(data)
return sum((x-c)**2 for x in data)
def _stddev(data, ddof=0):
"""Calculates the population standard deviation
by default; specify ddof=1 to compute the sample
standard deviation."""
n = len(data)
ss = _ss(data)
pvar = ss/(n-ddof)
return pvar**0.5
# The examples in the doctests below come from https://support.google.com/docs/answer/3094054 and
# related articles, which helps ensure correctness and compatibility.
def STDEV(value, *more_values):
"""
Calculates the standard deviation based on a sample, ignoring non-numerical values.
>>> STDEV([2, 5, 8, 13, 10])
4.277849927241488
>>> STDEV([2, 5, 8, 13, 10, True, False, "Test"])
4.277849927241488
>>> STDEV([2, 5, 8, 13, 10], 3, 12, 15)
4.810702354423639
>>> STDEV([2, 5, 8, 13, 10], [3, 12, 15])
4.810702354423639
>>> STDEV([5])
Traceback (most recent call last):
...
ZeroDivisionError: float division by zero
"""
return _stddev(list(_chain_numeric(value, *more_values)), 1)
def STDEVA(value, *more_values):
"""
Calculates the standard deviation based on a sample, setting text to the value `0`.
>>> STDEVA([2, 5, 8, 13, 10])
4.277849927241488
>>> STDEVA([2, 5, 8, 13, 10, True, False, "Test"])
4.969550137731641
>>> STDEVA([2, 5, 8, 13, 10], 1, 0, 0)
4.969550137731641
>>> STDEVA([2, 5, 8, 13, 10], [1, 0, 0])
4.969550137731641
>>> STDEVA([5])
Traceback (most recent call last):
...
ZeroDivisionError: float division by zero
"""
return _stddev(list(_chain_numeric_a(value, *more_values)), 1)
def STDEVP(value, *more_values):
"""
Calculates the standard deviation based on an entire population, ignoring non-numerical values.
>>> STDEVP([2, 5, 8, 13, 10])
3.8262252939417984
>>> STDEVP([2, 5, 8, 13, 10, True, False, "Test"])
3.8262252939417984
>>> STDEVP([2, 5, 8, 13, 10], 3, 12, 15)
4.5
>>> STDEVP([2, 5, 8, 13, 10], [3, 12, 15])
4.5
>>> STDEVP([5])
0.0
"""
return _stddev(list(_chain_numeric(value, *more_values)), 0)
def STDEVPA(value, *more_values):
"""
Calculates the standard deviation based on an entire population, setting text to the value `0`.
>>> STDEVPA([2, 5, 8, 13, 10])
3.8262252939417984
>>> STDEVPA([2, 5, 8, 13, 10, True, False, "Test"])
4.648588495446763
>>> STDEVPA([2, 5, 8, 13, 10], 1, 0, 0)
4.648588495446763
>>> STDEVPA([2, 5, 8, 13, 10], [1, 0, 0])
4.648588495446763
>>> STDEVPA([5])
0.0
"""
return _stddev(list(_chain_numeric_a(value, *more_values)), 0)
def STEYX(data_y, data_x):
"""Calculates the standard error of the predicted y-value for each x in the regression of a dataset."""
raise NotImplementedError()
def T_INV(probability, degrees_freedom):
"""Calculates the negative inverse of the one-tailed TDIST function."""
raise NotImplementedError()
def T_INV_2T(probability, degrees_freedom):
"""Calculates the inverse of the two-tailed TDIST function."""
raise NotImplementedError()
def TDIST(x, degrees_freedom, tails):
"""Calculates the probability for Student's t-distribution with a given input (x)."""
raise NotImplementedError()
def TINV(probability, degrees_freedom):
"""Calculates the inverse of the two-tailed TDIST function."""
raise NotImplementedError()
def TRIMMEAN(data, exclude_proportion):
"""Calculates the mean of a dataset excluding some proportion of data from the high and low ends of the dataset."""
raise NotImplementedError()
def TTEST(range1, range2, tails, type):
"""Returns the probability associated with t-test. Determines whether two samples are likely to have come from the same two underlying populations that have the same mean."""
raise NotImplementedError()
def VAR(value1, value2):
"""Calculates the variance based on a sample."""
raise NotImplementedError()
def VARA(value1, value2):
"""Calculates an estimate of variance based on a sample, setting text to the value `0`."""
raise NotImplementedError()
def VARP(value1, value2):
"""Calculates the variance based on an entire population."""
raise NotImplementedError()
def VARPA(value1, value2):
"""Calculates the variance based on an entire population, setting text to the value `0`."""
raise NotImplementedError()
def WEIBULL(x, shape, scale, cumulative):
"""
Returns the value of the Weibull distribution function (or Weibull cumulative distribution
function) for a specified shape and scale.
"""
raise NotImplementedError()
def ZTEST(data, value, standard_deviation):
"""Returns the two-tailed P-value of a Z-test with standard distribution."""
raise NotImplementedError()

View File

@@ -0,0 +1,270 @@
from datetime import date, datetime, timedelta
import os
import timeit
import unittest
import moment
import schedule
from functions.date import DTIME
from functions import date as _date
DT = DTIME
TICK = timedelta.resolution
_orig_global_tz_getter = None
class TestSchedule(unittest.TestCase):
def assertDate(self, date_or_dtime, expected_str):
"""Formats date_or_dtime and compares the formatted value."""
return self.assertEqual(date_or_dtime.strftime("%Y-%m-%d %H:%M:%S"), expected_str)
def assertDateIso(self, date_or_dtime, expected_str):
"""Formats date_or_dtime and compares the formatted value."""
return self.assertEqual(date_or_dtime.isoformat(' '), expected_str)
def assertDelta(self, delta, months=0, **timedelta_args):
"""Asserts that the given delta corresponds to the given number of various units."""
self.assertEqual(delta._months, months)
self.assertEqual(delta._timedelta, timedelta(**timedelta_args))
@classmethod
def setUpClass(cls):
global _orig_global_tz_getter # pylint: disable=global-statement
_orig_global_tz_getter = _date._get_global_tz
_date._get_global_tz = lambda: moment.tzinfo('America/New_York')
@classmethod
def tearDownClass(cls):
_date._get_global_tz = _orig_global_tz_getter
def test_round_down_to_unit(self):
RDU = schedule._round_down_to_unit
self.assertDate(RDU(DT("2018-09-04 14:38:11"), "years"), "2018-01-01 00:00:00")
self.assertDate(RDU(DT("2018-01-01 00:00:00"), "years"), "2018-01-01 00:00:00")
self.assertDate(RDU(DT("2018-01-01 00:00:00") - TICK, "years"), "2017-01-01 00:00:00")
self.assertDate(RDU(DT("2018-09-04 14:38:11"), "months"), "2018-09-01 00:00:00")
self.assertDate(RDU(DT("2018-09-01 00:00:00"), "months"), "2018-09-01 00:00:00")
self.assertDate(RDU(DT("2018-09-01 00:00:00") - TICK, "months"), "2018-08-01 00:00:00")
# Note that 9/4 was a Tuesday, so start of the week (Sunday) is 9/2
self.assertDate(RDU(DT("2018-09-04 14:38:11"), "weeks"), "2018-09-02 00:00:00")
self.assertDate(RDU(DT("2018-09-02 00:00:00"), "weeks"), "2018-09-02 00:00:00")
self.assertDate(RDU(DT("2018-09-02 00:00:00") - TICK, "weeks"), "2018-08-26 00:00:00")
self.assertDate(RDU(DT("2018-09-04 14:38:11"), "days"), "2018-09-04 00:00:00")
self.assertDate(RDU(DT("2018-09-04 00:00:00"), "days"), "2018-09-04 00:00:00")
self.assertDate(RDU(DT("2018-09-04 00:00:00") - TICK, "days"), "2018-09-03 00:00:00")
self.assertDate(RDU(DT("2018-09-04 14:38:11"), "hours"), "2018-09-04 14:00:00")
self.assertDate(RDU(DT("2018-09-04 14:00:00"), "hours"), "2018-09-04 14:00:00")
self.assertDate(RDU(DT("2018-09-04 14:00:00") - TICK, "hours"), "2018-09-04 13:00:00")
self.assertDate(RDU(DT("2018-09-04 14:38:11"), "minutes"), "2018-09-04 14:38:00")
self.assertDate(RDU(DT("2018-09-04 14:38:00"), "minutes"), "2018-09-04 14:38:00")
self.assertDate(RDU(DT("2018-09-04 14:38:00") - TICK, "minutes"), "2018-09-04 14:37:00")
self.assertDate(RDU(DT("2018-09-04 14:38:11"), "seconds"), "2018-09-04 14:38:11")
self.assertDate(RDU(DT("2018-09-04 14:38:11") - TICK, "seconds"), "2018-09-04 14:38:10")
with self.assertRaisesRegexp(ValueError, r"Invalid unit inches"):
RDU(DT("2018-09-04 14:38:11"), "inches")
def test_round_down_to_unit_tz(self):
RDU = schedule._round_down_to_unit
dt = datetime(2018, 1, 1, 0, 0, 0, tzinfo=moment.tzinfo("America/New_York"))
self.assertDateIso(RDU(dt, "years"), "2018-01-01 00:00:00-05:00")
self.assertDateIso(RDU(dt - TICK, "years"), "2017-01-01 00:00:00-05:00")
self.assertDateIso(RDU(dt, "months"), "2018-01-01 00:00:00-05:00")
self.assertDateIso(RDU(dt - TICK, "months"), "2017-12-01 00:00:00-05:00")
# 2018-01-01 is a Monday
self.assertDateIso(RDU(dt, "weeks"), "2017-12-31 00:00:00-05:00")
self.assertDateIso(RDU(dt - timedelta(days=1) - TICK, "weeks"), "2017-12-24 00:00:00-05:00")
self.assertDateIso(RDU(dt, "days"), "2018-01-01 00:00:00-05:00")
self.assertDateIso(RDU(dt - TICK, "days"), "2017-12-31 00:00:00-05:00")
self.assertDateIso(RDU(dt, "hours"), "2018-01-01 00:00:00-05:00")
self.assertDateIso(RDU(dt - TICK, "hours"), "2017-12-31 23:00:00-05:00")
def test_parse_interval(self):
self.assertEqual(schedule._parse_interval("annual"), (1, "years"))
self.assertEqual(schedule._parse_interval("daily"), (1, "days"))
self.assertEqual(schedule._parse_interval("1-year"), (1, "years"))
self.assertEqual(schedule._parse_interval("1 year"), (1, "years"))
self.assertEqual(schedule._parse_interval("1 Years"), (1, "years"))
self.assertEqual(schedule._parse_interval("25-months"), (25, "months"))
self.assertEqual(schedule._parse_interval("3-day"), (3, "days"))
self.assertEqual(schedule._parse_interval("2-hour"), (2, "hours"))
with self.assertRaisesRegexp(ValueError, "Not a valid interval"):
schedule._parse_interval("1Year")
with self.assertRaisesRegexp(ValueError, "Not a valid interval"):
schedule._parse_interval("1y")
with self.assertRaisesRegexp(ValueError, "Unknown unit"):
schedule._parse_interval("1-daily")
def test_parse_slot(self):
self.assertDelta(schedule._parse_slot('Jan-15', 'years'), months=0, days=14)
self.assertDelta(schedule._parse_slot('1/15', 'years'), months=0, days=14)
self.assertDelta(schedule._parse_slot('march-1', 'years'), months=2, days=0)
self.assertDelta(schedule._parse_slot('03/09', 'years'), months=2, days=8)
self.assertDelta(schedule._parse_slot('/15', 'months'), days=14)
self.assertDelta(schedule._parse_slot('/1', 'months'), days=0)
self.assertDelta(schedule._parse_slot('Mon', 'weeks'), days=1)
self.assertDelta(schedule._parse_slot('tu', 'weeks'), days=2)
self.assertDelta(schedule._parse_slot('Friday', 'weeks'), days=5)
self.assertDelta(schedule._parse_slot('10am', 'days'), hours=10)
self.assertDelta(schedule._parse_slot('1:30pm', 'days'), hours=13, minutes=30)
self.assertDelta(schedule._parse_slot('15:45', 'days'), hours=15, minutes=45)
self.assertDelta(schedule._parse_slot('Apr-1 9am', 'years'), months=3, days=0, hours=9)
self.assertDelta(schedule._parse_slot('/3 12:30', 'months'), days=2, hours=12, minutes=30)
self.assertDelta(schedule._parse_slot('Sat 6:15pm', 'weeks'), days=6, hours=18, minutes=15)
self.assertDelta(schedule._parse_slot(':45', 'hours'), minutes=45)
self.assertDelta(schedule._parse_slot(':00', 'hours'), minutes=00)
self.assertDelta(schedule._parse_slot('+1d', 'days'), days=1)
self.assertDelta(schedule._parse_slot('+15d', 'months'), days=15)
self.assertDelta(schedule._parse_slot('+3w', 'weeks'), weeks=3)
self.assertDelta(schedule._parse_slot('+2m', 'years'), months=2)
self.assertDelta(schedule._parse_slot('+1y', 'years'), months=12)
# Test a few combinations.
self.assertDelta(schedule._parse_slot('+1y 4/5 3:45pm +30S', 'years'),
months=15, days=4, hours=15, minutes=45, seconds=30)
self.assertDelta(schedule._parse_slot('+2w Wed +6H +20M +40S', 'weeks'),
weeks=2, days=3, hours=6, minutes=20, seconds=40)
self.assertDelta(schedule._parse_slot('+2m /20 11pm', 'months'), months=2, days=19, hours=23)
self.assertDelta(schedule._parse_slot('+2M +30S', 'minutes'), minutes=2, seconds=30)
def test_parse_slot_errors(self):
# Test failures with duplicate units
with self.assertRaisesRegexp(ValueError, 'Duplicate unit'):
schedule._parse_slot('+1d +2d', 'weeks')
with self.assertRaisesRegexp(ValueError, 'Duplicate unit'):
schedule._parse_slot('9:30am +2H', 'days')
with self.assertRaisesRegexp(ValueError, 'Duplicate unit'):
schedule._parse_slot('/15 +1d', 'months')
with self.assertRaisesRegexp(ValueError, 'Duplicate unit'):
schedule._parse_slot('Feb-1 12:30pm +20M', 'years')
# Test failures with improper slot types
with self.assertRaisesRegexp(ValueError, 'Invalid slot.*for unit'):
schedule._parse_slot('Feb-1', 'weeks')
with self.assertRaisesRegexp(ValueError, 'Invalid slot.*for unit'):
schedule._parse_slot('Monday', 'months')
with self.assertRaisesRegexp(ValueError, 'Invalid slot.*for unit'):
schedule._parse_slot('4/15', 'hours')
with self.assertRaisesRegexp(ValueError, 'Invalid slot.*for unit'):
schedule._parse_slot('/1', 'years')
# Test failures with outright invalid slot syntax.
with self.assertRaisesRegexp(ValueError, 'Invalid slot'):
schedule._parse_slot('Feb:1', 'weeks')
with self.assertRaisesRegexp(ValueError, 'Invalid slot'):
schedule._parse_slot('/1d', 'months')
with self.assertRaisesRegexp(ValueError, 'Invalid slot'):
schedule._parse_slot('10', 'hours')
with self.assertRaisesRegexp(ValueError, 'Invalid slot'):
schedule._parse_slot('H1', 'years')
# Test failures with unknown values
with self.assertRaisesRegexp(ValueError, 'Unknown month'):
schedule._parse_slot('februarium-1', 'years')
with self.assertRaisesRegexp(ValueError, 'Unknown day of the week'):
schedule._parse_slot('snu', 'weeks')
with self.assertRaisesRegexp(ValueError, 'Unknown unit'):
schedule._parse_slot('+1t', 'hours')
def test_schedule(self):
# A few more examples. The ones in doctest strings are those that help documentation; the rest
# are in this file to keep the size of the main file more manageable.
# Note that the start of 2018-01-01 is a Monday
self.assertEqual(list(schedule.SCHEDULE(
"1-week: +1d 9:30am, +4d 3:30pm", start=datetime(2018,1,1), end=datetime(2018,1,31))),
[
DT("2018-01-01 09:30:00"), DT("2018-01-04 15:30:00"),
DT("2018-01-08 09:30:00"), DT("2018-01-11 15:30:00"),
DT("2018-01-15 09:30:00"), DT("2018-01-18 15:30:00"),
DT("2018-01-22 09:30:00"), DT("2018-01-25 15:30:00"),
DT("2018-01-29 09:30:00"),
])
self.assertEqual(list(schedule.SCHEDULE(
"3-month: +0d 12pm", start=datetime(2018,1,1), end=datetime(2018,6,30))),
[DT('2018-01-01 12:00:00'), DT('2018-04-01 12:00:00')])
# Ensure we can use date() object for start/end too.
self.assertEqual(list(schedule.SCHEDULE(
"3-month: +0d 12pm", start=date(2018,1,1), end=date(2018,6,30))),
[DT('2018-01-01 12:00:00'), DT('2018-04-01 12:00:00')])
# We can even use strings.
self.assertEqual(list(schedule.SCHEDULE(
"3-month: +0d 12pm", start="2018-01-01", end="2018-06-30")),
[DT('2018-01-01 12:00:00'), DT('2018-04-01 12:00:00')])
def test_timezone(self):
# Verify that the time zone of `start` determines the time zone of generated times.
tz_ny = moment.tzinfo("America/New_York")
self.assertEqual([d.isoformat(' ') for d in schedule.SCHEDULE(
"daily: 9am", count=4, start=datetime(2018, 2, 14, tzinfo=tz_ny))],
[ '2018-02-14 09:00:00-05:00', '2018-02-15 09:00:00-05:00',
'2018-02-16 09:00:00-05:00', '2018-02-17 09:00:00-05:00' ])
tz_la = moment.tzinfo("America/Los_Angeles")
self.assertEqual([d.isoformat(' ') for d in schedule.SCHEDULE(
"daily: 9am, 4:30pm", count=4, start=datetime(2018, 2, 14, 9, 0, tzinfo=tz_la))],
[ '2018-02-14 09:00:00-08:00', '2018-02-14 16:30:00-08:00',
'2018-02-15 09:00:00-08:00', '2018-02-15 16:30:00-08:00' ])
tz_utc = moment.tzinfo("UTC")
self.assertEqual([d.isoformat(' ') for d in schedule.SCHEDULE(
"daily: 9am, 4:30pm", count=4, start=datetime(2018, 2, 14, 17, 0, tzinfo=tz_utc))],
[ '2018-02-15 09:00:00+00:00', '2018-02-15 16:30:00+00:00',
'2018-02-16 09:00:00+00:00', '2018-02-16 16:30:00+00:00' ])
# This is not really a test but just a way to see some timing information about Schedule
# implementation. Run with env PY_TIMING_TESTS=1 in the environment, and the console output will
# include the measured times.
@unittest.skipUnless(os.getenv("PY_TIMING_TESTS") == "1", "Set PY_TIMING_TESTS=1 for timing")
def test_timing(self):
N = 1000
sched = "weekly: Mo 10:30am, We 10:30am"
setup = """
from functions import schedule
from datetime import datetime
"""
setup = "from functions import test_schedule as t"
expected_result = [
datetime(2018, 9, 24, 10, 30), datetime(2018, 9, 26, 22, 30),
datetime(2018, 10, 1, 10, 30), datetime(2018, 10, 3, 22, 30),
]
self.assertEqual(timing_schedule_full(), expected_result)
t = min(timeit.repeat(stmt="t.timing_schedule_full()", setup=setup, number=N, repeat=3))
print "\n*** SCHEDULE call with 4 points: %.2f us" % (t * 1000000 / N)
t = min(timeit.repeat(stmt="t.timing_schedule_init()", setup=setup, number=N, repeat=3))
print "*** Schedule constructor: %.2f us" % (t * 1000000 / N)
self.assertEqual(timing_schedule_series(), expected_result)
t = min(timeit.repeat(stmt="t.timing_schedule_series()", setup=setup, number=N, repeat=3))
print "*** Schedule series with 4 points: %.2f us" % (t * 1000000 / N)
def timing_schedule_full():
return list(schedule.SCHEDULE("weekly: Mo 10:30am, We 10:30pm",
start=datetime(2018, 9, 23), count=4))
def timing_schedule_init():
return schedule.Schedule("weekly: Mo 10:30am, We 10:30pm")
def timing_schedule_series(sched=schedule.Schedule("weekly: Mo 10:30am, We 10:30pm")):
return list(sched.series(datetime(2018, 9, 23), None, count=4))

View File

@@ -0,0 +1,590 @@
# -*- coding: UTF-8 -*-
import datetime
import dateutil.parser
import numbers
import re
from usertypes import AltText # pylint: disable=import-error
def CHAR(table_number):
"""
Convert a number into a character according to the current Unicode table.
Same as `unichr(number)`.
>>> CHAR(65)
u'A'
>>> CHAR(33)
u'!'
"""
return unichr(table_number)
# See http://stackoverflow.com/a/93029/328565
_control_chars = ''.join(map(unichr, range(0,32) + range(127,160)))
_control_char_re = re.compile('[%s]' % re.escape(_control_chars))
def CLEAN(text):
"""
Returns the text with the non-printable characters removed.
This removes both characters with values 0 through 31, and other Unicode characters in the
"control characters" category.
>>> CLEAN(CHAR(9) + "Monthly report" + CHAR(10))
u'Monthly report'
"""
return _control_char_re.sub('', text)
def CODE(string):
"""
Returns the numeric Unicode map value of the first character in the string provided.
Same as `ord(string[0])`.
>>> CODE("A")
65
>>> CODE("!")
33
>>> CODE("!A")
33
"""
return ord(string[0])
def CONCATENATE(string, *more_strings):
"""
Joins together any number of text strings into one string. Also available under the name
`CONCAT`. Same as the Python expression `"".join(array_of_strings)`.
>>> CONCATENATE("Stream population for ", "trout", " ", "species", " is ", 32, "/mile.")
u'Stream population for trout species is 32/mile.'
>>> CONCATENATE("In ", 4, " days it is ", datetime.date(2016,1,1))
u'In 4 days it is 2016-01-01'
>>> CONCATENATE("abc")
u'abc'
>>> CONCAT(0, "abc")
u'0abc'
"""
return u''.join(unicode(val) for val in (string,) + more_strings)
CONCAT = CONCATENATE
def DOLLAR(number, decimals=2):
"""
Formats a number into a formatted dollar amount, with decimals rounded to the specified place (.
If decimals value is omitted, it defaults to 2.
>>> DOLLAR(1234.567)
'$1,234.57'
>>> DOLLAR(1234.567, -2)
'$1,200'
>>> DOLLAR(-1234.567, -2)
'($1,200)'
>>> DOLLAR(-0.123, 4)
'($0.1230)'
>>> DOLLAR(99.888)
'$99.89'
>>> DOLLAR(0)
'$0.00'
>>> DOLLAR(10, 0)
'$10'
"""
formatted = "${:,.{}f}".format(round(abs(number), decimals), max(0, decimals))
return formatted if number >= 0 else "(" + formatted + ")"
def EXACT(string1, string2):
"""
Tests whether two strings are identical. Same as `string2 == string2`.
>>> EXACT("word", "word")
True
>>> EXACT("Word", "word")
False
>>> EXACT("w ord", "word")
False
"""
return string1 == string2
def FIND(find_text, within_text, start_num=1):
"""
Returns the position at which a string is first found within text.
Find is case-sensitive. The returned position is 1 if within_text starts with find_text.
Start_num specifies the character at which to start the search, defaulting to 1 (the first
character of within_text).
If find_text is not found, or start_num is invalid, raises ValueError.
>>> FIND("M", "Miriam McGovern")
1
>>> FIND("m", "Miriam McGovern")
6
>>> FIND("M", "Miriam McGovern", 3)
8
>>> FIND(" #", "Hello world # Test")
12
>>> FIND("gle", "Google", 1)
4
>>> FIND("GLE", "Google", 1)
Traceback (most recent call last):
...
ValueError: substring not found
>>> FIND("page", "homepage")
5
>>> FIND("page", "homepage", 6)
Traceback (most recent call last):
...
ValueError: substring not found
"""
return within_text.index(find_text, start_num - 1) + 1
def FIXED(number, decimals=2, no_commas=False):
"""
Formats a number with a fixed number of decimal places (2 by default), and commas.
If no_commas is True, then omits the commas.
>>> FIXED(1234.567, 1)
'1,234.6'
>>> FIXED(1234.567, -1)
'1,230'
>>> FIXED(-1234.567, -1, True)
'-1230'
>>> FIXED(44.332)
'44.33'
>>> FIXED(3521.478, 2, False)
'3,521.48'
>>> FIXED(-3521.478, 1, True)
'-3521.5'
>>> FIXED(3521.478, 0, True)
'3521'
>>> FIXED(3521.478, -2, True)
'3500'
"""
comma_flag = '' if no_commas else ','
return "{:{}.{}f}".format(round(number, decimals), comma_flag, max(0, decimals))
def LEFT(string, num_chars=1):
"""
Returns a substring of length num_chars from the beginning of the given string. If num_chars is
omitted, it is assumed to be 1. Same as `string[:num_chars]`.
>>> LEFT("Sale Price", 4)
'Sale'
>>> LEFT('Swededn')
'S'
>>> LEFT('Text', -1)
Traceback (most recent call last):
...
ValueError: num_chars invalid
"""
if num_chars < 0:
raise ValueError("num_chars invalid")
return string[:num_chars]
def LEN(text):
"""
Returns the number of characters in a text string. Same as `len(text)`.
>>> LEN("Phoenix, AZ")
11
>>> LEN("")
0
>>> LEN(" One ")
11
"""
return len(text)
def LOWER(text):
"""
Converts a specified string to lowercase. Same as `text.lower()`.
>>> LOWER("E. E. Cummings")
'e. e. cummings'
>>> LOWER("Apt. 2B")
'apt. 2b'
"""
return text.lower()
def MID(text, start_num, num_chars):
"""
Returns a segment of a string, starting at start_num. The first character in text has
start_num 1.
>>> MID("Fluid Flow", 1, 5)
'Fluid'
>>> MID("Fluid Flow", 7, 20)
'Flow'
>>> MID("Fluid Flow", 20, 5)
''
>>> MID("Fluid Flow", 0, 5)
Traceback (most recent call last):
...
ValueError: start_num invalid
"""
if start_num < 1:
raise ValueError("start_num invalid")
return text[start_num - 1 : start_num - 1 + num_chars]
def PROPER(text):
"""
Capitalizes each word in a specified string. It converts the first letter of each word to
uppercase, and all other letters to lowercase. Same as `text.title()`.
>>> PROPER('this is a TITLE')
'This Is A Title'
>>> PROPER('2-way street')
'2-Way Street'
>>> PROPER('76BudGet')
'76Budget'
"""
return text.title()
def REGEXEXTRACT(text, regular_expression):
"""
Extracts the first part of text that matches regular_expression.
>>> REGEXEXTRACT("Google Doc 101", "[0-9]+")
'101'
>>> REGEXEXTRACT("The price today is $826.25", "[0-9]*\\.[0-9]+[0-9]+")
'826.25'
If there is a parenthesized expression, it is returned instead of the whole match.
>>> REGEXEXTRACT("(Content) between brackets", "\\(([A-Za-z]+)\\)")
'Content'
>>> REGEXEXTRACT("Foo", "Bar")
Traceback (most recent call last):
...
ValueError: REGEXEXTRACT text does not match
"""
m = re.search(regular_expression, text)
if not m:
raise ValueError("REGEXEXTRACT text does not match")
return m.group(1) if m.lastindex else m.group(0)
def REGEXMATCH(text, regular_expression):
"""
Returns whether a piece of text matches a regular expression.
>>> REGEXMATCH("Google Doc 101", "[0-9]+")
True
>>> REGEXMATCH("Google Doc", "[0-9]+")
False
>>> REGEXMATCH("The price today is $826.25", "[0-9]*\\.[0-9]+[0-9]+")
True
>>> REGEXMATCH("(Content) between brackets", "\\(([A-Za-z]+)\\)")
True
>>> REGEXMATCH("Foo", "Bar")
False
"""
return bool(re.search(regular_expression, text))
def REGEXREPLACE(text, regular_expression, replacement):
"""
Replaces all parts of text matching the given regular expression with replacement text.
>>> REGEXREPLACE("Google Doc 101", "[0-9]+", "777")
'Google Doc 777'
>>> REGEXREPLACE("Google Doc", "[0-9]+", "777")
'Google Doc'
>>> REGEXREPLACE("The price is $826.25", "[0-9]*\\.[0-9]+[0-9]+", "315.75")
'The price is $315.75'
>>> REGEXREPLACE("(Content) between brackets", "\\(([A-Za-z]+)\\)", "Word")
'Word between brackets'
>>> REGEXREPLACE("Foo", "Bar", "Baz")
'Foo'
"""
return re.sub(regular_expression, replacement, text)
def REPLACE(old_text, start_num, num_chars, new_text):
"""
Replaces part of a text string with a different text string. Start_num is counted from 1.
>>> REPLACE("abcdefghijk", 6, 5, "*")
'abcde*k'
>>> REPLACE("2009", 3, 2, "10")
'2010'
>>> REPLACE('123456', 1, 3, '@')
'@456'
>>> REPLACE('foo', 1, 0, 'bar')
'barfoo'
>>> REPLACE('foo', 0, 1, 'bar')
Traceback (most recent call last):
...
ValueError: start_num invalid
"""
if start_num < 1:
raise ValueError("start_num invalid")
return old_text[:start_num - 1] + new_text + old_text[start_num - 1 + num_chars:]
def REPT(text, number_times):
"""
Returns specified text repeated a number of times. Same as `text * number_times`.
The result of the REPT function cannot be longer than 32767 characters, or it raises a
ValueError.
>>> REPT("*-", 3)
'*-*-*-'
>>> REPT('-', 10)
'----------'
>>> REPT('-', 0)
''
>>> len(REPT('---', 10000))
30000
>>> REPT('---', 11000)
Traceback (most recent call last):
...
ValueError: number_times invalid
>>> REPT('-', -1)
Traceback (most recent call last):
...
ValueError: number_times invalid
"""
if number_times < 0 or len(text) * number_times > 32767:
raise ValueError("number_times invalid")
return text * int(number_times)
def RIGHT(string, num_chars=1):
"""
Returns a substring of length num_chars from the end of a specified string. If num_chars is
omitted, it is assumed to be 1. Same as `string[-num_chars:]`.
>>> RIGHT("Sale Price", 5)
'Price'
>>> RIGHT('Stock Number')
'r'
>>> RIGHT('Text', 100)
'Text'
>>> RIGHT('Text', -1)
Traceback (most recent call last):
...
ValueError: num_chars invalid
"""
if num_chars < 0:
raise ValueError("num_chars invalid")
return string[-num_chars:]
def SEARCH(find_text, within_text, start_num=1):
"""
Returns the position at which a string is first found within text, ignoring case.
Find is case-sensitive. The returned position is 1 if within_text starts with find_text.
Start_num specifies the character at which to start the search, defaulting to 1 (the first
character of within_text).
If find_text is not found, or start_num is invalid, raises ValueError.
>>> SEARCH("e", "Statements", 6)
7
>>> SEARCH("margin", "Profit Margin")
8
>>> SEARCH(" ", "Profit Margin")
7
>>> SEARCH('"', 'The "boss" is here.')
5
>>> SEARCH("gle", "Google")
4
>>> SEARCH("GLE", "Google")
4
"""
# .lower() isn't always correct for unicode. See http://stackoverflow.com/a/29247821/328565
return within_text.lower().index(find_text.lower(), start_num - 1) + 1
def SUBSTITUTE(text, old_text, new_text, instance_num=None):
u"""
Replaces existing text with new text in a string. It is useful when you know the substring of
text to replace. Use REPLACE when you know the position of text to replace.
If instance_num is given, it specifies which occurrence of old_text to replace. If omitted, all
occurrences are replaced.
Same as `text.replace(old_text, new_text)` when instance_num is omitted.
>>> SUBSTITUTE("Sales Data", "Sales", "Cost")
'Cost Data'
>>> SUBSTITUTE("Quarter 1, 2008", "1", "2", 1)
'Quarter 2, 2008'
>>> SUBSTITUTE("Quarter 1, 2011", "1", "2", 3)
'Quarter 1, 2012'
More tests:
>>> SUBSTITUTE("Hello world", "", "-")
'Hello world'
>>> SUBSTITUTE("Hello world", " ", "-")
'Hello-world'
>>> SUBSTITUTE("Hello world", " ", 12.1)
'Hello12.1world'
>>> SUBSTITUTE(u"Hello world", u" ", 12.1)
u'Hello12.1world'
>>> SUBSTITUTE("Hello world", "world", "")
'Hello '
>>> SUBSTITUTE("Hello", "world", "")
'Hello'
Overlapping matches are all counted when looking for instance_num.
>>> SUBSTITUTE('abababab', 'abab', 'xxxx')
'xxxxxxxx'
>>> SUBSTITUTE('abababab', 'abab', 'xxxx', 1)
'xxxxabab'
>>> SUBSTITUTE('abababab', 'abab', 'xxxx', 2)
'abxxxxab'
>>> SUBSTITUTE('abababab', 'abab', 'xxxx', 3)
'ababxxxx'
>>> SUBSTITUTE('abababab', 'abab', 'xxxx', 4)
'abababab'
>>> SUBSTITUTE('abababab', 'abab', 'xxxx', 0)
Traceback (most recent call last):
...
ValueError: instance_num invalid
"""
if not old_text:
return text
if not isinstance(new_text, basestring):
new_text = str(new_text)
if instance_num is None:
return text.replace(old_text, new_text)
if instance_num <= 0:
raise ValueError("instance_num invalid")
# No trivial way to replace nth occurrence.
i = -1
for c in xrange(instance_num):
i = text.find(old_text, i + 1)
if i < 0:
return text
return text[:i] + new_text + text[i + len(old_text):]
def T(value):
"""
Returns value if value is text, or the empty string when value is not text.
>>> T('Text')
'Text'
>>> T(826)
''
>>> T('826')
'826'
>>> T(False)
''
>>> T('100 points')
'100 points'
>>> T(AltText('Text'))
'Text'
>>> T(float('nan'))
''
"""
return (value if isinstance(value, basestring) else
str(value) if isinstance(value, AltText) else "")
def TEXT(number, format_type):
"""
Converts a number into text according to a specified format. It is not yet implemented in
Grist.
"""
raise NotImplementedError()
_trim_re = re.compile(r' +')
def TRIM(text):
"""
Removes all spaces from text except for single spaces between words. Note that TRIM does not
remove other whitespace such as tab or newline characters.
>>> TRIM(" First Quarter\\n Earnings ")
'First Quarter\\n Earnings'
>>> TRIM("")
''
"""
return _trim_re.sub(' ', text.strip())
def UPPER(text):
"""
Converts a specified string to uppercase. Same as `text.lower()`.
>>> UPPER("e. e. cummings")
'E. E. CUMMINGS'
>>> UPPER("Apt. 2B")
'APT. 2B'
"""
return text.upper()
def VALUE(text):
"""
Converts a string in accepted date, time or number formats into a number or date.
>>> VALUE("$1,000")
1000
>>> VALUE("16:48:00") - VALUE("12:00:00")
datetime.timedelta(0, 17280)
>>> VALUE("01/01/2012")
datetime.datetime(2012, 1, 1, 0, 0)
>>> VALUE("")
0
>>> VALUE(0)
0
>>> VALUE("826")
826
>>> VALUE("-826.123123123")
-826.123123123
>>> VALUE(float('nan'))
nan
>>> VALUE("Invalid")
Traceback (most recent call last):
...
ValueError: text cannot be parsed to a number
>>> VALUE("13/13/13")
Traceback (most recent call last):
...
ValueError: text cannot be parsed to a number
"""
# This is not particularly robust, but makes an attempt to handle a number of cases: numbers,
# including optional comma separators, dates/times, leading dollar-sign.
if isinstance(text, (numbers.Number, datetime.date)):
return text
text = text.strip().lstrip('$')
nocommas = text.replace(',', '')
if nocommas == "":
return 0
try:
return int(nocommas)
except ValueError:
pass
try:
return float(nocommas)
except ValueError:
pass
try:
return dateutil.parser.parse(text)
except ValueError:
pass
raise ValueError('text cannot be parsed to a number')