(core) Error explanations from friendly-traceback

Summary: Extend formula error messages with explanations from https://github.com/friendly-traceback/friendly-traceback. Only for Python 3.

Test Plan: Updated several Python tests. In general, these require separate branches for Python 2 and 3.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3542
This commit is contained in:
Alex Hall 2022-08-11 13:03:45 +02:00
parent 31f54065f5
commit 49cb51bac5
11 changed files with 239 additions and 64 deletions

View File

@ -5,15 +5,20 @@ import {arrayToString} from 'app/common/arrayToString';
import * as marshal from 'app/common/marshal'; import * as marshal from 'app/common/marshal';
import {ISandbox, ISandboxCreationOptions, ISandboxCreator} from 'app/server/lib/ISandbox'; import {ISandbox, ISandboxCreationOptions, ISandboxCreator} from 'app/server/lib/ISandbox';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {DirectProcessControl, ISandboxControl, NoProcessControl, ProcessInfo, import {
SubprocessControl} from 'app/server/lib/SandboxControl'; DirectProcessControl,
ISandboxControl,
NoProcessControl,
ProcessInfo,
SubprocessControl
} from 'app/server/lib/SandboxControl';
import * as sandboxUtil from 'app/server/lib/sandboxUtil'; import * as sandboxUtil from 'app/server/lib/sandboxUtil';
import * as shutdown from 'app/server/lib/shutdown'; import * as shutdown from 'app/server/lib/shutdown';
import {ChildProcess, spawn} from 'child_process'; import {ChildProcess, spawn} from 'child_process';
import * as fs from 'fs';
import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
import {Stream, Writable} from 'stream'; import {Stream, Writable} from 'stream';
import * as _ from 'lodash';
import * as fs from 'fs';
import * as which from 'which'; import * as which from 'which';
type SandboxMethod = (...args: any[]) => any; type SandboxMethod = (...args: any[]) => any;
@ -570,15 +575,9 @@ function gvisor(options: ISandboxOptions): SandboxProcess {
// Check for local virtual environments created with core's // Check for local virtual environments created with core's
// install:python2 or install:python3 targets. They'll need // install:python2 or install:python3 targets. They'll need
// some extra sharing to make available in the sandbox. // some extra sharing to make available in the sandbox.
// This appears to currently be incompatible with checkpoints?
// Shares and checkpoints interact delicately because the file
// handle layout/ordering needs to remain exactly the same.
// Fixable no doubt, but for now I just disable this convenience
// if checkpoints are in use.
const venv = path.join(process.cwd(), const venv = path.join(process.cwd(),
pythonVersion === '2' ? 'venv' : 'sandbox_venv3'); pythonVersion === '2' ? 'venv' : 'sandbox_venv3');
const useCheckpoint = process.env.GRIST_CHECKPOINT && !paths.importDir; if (fs.existsSync(venv)) {
if (fs.existsSync(venv) && !useCheckpoint) {
wrapperArgs.addMount(venv); wrapperArgs.addMount(venv);
wrapperArgs.push('-s', path.join(venv, 'bin', 'python')); wrapperArgs.push('-s', path.join(venv, 'bin', 'python'));
} }
@ -589,7 +588,7 @@ function gvisor(options: ISandboxOptions): SandboxProcess {
// between the checkpoint and how it gets used later). // between the checkpoint and how it gets used later).
// If a sandbox is being used for import, it will have a special mount we can't // If a sandbox is being used for import, it will have a special mount we can't
// deal with easily right now. Should be possible to do in future if desired. // deal with easily right now. Should be possible to do in future if desired.
if (options.useGristEntrypoint && pythonVersion === '3' && useCheckpoint) { if (options.useGristEntrypoint && pythonVersion === '3' && process.env.GRIST_CHECKPOINT && !paths.importDir) {
if (process.env.GRIST_CHECKPOINT_MAKE) { if (process.env.GRIST_CHECKPOINT_MAKE) {
const child = const child =
spawn(command, [...wrapperArgs.get(), '--checkpoint', process.env.GRIST_CHECKPOINT!, spawn(command, [...wrapperArgs.get(), '--checkpoint', process.env.GRIST_CHECKPOINT!,

View File

@ -1,11 +1,14 @@
import ast import ast
import contextlib import contextlib
import itertools import itertools
import linecache
import re import re
import six import six
import astroid import astroid
import asttokens import asttokens
import friendly_errors
import textbuilder import textbuilder
import logger import logger
log = logger.Logger(__name__, logger.INFO) log = logger.Logger(__name__, logger.INFO)
@ -22,6 +25,13 @@ LAZY_ARG_FUNCTIONS = {
'PEEK': slice(0, 1), 'PEEK': slice(0, 1),
} }
class GristSyntaxError(SyntaxError):
"""
Indicates a formula is invalid in a Grist-specific way.
"""
def make_formula_body(formula, default_value, assoc_value=None): def make_formula_body(formula, default_value, assoc_value=None):
""" """
Given a formula, returns a textbuilder.Builder object suitable to be the body of a function, Given a formula, returns a textbuilder.Builder object suitable to be the body of a function,
@ -46,7 +56,7 @@ def make_formula_body(formula, default_value, assoc_value=None):
# Parse the formula into an abstract syntax tree (AST), catching syntax errors. # Parse the formula into an abstract syntax tree (AST), catching syntax errors.
try: try:
atok = asttokens.ASTTokens(tmp_formula.get_text(), parse=True) atok = asttokens.ASTTokens(tmp_formula.get_text(), parse=True, filename=code_filename)
except SyntaxError as e: except SyntaxError as e:
return textbuilder.Text(_create_syntax_error_code(tmp_formula, formula, e)) return textbuilder.Text(_create_syntax_error_code(tmp_formula, formula, e))
@ -91,8 +101,7 @@ def make_formula_body(formula, default_value, assoc_value=None):
message = "No `return` statement, and the last line isn't an expression." message = "No `return` statement, and the last line isn't an expression."
if isinstance(last_statement, ast.Assign): if isinstance(last_statement, ast.Assign):
message += " If you want to check for equality, use `==` instead of `=`." message += " If you want to check for equality, use `==` instead of `=`."
error = SyntaxError(message, error = GristSyntaxError(message, ('<string>', 1, 1, ""))
('<string>', 1, 1, ""))
return textbuilder.Text(_create_syntax_error_code(tmp_formula, formula, error)) return textbuilder.Text(_create_syntax_error_code(tmp_formula, formula, error))
# Apply the new set of patches to the original formula to get the real output. # Apply the new set of patches to the original formula to get the real output.
@ -129,11 +138,24 @@ def _create_syntax_error_code(builder, input_text, err):
output_offset = output_ln.line_to_offset(err.lineno, err.offset - 1 if err.offset else 0) output_offset = output_ln.line_to_offset(err.lineno, err.offset - 1 if err.offset else 0)
input_offset = builder.map_back_offset(output_offset) input_offset = builder.map_back_offset(output_offset)
line, col = input_ln.offset_to_line(input_offset) line, col = input_ln.offset_to_line(input_offset)
message = err.args[0]
input_text_line = input_text.splitlines()[line - 1] input_text_line = input_text.splitlines()[line - 1]
message = err.args[0]
err_type = type(err)
if isinstance(err, GristSyntaxError):
# Just use SyntaxError in the final code
err_type = SyntaxError
elif six.PY3:
# Add explanation from friendly-traceback.
# Only supported in Python 3.
# Not helpful for Grist-specific errors.
# Needs to use the source code, so save it to its source cache.
save_to_linecache(builder.get_text())
message += friendly_errors.friendly_message(err)
return "%s\nraise %s(%r, ('usercode', %r, %r, %r))" % ( return "%s\nraise %s(%r, ('usercode', %r, %r, %r))" % (
textbuilder.line_start_re.sub('# ', input_text.rstrip()), textbuilder.line_start_re.sub('# ', input_text.rstrip()),
type(err).__name__, message, line, col + 1, input_text_line) err_type.__name__, message, line, col + 1, input_text_line)
#---------------------------------------------------------------------- #----------------------------------------------------------------------
@ -299,7 +321,7 @@ class InferRecAssignment(InferenceTip):
@classmethod @classmethod
def filter(cls, node): def filter(cls, node):
if node.name == 'rec': if node.name == 'rec':
raise SyntaxError('Grist disallows assignment to the special variable "rec"', raise GristSyntaxError('Grist disallows assignment to the special variable "rec"',
('<string>', node.lineno, node.col_offset, "")) ('<string>', node.lineno, node.col_offset, ""))
@classmethod @classmethod
@ -315,8 +337,8 @@ class InferRecAttrAssignment(InferenceTip):
@classmethod @classmethod
def filter(cls, node): def filter(cls, node):
if isinstance(node.expr, astroid.nodes.Name) and node.expr.name == 'rec': if isinstance(node.expr, astroid.nodes.Name) and node.expr.name == 'rec':
raise SyntaxError("You can't assign a value to a column with `=`. " raise GristSyntaxError("You can't assign a value to a column with `=`. "
"If you mean to check for equality, use `==` instead.", "If you mean to check for equality, use `==` instead.",
('<string>', node.lineno, node.col_offset, "")) ('<string>', node.lineno, node.col_offset, ""))
@classmethod @classmethod
@ -379,3 +401,23 @@ def parse_grist_names(builder):
parsed_names.append(make_tuple(start, end, obj.name, node.arg)) parsed_names.append(make_tuple(start, end, obj.name, node.arg))
return [name for name in parsed_names if name] return [name for name in parsed_names if name]
code_filename = "usercode"
def save_to_linecache(source_code):
"""
Makes source code available to friendly-traceback and traceback formatting in general.
"""
if six.PY3:
import friendly_traceback.source_cache
friendly_traceback.source_cache.cache.add(code_filename, source_code)
else:
linecache.cache[code_filename] = (
len(source_code),
None,
[line + '\n' for line in source_code.splitlines()],
code_filename,
)

View File

@ -0,0 +1,43 @@
def friendly_message(exc):
"""
Returns a string to append to a standard error message.
If possible, the string contains a friendly explanation of the error.
Otherwise, the string is empty.
"""
try:
if "has no column" in str(exc):
# Avoid the standard AttributeError explanation
return ""
# Imported locally because it's Python 3 only
from friendly_traceback.core import FriendlyTraceback
fr = FriendlyTraceback(type(exc), exc, exc.__traceback__)
fr.assign_generic()
fr.assign_cause()
generic = fr.info["generic"] # broad explanation for the exception class
cause = fr.info.get("cause") # more specific explanation
if "https://github.com" in generic:
# This is a placeholder message when there is no explanation,
# with a suggestion to report the case on GitHub.
return ""
# Add a blank line between the standard message and the friendly message
result = "\n\n" + generic
# Check for the placeholder message again in the cause
if cause and "https://github.com" not in cause:
result += "\n" + cause
result = result.rstrip()
if isinstance(exc, SyntaxError):
result += "\n\n"
return result
except (Exception, SystemExit):
# This can go wrong in many ways, it's not worth propagating the error.
# friendly-traceback raises SystemExit when it encounters an internal error.
# Note that SystemExit is not a subclass of Exception.
return ""

View File

@ -208,18 +208,9 @@ def _is_special_table(table_id):
def exec_module_text(module_text): def exec_module_text(module_text):
mod = imp.new_module(codebuilder.code_filename)
codebuilder.save_to_linecache(module_text)
code_obj = compile(module_text, codebuilder.code_filename, "exec")
# pylint: disable=exec-used # pylint: disable=exec-used
filename = "usercode"
mod = imp.new_module(filename)
# Ensure that source lines show in tracebacks
linecache.cache[filename] = (
len(module_text),
None,
[line + '\n' for line in module_text.splitlines()],
filename,
)
code_obj = compile(module_text, filename, "exec")
exec(code_obj, mod.__dict__) exec(code_obj, mod.__dict__)
return mod return mod

View File

@ -15,9 +15,12 @@ import traceback
from datetime import date, datetime from datetime import date, datetime
from math import isnan from math import isnan
import six
import friendly_errors
import moment import moment
import records import records
import six import depend
class UnmarshallableError(ValueError): class UnmarshallableError(ValueError):
@ -306,6 +309,10 @@ class RaisedException(object):
if include_details: if include_details:
self.details = traceback.format_exc() self.details = traceback.format_exc()
self._message = str(error) + location self._message = str(error) + location
if not (isinstance(error, (SyntaxError, depend.CircularRefError)) or error != self.error):
# For SyntaxError, the friendly message was already added earlier.
# CircularRefError and CellError are Grist-specific and have no friendly message.
self._message += friendly_errors.friendly_message(error)
elif isinstance(error, InvalidTypedValue): elif isinstance(error, InvalidTypedValue):
self._message = error.typename self._message = error.typename
self.details = error.value self.details = error.value

View File

@ -72,22 +72,44 @@ class TestCodeBuilder(unittest.TestCase):
"'''test1'''\nreturn \"\"\"test2\"\"\"") "'''test1'''\nreturn \"\"\"test2\"\"\"")
# Test that we produce valid code when "$foo" occurs in invalid places. # Test that we produce valid code when "$foo" occurs in invalid places.
if six.PY2:
raise_code = "raise SyntaxError('invalid syntax', ('usercode', 1, 5, u'foo($bar=1)'))"
else:
raise_code = ("raise SyntaxError('invalid syntax\\n\\n"
"A `SyntaxError` occurs when Python cannot understand your code.\\n\\n', "
"('usercode', 1, 5, 'foo($bar=1)'))")
self.assertEqual(make_body('foo($bar=1)'), self.assertEqual(make_body('foo($bar=1)'),
"# foo($bar=1)\n" "# foo($bar=1)\n" + raise_code)
"raise SyntaxError('invalid syntax', ('usercode', 1, 5, %s'foo($bar=1)'))"
% unicode_prefix) if six.PY2:
raise_code = ("raise SyntaxError('invalid syntax', "
"('usercode', 1, 5, u'def $bar(): return 3'))")
else:
raise_code = ("raise SyntaxError('invalid syntax\\n\\n"
"A `SyntaxError` occurs when Python cannot understand your code.\\n\\n', "
"('usercode', 1, 5, 'def $bar(): return 3'))")
self.assertEqual(make_body('def $bar(): return 3'), self.assertEqual(make_body('def $bar(): return 3'),
"# def $bar(): return 3\n" "# def $bar(): return 3\n" + raise_code)
"raise SyntaxError('invalid syntax', "
"('usercode', 1, 5, %s'def $bar(): return 3'))"
% unicode_prefix)
# If $ is a syntax error, we don't want to turn it into a different syntax error. # If $ is a syntax error, we don't want to turn it into a different syntax error.
if six.PY2:
raise_code = ("raise SyntaxError('invalid syntax', "
"('usercode', 1, 17, u'$foo + (\"$%.2f\" $ ($17.5))'))")
else:
raise_code = ("raise SyntaxError('invalid syntax\\n\\n"
"A `SyntaxError` occurs when Python cannot understand your code.\\n\\n', "
"('usercode', 1, 17, '$foo + (\"$%.2f\" $ ($17.5))'))")
self.assertEqual(make_body('$foo + ("$%.2f" $ ($17.5))'), self.assertEqual(make_body('$foo + ("$%.2f" $ ($17.5))'),
'# $foo + ("$%.2f" $ ($17.5))\n' '# $foo + ("$%.2f" $ ($17.5))\n' + raise_code)
"raise SyntaxError('invalid syntax', "
"('usercode', 1, 17, {}'$foo + (\"$%.2f\" $ ($17.5))'))" if six.PY2:
.format(unicode_prefix)) raise_code = "raise SyntaxError('invalid syntax', ('usercode', 4, 10, u' return $ bar'))"
else:
raise_code = ("raise SyntaxError('invalid syntax\\n\\n"
"A `SyntaxError` occurs when Python cannot understand your code.\\n\\n"
"I am guessing that you wrote `$` by mistake.\\n"
"Removing it and writing `return bar` seems to fix the error.\\n\\n', "
"('usercode', 4, 10, ' return $ bar'))")
self.assertEqual(make_body('if $foo:\n' + self.assertEqual(make_body('if $foo:\n' +
' return $foo\n' + ' return $foo\n' +
'else:\n' + 'else:\n' +
@ -96,8 +118,7 @@ class TestCodeBuilder(unittest.TestCase):
'# return $foo\n' + '# return $foo\n' +
'# else:\n' + '# else:\n' +
'# return $ bar\n' + '# return $ bar\n' +
"raise SyntaxError('invalid syntax', ('usercode', 4, 10, %s' return $ bar'))" raise_code)
% unicode_prefix)
# Check for reasonable behaviour with non-empty text and no statements. # Check for reasonable behaviour with non-empty text and no statements.
self.assertEqual(make_body('# comment'), '# comment\npass') self.assertEqual(make_body('# comment'), '# comment\npass')

View File

@ -270,7 +270,7 @@ class EngineTestCase(unittest.TestCase):
def assertFormulaError(self, exc, type_, message, tracebackRegexp=None): def assertFormulaError(self, exc, type_, message, tracebackRegexp=None):
self.assertIsInstance(exc, objtypes.RaisedException) self.assertIsInstance(exc, objtypes.RaisedException)
self.assertIsInstance(exc.error, type_) self.assertIsInstance(exc.error, type_)
self.assertEqual(str(exc.error), message) self.assertEqual(exc._message, message)
if tracebackRegexp: if tracebackRegexp:
self.assertRegex(exc.details, tracebackRegexp) self.assertRegex(exc.details, tracebackRegexp)

View File

@ -54,12 +54,33 @@ else:
TypeError, 'SQRT() takes exactly 1 argument (2 given)', TypeError, 'SQRT() takes exactly 1 argument (2 given)',
r"TypeError: SQRT\(\) takes exactly 1 argument \(2 given\)") r"TypeError: SQRT\(\) takes exactly 1 argument \(2 given\)")
else: else:
self.assertFormulaError(self.engine.get_formula_error('Math', 'excel_formula', 3), self.assertFormulaError(
TypeError, 'SQRT() takes 1 positional argument but 2 were given', self.engine.get_formula_error('Math', 'excel_formula', 3), TypeError,
r"TypeError: SQRT\(\) takes 1 positional argument but 2 were given") 'SQRT() takes 1 positional argument but 2 were given\n\n'
'A `TypeError` is usually caused by trying\n'
'to combine two incompatible types of objects,\n'
'by calling a function with the wrong type of object,\n'
'or by trying to do an operation not allowed on a given type of object.\n\n'
'You apparently have called the function `SQRT` with\n'
'2 positional argument(s) while it requires 1\n'
'such positional argument(s).',
r"TypeError: SQRT\(\) takes 1 positional argument but 2 were given",
)
int_not_iterable_message = "'int' object is not iterable"
if six.PY3:
int_not_iterable_message += (
'\n\n'
'A `TypeError` is usually caused by trying\n'
'to combine two incompatible types of objects,\n'
'by calling a function with the wrong type of object,\n'
'or by trying to do an operation not allowed on a given type of object.\n\n'
'An iterable is an object capable of returning its members one at a time.\n'
'Python containers (`list, tuple, dict`, etc.) are iterables.\n'
'An iterable is required here.'
)
self.assertFormulaError(self.engine.get_formula_error('Math', 'built_in_formula', 3), self.assertFormulaError(self.engine.get_formula_error('Math', 'built_in_formula', 3),
TypeError, "'int' object is not iterable", TypeError, int_not_iterable_message,
textwrap.dedent( textwrap.dedent(
r""" r"""
File "usercode", line \d+, in built_in_formula File "usercode", line \d+, in built_in_formula
@ -68,8 +89,21 @@ else:
""" """
)) ))
if six.PY2:
message = "invalid syntax (usercode, line 5)"
else:
message = textwrap.dedent(
"""\
invalid syntax
A `SyntaxError` occurs when Python cannot understand your code.
I am guessing that you wrote `:` by mistake.
Removing it and writing `return 0` seems to fix the error.
(usercode, line 5)""")
self.assertFormulaError(self.engine.get_formula_error('Math', 'syntax_err', 3), self.assertFormulaError(self.engine.get_formula_error('Math', 'syntax_err', 3),
SyntaxError, "invalid syntax (usercode, line 5)", SyntaxError, message,
textwrap.dedent( textwrap.dedent(
r""" r"""
File "usercode", line 5 File "usercode", line 5
@ -88,6 +122,7 @@ else:
IndentationError: unexpected indent IndentationError: unexpected indent
""" """
) )
message = 'unexpected indent (usercode, line 2)'
else: else:
traceback_regex = textwrap.dedent( traceback_regex = textwrap.dedent(
r""" r"""
@ -96,12 +131,21 @@ else:
IndentationError: unexpected indent IndentationError: unexpected indent
""" """
) )
message = textwrap.dedent(
"""\
unexpected indent
An `IndentationError` occurs when a given line of code is
not indented (aligned vertically with other lines) as expected.
Line `2` identified above is more indented than expected.
(usercode, line 2)""")
self.assertFormulaError(self.engine.get_formula_error('Math', 'indent_err', 3), self.assertFormulaError(self.engine.get_formula_error('Math', 'indent_err', 3),
IndentationError, 'unexpected indent (usercode, line 2)', IndentationError, message, traceback_regex)
traceback_regex)
self.assertFormulaError(self.engine.get_formula_error('Math', 'other_err', 3), self.assertFormulaError(self.engine.get_formula_error('Math', 'other_err', 3),
TypeError, "'int' object is not iterable", TypeError, int_not_iterable_message,
textwrap.dedent( textwrap.dedent(
r""" r"""
File "usercode", line \d+, in other_err File "usercode", line \d+, in other_err
@ -350,9 +394,11 @@ else:
AttributeError, "Table 'AttrTest' has no column 'AA'", AttributeError, "Table 'AttrTest' has no column 'AA'",
r"AttributeError: Table 'AttrTest' has no column 'AA'") r"AttributeError: Table 'AttrTest' has no column 'AA'")
cell_error = self.engine.get_formula_error('AttrTest', 'C', 1) cell_error = self.engine.get_formula_error('AttrTest', 'C', 1)
self.assertFormulaError(cell_error, self.assertFormulaError(
objtypes.CellError, "AttributeError in referenced cell AttrTest[1].B", cell_error, objtypes.CellError,
r"CellError: AttributeError in referenced cell AttrTest\[1\].B") "Table 'AttrTest' has no column 'AA'\n(in referenced cell AttrTest[1].B)",
r"CellError: AttributeError in referenced cell AttrTest\[1\].B",
)
self.assertEqual( self.assertEqual(
objtypes.encode_object(cell_error), objtypes.encode_object(cell_error),
['E', ['E',

View File

@ -71,9 +71,12 @@ class TestGenCode(unittest.TestCase):
gcode.make_module(self.schema) gcode.make_module(self.schema)
generated = gcode.get_user_text() generated = gcode.get_user_text()
if six.PY3: if six.PY3:
generated = generated.replace( saved_sample = saved_sample.replace(
", 'for a in b'))", "raise SyntaxError('invalid syntax', ('usercode', 1, 11, u'for a in b'))",
", u'for a in b'))", "raise SyntaxError('invalid syntax\\n\\n"
"A `SyntaxError` occurs when Python cannot understand your code.\\n\\n"
"You wrote a `for` loop but\\nforgot to add a colon `:` at the end\\n\\n', "
"('usercode', 1, 11, 'for a in b'))"
) )
self.assertEqual(generated, saved_sample, "Generated code doesn't match sample:\n" + self.assertEqual(generated, saved_sample, "Generated code doesn't match sample:\n" +
"".join(difflib.unified_diff(generated.splitlines(True), "".join(difflib.unified_diff(generated.splitlines(True),

View File

@ -1,5 +1,8 @@
import copy import copy
import time import time
import six
import logger import logger
import objtypes import objtypes
import testutil import testutil
@ -669,8 +672,20 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
["id", "A", "B", "C"], ["id", "A", "B", "C"],
[1, 0, 0, div_error(0)], [1, 0, 0, div_error(0)],
]) ])
message = 'float division by zero'
if six.PY3:
message += """
A `ZeroDivisionError` occurs when you are attempting to divide a value
by zero either directly or by using some other mathematical operation.
You are dividing by the following term
rec.A
which is equal to zero."""
self.assertFormulaError(self.engine.get_formula_error('Math', 'C', 1), self.assertFormulaError(self.engine.get_formula_error('Math', 'C', 1),
ZeroDivisionError, 'float division by zero', ZeroDivisionError, message,
r"1/rec\.A \+ 1/rec\.B") r"1/rec\.A \+ 1/rec\.B")
self.update_record("Math", 1, A=1) self.update_record("Math", 1, A=1)
@ -682,7 +697,6 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
]) ])
error = self.engine.get_formula_error('Math', 'C', 1) error = self.engine.get_formula_error('Math', 'C', 1)
self.assertFormulaError(error, ZeroDivisionError, 'float division by zero') self.assertFormulaError(error, ZeroDivisionError, 'float division by zero')
self.assertEqual(error._message, 'float division by zero')
self.assertEqual(error.details, objtypes.RaisedException(ZeroDivisionError()).no_traceback().details) self.assertEqual(error.details, objtypes.RaisedException(ZeroDivisionError()).no_traceback().details)

View File

@ -1,4 +1,13 @@
astroid==2.5.7 # this is a difference between python 2 and 3, everything else is same # friendly-traceback and its dependencies, for python 3 only
friendly-traceback==0.5.46
stack-data==0.3.0
executing==0.8.3
pure-eval==0.2.2
# Different astroid version for python 3
astroid==2.5.7
# Everything after this is the same for python 2 and 3
asttokens==2.0.5 asttokens==2.0.5
backports.functools-lru-cache==1.6.4 backports.functools-lru-cache==1.6.4
chardet==4.0.0 chardet==4.0.0