diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 41023d037f..65b66a2dd0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,6 @@ on: push: - branches: [master, release] + pull_request: name: CI diff --git a/parser/src/fstring.rs b/parser/src/fstring.rs index 79a7524690..ed597b92e9 100644 --- a/parser/src/fstring.rs +++ b/parser/src/fstring.rs @@ -26,9 +26,37 @@ impl<'a> FStringParser<'a> { let mut spec = None; let mut delims = Vec::new(); let mut conversion = None; + let mut pred_expression_text = String::new(); + let mut trailing_seq = String::new(); while let Some(ch) = self.chars.next() { match ch { + // can be integrated better with the remainign code, but as a starting point ok + // in general I would do here a tokenizing of the fstrings to omit this peeking. + '!' if self.chars.peek() == Some(&'=') => { + expression.push('!'); + expression.push('='); + self.chars.next(); + } + + '=' if self.chars.peek() == Some(&'=') => { + expression.push('='); + expression.push('='); + self.chars.next(); + } + + '>' if self.chars.peek() == Some(&'=') => { + expression.push('>'); + expression.push('='); + self.chars.next(); + } + + '<' if self.chars.peek() == Some(&'=') => { + expression.push('<'); + expression.push('='); + self.chars.next(); + } + '!' if delims.is_empty() && self.chars.peek() != Some(&'=') => { if expression.trim().is_empty() { return Err(EmptyExpression); @@ -46,10 +74,19 @@ impl<'a> FStringParser<'a> { } }); - if self.chars.peek() != Some(&'}') { + let peek = self.chars.peek(); + if peek != Some(&'}') && peek != Some(&':') { return Err(ExpectedRbrace); } } + + // match a python 3.8 self documenting expression + // format '{' PYTHON_EXPRESSION '=' FORMAT_SPECIFIER? '}' + '=' if self.chars.peek() != Some(&'=') => { + // check for delims empty? + pred_expression_text = expression.to_string(); // safe expression before = to print it + } + ':' if delims.is_empty() => { let mut nested = false; let mut in_nested = false; @@ -121,14 +158,35 @@ impl<'a> FStringParser<'a> { if expression.is_empty() { return Err(EmptyExpression); } - return Ok(FormattedValue { - value: Box::new( - parse_expression(expression.trim()) - .map_err(|e| InvalidExpression(Box::new(e.error)))?, - ), - conversion, - spec, - }); + if pred_expression_text.is_empty() { + return Ok(FormattedValue { + value: Box::new( + parse_expression(expression.trim()) + .map_err(|e| InvalidExpression(Box::new(e.error)))?, + ), + conversion, + spec, + }); + } else { + return Ok(Joined { + values: vec![ + Constant { + value: pred_expression_text + "=", + }, + Constant { + value: trailing_seq, + }, + FormattedValue { + value: Box::new( + parse_expression(expression.trim()) + .map_err(|e| InvalidExpression(Box::new(e.error)))?, + ), + conversion, + spec, + }, + ], + }); + } } '"' | '\'' => { expression.push(ch); @@ -139,6 +197,11 @@ impl<'a> FStringParser<'a> { } } } + + ' ' if !pred_expression_text.is_empty() => { + trailing_seq.push(ch); + } + _ => { expression.push(ch); } @@ -298,9 +361,34 @@ mod tests { ); } + #[test] + fn test_fstring_parse_selfdocumenting_base() { + let src = String::from("{user=}"); + let parse_ast = parse_fstring(&src); + + assert!(parse_ast.is_ok()); + } + + #[test] + fn test_fstring_parse_selfdocumenting_base_more() { + let src = String::from("mix {user=} with text and {second=}"); + let parse_ast = parse_fstring(&src); + + assert!(parse_ast.is_ok()); + } + + #[test] + fn test_fstring_parse_selfdocumenting_format() { + let src = String::from("{user=:>10}"); + let parse_ast = parse_fstring(&src); + + assert!(parse_ast.is_ok()); + } + #[test] fn test_parse_invalid_fstring() { assert_eq!(parse_fstring("{5!a"), Err(ExpectedRbrace)); + assert_eq!(parse_fstring("{5!a1}"), Err(ExpectedRbrace)); assert_eq!(parse_fstring("{5!"), Err(ExpectedRbrace)); @@ -318,6 +406,8 @@ mod tests { assert_eq!(parse_fstring("{a:{b}"), Err(UnclosedLbrace)); assert_eq!(parse_fstring("{"), Err(UnclosedLbrace)); + assert_eq!(parse_fstring("{}"), Err(EmptyExpression)); + // TODO: check for InvalidExpression enum? assert!(parse_fstring("{class}").is_err()); } @@ -328,4 +418,25 @@ mod tests { let parse_ast = parse_fstring(&source); assert!(parse_ast.is_ok()); } + + #[test] + fn test_parse_fstring_equals() { + let source = String::from("{42 == 42}"); + let parse_ast = parse_fstring(&source); + assert!(parse_ast.is_ok()); + } + + #[test] + fn test_parse_fstring_selfdoc_prec_space() { + let source = String::from("{x =}"); + let parse_ast = parse_fstring(&source); + assert!(parse_ast.is_ok()); + } + + #[test] + fn test_parse_fstring_selfdoc_trailing_space() { + let source = String::from("{x= }"); + let parse_ast = parse_fstring(&source); + assert!(parse_ast.is_ok()); + } } diff --git a/tests/snippets/dict_union.py b/tests/snippets/dict_union.py new file mode 100644 index 0000000000..29e0718d45 --- /dev/null +++ b/tests/snippets/dict_union.py @@ -0,0 +1,83 @@ + +import testutils + +def test_dunion_ior0(): + a={1:2,2:3} + b={3:4,5:6} + a|=b + + assert a == {1:2,2:3,3:4,5:6}, f"wrong value assigned {a=}" + assert b == {3:4,5:6}, f"right hand side modified, {b=}" + +def test_dunion_or0(): + a={1:2,2:3} + b={3:4,5:6} + c=a|b + + assert a == {1:2,2:3}, f"left hand side of non-assignment operator modified {a=}" + assert b == {3:4,5:6}, f"right hand side of non-assignment operator modified, {b=}" + assert c == {1:2,2:3, 3:4, 5:6}, f"unexpected result of dict union {c=}" + + +def test_dunion_or1(): + a={1:2,2:3} + b={3:4,5:6} + c=a.__or__(b) + + assert a == {1:2,2:3}, f"left hand side of non-assignment operator modified {a=}" + assert b == {3:4,5:6}, f"right hand side of non-assignment operator modified, {b=}" + assert c == {1:2,2:3, 3:4, 5:6}, f"unexpected result of dict union {c=}" + + +def test_dunion_ror0(): + a={1:2,2:3} + b={3:4,5:6} + c=b.__ror__(a) + + assert a == {1:2,2:3}, f"left hand side of non-assignment operator modified {a=}" + assert b == {3:4,5:6}, f"right hand side of non-assignment operator modified, {b=}" + assert c == {1:2,2:3, 3:4, 5:6}, f"unexpected result of dict union {c=}" + + +def test_dunion_other_types(): + def perf_test_or(other_obj): + d={1:2} + try: + d.__or__(other_obj) + except: + return True + return False + + def perf_test_ior(other_obj): + d={1:2} + try: + d.__ior__(other_obj) + except: + return True + return False + + def perf_test_ror(other_obj): + d={1:2} + try: + d.__ror__(other_obj) + except: + return True + return False + + test_fct={'__or__':perf_test_or, '__ror__':perf_test_ror, '__ior__':perf_test_ior} + others=['FooBar', 42, [36], set([19]), ['aa'], None] + for tfn,tf in test_fct.items(): + for other in others: + assert tf(other), f"Failed: dict {tfn}, accepted {other}" + + + + +testutils.skip_if_unsupported(3,9,test_dunion_ior0) +testutils.skip_if_unsupported(3,9,test_dunion_or0) +testutils.skip_if_unsupported(3,9,test_dunion_or1) +testutils.skip_if_unsupported(3,9,test_dunion_ror0) +testutils.skip_if_unsupported(3,9,test_dunion_other_types) + + + diff --git a/tests/snippets/fstrings.py b/tests/snippets/fstrings.py index 2d25abf6dc..b72e6b796c 100644 --- a/tests/snippets/fstrings.py +++ b/tests/snippets/fstrings.py @@ -1,4 +1,20 @@ from testutils import assert_raises + +#test only makes sense with python 3.8 or higher (or RustPython) +import sys +import platform +if platform.python_implementation() == 'CPython': + assert sys.version_info.major == 3, 'Incompatible Python Version, expected CPython 3.8 or later' + assert sys.version_info.minor == 8, 'Incompatible Python Version, expected CPython 3.8 or later' +elif platform.python_implementation == 'RustPython': + # ok + pass +else: + # other implementation - lets give it a try + pass + + +# lets start tersing foo = 'bar' assert f"{''}" == '' @@ -17,6 +33,18 @@ assert f'{16:0>+#10x}' == '00000+0x10' assert f"{{{(lambda x: f'hello, {x}')('world}')}" == '{hello, world}' + +# base test of self documenting strings +#assert f'{foo=}' == 'foo=bar' # TODO ' missing + +num=42 + +f'{num=}' # keep this line as it will fail when using a python 3.7 interpreter + +assert f'{num=}' == 'num=42' +assert f'{num=:>10}' == 'num= 42' + + spec = "0>+#10x" assert f"{16:{spec}}{foo}" == '00000+0x10bar' @@ -61,3 +89,84 @@ def __str__(self): assert f'>{v!r}' == ">'\u262e'" assert f'>{v!s}' == '>\u262e' assert f'>{v!a}' == r">'\u262e'" + + + +# Test format specifier after conversion flag +#assert f'{"42"!s:<5}' == '42 ', '#' + f'{"42"!s:5}' +'#' # TODO: default alignment in cpython is left + +assert f'{"42"!s:<5}' == '42 ', '#' + f'{"42"!s:<5}' +'#' +assert f'{"42"!s:>5}' == ' 42', '#' + f'{"42"!s:>5}' +'#' + +#assert f'{"42"=!s:5}' == '"42"=42 ', '#'+ f'{"42"=!s:5}' +'#' # TODO default alingment in cpython is left +assert f'{"42"=!s:<5}' == '"42"=42 ', '#'+ f'{"42"=!s:<5}' +'#' +assert f'{"42"=!s:>5}' == '"42"= 42', '#'+ f'{"42"=!s:>5}' +'#' + + + +### Tests for fstring selfdocumenting form CPython + +class C: + def assertEqual(self, a,b): + assert a==b, "{0} == {1}".format(a,b) + +self=C() + +x = 'A string' +self.assertEqual(f'{10=}', '10=10') +# self.assertEqual(f'{x=}', 'x=' + x )#repr(x)) # TODO: add ' when printing strings +# self.assertEqual(f'{x =}', 'x =' + x )# + repr(x)) # TODO: implement ' handling +self.assertEqual(f'{x=!s}', 'x=' + str(x)) +# # self.assertEqual(f'{x=!r}', 'x=' + x) #repr(x)) # !r not supported +# self.assertEqual(f'{x=!a}', 'x=' + ascii(x)) + +x = 2.71828 +self.assertEqual(f'{x=:.2f}', 'x=' + format(x, '.2f')) +self.assertEqual(f'{x=:}', 'x=' + format(x, '')) +self.assertEqual(f'{x=!r:^20}', 'x=' + format(repr(x), '^20')) #TODO formatspecifier after conversion flsg is currently not supported (also for classical fstrings) +self.assertEqual(f'{x=!s:^20}', 'x=' + format(str(x), '^20')) +self.assertEqual(f'{x=!a:^20}', 'x=' + format(ascii(x), '^20')) + +x = 9 +self.assertEqual(f'{3*x+15=}', '3*x+15=42') + +# There is code in ast.c that deals with non-ascii expression values. So, +# use a unicode identifier to trigger that. +tenπ = 31.4 +self.assertEqual(f'{tenπ=:.2f}', 'tenπ=31.40') + +# Also test with Unicode in non-identifiers. +#self.assertEqual(f'{"Σ"=}', '"Σ"=\'Σ\'') ' TODO ' missing + +# Make sure nested fstrings still work. +self.assertEqual(f'{f"{3.1415=:.1f}":*^20}', '*****3.1415=3.1*****') + +# Make sure text before and after an expression with = works +# correctly. +pi = 'π' +#self.assertEqual(f'alpha α {pi=} ω omega', "alpha α pi='π' ω omega") # ' missing around pi + +# Check multi-line expressions. +#self.assertEqual(f'''{3=}''', '\n3\n=3') # TODO: multiline f strings not supported, seems to be an rustpython issue + +# Since = is handled specially, make sure all existing uses of +# it still work. + +self.assertEqual(f'{0==1}', 'False') +self.assertEqual(f'{0!=1}', 'True') +self.assertEqual(f'{0<=1}', 'True') +self.assertEqual(f'{0>=1}', 'False') + +# Make sure leading and following text works. +# x = 'foo' +#self.assertEqual(f'X{x=}Y', 'Xx='+repr(x)+'Y') # TODO ' +# self.assertEqual(f'X{x=}Y', 'Xx='+x+'Y') # just for the moment + +# Make sure whitespace around the = works. +# self.assertEqual(f'X{x =}Y', 'Xx ='+repr(x)+'Y') # TODO ' +# self.assertEqual(f'X{x= }Y', 'Xx= '+repr(x)+'Y') # TODO ' +# self.assertEqual(f'X{x = }Y', 'Xx = '+repr(x)+'Y') # TODO ' + +# self.assertEqual(f'X{x =}Y', 'Xx ='+x+'Y') +# self.assertEqual(f'X{x= }Y', 'Xx= '+x+'Y') +# self.assertEqual(f'X{x = }Y', 'Xx = '+x+'Y') \ No newline at end of file diff --git a/tests/snippets/testutils.py b/tests/snippets/testutils.py index 8a9fdddb2f..c779d2c898 100644 --- a/tests/snippets/testutils.py +++ b/tests/snippets/testutils.py @@ -1,3 +1,6 @@ +import platform +import sys + def assert_raises(expected, *args, _msg=None, **kw): if args: f, f_args = args[0], args[1:] @@ -67,3 +70,26 @@ def assert_isinstance(obj, klass): def assert_in(a, b): _assert_print(lambda: a in b, [a, 'in', b]) + +def skip_if_unsupported(req_maj_vers, req_min_vers, test_fct): + def exec(): + test_fct() + + if platform.python_implementation() == 'RustPython': + exec() + elif sys.version_info.major>=req_maj_vers and sys.version_info.minor>=req_min_vers: + exec() + else: + print(f'Skipping test as a higher python version is required. Using {platform.python_implementation()} {platform.python_version()}') + +def fail_if_unsupported(req_maj_vers, req_min_vers, test_fct): + def exec(): + test_fct() + + if platform.python_implementation() == 'RustPython': + exec() + elif sys.version_info.major>=req_maj_vers and sys.version_info.minor>=req_min_vers: + exec() + else: + assert False, f'Test cannot performed on this python version. {platform.python_implementation()} {platform.python_version()}' + diff --git a/vm/src/obj/objdict.rs b/vm/src/obj/objdict.rs index a5330bdbcf..e2cdb108db 100644 --- a/vm/src/obj/objdict.rs +++ b/vm/src/obj/objdict.rs @@ -104,6 +104,17 @@ impl PyDictRef { Ok(()) } + fn merge_dict( + dict: &DictContentType, + dict_other: PyDictRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + for (key, value) in dict_other { + dict.insert(vm, &key, value)?; + } + Ok(()) + } + #[pyclassmethod] fn fromkeys( class: PyClassRef, @@ -320,6 +331,38 @@ impl PyDictRef { PyDictRef::merge(&self.entries, dict_obj, kwargs, vm) } + #[pymethod(name = "__ior__")] + fn ior(self, other: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dicted: Result = other.clone().downcast(); + if let Ok(other) = dicted { + PyDictRef::merge_dict(&self.entries, other, vm)?; + return Ok(self.into_object()); + } + Err(vm.new_type_error("__ior__ not implemented for non-dict type".to_owned())) + } + + #[pymethod(name = "__ror__")] + fn ror(self, other: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dicted: Result = other.clone().downcast(); + if let Ok(other) = dicted { + let other_cp = other.copy(); + PyDictRef::merge_dict(&other_cp.entries, self, vm)?; + return Ok(other_cp); + } + Err(vm.new_type_error("__ror__ not implemented for non-dict type".to_owned())) + } + + #[pymethod(name = "__or__")] + fn or(self, other: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dicted: Result = other.clone().downcast(); + if let Ok(other) = dicted { + let self_cp = self.copy(); + PyDictRef::merge_dict(&self_cp.entries, other, vm)?; + return Ok(self_cp); + } + Err(vm.new_type_error("__or__ not implemented for non-dict type".to_owned())) + } + #[pymethod] fn pop( self, diff --git a/vm/src/stdlib/math.rs b/vm/src/stdlib/math.rs index 6873e4352e..1ddbdf2893 100644 --- a/vm/src/stdlib/math.rs +++ b/vm/src/stdlib/math.rs @@ -9,9 +9,9 @@ use statrs::function::gamma::{gamma, ln_gamma}; use num_bigint::BigInt; use num_traits::{One, Zero}; -use crate::function::OptionalArg; +use crate::function::{OptionalArg, PyFuncArgs}; use crate::obj::objfloat::{self, IntoPyFloat, PyFloatRef}; -use crate::obj::objint::{self, PyIntRef}; +use crate::obj::objint::{self, PyInt, PyIntRef}; use crate::obj::objtype; use crate::pyobject::{Either, PyObjectRef, PyResult, TypeProtocol}; use crate::vm::VirtualMachine; @@ -272,9 +272,55 @@ fn math_ldexp( Ok(value * (2_f64).powf(objint::try_float(i.as_bigint(), vm)?)) } -fn math_gcd(a: PyIntRef, b: PyIntRef) -> BigInt { +fn math_perf_arb_len_int_op( + args: PyFuncArgs, + vm: &VirtualMachine, + op: F, + default: BigInt, +) -> PyResult +where + F: Fn(&BigInt, &PyInt) -> BigInt, +{ + if !args.kwargs.is_empty() { + Err(vm.new_type_error("Takes no keyword arguments".to_owned())) + } else if args.args.is_empty() { + Ok(default) + } else if args.args.len() == 1 { + let a: PyObjectRef = args.args[0].clone(); + if let Some(aa) = a.payload_if_subclass::(vm) { + let res = op(aa.as_bigint(), aa); + Ok(res) + } else { + Err(vm.new_type_error("Only integer arguments are supported".to_owned())) + } + } else { + let a = args.args[0].clone(); + if let Some(aa) = a.payload_if_subclass::(vm) { + let mut res = aa.as_bigint().clone(); + for b in args.args[1..].iter() { + if let Some(bb) = b.payload_if_subclass::(vm) { + res = op(&res, bb); + } else { + return Err( + vm.new_type_error("Only integer arguments are supported".to_owned()) + ); + } + } + Ok(res) + } else { + Err(vm.new_type_error("Only integer arguments are supported".to_owned())) + } + } +} + +fn math_gcd(args: PyFuncArgs, vm: &VirtualMachine) -> PyResult { + use num_integer::Integer; + math_perf_arb_len_int_op(args, vm, |x, y| x.gcd(y.as_bigint()), BigInt::zero()) +} + +fn math_lcm(args: PyFuncArgs, vm: &VirtualMachine) -> PyResult { use num_integer::Integer; - a.as_bigint().gcd(b.as_bigint()) + math_perf_arb_len_int_op(args, vm, |x, y| x.lcm(y.as_bigint()), BigInt::one()) } fn math_factorial(value: PyIntRef, vm: &VirtualMachine) -> PyResult { @@ -436,6 +482,7 @@ pub fn make_module(vm: &VirtualMachine) -> PyObjectRef { // Gcd function "gcd" => ctx.new_function(math_gcd), + "lcm" => ctx.new_function(math_lcm), // Factorial function "factorial" => ctx.new_function(math_factorial),