diff --git a/SYMBOLS_MANIFEST.txt b/SYMBOLS_MANIFEST.txt index 5815b2485..6b3bd147b 100644 --- a/SYMBOLS_MANIFEST.txt +++ b/SYMBOLS_MANIFEST.txt @@ -131,6 +131,7 @@ System`Arrow System`Arrow3DBox System`ArrowBox System`Arrowheads +System`AssociateTo System`Association System`AssociationQ System`Assuming @@ -634,6 +635,7 @@ System`KelvinBer System`KelvinKei System`KelvinKer System`Key +System`KeyDropFrom System`KeyExistsQ System`Keys System`Khinchin diff --git a/mathics/builtin/associations/modifying.py b/mathics/builtin/associations/modifying.py new file mode 100644 index 000000000..2f09158f6 --- /dev/null +++ b/mathics/builtin/associations/modifying.py @@ -0,0 +1,90 @@ +""" +Modifying Associations +""" + +from typing import Optional + +from mathics.core.atoms.associations import Association +from mathics.core.attributes import A_HOLD_FIRST, A_PROTECTED +from mathics.core.builtin import Builtin +from mathics.core.element import BaseElement +from mathics.core.evaluation import Evaluation +from mathics.eval.associations.modifying import eval_AssociateTo, eval_KeyDropFrom + + +class AssociateTo(Builtin): + """ + + :WMA link: + https://reference.wolfram.com/language/ref/AssociateTo.html + +
+
'AssociateTo'[$a$, $key$ -> $val$] +
Adds key-value pair $key$ -> $val$ to $a$. + +
+ + Create an association + >> assoc = Association[{a -> 1, b -> 2, c -> 3}] + = <|a ⇾ 1, b ⇾ 2, c ⇾ 3|> + + Change the value associated with key $a$: + >> AssociateTo[assoc, a -> 10] + = <|a ⇾ 10, b ⇾ 2, c ⇾ 3|> + + Add key $d$ rather than change it since it is not already in $a$: + >> AssociateTo[assoc, d->4] + = <|a ⇾ 10, b ⇾ 2, c ⇾ 3, d ⇾ 4|> + + Change and add keys using several keys-value pairs: + >> AssociateTo[assoc, {d -> 5, e-> f}] + = <|a ⇾ 10, b ⇾ 2, c ⇾ 3, d ⇾ 5, e ⇾ f|> + """ + + attributes = A_HOLD_FIRST | A_PROTECTED + eval_error = Builtin.generic_argument_error + expected_args = 2 + + summary_text = "update an Association" + + def eval( + self, a: BaseElement, expr: BaseElement, evaluation: Evaluation + ) -> Optional[Association]: + """AssociateTo[a_, expr_]""" + + return eval_AssociateTo(a, expr, evaluation) + + +class KeyDropFrom(Builtin): + """ + + :WMA link: + https://reference.wolfram.com/language/ref/KeyDropFrom.html + +
+
'KeyDropFrom'[$a$, $key$] +
Removes key pair from association $a$. + +
+ + >> assoc = <|a ⇾ 1, b ⇾ 2|> + = <|a ⇾ 1, b ⇾ 2|> + >> KeyDropFrom[assoc, a] + = <|b ⇾ 2|> + + >> KeyDropFrom[assoc, {b, d}] + = <||> + """ + + attributes = A_HOLD_FIRST | A_PROTECTED + eval_error = Builtin.generic_argument_error + expected_args = 2 + + summary_text = "remove a key from an Association" + + def eval( + self, a: BaseElement, key: BaseElement, evaluation: Evaluation + ) -> Optional[Association]: + """KeyDropFrom[a_, key_]""" + + return eval_KeyDropFrom(a, key, evaluation) diff --git a/mathics/builtin/messages.py b/mathics/builtin/messages.py index 4a729ef02..1ec62af48 100644 --- a/mathics/builtin/messages.py +++ b/mathics/builtin/messages.py @@ -215,6 +215,8 @@ class General(Builtin): "intp": "Positive integer expected.", "intnn": "Non-negative integer expected.", "intnm": "Non-negative machine-sized integer expected at position `1` in `2`.", + "invak": "The argument `1` is not a valid Association.", + "invlb": "The argument `1` is not a list, Rule or Association.", "invrl": "The argument `1` is not a valid Association or a list of rules.", "iterb": "Iterator does not have appropriate bounds.", "ivar": "`1` is not a valid variable.", diff --git a/mathics/core/atoms/associations.py b/mathics/core/atoms/associations.py index 64d90b69c..3523e9ccc 100644 --- a/mathics/core/atoms/associations.py +++ b/mathics/core/atoms/associations.py @@ -1,6 +1,6 @@ """ -Mathics3 implementation of an Association atom -6""" +Mathics3 implementation of an Association atom. +""" from typing import Any, Iterable, Optional @@ -19,7 +19,7 @@ class Association(Atom, BoxElementMixin): """An Association is an Atom collection that maps keys to values, similar to a Python dictionary. - Each Key-Value mappings of an Association is called a Rule; but + Each key-value mapping of an Association is called a Rule; but this kind of Rule is distinct from (or a degenerate form of) the pattern-matching RewriteRules found in DelayedRule and Set builtins. @@ -42,10 +42,9 @@ def __init__(self, elements: Iterable, expr: Optional[Expression] = None): raise TypeError(f"Association keys must be Rules, got {rule_expr}") self.collection[rule_expr.elements[0]] = rule_expr.elements[1] - self.update_for_change() return - # Add some dictionary like methods so that we can treat an Association object + # Add some dictionary-like methods so that we can treat an Association object # as we would a dictionary. def __delitem__(self, key: BaseElement) -> None: @@ -60,10 +59,8 @@ def __delitem__(self, key: BaseElement) -> None: Side effects: Updates self.collection """ - if key not in self.collection: - raise KeyError(key) - del self.collection[key] + self._expr = None def __eq__(self, other: Any) -> bool: """Check equality with another Association.""" @@ -74,8 +71,8 @@ def __eq__(self, other: Any) -> bool: return False # "other" is an Association that is not literal like us, - # and has the same number items in its collection. - # Here, we have compare key-value pairs + # and has the same number of items in its collection. + # Here, we have to compare key-value pairs. return self.collection == other.collection # If for some reason the above does not work: @@ -105,7 +102,15 @@ def __getitem__(self, key: Any) -> Any: return self.collection[key] raise KeyError(key) + # FIXME: We probably shouldn't have this. + # Find out what needs it and adjust that. def __hash__(self) -> int: + hash_elements = [] + for key, value in self.collection.items(): + # Update hash component + hash_elements.append((hash(key), hash(value))) + + self._hash = hash(("Association", tuple(hash_elements))) return self._hash def __setitem__(self, key: BaseElement, value: BaseElement) -> None: @@ -194,11 +199,28 @@ def head(self) -> Symbol: return SymbolRule def items(self): - """Return the values of an the association. + """Return the values of the association. Behaves like dict.items(). """ return self.collection.items() + def pop(self, key: BaseElement) -> BaseElement: + """pops a key-value pair from the association. + + Args: + key: The key to remove. + + Raises: + KeyError: If the key is not found in the association. + + Side effects: + Updates self.collection + """ + popped = self.collection.pop(key, None) + if popped: + self._expr = None + return popped + def sameQ(self, other: Any) -> bool: """ Mathics3 SameQ comparison. @@ -221,34 +243,27 @@ def sameQ(self, other: Any) -> bool: return True def to_python(self, *args, **kwargs) -> Optional[dict]: - # FIXME - return None + result = {} + for key, value in self.collection.items(): + if hasattr(key, "to_python") and hasattr(value, "to_python"): + result[key.to_python()] = value.to_python() + else: + return None + return result def to_sympy(self, **kwargs): return None def values(self): - """Return the values of an the association. + """Return the values of the association. Behaves like dict.values(). """ return self.collection.values() def update(self, e: Iterable): - """Return the values of an the association. + """Return the values of the association. Behaves like dict.update() except we return the update object value """ self.collection.update(e) - self.update_for_change() self._expr = None - - def update_for_change(self): - """ - Things we have to do when the Association is changed. - """ - hash_elements = [] - for key, value in self.collection.items(): - # Update hash component - hash_elements.append((hash(key), hash(value))) - - self._hash = hash(("Association", tuple(hash_elements))) diff --git a/mathics/eval/associations/__init__.py b/mathics/eval/associations/__init__.py index be91ec16e..6494054e0 100644 --- a/mathics/eval/associations/__init__.py +++ b/mathics/eval/associations/__init__.py @@ -1,4 +1,4 @@ """ Evaluation routines and associated code for Built-in functions found under module -mathics.builtins.assications. +mathics.builtins.associations. """ diff --git a/mathics/eval/associations/modifying.py b/mathics/eval/associations/modifying.py new file mode 100644 index 000000000..52b701804 --- /dev/null +++ b/mathics/eval/associations/modifying.py @@ -0,0 +1,81 @@ +""" +Evaluation routines for builtins in mathics.builtin.associations.modifying +""" + +from typing import Optional + +from mathics.core.atoms.associations import Association +from mathics.core.attributes import A_PROTECTED +from mathics.core.element import BaseElement +from mathics.core.evaluation import Evaluation +from mathics.core.expression import Expression +from mathics.core.list import ListExpression +from mathics.core.rules import is_rule +from mathics.core.symbols import Symbol +from mathics.core.systemsymbols import SymbolAssociation + + +def eval_AssociateTo( + a: BaseElement, expr: BaseElement, evaluation: Evaluation +) -> Optional[Association]: + """AssociateTo[a_, expr_]""" + + # FIXME: we should test for Readable? And Writable? + attributes = 0 + if isinstance(a, Symbol): + assoc_value = a.evaluate(evaluation) + attributes = evaluation.definitions.get_attributes(a.name) + else: + assoc_value = expr + + if not isinstance(assoc_value, Association): + evaluation.message("AssociateTo", "invak", assoc_value) + return + + if is_rule(expr): + changed = Association([expr]) + elif isinstance(expr, ListExpression): + changed = Association(expr.elements) + else: + changed = expr + + if (attributes & A_PROTECTED) or isinstance(a, Expression): + evaluation.message("Set", "write", SymbolAssociation, a) + return + + if not isinstance(changed, Association): + evaluation.message("AssociateTo", "invlb", expr) + return + + assoc_value.update(changed.collection) + return assoc_value + + +def eval_KeyDropFrom( + a: BaseElement, key: BaseElement, evaluation: Evaluation +) -> Optional[Association]: + """KeyDropFrom[a_, key_]""" + + # FIXME: we should test for Readable? And Writable? + attributes = 0 + if isinstance(a, Symbol): + assoc_value = a.evaluate(evaluation) + attributes = evaluation.definitions.get_attributes(a.name) + else: + assoc_value = a + + if (attributes & A_PROTECTED) or isinstance(a, Expression): + evaluation.message("Set", "write", SymbolAssociation, a) + return + + if not isinstance(assoc_value, Association): + # If it's not an association, we might just return it or handle errors. + return a + + if isinstance(key, ListExpression): + keys = key.elements + else: + keys = [key] + for key in keys: + assoc_value.pop(key) + return assoc_value diff --git a/mathics/repl.py b/mathics/repl.py index a09748a8e..6afb0cc00 100644 --- a/mathics/repl.py +++ b/mathics/repl.py @@ -123,7 +123,7 @@ def __init__( except ImportError: pass - # Try importing colorama to escape ansi sequences for cross platform + # Try importing colorama to escape ANSI sequences for cross-platform # colors try: from colorama import init as colorama_init @@ -212,7 +212,7 @@ def get_out_prompt(self, form=None) -> str: """ Return a prompt string to be shown before showing output. """ - line_number = self.last_line_number + line_number = self.last_line_number - 1 if form: return "{3}{0}[{4}{1}{5}]//{2}= {6}".format( self.out_prefix, line_number, form, *self.outcolors diff --git a/test/builtin/associations/test_modifying.py b/test/builtin/associations/test_modifying.py new file mode 100644 index 000000000..99a97a777 --- /dev/null +++ b/test/builtin/associations/test_modifying.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for mathics.builtins.associations.modifying +""" +from test.helper import check_evaluation + +import pytest + + +@pytest.mark.parametrize( + ("str_expr", "expected_messages", "str_expected", "assert_message"), + [ + ( + "AssociateTo[1, 1⇾2]", + ["The argument 1 ⇾ 2 is not a valid Association."], + "AssociateTo[1, 1⇾2]", + "AssociateTo first parameter should be an association not an Integer.", + ), + ( + "Clear[x]; AssociateTo[x, 1⇾2]", + ["The argument x is not a valid Association."], + "AssociateTo[x, 1⇾2]", + "AssociateTo first parameter should have a value.", + ), + ( + "assoc = <|a⇾1|>; AssociateTo[assoc, 1]", + ["The argument 1 is not a list, Rule or Association."], + "AssociateTo[assoc, 1]", + "AssociateTo first parameter wrong type.", + ), + ], +) +def test_AssociateTo(str_expr, expected_messages, str_expected, assert_message): + check_evaluation( + str_expr, + str_expected, + failure_message=assert_message, + expected_messages=expected_messages, + )