diff --git a/SYMBOLS_MANIFEST.txt b/SYMBOLS_MANIFEST.txt index 87a2cbf3e1..8380e82db8 100644 --- a/SYMBOLS_MANIFEST.txt +++ b/SYMBOLS_MANIFEST.txt @@ -215,6 +215,7 @@ System`ChebyshevT System`ChebyshevU System`Check System`CheckAbort +System`CheckArguments System`ChessboardDistance System`Chop System`Circle diff --git a/mathics/builtin/directories/directory_operations.py b/mathics/builtin/directories/directory_operations.py index 11f72a6230..d462f04cf7 100644 --- a/mathics/builtin/directories/directory_operations.py +++ b/mathics/builtin/directories/directory_operations.py @@ -106,7 +106,6 @@ class DeleteDirectory(Builtin): ), "nodir": "Directory `1` not found.", "dirne": "Directory `1` not empty.", - "optx": "Unknown option `1` in `2`", "idcts": "DeleteContents expects either True or False.", # MMA Bug } options = { diff --git a/mathics/builtin/messages.py b/mathics/builtin/messages.py index a3320e3ee3..19a9e81e8c 100644 --- a/mathics/builtin/messages.py +++ b/mathics/builtin/messages.py @@ -233,12 +233,14 @@ class General(Builtin): "noopen": "Cannot open `1`.", "nord": "Invalid comparison with `1` attempted.", "normal": "Nonatomic expression expected at position `1` in `2`.", + "notnorm": "Argument `1` must be a nonatomic expression.", "noval": ("Symbol `1` in part assignment does not have an immediate value."), "obspkg": "In WL, this package is obsolete.", "openx": "`1` is not open.", "optb": "Optional object `1` in `2` is not a single blank.", "optnf": "Option name `1` is not a known option for `2`.", "opttf": "Value of option `1` -> `2` should be True or False.", + "optx": "Unknown option `1` in `2`.", "ovfl": "Overflow occurred in computation.", "partd": "Part specification is longer than depth of object.", "partw": "Part `1` of `2` does not exist.", diff --git a/mathics/builtin/options/functions.py b/mathics/builtin/options/functions.py index 50ebb5415b..e3977e2330 100644 --- a/mathics/builtin/options/functions.py +++ b/mathics/builtin/options/functions.py @@ -2,18 +2,142 @@ Setting Up Options for Functions """ -from mathics.core.atoms import Integer1, String +import sys +from typing import Optional + +from mathics.core.atoms import Integer, Integer1, String +from mathics.core.attributes import A_HOLD_FIRST, A_PROTECTED, A_READ_PROTECTED from mathics.core.builtin import Builtin, get_option from mathics.core.evaluation import Evaluation from mathics.core.expression import Expression from mathics.core.list import ListExpression -from mathics.core.symbols import Symbol, ensure_context -from mathics.core.systemsymbols import SymbolRule -from mathics.eval.options.function import options_to_rules +from mathics.core.rules import is_option_rule +from mathics.core.symbols import BooleanType, Symbol, ensure_context +from mathics.core.systemsymbols import SymbolDirectedInfinity, SymbolRule +from mathics.eval.options.functions import ( + eval_CheckArguments, + eval_CheckArguments_with_association, + options_to_rules, +) from mathics.eval.options.options import eval_Option_with_names, eval_Options from mathics.eval.patterns import Matcher +class CheckArguments(Builtin): + """ + + :WMA link: + https://reference.wolfram.com/language/ref/CheckArguments.html + +
+
'CheckArguments'[$f[args]$, $n$] +
returns 'True" if args consists of $n$ positional arguments followed by valid options \ + for $f$ and 'False' if not. + +
'CheckArguments'[$f[args]$, ${min, max$] +
same as above but checks only the positional arguments between $min$, and $max$. +
+ + First declare an option for function $f$: + >> Options[f] = {a -> 0} + = {a ⇾ 0} + + Now check that $f$ is called with one positional argument and known options: + >> CheckArguments[f[1, a->5], 1] + = True + + >> CheckArguments[f[1, 2], 1] + : f called with 2 arguments; 1 argument is expected. + = False + + Allow the option named "hidden" as well as any option of 'Graphics' to be set: + >> Options[f] = {normal -> Automatic} + = {normal ⇾ Automatic} + >> CheckArguments[f[1, normal -> 3, hidden -> 2, AspectRatio -> 1], {1, 2}, <|"ExtraOptions" -> {hidden -> 0, Graphics}|>] + = True + """ + + attributes = A_HOLD_FIRST | A_PROTECTED | A_READ_PROTECTED + # Set to check the number of arguments. + eval_error = Builtin.generic_argument_error + expected_args = (2, 3) + messages = { + "rspec": "The range specification `1` should have the form m, {m, n} or {m, Infinity}, " + "where m and n are integers and 0 <= m <= n." + } + + summary_text = "check arguments of a function" + + def eval_with_min_max( + self, expr, arg, evaluation: Evaluation + ) -> Optional[BooleanType]: + "CheckArguments[expr_, arg_]" + if not isinstance(expr, Expression): + evaluation.message("CheckArguments", "notnorm", Integer1) + return + + invalid_range_spec = False + if isinstance(arg, Integer): + invalid_range_spec = False + high_int = arg.value + low_int = min(1, high_int) + elif not (isinstance(arg, ListExpression) and len(arg.elements) == 2): + invalid_range_spec = True + elif not (isinstance((low := arg.elements[0]), Integer)): + invalid_range_spec = True + + if invalid_range_spec: + evaluation.message("CheckArguments", "rspec", arg) + return + + if isinstance(arg, ListExpression): + element1 = arg.elements[1] + low_int = arg.elements[0].value + if isinstance(element1, Integer): + high_int = element1.value + elif element1.has_form(SymbolDirectedInfinity, 1): + high_int = sys.maxsize + + if not (0 <= low_int <= high_int): + evaluation.message("CheckArguments", "rspec", arg) + return + + return eval_CheckArguments(expr, low_int, high_int, evaluation) + + def eval_with_assoc( + self, expr, spec, assoc, evaluation: Evaluation + ) -> Optional[Expression] | Symbol: + "CheckArguments[expr_, spec_, assoc_]" + + if not isinstance(expr, Expression): + evaluation.message("CheckArguments", "notnorm", Integer1) + return + + # Parse the spec argument + invalid_range_spec = False + if isinstance(spec, Integer): + invalid_range_spec = False + high_int = spec.value + low_int = min(1, high_int) + elif not (isinstance(spec, ListExpression) and len(spec.elements) == 2): + invalid_range_spec = True + elif not ( + isinstance((low := spec.elements[0]), Integer) + and isinstance((high := spec.elements[1]), Integer) + ): + invalid_range_spec = True + elif not (0 <= (low_int := low.value) <= (high_int := high.value)): + invalid_range_spec = True + + if invalid_range_spec: + evaluation.message("CheckArguments", "rspec", spec) + return + + return eval_CheckArguments_with_association( + expr, low_int, high_int, assoc, evaluation + ) + + class FilterRules(Builtin): """ @@ -45,11 +169,11 @@ class FilterRules(Builtin): def eval(self, rules, pattern, evaluation): "FilterRules[rules_List, pattern_]" - match = Matcher(pattern, evaluation).match + match_result = Matcher(pattern, evaluation).match def matched(): for rule in rules.elements: - if rule.has_form("Rule", 2) and match(rule.elements[0], evaluation): + if is_option_rule(rule) and match_result(rule.elements[0], evaluation): yield rule return ListExpression(*list(matched())) diff --git a/mathics/core/rules.py b/mathics/core/rules.py index 0a0b83e5a6..615ada9184 100644 --- a/mathics/core/rules.py +++ b/mathics/core/rules.py @@ -80,7 +80,7 @@ def is_option_rule(element: Any) -> bool: or: key :> value """ - return element.has_form(RULE_SYMBOL_HEADS, 2) + return isinstance(element, Expression) and element.has_form(RULE_SYMBOL_HEADS, 2) def is_rule(element: Any, include_delayed: bool = True) -> bool: diff --git a/mathics/eval/options/__init__.py b/mathics/eval/options/__init__.py index 0d8d78b9f0..b4b8ef0a16 100644 --- a/mathics/eval/options/__init__.py +++ b/mathics/eval/options/__init__.py @@ -3,7 +3,7 @@ mathics.builtins.options. """ -from mathics.eval.options.function import filter_from_iterable, options_to_rules +from mathics.eval.options.functions import filter_from_iterable, options_to_rules from mathics.eval.options.options import eval_Options from mathics.eval.options.values import filter_non_default_values diff --git a/mathics/eval/options/function.py b/mathics/eval/options/function.py deleted file mode 100644 index 72068e55de..0000000000 --- a/mathics/eval/options/function.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Callable, Optional - -from mathics.core.expression import Expression -from mathics.core.symbols import Symbol, strip_context -from mathics.core.systemsymbols import SymbolRule - - -def filter_from_iterable(elems) -> Callable: - """ - Build a filter function from an iterable. - The filter function returns `True` if - the name after striping its context is in - the interable. - """ - - def filter(name, value): - return strip_context(name) in elems - - return filter - - -def options_to_rules(options, filter: Optional[Callable] = None): - items = sorted(options.items()) - if filter is not None: - items = [(name, value) for name, value in items if filter(name, value)] - return [Expression(SymbolRule, Symbol(name), value) for name, value in items] diff --git a/mathics/eval/options/functions.py b/mathics/eval/options/functions.py new file mode 100644 index 0000000000..197bc368b4 --- /dev/null +++ b/mathics/eval/options/functions.py @@ -0,0 +1,198 @@ +from typing import Callable, Optional + +from mathics.core.atoms import Integer, String +from mathics.core.atoms.associations import Association +from mathics.core.evaluation import Evaluation +from mathics.core.expression import Expression +from mathics.core.list import ListExpression +from mathics.core.rules import is_option_rule, is_rule +from mathics.core.symbols import ( + BooleanType, + Symbol, + SymbolFalse, + SymbolTrue, + strip_context, +) +from mathics.core.systemsymbols import SymbolRule + + +def _extract_option_name(arg) -> str | None: + """ + Extract the option name from a rule or list of rules. + Returns the option name as a string or None. + """ + if is_rule(arg): + # arg is Rule[name, value] or RuleDelayed[name, value] or Expression[SymbolRule..] + option_symbol = arg.elements[0] + if isinstance(option_symbol, Symbol): + return option_symbol.name + elif isinstance(arg, ListExpression): + # For a list of rules, all must have the same option names + # or be valid options. We only check the first for now. + if arg.elements and is_option_rule(arg.elements[0]): + return _extract_option_name(arg.elements[0]) + return None + + +def _partition_arguments( + args: tuple, head, extra_options: dict, evaluation: Evaluation +) -> tuple: + """ + An argument is treated as an option if: + * It matches an option pattern (Rule, RuleDelayed, or List of Rules), and + * Its left-hand side (option name) is declared in the head's Options + + If no Options are declared for the head, trailing option patterns + are still recognized as options. + Split args into positional arguments and trailing options/rules. + Returns (pos_args, opt_args, isValidOptions). + """ + positional_args = [] + option_args = [] + options_start_index = len(args) + + # Get the declared options for this head + declared_options = evaluation.definitions.get_options(head.name) | extra_options + + # Trailing rules matching OptionsPattern are treated as options + # if they match declared options or if they are option patterns. + for arg in reversed(args): + # Check if arg is Rule[k, v], RuleDelayed[k, v], or List of Rules + if is_option_pattern(arg, evaluation): + # If we have declared options, check if this rule matches a declared option + if declared_options: + option_name = _extract_option_name(arg) + if option_name: + if option_name in declared_options: + option_args.insert(0, arg) + options_start_index -= 1 + else: + evaluation.message(head.name, "optx", String(option_name), args) + return [], [], -1 + else: + break + else: + # No declared options, so all trailing rules are treated as positional arguments + break + # option_args.insert(0, arg) + # options_start_index -= 1 + else: + # Once a non-option argument is encountered from the right, + # remaining items to the left are all positional arguments. + break + + positional_args = list(args[: len(args) - len(option_args)]) + return positional_args, option_args, options_start_index + + +def eval_CheckArguments( + expr, + min_arg_index: int, + max_arg_index: int, + evaluation: Evaluation, + extra_options: dict = {}, +) -> BooleanType: + + head = expr.head + args = expr.elements # tuple of argument Expression nodes + + positional_args, option_args, options_start_index = _partition_arguments( + args, head, extra_options, evaluation + ) + + # -1 is a sentinal that we failed on options checking, + # and _partition_arguments has already given a message. + if options_start_index == -1: + return SymbolFalse + + if option_args and options_start_index > max_arg_index: + evaluation.message( + "CheckArguments", + "nonopt", + args[min_arg_index], + Integer(min_arg_index), + expr, + ) + return SymbolFalse + + len_positional_args = len(positional_args) + + if not (min_arg_index <= len_positional_args <= max_arg_index): + # FIXME: should we distingish between + # CheckArguments[x[], 1] and CheckArguments[x[], {1, 1}] + # kinds of errors? + if max_arg_index == 1: + evaluation.message( + "CheckArguments", + "argx", + head, + Integer(len_positional_args), + ) + else: + evaluation.message( + "CheckArguments", + "argrx", + head, + Integer(len_positional_args), + Integer(max_arg_index), + ) + return SymbolFalse + + return SymbolTrue + + +def eval_CheckArguments_with_association( + expr, + min_arg_index: int, + max_arg_index: int, + assoc: Association, + evaluation: Evaluation, +) -> BooleanType: + + # Extract ExtraOptions from assoc. + extra_options_dict = {} + if isinstance(assoc, Association): + extra_options = assoc.get(String("ExtraOptions")) + if isinstance(extra_options, ListExpression): + for option in extra_options: + if is_option_rule(option): + key, value = option.elements + extra_options_dict[key.name] = value + elif isinstance(option, Symbol): + extra_options_dict |= evaluation.definitions.get_options( + option.name + ) + + return eval_CheckArguments( + expr, min_arg_index, max_arg_index, evaluation, extra_options_dict + ) + + +def filter_from_iterable(elems) -> Callable: + """ + Build a filter function from an iterable. + The filter function returns `True` if + the name after striping its context is in + the interable. + """ + + def filter(name, value): + return strip_context(name) in elems + + return filter + + +def is_option_pattern(expr, evaluation: Evaluation): + """Check if an expression is a Rule, RuleDelayed, or List of Rules.""" + if is_option_rule(expr): + return True + if isinstance(expr, ListExpression): + return all(is_option_pattern(e, evaluation) for e in expr.elements) + return False + + +def options_to_rules(options, filter: Optional[Callable] = None): + items = sorted(options.items()) + if filter is not None: + items = [(name, value) for name, value in items if filter(name, value)] + return [Expression(SymbolRule, Symbol(name), value) for name, value in items] diff --git a/test/builtin/options/__init__.py b/test/builtin/options/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/builtin/options/test_options.py b/test/builtin/options/test_options.py new file mode 100644 index 0000000000..7f60925791 --- /dev/null +++ b/test/builtin/options/test_options.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +""" +Unit tests from mathics.builtin.options. +""" + +from test.helper import check_arg_counts, check_evaluation + +import pytest + + +@pytest.mark.parametrize( + ("str_expr", "msgs", "str_expected", "assert_msg"), + [ + ( + ( + 'f[x_, OptionsPattern[f]] := x ^ OptionValue["m"];' + 'Options[f] = {"m" -> 7};f[x]' + ), + None, + "x ^ 7", + None, + ), + ("f /: Options[f] = {a -> b}", None, "{a ⇾ b}", None), + ("Options[f]", None, "{a ⇾ b}", None), + ( + "f /: Options[g] := {a -> b}", + ("Rule for Options can only be attached to g.",), + "$Failed", + None, + ), + ( + "Options[f] = a /; True", + ("a /; True is not a valid list of option rules.",), + "a /; True", + None, + ), + ( + "Options[Plot, Ticks]", + None, + "{Ticks ⇾ Automatic}", + None, + ), + ( + 'Options[Plot, "Ticks"]', + None, + "{Ticks ⇾ Automatic}", + None, + ), + ( + "Options[AtomQ, Foo]", + ["Option name Foo is not a known option for AtomQ."], + "Options[AtomQ, Foo]", + None, + ), + ( + 'Options[AtomQ, "Foo"]', + ["Option name Foo is not a known option for AtomQ."], + "Options[AtomQ, Foo]", + None, + ), + ], +) +def test_checkarguments(str_expr, msgs, str_expected, assert_msg): + """ """ + check_evaluation( + str_expr, + str_expected, + to_string_expr=True, + to_string_expected=True, + hold_expected=True, + failure_message=assert_msg, + expected_messages=msgs, + ) + + +@pytest.mark.parametrize( + ("str_expr", "msgs", "str_expected", "assert_msg"), + [ + ( + "CheckArguments[x[], 0]", + None, + "True", + "CheckArguments with zero arguments", + ), + ( + "CheckArguments[x[], 1]", + ("x called with 0 arguments; 1 argument is expected.",), + "False", + "CheckArguments mismatch with 0-argument call", + ), + ( + 'CheckArguments[x[x], "foo"]', + ( + "The range specification foo should have the form m, {m, n} or {m, Infinity}, where m and n are integers and 0 <= m <= n.", + ), + "CheckArguments[x[x], foo]", + "CheckArguments called with an invalid string 2nd argument", + ), + ( + "Clear[g]; CheckArguments[g[1, a->5], 0]", + ("g called with 2 arguments; 0 arguments are expected.",), + "False", + "CheckArguments with undeclared option parameter and mismatched count", + ), + ( + "Options[f] = {a -> 0}; CheckArguments[f[1, a->5], 0]", + ( + "Options expected (instead of 1) beyond position 0 in f[1, a ⇾ 5]. An option must be a rule or a list of rules.", + ), + "False", + "CheckArguments with declared option parameter and mismatched count", + ), + ( + "CheckArguments[f[1, 2, 3, a -> 0], {1, 3}]", + None, + "True", + "CheckArguments {min, max} form of CheckArguments", + ), + ], +) +def test_options(str_expr, msgs, str_expected, assert_msg): + """ """ + check_evaluation( + str_expr, + str_expected, + to_string_expr=True, + to_string_expected=True, + hold_expected=True, + failure_message=assert_msg, + expected_messages=msgs, + ) + + +@pytest.mark.parametrize( + ("function_name", "msg_fragment"), + [ + ( + "CheckArguments", + "2 or 3 arguments are", + ), + ], +) +def test_arg_errors(function_name, msg_fragment): + """ """ + check_arg_counts(function_name, msg_fragment) diff --git a/test/builtin/test_options.py b/test/builtin/test_options.py deleted file mode 100644 index c3b7cd87b3..0000000000 --- a/test/builtin/test_options.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Unit tests from mathics.builtin.options. -""" - - -from test.helper import check_evaluation - -import pytest - - -@pytest.mark.parametrize( - ("str_expr", "msgs", "str_expected", "fail_msg"), - [ - ( - ( - 'f[x_, OptionsPattern[f]] := x ^ OptionValue["m"];' - 'Options[f] = {"m" -> 7};f[x]' - ), - None, - "x ^ 7", - None, - ), - ("f /: Options[f] = {a -> b}", None, "{a ⇾ b}", None), - ("Options[f]", None, "{a ⇾ b}", None), - ( - "f /: Options[g] := {a -> b}", - ("Rule for Options can only be attached to g.",), - "$Failed", - None, - ), - ( - "Options[f] = a /; True", - ("a /; True is not a valid list of option rules.",), - "a /; True", - None, - ), - ( - "Options[Plot, Ticks]", - None, - "{Ticks ⇾ Automatic}", - None, - ), - ( - 'Options[Plot, "Ticks"]', - None, - "{Ticks ⇾ Automatic}", - None, - ), - ( - "Options[AtomQ, Foo]", - ["Option name Foo is not a known option for AtomQ."], - "Options[AtomQ, Foo]", - None, - ), - ( - 'Options[AtomQ, "Foo"]', - ["Option name Foo is not a known option for AtomQ."], - "Options[AtomQ, Foo]", - None, - ), - ], -) -def test_options(str_expr, msgs, str_expected, fail_msg): - """ """ - check_evaluation( - str_expr, - str_expected, - to_string_expr=True, - to_string_expected=True, - hold_expected=True, - failure_message=fail_msg, - expected_messages=msgs, - )