spreadsheet

terminal spreadsheet application
git clone https://git.ce9e.org/spreadsheet.git

commit
9b711dfb6fac33d95989e79836fb8a7326ff4de7
parent
e96a2a8d36523dbeb960190e1bd43b2809fba8d5
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2026-05-08 10:13
refactor: types

Diffstat

M sheet/__main__.py 7 ++++---
M sheet/expression.py 359 ++++++++++++++++++++++++++++++++++++++-----------------------
M sheet/sheet.py 109 ++++++++++++++++++++++++++++++-------------------------------

3 files changed, 280 insertions, 195 deletions


diff --git a/sheet/__main__.py b/sheet/__main__.py

@@ -8,8 +8,9 @@ from .csv import load_csv
    8     8 from .expression import x2col
    9     9 from .input import Input
   10    10 from .sheet import Bar
   11    -1 from .sheet import Sheet
   -1    11 from .sheet import Value
   12    12 from .sheet import iter_range
   -1    13 from .sheet import Sheet
   13    14 from .term import align_center
   14    15 from .term import align_left
   15    16 from .term import align_right
@@ -37,8 +38,8 @@ q            - quit
   37    38 """
   38    39 
   39    40 
   40    -1 def to_cell(value: float|int|str|None|Exception, width: int) -> str:
   41    -1     if isinstance(value, float|int):
   -1    41 def to_cell(value: Value, width: int) -> str:
   -1    42     if isinstance(value, float | int):
   42    43         s = f'{{:{width}.{min(width - 2, 6)}g}}'.format(value)
   43    44         return align_right(s, width)
   44    45     elif isinstance(value, str):

diff --git a/sheet/expression.py b/sheet/expression.py

@@ -1,43 +1,45 @@
   -1     1 import abc
    1     2 import re
    2     3 import string
   -1     4 from dataclasses import dataclass
    3     5 
    4     6 
    5     7 class ParseError(ValueError):
    6     8     pass
    7     9 
    8    10 
    9    -1 def parse_any(text, parsers):
   10    -1     for parser in parsers:
   11    -1         try:
   12    -1             return parser(text)
   13    -1         except ParseError:
   14    -1             pass
   15    -1     raise ParseError(f'None of the subparsers matched for {text}')
   -1    11 class Expr(abc.ABC):
   -1    12     @classmethod
   -1    13     @abc.abstractmethod
   -1    14     def parse(cls, text: str) -> tuple['Expr', str]:
   -1    15         ...
   -1    16 
   -1    17     @abc.abstractmethod
   -1    18     def unparse(self) -> str:
   -1    19         ...
   16    20 
   -1    21     @abc.abstractmethod
   -1    22     def shift_refs(self, d: tuple[int, int]) -> 'Expr':
   -1    23         ...
   17    24 
   18    -1 def parse_re(text, pattern):
   -1    25 
   -1    26 def parse_re(text: str, pattern: str) -> tuple[re.Match, str]:
   19    27     m = re.match(pattern, text)
   20    28     if not m:
   21    29         raise ParseError
   22    30     return m, text[m.end():]
   23    31 
   24    32 
   25    -1 def parse_string(text):
   26    -1     m, tail = parse_re(text, r'"[^"]*"')
   27    -1     return ('str', m[0][1:-1], m[0]), tail
   28    -1 
   29    -1 
   30    -1 def parse_float(text):
   31    -1     m, tail = parse_re(text, r'[0-9]+\.[0-9]+')
   32    -1     return ('float', float(m[0]), m[0]), tail
   33    -1 
   34    -1 
   35    -1 def parse_int(text):
   36    -1     m, tail = parse_re(text, r'[0-9]+')
   37    -1     return ('int', int(m[0], 10), m[0]), tail
   -1    33 def parse_any(text: str, parsers: list) -> tuple[Expr, str]:
   -1    34     for parser in parsers:
   -1    35         try:
   -1    36             return parser(text)
   -1    37         except ParseError:
   -1    38             pass
   -1    39     raise ParseError(f'None of the subparsers matched for {text}')
   38    40 
   39    41 
   40    -1 def col2x(col):
   -1    42 def col2x(col: str) -> int:
   41    43     alph = string.ascii_uppercase
   42    44     x = -1
   43    45     for c in col:
@@ -45,7 +47,7 @@ def col2x(col):
   45    47     return x
   46    48 
   47    49 
   48    -1 def x2col(x):
   -1    50 def x2col(x: int) -> str:
   49    51     if x < 0:
   50    52         return '#'
   51    53     a, b = divmod(x, len(string.ascii_uppercase))
@@ -56,133 +58,218 @@ def x2col(x):
   56    58     return s
   57    59 
   58    60 
   59    -1 def parse_ref(text):
   60    -1     m, tail = parse_re(text, r'(\$)?([A-Z]+)(\$)?([1-9][0-9]*)')
   61    -1     x_fixed = bool(m[1])
   62    -1     x = col2x(m[2])
   63    -1     y_fixed = bool(m[3])
   64    -1     y = int(m[4], 10) - 1
   65    -1     return ('ref', (x, y), (x_fixed, y_fixed)), tail
   -1    61 @dataclass(frozen=True)
   -1    62 class String(Expr):
   -1    63     value: str
   -1    64     raw: str
   66    65 
   -1    66     @classmethod
   -1    67     def parse(cls, text: str) -> tuple['String', str]:
   -1    68         m, tail = parse_re(text, r'"[^"]*"')
   -1    69         return cls(m[0][1:-1], m[0]), tail
   67    70 
   68    -1 def parse_range(text):
   69    -1     ref1, tail = parse_ref(text)
   70    -1     _, tail = parse_re(tail, r':')
   71    -1     ref2, tail = parse_ref(tail)
   72    -1     return ('range', ref1, ref2), tail
   -1    71     def unparse(self) -> str:
   -1    72         return self.raw
   73    73 
   -1    74     def shift_refs(self, d: tuple[int, int]) -> 'String':
   -1    75         return self
   74    76 
   75    -1 def parse_brace(text):
   76    -1     _, tail = parse_re(text, r'\(')
   77    -1     exp, tail = parse_expression(tail)
   78    -1     _, tail = parse_re(tail, r'\)')
   79    -1     return ('brace', exp), tail
   80    77 
   -1    78 @dataclass(frozen=True)
   -1    79 class Float(Expr):
   -1    80     value: float
   -1    81     raw: str
   81    82 
   82    -1 def parse_call(text):
   83    -1     m, tail = parse_re(text, r'[a-zA-Z][a-zA-Z0-9]*')
   84    -1     _, tail = parse_re(tail, r'\(')
   85    -1     args = []
   86    -1     commas = []
   87    -1     if tail.startswith(')'):
   88    -1         return (m[0], args, commas), tail[1:]
   89    -1     while True:
   90    -1         arg, tail = parse_expression(tail)
   91    -1         args.append(arg)
   92    -1         if tail.startswith(')'):
   93    -1             return (m[0], args, commas), tail[1:]
   94    -1         c, tail = parse_re(tail, r',\s*')
   95    -1         commas.append(c[0])
   96    -1     raise ParseError('no closing brace on function call')
   97    -1 
   98    -1 
   99    -1 def parse_expression3(text):
  100    -1     return parse_any(text, [
  101    -1         parse_string,
  102    -1         parse_float,
  103    -1         parse_int,
  104    -1         parse_range,
  105    -1         parse_ref,
  106    -1         parse_call,
  107    -1         parse_brace,
  108    -1     ])
  109    -1 
  110    -1 
  111    -1 def parse_expression2(text):
  112    -1     lhs, tail = parse_expression3(text)
  113    -1     while True:
  114    -1         try:
  115    -1             m, tail = parse_re(tail, r'\s*[*/]\s*')
  116    -1         except ParseError:
  117    -1             break
  118    -1         rhs, tail = parse_expression3(tail)
  119    -1         lhs = m[0].strip(), lhs, rhs, m[0]
  120    -1     return lhs, tail
   -1    83     @classmethod
   -1    84     def parse(cls, text: str) -> tuple['Float', str]:
   -1    85         m, tail = parse_re(text, r'[0-9]+\.[0-9]+')
   -1    86         return cls(float(m[0]), m[0]), tail
  121    87 
   -1    88     def unparse(self) -> str:
   -1    89         return self.raw
  122    90 
  123    -1 def parse_expression(text):
  124    -1     lhs, tail = parse_expression2(text)
  125    -1     while True:
  126    -1         try:
  127    -1             m, tail = parse_re(tail, r'\s*[-+]\s*')
  128    -1         except ParseError:
  129    -1             break
  130    -1         rhs, tail = parse_expression2(tail)
  131    -1         lhs = m[0].strip(), lhs, rhs, m[0]
  132    -1     return lhs, tail
   -1    91     def shift_refs(self, d: tuple[int, int]) -> 'Float':
   -1    92         return self
  133    93 
  134    94 
  135    -1 def parse(text):
  136    -1     expr, tail = parse_expression(text)
  137    -1     if tail:
  138    -1         raise ParseError(f'unexpected tail: {tail}')
  139    -1     return expr
   -1    95 @dataclass(frozen=True)
   -1    96 class Int(Expr):
   -1    97     value: int
   -1    98     raw: str
  140    99 
   -1   100     @classmethod
   -1   101     def parse(cls, text: str) -> tuple['Int', str]:
   -1   102         m, tail = parse_re(text, r'[0-9]+')
   -1   103         return cls(int(m[0], 10), m[0]), tail
  141   104 
  142    -1 def unparse(expr):
  143    -1     if expr[0] in ['str', 'float', 'int']:
  144    -1         return expr[2]
  145    -1     elif expr[0] == 'ref':
   -1   105     def unparse(self) -> str:
   -1   106         return self.raw
   -1   107 
   -1   108     def shift_refs(self, d: tuple[int, int]) -> 'Int':
   -1   109         return self
   -1   110 
   -1   111 
   -1   112 @dataclass(frozen=True)
   -1   113 class Ref(Expr):
   -1   114     cell: tuple[int, int]
   -1   115     fixed: tuple[bool, bool]
   -1   116 
   -1   117     @classmethod
   -1   118     def parse(cls, text: str) -> tuple['Ref', str]:
   -1   119         m, tail = parse_re(text, r'(\$)?([A-Z]+)(\$)?([1-9][0-9]*)')
   -1   120         x_fixed = bool(m[1])
   -1   121         x = col2x(m[2])
   -1   122         y_fixed = bool(m[3])
   -1   123         y = int(m[4], 10) - 1
   -1   124         return cls((x, y), (x_fixed, y_fixed)), tail
   -1   125 
   -1   126     def unparse(self) -> str:
  146   127         s = ''
  147    -1         if expr[2][0]:
  148    -1             s += '$'
  149    -1         s += x2col(expr[1][0])
  150    -1         if expr[2][1]:
  151    -1             s += '$'
  152    -1         s += str(expr[1][1] + 1)
   -1   128         s += '$' if self.fixed[0] else ''
   -1   129         s += x2col(self.cell[0])
   -1   130         s += '$' if self.fixed[1] else ''
   -1   131         s += str(self.cell[1] + 1)
  153   132         return s
  154    -1     elif expr[0] == 'range':
  155    -1         return unparse(expr[1]) + ':' + unparse(expr[2])
  156    -1     elif expr[0] == 'brace':
  157    -1         return '(' + unparse(expr[1]) + ')'
  158    -1     elif expr[0] in '+-*/':
  159    -1         return unparse(expr[1]) + expr[3] + unparse(expr[2])
  160    -1     else:
  161    -1         name, args, commas = expr
  162    -1         assert len(args) == len(commas) + 1
  163    -1         sargs = ''
  164    -1         if args:
  165    -1             for i, comma in enumerate(commas):
  166    -1                 sargs += unparse(args[i]) + comma
  167    -1             sargs += unparse(args[-1])
  168    -1         return f'{name}({sargs})'
  169    -1 
  170   133 
  171    -1 def shift_refs(expr, d):
  172    -1     if expr[0] == 'ref':
  173    -1         x, y = expr[1]
  174    -1         if not expr[2][0]:
   -1   134     def shift_refs(self, d: tuple[int, int]) -> 'Ref':
   -1   135         x, y = self.cell
   -1   136         if not self.fixed[0]:
  175   137             x += d[0]
  176    -1         if not expr[2][1]:
   -1   138         if not self.fixed[1]:
  177   139             y += d[1]
  178    -1         return 'ref', (x, y), expr[2]
  179    -1     elif expr[0] in ['str', 'float', 'int']:
  180    -1         return expr
  181    -1     elif expr[0] == 'range':
  182    -1         return 'range', shift_refs(expr[1], d), shift_refs(expr[2], d)
  183    -1     elif expr[0] == 'brace':
  184    -1         return 'brace', shift_refs(expr[1], d)
  185    -1     elif expr[0] in '+-*/':
  186    -1         return expr[0], shift_refs(expr[1], d), shift_refs(expr[2], d), expr[3]
  187    -1     else:
  188    -1         return expr[0], [shift_refs(arg, d) for arg in expr[1]], expr[2]
   -1   140         return Ref((x, y), self.fixed)
   -1   141 
   -1   142 
   -1   143 @dataclass(frozen=True)
   -1   144 class Range(Expr):
   -1   145     start: Ref
   -1   146     end: Ref
   -1   147 
   -1   148     @classmethod
   -1   149     def parse(cls, text: str) -> tuple['Range', str]:
   -1   150         ref1, tail = Ref.parse(text)
   -1   151         _, tail = parse_re(tail, r':')
   -1   152         ref2, tail = Ref.parse(tail)
   -1   153         return cls(ref1, ref2), tail
   -1   154 
   -1   155     def unparse(self) -> str:
   -1   156         return f'{self.start.unparse()}:{self.end.unparse()}'
   -1   157 
   -1   158     def shift_refs(self, d: tuple[int, int]) -> 'Range':
   -1   159         return Range(self.start.shift_refs(d), self.end.shift_refs(d))
   -1   160 
   -1   161 
   -1   162 @dataclass(frozen=True)
   -1   163 class Brace(Expr):
   -1   164     inner: Expr
   -1   165 
   -1   166     @classmethod
   -1   167     def parse(cls, text: str) -> tuple['Brace', str]:
   -1   168         _, tail = parse_re(text, r'\(')
   -1   169         exp, tail = Op.parse(tail)
   -1   170         _, tail = parse_re(tail, r'\)')
   -1   171         return cls(exp), tail
   -1   172 
   -1   173     def unparse(self) -> str:
   -1   174         return f'({self.inner.unparse()})'
   -1   175 
   -1   176     def shift_refs(self, d: tuple[int, int]) -> 'Brace':
   -1   177         return Brace(self.inner.shift_refs(d))
   -1   178 
   -1   179 
   -1   180 @dataclass(frozen=True)
   -1   181 class Call(Expr):
   -1   182     name: str
   -1   183     args: list[Expr]
   -1   184     commas: list[str]
   -1   185 
   -1   186     @classmethod
   -1   187     def parse(cls, text: str) -> tuple['Call', str]:
   -1   188         m, tail = parse_re(text, r'[a-zA-Z][a-zA-Z0-9]*')
   -1   189         _, tail = parse_re(tail, r'\(')
   -1   190         args = []
   -1   191         commas = []
   -1   192         if tail.startswith(')'):
   -1   193             return cls(m[0], args, commas), tail[1:]
   -1   194         while True:
   -1   195             arg, tail = Op.parse(tail)
   -1   196             args.append(arg)
   -1   197             if tail.startswith(')'):
   -1   198                 return cls(m[0], args, commas), tail[1:]
   -1   199             c, tail = parse_re(tail, r',\s*')
   -1   200             commas.append(c[0])
   -1   201         raise ParseError('no closing brace on function call')
   -1   202 
   -1   203     def unparse(self) -> str:
   -1   204         assert len(self.args) == len(self.commas) + 1
   -1   205         sargs = ''
   -1   206         if self.args:
   -1   207             for i, comma in enumerate(self.commas):
   -1   208                 sargs += str(self.args[i].unparse()) + comma
   -1   209             sargs += str(self.args[-1].unparse())
   -1   210         return f'{self.name}({sargs})'
   -1   211 
   -1   212     def shift_refs(self, d: tuple[int, int]) -> 'Call':
   -1   213         return Call(
   -1   214             self.name,
   -1   215             [arg.shift_refs(d) for arg in self.args],
   -1   216             self.commas,
   -1   217         )
   -1   218 
   -1   219 
   -1   220 @dataclass(frozen=True)
   -1   221 class Op(Expr):
   -1   222     op: str
   -1   223     lhs: Expr
   -1   224     rhs: Expr
   -1   225     op_raw: str
   -1   226 
   -1   227     @classmethod
   -1   228     def _parse(cls, text: str, pattern: str, parse_child) -> tuple['Op', str]:
   -1   229         lhs, tail = parse_child(text)
   -1   230         while True:
   -1   231             try:
   -1   232                 m, tail = parse_re(tail, pattern)
   -1   233             except ParseError:
   -1   234                 break
   -1   235             rhs, tail = parse_child(tail)
   -1   236             lhs = cls(m[0].strip(), lhs, rhs, m[0])
   -1   237         return lhs, tail
   -1   238 
   -1   239     @classmethod
   -1   240     def parse_basic(cls, text: str) -> tuple[Expr, str]:
   -1   241         return parse_any(text, [
   -1   242             String.parse,
   -1   243             Float.parse,
   -1   244             Int.parse,
   -1   245             Range.parse,
   -1   246             Ref.parse,
   -1   247             Call.parse,
   -1   248             Brace.parse,
   -1   249         ])
   -1   250 
   -1   251     @classmethod
   -1   252     def parse_mult(cls, text: str) -> tuple['Op', str]:
   -1   253         return cls._parse(text, r'\s*[*/]\s*', cls.parse_basic)
   -1   254 
   -1   255     @classmethod
   -1   256     def parse(cls, text: str) -> tuple['Op', str]:
   -1   257         return cls._parse(text, r'\s*[-+]\s*', cls.parse_mult)
   -1   258 
   -1   259     def unparse(self) -> str:
   -1   260         return f'{self.lhs.unparse()}{self.op_raw}{self.rhs.unparse()}'
   -1   261 
   -1   262     def shift_refs(self, d: tuple[int, int]) -> 'Op':
   -1   263         return Op(
   -1   264             self.op,
   -1   265             self.lhs.shift_refs(d),
   -1   266             self.rhs.shift_refs(d),
   -1   267             self.op_raw,
   -1   268         )
   -1   269 
   -1   270 
   -1   271 def parse(text: str) -> Expr:
   -1   272     expr, tail = Op.parse(text)
   -1   273     if tail:
   -1   274         raise ParseError(f'unexpected tail: {tail}')
   -1   275     return expr

diff --git a/sheet/sheet.py b/sheet/sheet.py

@@ -1,9 +1,8 @@
    1     1 import math
   -1     2 from collections.abc import Callable
   -1     3 from collections.abc import Generator
    2     4 
    3    -1 from .expression import ParseError
    4    -1 from .expression import parse
    5    -1 from .expression import shift_refs
    6    -1 from .expression import unparse
   -1     5 from . import expression
    7     6 
    8     7 BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']
    9     8 
@@ -22,7 +21,13 @@ class Bar:
   22    21             return a * BLOCKS[-1] + BLOCKS[b] + (width - a - 1) * BLOCKS[0]
   23    22 
   24    23 
   25    -1 FUNCTIONS = {
   -1    24 Cell = tuple[int, int]
   -1    25 RawValue = float | int | str
   -1    26 Parsed = RawValue | expression.Expr | expression.ParseError
   -1    27 Value = RawValue | Bar | None | Exception
   -1    28 
   -1    29 
   -1    30 FUNCTIONS: dict[str, tuple[Callable, str | int]] = {
   26    31     'sum': (sum, 'range'),
   27    32     'min': (min, 'range'),
   28    33     'max': (max, 'range'),
@@ -36,7 +41,7 @@ FUNCTIONS = {
   36    41 }
   37    42 
   38    43 
   39    -1 def iter_range(cell1, cell2):
   -1    44 def iter_range(cell1: Cell, cell2: Cell) -> Generator[Cell, None, None]:
   40    45     x1, y1 = cell1
   41    46     x2, y2 = cell2
   42    47     if x1 > x2:
@@ -48,7 +53,7 @@ def iter_range(cell1, cell2):
   48    53             yield x, y
   49    54 
   50    55 
   51    -1 def to_number(value: float|int|str|Bar|None|Exception) -> float|int:
   -1    56 def to_number(value: Value) -> float | int:
   52    57     if isinstance(value, float):
   53    58         return value
   54    59     elif isinstance(value, int):
@@ -65,16 +70,16 @@ def to_number(value: float|int|str|Bar|None|Exception) -> float|int:
   65    70 
   66    71 class Sheet:
   67    72     def __init__(self):
   68    -1         self.raw = {}
   69    -1         self.parsed = {}
   70    -1         self.cache = {}
   -1    73         self.raw: dict[Cell, str] = {}
   -1    74         self.parsed: dict[Cell, Parsed] = {}
   -1    75         self.cache: dict[Cell, Value] = {}
   71    76 
   72    -1     def parse(self, raw: str) -> tuple|float|int|str:
   -1    77     def parse(self, raw: str) -> Parsed:
   73    78         if raw.startswith('='):
   74    79             try:
   75    -1                 return parse(raw[1:])
   76    -1             except ParseError as err:
   77    -1                 return ('err', err)
   -1    80                 return expression.parse(raw[1:])
   -1    81             except expression.ParseError as err:
   -1    82                 return err
   78    83         try:
   79    84             return int(raw, 10)
   80    85         except ValueError:
@@ -85,52 +90,43 @@ class Sheet:
   85    90             pass
   86    91         return raw
   87    92 
   88    -1     def call_function(
   89    -1         self, name: str, args: list[tuple], _commas: list[str]
   90    -1     ) -> float|int|str|Bar:
   -1    93     def call_function(self, name: str, args: list[expression.Expr]) -> Value:
   91    94         fn, nargs = FUNCTIONS[name.lower()]
   92    95         if nargs == 'range':
   93    -1             if len(args) != 1 or args[0][0] != 'range':
   -1    96             if len(args) != 1 or not isinstance(args[0], expression.Range):
   94    97                 raise ValueError(args)
   95    -1             _, ref1, ref2 = args[0]
   96    98             return fn(
   97    99                 to_number(self.get_value(ref))
   98    -1                 for ref in iter_range(ref1[1], ref2[1])
   -1   100                 for ref in iter_range(args[0].start.cell, args[0].end.cell)
   99   101             )
  100   102         else:
  101   103             if len(args) != nargs:
  102   104                 raise ValueError(args)
  103   105             return fn(*[to_number(self.evaluate(a)) for a in args])
  104   106 
  105    -1     def evaluate(self, expr: tuple) -> float|int|str|Bar:
  106    -1         if expr[0] in ['int', 'float', 'str']:
  107    -1             return expr[1]
  108    -1         elif expr[0] == 'ref':
  109    -1             return self.get_value(expr[1])
  110    -1         elif expr[0] == 'brace':
  111    -1             return self.evaluate(expr[1])
  112    -1         elif expr[0] == 'err':
  113    -1             raise expr[1]
  114    -1         elif expr[0] == '+':
  115    -1             lhs = to_number(self.evaluate(expr[1]))
  116    -1             rhs = to_number(self.evaluate(expr[2]))
  117    -1             return lhs + rhs
  118    -1         elif expr[0] == '-':
  119    -1             lhs = to_number(self.evaluate(expr[1]))
  120    -1             rhs = to_number(self.evaluate(expr[2]))
  121    -1             return lhs - rhs
  122    -1         elif expr[0] == '*':
  123    -1             lhs = to_number(self.evaluate(expr[1]))
  124    -1             rhs = to_number(self.evaluate(expr[2]))
  125    -1             return lhs * rhs
  126    -1         elif expr[0] == '/':
  127    -1             lhs = to_number(self.evaluate(expr[1]))
  128    -1             rhs = to_number(self.evaluate(expr[2]))
  129    -1             return lhs / rhs
  130    -1         else:
  131    -1             return self.call_function(*expr)
  132    -1 
  133    -1     def set(self, cell, raw: str):
   -1   107     def evaluate(self, expr: expression.Expr) -> Value:
   -1   108         if isinstance(expr, (expression.Int, expression.Float, expression.String)):
   -1   109             return expr.value
   -1   110         elif isinstance(expr, expression.Ref):
   -1   111             return self.get_value(expr.cell)
   -1   112         elif isinstance(expr, expression.Brace):
   -1   113             return self.evaluate(expr.inner)
   -1   114         elif isinstance(expr, expression.Op):
   -1   115             lhs = to_number(self.evaluate(expr.lhs))
   -1   116             rhs = to_number(self.evaluate(expr.rhs))
   -1   117             if expr.op == '+':
   -1   118                 return lhs + rhs
   -1   119             elif expr.op == '-':
   -1   120                 return lhs - rhs
   -1   121             elif expr.op == '*':
   -1   122                 return lhs * rhs
   -1   123             elif expr.op == '/':
   -1   124                 return lhs / rhs
   -1   125         elif isinstance(expr, expression.Call):
   -1   126             return self.call_function(expr.name, expr.args)
   -1   127         raise ValueError(expr)
   -1   128 
   -1   129     def set(self, cell: Cell, raw: str):
  134   130         if raw:
  135   131             self.raw[cell] = raw
  136   132             self.parsed[cell] = self.parse(raw)
@@ -139,22 +135,23 @@ class Sheet:
  139   135             del self.parsed[cell]
  140   136         self.cache = {}
  141   137 
  142    -1     def set_shifted(self, cell, raw: str, shift) -> str:
   -1   138     def set_shifted(self, cell: Cell, raw: str, shift: Cell):
  143   139         if raw.startswith('='):
  144   140             expr = self.parse(raw)
  145    -1             shifted = shift_refs(expr, shift)
  146    -1             raw = '=' + unparse(shifted)
   -1   141             if isinstance(expr, expression.Expr):
   -1   142                 shifted = expr.shift_refs(shift)
   -1   143                 raw = f'={shifted.unparse()}'
  147   144         self.set(cell, raw)
  148   145 
  149    -1     def get_raw(self, cell) -> str:
   -1   146     def get_raw(self, cell: Cell) -> str:
  150   147         return self.raw.get(cell, '')
  151   148 
  152    -1     def get_parsed(self, cell) -> tuple|float|int|str|None:
   -1   149     def get_parsed(self, cell: Cell) -> Parsed | None:
  153   150         return self.parsed.get(cell)
  154   151 
  155    -1     def get_value(self, cell) -> float|int|str|Bar|None|Exception:
   -1   152     def get_value(self, cell: Cell) -> Value:
  156   153         parsed = self.get_parsed(cell)
  157    -1         if isinstance(parsed, tuple):
   -1   154         if isinstance(parsed, expression.Expr):
  158   155             if cell not in self.cache:
  159   156                 self.cache[cell] = ReferenceError(cell)
  160   157                 try: