Module refinery.lib.scripts.xlm.handlers.functions
The numeric and logic commands of the macro language: the arithmetic of numbers, the truth of
conditions, the counting of arguments, and the Roman numerals of _xlfn.ARABIC.
Expand source code Browse git
"""
The numeric and logic commands of the macro language: the arithmetic of numbers, the truth of
conditions, the counting of arguments, and the Roman numerals of `_xlfn.ARABIC`.
"""
from __future__ import annotations
import datetime
import decimal
import math
import random
from typing import TYPE_CHECKING
from refinery.lib.excel import synthesize_formula
from refinery.lib.excel.common import datetime_to_serial
from refinery.lib.excel.formula.model import XlA1Reference, XlMissingArgument, XlR1C1Reference
from refinery.lib.scripts.xlm.evaluate import evaluate_expression
from refinery.lib.scripts.xlm.guess import guess_day
from refinery.lib.scripts.xlm.trace import XlmStatus
from refinery.lib.scripts.xlm.values import (
XlmOutcome,
XlmValue,
condition,
error_outcome,
is_number,
wrap_literal,
)
if TYPE_CHECKING:
from refinery.lib.excel.formula.model import XlFunctionCall
from refinery.lib.scripts.xlm.engine import XlmEngine
from refinery.lib.scripts.xlm.references import XlmCursor
#: How many seconds the answer of `NOW` advances from one call to the next, so that a program
#: which waits for a moment of the day moves on instead of reading the same moment forever.
_NOW_STEP = 2
#: How many times the answer of `ISERROR` may repeat at one cell before the emulator flips it,
#: to free a program stuck oscillating between two cells over a condition that never changes.
_ISERROR_REPEAT_LIMIT = 10
_ROMAN_VALUES = (
('M', 1000),
('CM', 900),
('D', 500),
('CD', 400),
('C', 100),
('XC', 90),
('L', 50),
('XL', 40),
('X', 10),
('IX', 9),
('V', 5),
('IV', 4),
('I', 1),
)
def _partial(spelled: str) -> XlmOutcome:
return XlmOutcome(value=XlmValue(value=spelled, partial=True))
def _from_roman(spelled: str) -> int:
result = 0
position = 0
for numeral, value in _ROMAN_VALUES:
while spelled[position:position + len(numeral)] == numeral:
result += value
position += len(numeral)
if position != len(spelled):
raise ValueError(spelled)
return result
def _numeric(
engine: XlmEngine,
call: XlFunctionCall,
cursor: XlmCursor,
index: int = 0,
) -> XlmValue | None:
"""
One numeric operand of a command, or `None` when the command cannot compute: an operand
the program never finished, or one that spells no number, leaves the command unevaluated.
"""
argument = evaluate_expression(engine, call.arguments[index], cursor)
if argument.partial or not is_number(argument.value):
return None
return argument
def _rounded(number: float, digits: int, rounding: str) -> float:
"""
The number rounded to the given count of decimal digits in the direction a rounding mode of
`decimal` names; a negative count rounds to the left of the decimal point. The rounding
applies to the shortest decimal spelling of the number, the spelling Excel rounds, so that
a tie that spelling shows is a tie.
"""
spelled = decimal.Decimal(repr(number))
exponent = spelled.as_tuple().exponent
if isinstance(exponent, int) and exponent >= -digits:
return number
return float(spelled.quantize(decimal.Decimal(1).scaleb(-digits), rounding=rounding))
def _round_to_digits(
engine: XlmEngine,
call: XlFunctionCall,
cursor: XlmCursor,
rounding: str,
) -> XlmOutcome:
"""
The rounding commands: the number of the first argument rounded to the count of decimal
digits the second argument names, zero when the call names none.
"""
spelled = synthesize_formula(call)
number = _numeric(engine, call, cursor)
if number is None:
return _partial(spelled)
digits = 0
if len(call.arguments) > 1 and not isinstance(call.arguments[1], XlMissingArgument):
count = _numeric(engine, call, cursor, 1)
if count is None:
return _partial(spelled)
digits = int(float(count.value))
try:
result = _rounded(float(number.value), digits, rounding)
except decimal.DecimalException:
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=result))
def _abs(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
argument = _numeric(engine, call, cursor)
if argument is None:
return _partial(synthesize_formula(call))
if isinstance(argument.value, bool):
return XlmOutcome(value=XlmValue(value=int(argument.value)))
return XlmOutcome(value=XlmValue(value=abs(float(argument.value))))
def _int(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
The greatest integer that does not exceed the number, as `INT` rounds down.
"""
argument = _numeric(engine, call, cursor)
if argument is None:
return _partial(synthesize_formula(call))
return XlmOutcome(value=XlmValue(value=math.floor(float(argument.value))))
def _sqrt(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
argument = _numeric(engine, call, cursor)
if argument is None:
return _partial(synthesize_formula(call))
try:
result = math.sqrt(float(argument.value))
except ValueError:
return _partial(synthesize_formula(call))
return XlmOutcome(value=XlmValue(value=result))
def _trunc(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
The number cut toward zero to the count of decimal digits the call names.
"""
return _round_to_digits(engine, call, cursor, decimal.ROUND_DOWN)
def _value(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
spelled = synthesize_formula(call)
argument = evaluate_expression(engine, call.arguments[0], cursor)
if not argument.partial and is_number(argument.value):
number = float(argument.value)
if number.is_integer():
return XlmOutcome(value=XlmValue(value=int(number)))
return XlmOutcome(value=XlmValue(value=number))
if argument.partial:
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=0, text=spelled), status=XlmStatus.Error)
def _pair(
engine: XlmEngine,
call: XlFunctionCall,
cursor: XlmCursor,
) -> tuple[XlmValue, XlmValue] | None:
"""
The two numeric operands of a command, or `None` when the command cannot compute.
"""
left = evaluate_expression(engine, call.arguments[0], cursor)
right = evaluate_expression(engine, call.arguments[1], cursor)
if (
left.partial
or right.partial
or not is_number(left.value)
or not is_number(right.value)
):
return None
return left, right
def _mod(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
spelled = synthesize_formula(call)
operands = _pair(engine, call, cursor)
if operands is None:
return _partial(spelled)
left, right = operands
try:
result = float(left.value) % float(right.value)
except ZeroDivisionError:
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=result))
def _round(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
The number rounded to the count of decimal digits the call names, a tie away from zero.
"""
return _round_to_digits(engine, call, cursor, decimal.ROUND_HALF_UP)
def _round_up(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
The number rounded away from zero to the count of decimal digits the call names.
"""
return _round_to_digits(engine, call, cursor, decimal.ROUND_UP)
def _randbetween(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
spelled = synthesize_formula(call)
operands = _pair(engine, call, cursor)
if operands is None:
return _partial(spelled)
left, right = operands
try:
result = random.randint(int(float(left.value)), int(float(right.value)))
except ValueError:
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=result))
def _quotient(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
The integer part of the division of the two operands, the remainder discarded toward zero.
"""
spelled = synthesize_formula(call)
operands = _pair(engine, call, cursor)
if operands is None:
return _partial(spelled)
left, right = operands
try:
result = math.trunc(float(left.value) / float(right.value))
except (ZeroDivisionError, OverflowError):
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=result))
def _extreme(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor, choose) -> XlmOutcome:
spelled = synthesize_formula(call)
numbers: list[float] = []
for node in call.arguments:
argument = evaluate_expression(engine, node, cursor)
if argument.partial or not is_number(argument.value):
return _partial(spelled)
numbers.append(float(argument.value))
if not numbers:
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=choose(numbers)))
def _max(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
return _extreme(engine, call, cursor, max)
def _min(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
return _extreme(engine, call, cursor, min)
def _product(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
spelled = synthesize_formula(call)
total = 1.0
seen = 0
for node in call.arguments:
argument = evaluate_expression(engine, node, cursor)
if argument.partial or not is_number(argument.value):
return _partial(spelled)
total *= float(argument.value)
seen += 1
if not seen:
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=total))
def _sum(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
spelled = synthesize_formula(call)
total = 0.0
for node in call.arguments:
argument = evaluate_expression(engine, node, cursor)
if argument.partial or not is_number(argument.value):
return _partial(spelled)
total += float(argument.value)
return XlmOutcome(value=XlmValue(value=total))
def _and(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
value = True
failure = None
for node in call.arguments:
argument = evaluate_expression(engine, node, cursor)
if argument.partial:
return XlmOutcome(value=XlmValue(value=False, partial=True))
truth = condition(argument)
if isinstance(truth, XlmValue):
failure = failure or truth
elif not truth:
value = False
if failure is not None:
return XlmOutcome(value=failure)
return XlmOutcome(value=XlmValue(value=value))
def _or(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
value = False
failure = None
for node in call.arguments:
argument = evaluate_expression(engine, node, cursor)
if argument.partial:
return XlmOutcome(value=XlmValue(value=False, partial=True))
truth = condition(argument)
if isinstance(truth, XlmValue):
failure = failure or truth
elif truth:
value = True
if failure is not None:
return XlmOutcome(value=failure)
return XlmOutcome(value=XlmValue(value=value))
def _not(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
argument = evaluate_expression(engine, call.arguments[0], cursor)
if argument.partial:
return XlmOutcome(value=XlmValue(value=True, partial=True))
truth = condition(argument)
if isinstance(truth, XlmValue):
return error_outcome(truth)
return XlmOutcome(value=XlmValue(value=not truth))
def _isnumber(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
Whether the argument holds a number, by its type rather than its spelling: a number —
the serial a date cell holds included — answers `TRUE`, and a text, a boolean, an error
value, and an empty cell answer `FALSE`.
"""
argument = evaluate_expression(engine, call.arguments[0], cursor)
if argument.partial:
return XlmOutcome(value=XlmValue(
value=True,
text=F'ISNUMBER({argument.text})',
partial=True,
))
return XlmOutcome(value=XlmValue(value=(
isinstance(argument.value, (int, float)) and not isinstance(argument.value, bool)
)))
def _iserror(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
Whether the argument is an error value, or a value that computes nothing. The answer flips
once it repeated at one cell often enough, to free a program stuck waiting on a condition
that never changes.
"""
argument = evaluate_expression(engine, call.arguments[0], cursor)
result = argument.value is None or argument.error
if engine.iserror_at is None:
engine.iserror_flag = result
engine.iserror_at = cursor
engine.iserror_repeats = 1
elif engine.iserror_at == cursor:
if engine.iserror_flag != result:
engine.iserror_flag = result
engine.iserror_repeats = 1
elif engine.iserror_repeats < _ISERROR_REPEAT_LIMIT:
engine.iserror_repeats += 1
else:
result = not result
engine.iserror_at = None
return XlmOutcome(value=XlmValue(
value=result,
text=F'ISERROR({wrap_literal(argument.unwrap())})',
))
def _count(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
"""
How many of the arguments are numbers: a reference or a range counts the cells that hold a
number, and any other argument counts when it is a number, a truth value, or a text that
spells a number. A value the program never finished leaves the count unevaluated.
"""
spelled = synthesize_formula(call)
count = 0
for node in call.arguments:
corners = engine.range_corners(node, cursor)
if corners is None and isinstance(node, (XlA1Reference, XlR1C1Reference)):
reference = engine.node_reference(node, cursor)
if reference is not None:
corners = (reference, reference)
if corners is not None:
for reference in engine.range_cells(corners):
value = engine.read_reference(reference, cursor)
if value.partial:
return _partial(spelled)
if (
not value.error
and not isinstance(value.value, (str, bool))
and is_number(value.value)
):
count += 1
continue
if isinstance(node, XlMissingArgument):
continue
argument = evaluate_expression(engine, node, cursor)
if argument.partial:
return _partial(spelled)
if not argument.error and is_number(argument.value):
count += 1
return XlmOutcome(value=XlmValue(value=count))
def _day(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
spelled = synthesize_formula(call)
if engine.day >= 0:
text = str(engine.day)
return XlmOutcome(value=XlmValue(value=text, text=text))
argument = evaluate_expression(engine, call.arguments[0], cursor)
if argument.partial:
return _partial(spelled)
if argument.date:
day = guess_day(engine)
engine.day = day
text = str(day)
return XlmOutcome(value=XlmValue(value=text, text=text))
if is_number(argument.value):
return XlmOutcome(
value=XlmValue(value=0, text='DAY(Serial Date)'),
status=XlmStatus.NotImplemented,
)
return _partial(spelled)
def _now(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
moment = datetime.datetime.now() + datetime.timedelta(
seconds=engine.now_count * _NOW_STEP,
)
engine.now_count += 1
return XlmOutcome(value=XlmValue(value=datetime_to_serial(moment, False)))
def _arabic(engine: XlmEngine, call: XlFunctionCall, cursor: XlmCursor) -> XlmOutcome:
spelled = synthesize_formula(call)
argument = evaluate_expression(engine, call.arguments[0], cursor)
if argument.partial:
return _partial(spelled)
try:
result = _from_roman(argument.unwrap().upper())
except ValueError:
return _partial(spelled)
return XlmOutcome(value=XlmValue(value=result))
FUNCTION_HANDLERS = {
'ABS': _abs,
'AND': _and,
'COUNT': _count,
'DAY': _day,
'INT': _int,
'ISERROR': _iserror,
'ISNUMBER': _isnumber,
'MAX': _max,
'MIN': _min,
'MOD': _mod,
'NOT': _not,
'NOW': _now,
'OR': _or,
'PRODUCT': _product,
'QUOTIENT': _quotient,
'RANDBETWEEN': _randbetween,
'ROUND': _round,
'ROUNDUP': _round_up,
'SQRT': _sqrt,
'SUM': _sum,
'TRUNC': _trunc,
'VALUE': _value,
'_xlfn.ARABIC': _arabic,
}