From 4268b07f8f0ef9eee766e09c9d51decc572d6698 Mon Sep 17 00:00:00 2001 From: rocky Date: Sun, 30 Aug 2026 20:21:26 -0400 Subject: [PATCH 1/5] WIP - fill in more CheckArguments code --- mathics/builtin/messages.py | 1 + mathics/builtin/options/functions.py | 71 ++++++++++++++++++++++++++-- mathics/eval/options/__init__.py | 2 +- mathics/eval/options/function.py | 26 ---------- 4 files changed, 68 insertions(+), 32 deletions(-) delete mode 100644 mathics/eval/options/function.py diff --git a/mathics/builtin/messages.py b/mathics/builtin/messages.py index a3320e3ee..124ca721e 100644 --- a/mathics/builtin/messages.py +++ b/mathics/builtin/messages.py @@ -233,6 +233,7 @@ class General(Builtin): "noopen": "Cannot open `1`.", "nord": "Invalid comparison with `1` attempted.", "normal": "Nonatomic expression expected at position `1` in `2`.", + "notnorm": "Arugment `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.", diff --git a/mathics/builtin/options/functions.py b/mathics/builtin/options/functions.py index 50ebb5415..bc0b3ad93 100644 --- a/mathics/builtin/options/functions.py +++ b/mathics/builtin/options/functions.py @@ -2,18 +2,79 @@ Setting Up Options for Functions """ -from mathics.core.atoms import Integer1, String +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.rules import is_option_rule +from mathics.core.symbols import BooleanType, Symbol, ensure_context from mathics.core.systemsymbols import SymbolRule -from mathics.eval.options.function import options_to_rules +from mathics.eval.options.functions import eval_CheckArguments, 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$. +
+ + >> FilterRules[{x -> 100, y -> 1000}, x] + = {x ⇾ 100} + + >> FilterRules[{x -> 100, y -> 1000, z -> 10000}, {a, b, x, z}] + = {x ⇾ 100, z ⇾ 10000} + """ + + attributes = A_HOLD_FIRST | A_PROTECTED | A_READ_PROTECTED + 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 = True + elif not (isinstance(expr, ListExpression) and len(expr.elements) != 2): + invalid_range_spec = True + elif not isinstance((low := expr.elements[0]), Integer) and isinstance( + (high := expr.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", arg) + + return eval_CheckArguments(expr, low_int, high_int, evaluation) + + class FilterRules(Builtin): """ @@ -45,11 +106,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/eval/options/__init__.py b/mathics/eval/options/__init__.py index 0d8d78b9f..b4b8ef0a1 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 72068e55d..000000000 --- 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] From 2475b8e35aab6c1650be64d7f12ec010c2f0ddd2 Mon Sep 17 00:00:00 2001 From: rocky Date: Mon, 31 Aug 2026 21:46:38 -0400 Subject: [PATCH 2/5] First working CheckArguments[] --- SYMBOLS_MANIFEST.txt | 1 + mathics/builtin/options/functions.py | 32 +++++-- mathics/core/rules.py | 2 +- mathics/eval/options/functions.py | 113 ++++++++++++++++++++++ test/builtin/options/__init__.py | 0 test/builtin/options/test_options.py | 137 +++++++++++++++++++++++++++ test/builtin/test_options.py | 74 --------------- 7 files changed, 275 insertions(+), 84 deletions(-) create mode 100644 mathics/eval/options/functions.py create mode 100644 test/builtin/options/__init__.py create mode 100644 test/builtin/options/test_options.py delete mode 100644 test/builtin/test_options.py diff --git a/SYMBOLS_MANIFEST.txt b/SYMBOLS_MANIFEST.txt index 87a2cbf3e..8380e82db 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/options/functions.py b/mathics/builtin/options/functions.py index bc0b3ad93..1bcdbca8e 100644 --- a/mathics/builtin/options/functions.py +++ b/mathics/builtin/options/functions.py @@ -33,14 +33,22 @@ class CheckArguments(Builtin):
same as above but checks only the positional arguments between $min$, and $max$. - >> FilterRules[{x -> 100, y -> 1000}, x] - = {x ⇾ 100} + First declare an option for function $f$: + >> Options[f] = {a -> 0} + = {a ⇾ 0} - >> FilterRules[{x -> 100, y -> 1000, z -> 10000}, {a, b, x, z}] - = {x ⇾ 100, z ⇾ 10000} + 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 """ 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}, " @@ -52,18 +60,23 @@ class CheckArguments(Builtin): def eval_with_min_max( self, expr, arg, evaluation: Evaluation ) -> Optional[BooleanType]: - "CheckArguments[expr], arg_}]" + "CheckArguments[expr_, arg_]" if not isinstance(expr, Expression): evaluation.message("CheckArguments", "notnorm", Integer1) return + elements = expr.elements + 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(expr, ListExpression) and len(expr.elements) != 2): - invalid_range_spec = True - elif not isinstance((low := expr.elements[0]), Integer) and isinstance( - (high := expr.elements[1]), Integer + elif not ( + isinstance((low := arg.elements[0]), Integer) + and isinstance((high := arg.elements[1]), Integer) ): invalid_range_spec = True elif not (0 <= (low_int := low.value) <= (high_int := high.value)): @@ -71,6 +84,7 @@ def eval_with_min_max( if invalid_range_spec: evaluation.message("CheckArguments", "rspec", arg) + return return eval_CheckArguments(expr, low_int, high_int, evaluation) diff --git a/mathics/core/rules.py b/mathics/core/rules.py index 0a0b83e5a..615ada918 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/functions.py b/mathics/eval/options/functions.py new file mode 100644 index 000000000..b80fdb30f --- /dev/null +++ b/mathics/eval/options/functions.py @@ -0,0 +1,113 @@ +from typing import Callable, Optional + +from mathics.core.atoms import Integer +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 +from mathics.core.symbols import ( + BooleanType, + Symbol, + SymbolFalse, + SymbolTrue, + strip_context, +) +from mathics.core.systemsymbols import SymbolRule + + +def _get_known_options(head, evaluation: Evaluation) -> dict: + """Fetch defined options for head, if head has Options defined.""" + # Query evaluation definitions for Options[head] + # Return a set/dict of option names if available, else empty dict + opts = evaluation.definitions.get_options(head) + return opts if opts else {} + + +def _parse_spec(self, spec, evaluation: Evaluation) -> tuple: + """Extract min and max positional argument count from spec.""" + if isinstance(spec, Integer): + val = spec.value + return val, val + elif isinstance(spec, ListExpression): + if len(spec.elements) == 2: + min_val = spec.elements[0].to_python() + max_val = spec.elements[1].to_python() + if isinstance(min_val, int) and isinstance(max_val, int): + return min_val, max_val + return None, None + + +def eval_CheckArguments( + expr, min_arg: int, max_arg: int, evaluation: Evaluation +) -> BooleanType: + + head = expr.head + args = expr.elements # tuple of argument Expression nodes + + pos_args, opt_args = _partition_arguments(args, evaluation) + + num_pos = len(pos_args) + + if num_pos < min_arg or num_pos > max_arg: + # FIXME: should be distingish between + # CheckArguments[x[], 1] and CheckArguments[x[], {1, 1}] + # kinds of errors? + evaluation.message( + "CheckArguments", "argx", head, Integer(num_pos), Integer(max_arg) + ) + return SymbolFalse + + return SymbolTrue + + +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] + + +def _partition_arguments(args, evaluation: Evaluation): + """ + Split args into positional arguments and trailing options/rules. + Returns (pos_args, opt_args, isValidOptions). + """ + pos_args = [] + opt_args = [] + + # Trailing rules matching OptionsPattern are treated as options + # if `known_options` is not empty, or trailing rules are encountered. + 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): + opt_args.insert(0, args) + else: + # Once a non-option argument is encountered from the right, + # remaining items to the left are all positional arguments. + break + + pos_args = list(args[: len(args) - len(opt_args)]) + return pos_args, opt_args diff --git a/test/builtin/options/__init__.py b/test/builtin/options/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/builtin/options/test_options.py b/test/builtin/options/test_options.py new file mode 100644 index 000000000..ee0cb7eb9 --- /dev/null +++ b/test/builtin/options/test_options.py @@ -0,0 +1,137 @@ +# -*- 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", + ), + # ( + # "CheckArguments[f[1, a->5], 0]", + # "f 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" + # ), + ], +) +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 c3b7cd87b..000000000 --- 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, - ) From 7b9d3b0fb0c0b14a9ac1f5eabdcab0cd8a2f9d33 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 1 Sep 2026 07:43:58 -0400 Subject: [PATCH 3/5] CheckArguments needs to look for defined options in its calculation --- mathics/builtin/messages.py | 2 +- mathics/eval/options/functions.py | 127 ++++++++++++++++++++------- test/builtin/options/test_options.py | 26 +++--- 3 files changed, 111 insertions(+), 44 deletions(-) diff --git a/mathics/builtin/messages.py b/mathics/builtin/messages.py index 124ca721e..763558717 100644 --- a/mathics/builtin/messages.py +++ b/mathics/builtin/messages.py @@ -233,7 +233,7 @@ class General(Builtin): "noopen": "Cannot open `1`.", "nord": "Invalid comparison with `1` attempted.", "normal": "Nonatomic expression expected at position `1` in `2`.", - "notnorm": "Arugment `1` must be a nonatomic expression.", + "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.", diff --git a/mathics/eval/options/functions.py b/mathics/eval/options/functions.py index b80fdb30f..6a35fbcf8 100644 --- a/mathics/eval/options/functions.py +++ b/mathics/eval/options/functions.py @@ -15,11 +15,29 @@ 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_option_rule(arg): + # arg is Rule[name, value] or RuleDelayed[name, value] + 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 _get_known_options(head, evaluation: Evaluation) -> dict: """Fetch defined options for head, if head has Options defined.""" # Query evaluation definitions for Options[head] # Return a set/dict of option names if available, else empty dict - opts = evaluation.definitions.get_options(head) + opts = evaluation.definitions.get_options(head.name) return opts if opts else {} @@ -37,24 +55,94 @@ def _parse_spec(self, spec, evaluation: Evaluation) -> tuple: return None, None +def _partition_arguments(args: tuple, head, 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 = _get_known_options(head, evaluation) + + # 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 and option_name in declared_options: + option_args.insert(0, arg) + options_start_index -= 1 + else: + # Not a declared option, so treat as positional argument + 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: int, max_arg: int, evaluation: Evaluation + expr, min_arg_index: int, max_arg_index: int, evaluation: Evaluation ) -> BooleanType: head = expr.head args = expr.elements # tuple of argument Expression nodes - pos_args, opt_args = _partition_arguments(args, evaluation) + positional_args, option_args, options_start_index = _partition_arguments( + args, head, evaluation + ) - num_pos = len(pos_args) + if option_args and options_start_index > min_arg_index: + evaluation.message( + "CheckArguments", + "nonopt", + args[min_arg_index], + Integer(min_arg_index), + expr, + ) + return SymbolFalse + + len_positional_args = len(positional_args) - if num_pos < min_arg or num_pos > max_arg: + if len_positional_args < min_arg_index or len_positional_args > max_arg_index: # FIXME: should be distingish between # CheckArguments[x[], 1] and CheckArguments[x[], {1, 1}] # kinds of errors? - evaluation.message( - "CheckArguments", "argx", head, Integer(num_pos), Integer(max_arg) - ) + 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 @@ -88,26 +176,3 @@ def options_to_rules(options, filter: Optional[Callable] = None): 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] - - -def _partition_arguments(args, evaluation: Evaluation): - """ - Split args into positional arguments and trailing options/rules. - Returns (pos_args, opt_args, isValidOptions). - """ - pos_args = [] - opt_args = [] - - # Trailing rules matching OptionsPattern are treated as options - # if `known_options` is not empty, or trailing rules are encountered. - 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): - opt_args.insert(0, args) - else: - # Once a non-option argument is encountered from the right, - # remaining items to the left are all positional arguments. - break - - pos_args = list(args[: len(args) - len(opt_args)]) - return pos_args, opt_args diff --git a/test/builtin/options/test_options.py b/test/builtin/options/test_options.py index ee0cb7eb9..54d427167 100644 --- a/test/builtin/options/test_options.py +++ b/test/builtin/options/test_options.py @@ -96,18 +96,20 @@ def test_checkarguments(str_expr, msgs, str_expected, assert_msg): "CheckArguments[x[x], foo]", "CheckArguments called with an invalid string 2nd argument", ), - # ( - # "CheckArguments[f[1, a->5], 0]", - # "f 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" - # ), + ( + "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", + ), ], ) def test_options(str_expr, msgs, str_expected, assert_msg): From e749b8f2823d87e05b356c01bf6d2630762846d3 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 1 Sep 2026 11:52:02 -0400 Subject: [PATCH 4/5] Support ExtraOptions in CheckArguments --- .../directories/directory_operations.py | 1 - mathics/builtin/messages.py | 1 + mathics/builtin/options/functions.py | 47 +++++++++- mathics/eval/options/functions.py | 92 +++++++++++-------- test/builtin/options/test_options.py | 6 ++ 5 files changed, 107 insertions(+), 40 deletions(-) diff --git a/mathics/builtin/directories/directory_operations.py b/mathics/builtin/directories/directory_operations.py index 11f72a623..d462f04cf 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 763558717..19a9e81e8 100644 --- a/mathics/builtin/messages.py +++ b/mathics/builtin/messages.py @@ -240,6 +240,7 @@ class General(Builtin): "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 1bcdbca8e..80c2e73b9 100644 --- a/mathics/builtin/options/functions.py +++ b/mathics/builtin/options/functions.py @@ -13,7 +13,11 @@ from mathics.core.rules import is_option_rule from mathics.core.symbols import BooleanType, Symbol, ensure_context from mathics.core.systemsymbols import SymbolRule -from mathics.eval.options.functions import eval_CheckArguments, options_to_rules +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 @@ -44,6 +48,12 @@ class CheckArguments(Builtin): >> 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 @@ -65,8 +75,6 @@ def eval_with_min_max( evaluation.message("CheckArguments", "notnorm", Integer1) return - elements = expr.elements - invalid_range_spec = False if isinstance(arg, Integer): invalid_range_spec = False @@ -88,6 +96,39 @@ def eval_with_min_max( 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): """ diff --git a/mathics/eval/options/functions.py b/mathics/eval/options/functions.py index 6a35fbcf8..2aac3e1c4 100644 --- a/mathics/eval/options/functions.py +++ b/mathics/eval/options/functions.py @@ -1,10 +1,11 @@ from typing import Callable, Optional -from mathics.core.atoms import Integer +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 +from mathics.core.rules import is_option_rule, is_rule from mathics.core.symbols import ( BooleanType, Symbol, @@ -20,8 +21,8 @@ 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_option_rule(arg): - # arg is Rule[name, value] or RuleDelayed[name, value] + 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 @@ -33,29 +34,9 @@ def _extract_option_name(arg) -> str | None: return None -def _get_known_options(head, evaluation: Evaluation) -> dict: - """Fetch defined options for head, if head has Options defined.""" - # Query evaluation definitions for Options[head] - # Return a set/dict of option names if available, else empty dict - opts = evaluation.definitions.get_options(head.name) - return opts if opts else {} - - -def _parse_spec(self, spec, evaluation: Evaluation) -> tuple: - """Extract min and max positional argument count from spec.""" - if isinstance(spec, Integer): - val = spec.value - return val, val - elif isinstance(spec, ListExpression): - if len(spec.elements) == 2: - min_val = spec.elements[0].to_python() - max_val = spec.elements[1].to_python() - if isinstance(min_val, int) and isinstance(max_val, int): - return min_val, max_val - return None, None - - -def _partition_arguments(args: tuple, head, evaluation: Evaluation) -> tuple: +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 @@ -71,7 +52,7 @@ def _partition_arguments(args: tuple, head, evaluation: Evaluation) -> tuple: options_start_index = len(args) # Get the declared options for this head - declared_options = _get_known_options(head, evaluation) + 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. @@ -81,11 +62,14 @@ def _partition_arguments(args: tuple, head, evaluation: Evaluation) -> tuple: # 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 and option_name in declared_options: - option_args.insert(0, arg) - options_start_index -= 1 + 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: - # Not a declared option, so treat as positional argument break else: # No declared options, so all trailing rules are treated as positional arguments @@ -102,17 +86,26 @@ def _partition_arguments(args: tuple, head, evaluation: Evaluation) -> tuple: def eval_CheckArguments( - expr, min_arg_index: int, max_arg_index: int, evaluation: Evaluation + 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, evaluation + args, head, extra_options, evaluation ) - if option_args and options_start_index > min_arg_index: + # -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", @@ -125,7 +118,7 @@ def eval_CheckArguments( len_positional_args = len(positional_args) if len_positional_args < min_arg_index or len_positional_args > max_arg_index: - # FIXME: should be distingish between + # FIXME: should we distingish between # CheckArguments[x[], 1] and CheckArguments[x[], {1, 1}] # kinds of errors? if max_arg_index == 1: @@ -148,6 +141,33 @@ def eval_CheckArguments( 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. diff --git a/test/builtin/options/test_options.py b/test/builtin/options/test_options.py index 54d427167..7f6092579 100644 --- a/test/builtin/options/test_options.py +++ b/test/builtin/options/test_options.py @@ -110,6 +110,12 @@ def test_checkarguments(str_expr, msgs, str_expected, assert_msg): "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): From eb78b5c7794e1dff6550cf6a660d9e4abd8b2bbf Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 1 Sep 2026 12:53:59 -0400 Subject: [PATCH 5/5] Allow Infinity as a CheckArguments option --- mathics/builtin/options/functions.py | 22 +++++++++++++++------- mathics/eval/options/functions.py | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/mathics/builtin/options/functions.py b/mathics/builtin/options/functions.py index 80c2e73b9..e3977e233 100644 --- a/mathics/builtin/options/functions.py +++ b/mathics/builtin/options/functions.py @@ -2,6 +2,7 @@ Setting Up Options for Functions """ +import sys from typing import Optional from mathics.core.atoms import Integer, Integer1, String @@ -12,7 +13,7 @@ from mathics.core.list import ListExpression from mathics.core.rules import is_option_rule from mathics.core.symbols import BooleanType, Symbol, ensure_context -from mathics.core.systemsymbols import SymbolRule +from mathics.core.systemsymbols import SymbolDirectedInfinity, SymbolRule from mathics.eval.options.functions import ( eval_CheckArguments, eval_CheckArguments_with_association, @@ -82,18 +83,25 @@ def eval_with_min_max( 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) - and isinstance((high := arg.elements[1]), Integer) - ): - invalid_range_spec = True - elif not (0 <= (low_int := low.value) <= (high_int := high.value)): + 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( diff --git a/mathics/eval/options/functions.py b/mathics/eval/options/functions.py index 2aac3e1c4..197bc368b 100644 --- a/mathics/eval/options/functions.py +++ b/mathics/eval/options/functions.py @@ -117,7 +117,7 @@ def eval_CheckArguments( len_positional_args = len(positional_args) - if len_positional_args < min_arg_index or len_positional_args > max_arg_index: + 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?