diff --git a/.coveragerc b/.coveragerc index ba32b0c..1883bc8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,6 @@ [run] data_file = reports/.coverage -source = calc +source = calc, symmath branch = true relative_files = True diff --git a/.gitignore b/.gitignore index 7c54329..e0fb104 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ build .pytest_cache .coverage venv/ +.vscode/ diff --git a/Makefile b/Makefile index fc4059e..9077b1b 100755 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ #!/usr/bin/make -f -.PHONY: help +.PHONY: help requirements help: ## This. @perl -ne 'print if /^[a-zA-Z_-]+:.*## .*$$/' $(MAKEFILE_LIST) \ | sort \ @@ -12,6 +12,11 @@ clean: ## Remove all build artifacts test: ## Run the library test suite tox +requirements: ## install development environment requirements + pip install -r requirements/pip.txt + pip install -r requirements/pip_tools.txt + pip install -r requirements/test.txt + upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade upgrade: ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in pip install -q -r requirements/pip_tools.txt diff --git a/README.rst b/README.rst index 51058f9..b881f44 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ openedx-calc ============ -A helper library for mathematical calculations, used by the `edx-platform`_. +A helper library for mathematical calculations and symbolic mathematics, used by the `edx-platform`_. This code originally lived in the `edx-platform`_ repo, but now exists here independently. diff --git a/calc/__init__.py b/calc/__init__.py index a863fe3..8fa57c1 100644 --- a/calc/__init__.py +++ b/calc/__init__.py @@ -6,4 +6,4 @@ from .calc import * -__version__ = '2.0.1' +__version__ = '3.0.0' diff --git a/calc/calc.py b/calc/calc.py index f4d98a1..d82d7f2 100644 --- a/calc/calc.py +++ b/calc/calc.py @@ -8,6 +8,7 @@ import numbers import operator +from functools import reduce import numpy from pyparsing import ( CaselessLiteral, @@ -28,7 +29,7 @@ ) from . import functions -from functools import reduce + # Functions available by default # We use scimath variants which give complex results when needed. For example: @@ -86,14 +87,12 @@ class UndefinedVariable(Exception): """ Indicate when a student inputs a variable which was not expected. """ - pass class UnmatchedParenthesis(Exception): """ Indicate when a student inputs a formula with mismatched parentheses. """ - pass def lower_dict(input_dict): @@ -216,14 +215,14 @@ def eval_product(parse_result): return prod -def add_defaults(variables, functions, case_sensitive): +def add_defaults(additional_variables, additional_functions, case_sensitive): """ Create dictionaries with both the default and user-defined variables. """ all_variables = dict(DEFAULT_VARIABLES) all_functions = dict(DEFAULT_FUNCTIONS) - all_variables.update(variables) - all_functions.update(functions) + all_variables.update(additional_variables) + all_functions.update(additional_functions) if not case_sensitive: all_variables = lower_dict(all_variables) @@ -232,7 +231,7 @@ def add_defaults(variables, functions, case_sensitive): return (all_variables, all_functions) -def evaluator(variables, functions, math_expr, case_sensitive=False): +def evaluator(variables, unary_functions, math_expr, case_sensitive=False): """ Evaluate an expression; that is, take a string of math and return a float. @@ -250,7 +249,7 @@ def evaluator(variables, functions, math_expr, case_sensitive=False): math_interpreter.parse_algebra() # Get our variables together. - all_variables, all_functions = add_defaults(variables, functions, case_sensitive) + all_variables, all_functions = add_defaults(variables, unary_functions, case_sensitive) # ...and check them math_interpreter.check_variables(all_variables, all_functions) @@ -355,7 +354,7 @@ def parse_algebra(self): inner_number = Combine(inner_number) # SI suffixes and percent. - number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys()) + number_suffix = MatchFirst(Literal(k) for k in SUFFIXES) # 0.33k or 17 plus_minus = Literal('+') | Literal('-') @@ -459,7 +458,7 @@ def check_variables(self, valid_variables, valid_functions): casify = lambda x: x.lower() # Lowercase for case insens. bad_vars = {var for var in self.variables_used - if casify(var) not in valid_variables} + if casify(var) not in valid_variables} if bad_vars: varnames = ", ".join(sorted(bad_vars)) @@ -479,7 +478,7 @@ def check_variables(self, valid_variables, valid_functions): raise UndefinedVariable(message) bad_funcs = {func for func in self.functions_used - if casify(func) not in valid_functions} + if casify(func) not in valid_functions} if bad_funcs: funcnames = ', '.join(sorted(bad_funcs)) message = f"Invalid Input: {funcnames} not permitted in answer as a function" diff --git a/calc/preview.py b/calc/preview.py index 3c3a4e6..b9c44bb 100644 --- a/calc/preview.py +++ b/calc/preview.py @@ -8,8 +8,8 @@ string of latex, store it in a custom class `LatexRendered`. """ -from .calc import DEFAULT_FUNCTIONS, DEFAULT_VARIABLES, SUFFIXES, ParseAugmenter from functools import reduce +from .calc import DEFAULT_FUNCTIONS, DEFAULT_VARIABLES, SUFFIXES, ParseAugmenter class LatexRendered: diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..8ed6c2a --- /dev/null +++ b/pylintrc @@ -0,0 +1,487 @@ +# *************************** +# ** DO NOT EDIT THIS FILE ** +# *************************** +# +# This file was generated by edx-lint: https://github.com/edx/edx-lint +# +# If you want to change this file, you have two choices, depending on whether +# you want to make a local change that applies only to this repo, or whether +# you want to make a central change that applies to all repos using edx-lint. +# +# Note: If your pylintrc file is simply out-of-date relative to the latest +# pylintrc in edx-lint, ensure you have the latest edx-lint installed +# and then follow the steps for a "LOCAL CHANGE". +# +# LOCAL CHANGE: +# +# 1. Edit the local pylintrc_tweaks file to add changes just to this +# repo's file. +# +# 2. Run: +# +# $ edx_lint write pylintrc +# +# 3. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# CENTRAL CHANGE: +# +# 1. Edit the pylintrc file in the edx-lint repo at +# https://github.com/edx/edx-lint/blob/master/edx_lint/files/pylintrc +# +# 2. install the updated version of edx-lint (in edx-lint): +# +# $ pip install . +# +# 3. Run (in edx-lint): +# +# $ edx_lint write pylintrc +# +# 4. Make a new version of edx_lint, submit and review a pull request with the +# pylintrc update, and after merging, update the edx-lint version and +# publish the new version. +# +# 5. In your local repo, install the newer version of edx-lint. +# +# 6. Run: +# +# $ edx_lint write pylintrc +# +# 7. This will modify the local file. Submit a pull request to get it +# checked in so that others will benefit. +# +# +# +# +# +# STAY AWAY FROM THIS FILE! +# +# +# +# +# +# SERIOUSLY. +# +# ------------------------------ +# Generated by edx-lint version: 5.2.1 +# ------------------------------ +[MASTER] +ignore = +persistent = yes +load-plugins = edx_lint.pylint,pylint_celery + +[MESSAGES CONTROL] +enable = + blacklisted-name, + line-too-long, + + abstract-class-instantiated, + abstract-method, + access-member-before-definition, + anomalous-backslash-in-string, + anomalous-unicode-escape-in-string, + arguments-differ, + assert-on-tuple, + assigning-non-slot, + assignment-from-no-return, + assignment-from-none, + attribute-defined-outside-init, + bad-except-order, + bad-format-character, + bad-format-string-key, + bad-format-string, + bad-open-mode, + bad-reversed-sequence, + bad-staticmethod-argument, + bad-str-strip-call, + bad-super-call, + binary-op-exception, + boolean-datetime, + catching-non-exception, + cell-var-from-loop, + confusing-with-statement, + continue-in-finally, + cyclical-import, + dangerous-default-value, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + duplicate-argument-name, + duplicate-bases, + duplicate-except, + duplicate-key, + eq-without-hash, + exception-escape, + exception-message-attribute, + expression-not-assigned, + filter-builtin-not-iterating, + format-combined-specification, + format-needs-mapping, + function-redefined, + global-variable-undefined, + import-error, + import-self, + inconsistent-mro, + indexing-exception, + inherit-non-class, + init-is-generator, + invalid-all-object, + invalid-encoded-data, + invalid-format-index, + invalid-length-returned, + invalid-sequence-index, + invalid-slice-index, + invalid-slots-object, + invalid-slots, + invalid-str-codec, + invalid-unary-operand-type, + logging-too-few-args, + logging-too-many-args, + logging-unsupported-format, + lost-exception, + map-builtin-not-iterating, + method-hidden, + misplaced-bare-raise, + misplaced-future, + missing-format-argument-key, + missing-format-attribute, + missing-format-string-key, + missing-super-argument, + mixed-fomat-string, + model-unicode-not-callable, + no-member, + no-method-argument, + no-name-in-module, + no-self-argument, + no-value-for-parameter, + non-iterator-returned, + non-parent-method-called, + nonexistent-operator, + nonimplemented-raised, + nonstandard-exception, + not-a-mapping, + not-an-iterable, + not-callable, + not-context-manager, + not-in-loop, + pointless-statement, + pointless-string-statement, + property-on-old-class, + raising-bad-type, + raising-non-exception, + raising-string, + range-builtin-not-iterating, + redefined-builtin, + redefined-in-handler, + redefined-outer-name, + redefined-variable-type, + redundant-keyword-arg, + relative-import, + repeated-keyword, + return-arg-in-generator, + return-in-init, + return-outside-function, + signature-differs, + slots-on-old-class, + super-init-not-called, + super-method-not-called, + super-on-old-class, + syntax-error, + sys-max-int, + test-inherits-tests, + too-few-format-args, + too-many-format-args, + too-many-function-args, + translation-of-non-string, + truncated-format-string, + unbalance-tuple-unpacking, + undefined-all-variable, + undefined-loop-variable, + undefined-variable, + unexpected-keyword-arg, + unexpected-special-method-signature, + unpacking-non-sequence, + unreachable, + unsubscriptable-object, + unsupported-binary-operation, + unsupported-membership-test, + unused-format-string-argument, + unused-format-string-key, + used-before-assignment, + using-constant-test, + yield-outside-function, + zip-builtin-not-iterating, + + astroid-error, + django-not-available-placeholder, + django-not-available, + fatal, + method-check-failed, + parse-error, + raw-checker-failed, + + empty-docstring, + invalid-characters-in-docstring, + missing-docstring, + wrong-spelling-in-comment, + wrong-spelling-in-docstring, + + unused-argument, + unused-import, + unused-variable, + + eval-used, + exec-used, + + bad-classmethod-argument, + bad-mcs-classmethod-argument, + bad-mcs-method-argument, + bad-whitespace, + bare-except, + broad-except, + consider-iterating-dictionary, + consider-using-enumerate, + global-at-module-level, + global-variable-not-assigned, + literal-used-as-attribute, + logging-format-interpolation, + logging-not-lazy, + metaclass-assignment, + model-has-unicode, + model-missing-unicode, + model-no-explicit-unicode, + multiple-imports, + multiple-statements, + no-classmethod-decorator, + no-staticmethod-decorator, + old-raise-syntax, + old-style-class, + protected-access, + redundant-unittest-assert, + reimported, + simplifiable-if-statement, + simplifiable-range, + singleton-comparison, + superfluous-parens, + unidiomatic-typecheck, + unnecessary-lambda, + unnecessary-pass, + unnecessary-semicolon, + unneeded-not, + useless-else-on-loop, + wrong-assert-type, + + deprecated-method, + deprecated-module, + + too-many-boolean-expressions, + too-many-nested-blocks, + too-many-statements, + + wildcard-import, + wrong-import-order, + wrong-import-position, + + missing-final-newline, + mixed-indentation, + mixed-line-endings, + trailing-newlines, + trailing-whitespace, + unexpected-line-ending-format, + + bad-inline-option, + bad-option-value, + deprecated-pragma, + unrecognized-inline-option, + useless-suppression, + + cmp-method, + coerce-method, + delslice-method, + dict-iter-method, + dict-view-method, + div-method, + getslice-method, + hex-method, + idiv-method, + next-method-called, + next-method-defined, + nonzero-method, + oct-method, + rdiv-method, + setslice-method, + using-cmp-argument, +disable = + bad-continuation, + bad-indentation, + consider-using-f-string, + duplicate-code, + file-ignored, + fixme, + global-statement, + invalid-name, + locally-disabled, + locally-enabled, + lowercase-l-suffix, + misplaced-comparison-constant, + no-else-return, + no-init, + no-self-use, + suppressed-message, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + ungrouped-imports, + unspecified-encoding, + unused-wildcard-import, + use-maxsplit-arg, + + feature-toggle-needs-doc, + illegal-waffle-usage, + + apply-builtin, + backtick, + bad-python3-import, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + deprecated-itertools-function, + deprecated-operator-function, + deprecated-str-translate-call, + deprecated-string-function, + deprecated-sys-function, + deprecated-types-field, + deprecated-urllib-function, + execfile-builtin, + file-builtin, + import-star-module-level, + input-builtin, + intern-builtin, + long-builtin, + long-suffix, + no-absolute-import, + non-ascii-bytes-literal, + old-division, + old-ne-operator, + old-octal-literal, + parameter-unpacking, + print-statement, + raw_input-builtin, + reduce-builtin, + reload-builtin, + round-builtin, + standarderror-builtin, + unichr-builtin, + unicode-builtin, + unpacking-in-except, + xrange-builtin, + + logging-fstring-interpolation, + +[REPORTS] +output-format = text +files-output = no +reports = no +score = no + +[BASIC] +bad-functions = map,filter,apply,input +module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ +class-rgx = [A-Z_][a-zA-Z0-9]+$ +function-rgx = ([a-z_][a-z0-9_]{2,40}|test_[a-z0-9_]+)$ +method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ +attr-rgx = [a-z_][a-z0-9_]{2,30}$ +argument-rgx = [a-z_][a-z0-9_]{2,30}$ +variable-rgx = [a-z_][a-z0-9_]{2,30}$ +class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ +inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ +good-names = f,i,j,k,db,ex,Run,_,__ +bad-names = foo,bar,baz,toto,tutu,tata +no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ +docstring-min-length = 5 + +[FORMAT] +max-line-length = 120 +ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ +single-line-if-stmt = no +no-space-check = trailing-comma,dict-separator +max-module-lines = 1000 +indent-string = ' ' + +[MISCELLANEOUS] +notes = FIXME,XXX,TODO + +[SIMILARITIES] +min-similarity-lines = 4 +ignore-comments = yes +ignore-docstrings = yes +ignore-imports = no + +[TYPECHECK] +ignore-mixin-members = yes +ignored-classes = SQLObject +unsafe-load-any-extension = yes +generated-members = + REQUEST, + acl_users, + aq_parent, + objects, + DoesNotExist, + can_read, + can_write, + get_url, + size, + content, + status_code, + create, + build, + fields, + tag, + org, + course, + category, + name, + revision, + _meta, + +[VARIABLES] +init-import = no +dummy-variables-rgx = _|dummy|unused|.*_unused +additional-builtins = + +[CLASSES] +defining-attr-methods = __init__,__new__,setUp +valid-classmethod-first-arg = cls +valid-metaclass-classmethod-first-arg = mcs + +[DESIGN] +max-args = 5 +ignored-argument-names = _.* +max-locals = 15 +max-returns = 6 +max-branches = 12 +max-statements = 50 +max-parents = 7 +max-attributes = 7 +min-public-methods = 2 +max-public-methods = 20 + +[IMPORTS] +deprecated-modules = regsub,TERMIOS,Bastion,rexec +import-graph = +ext-import-graph = +int-import-graph = + +[EXCEPTIONS] +overgeneral-exceptions = Exception + +# 0cfdab5f522ca16c0e457065f6599c832489a460 diff --git a/requirements/base.in b/requirements/base.in index a339c15..1b77892 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,6 +1,9 @@ # Core requirements for using this package -c constraints.txt -pyparsing +lxml +markupsafe numpy +pyparsing scipy +sympy diff --git a/requirements/base.txt b/requirements/base.txt index ac8aeea..23bd305 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,14 +1,22 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.8 # To update, run: # # make upgrade # -numpy==1.21.1 +lxml==4.7.1 + # via -r requirements/base.in +markupsafe==2.0.1 + # via -r requirements/base.in +mpmath==1.2.1 + # via sympy +numpy==1.22.1 # via # -r requirements/base.in # scipy -pyparsing==2.4.7 +pyparsing==3.0.6 + # via -r requirements/base.in +scipy==1.7.3 # via -r requirements/base.in -scipy==1.7.1 +sympy==1.9 # via -r requirements/base.in diff --git a/requirements/ci.txt b/requirements/ci.txt index df805dc..3e19104 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -1,55 +1,51 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.8 # To update, run: # # make upgrade # -backports.entry-points-selectable==1.1.0 - # via - # -r requirements/tox.txt - # virtualenv -certifi==2021.5.30 +certifi==2021.10.8 # via requests -charset-normalizer==2.0.4 +charset-normalizer==2.0.10 # via requests -coverage==5.5 +coverage==6.2 # via coveralls -coveralls==3.2.0 +coveralls==3.3.1 # via -r requirements/ci.in -distlib==0.3.2 +distlib==0.3.4 # via # -r requirements/tox.txt # virtualenv docopt==0.6.2 # via coveralls -filelock==3.0.12 +filelock==3.4.2 # via # -r requirements/tox.txt # tox # virtualenv -idna==3.2 +idna==3.3 # via requests -packaging==21.0 +packaging==21.3 # via # -r requirements/tox.txt # tox -platformdirs==2.2.0 +platformdirs==2.4.1 # via # -r requirements/tox.txt # virtualenv -pluggy==0.13.1 +pluggy==1.0.0 # via # -r requirements/tox.txt # tox -py==1.10.0 +py==1.11.0 # via # -r requirements/tox.txt # tox -pyparsing==2.4.7 +pyparsing==3.0.6 # via # -r requirements/tox.txt # packaging -requests==2.26.0 +requests==2.27.1 # via coveralls six==1.16.0 # via @@ -60,11 +56,11 @@ toml==0.10.2 # via # -r requirements/tox.txt # tox -tox==3.24.1 +tox==3.24.5 # via -r requirements/tox.txt -urllib3==1.26.6 +urllib3==1.26.8 # via requests -virtualenv==20.7.0 +virtualenv==20.13.0 # via # -r requirements/tox.txt # tox diff --git a/requirements/pip.txt b/requirements/pip.txt index 1201145..de37a34 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -2,13 +2,13 @@ # This file is autogenerated by pip-compile with python 3.8 # To update, run: # -# pip-compile --allow-unsafe --output-file=requirements/pip.txt requirements/pip.in +# make upgrade # -wheel==0.37.0 +wheel==0.37.1 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: pip==21.3.1 # via -r requirements/pip.in -setuptools==58.3.0 +setuptools==60.5.0 # via -r requirements/pip.in diff --git a/requirements/pip_tools.txt b/requirements/pip_tools.txt index 5a2ba88..18c84c8 100644 --- a/requirements/pip_tools.txt +++ b/requirements/pip_tools.txt @@ -1,18 +1,18 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.8 # To update, run: # # make upgrade # -click==8.0.1 +click==8.0.3 # via pip-tools -pep517==0.11.0 +pep517==0.12.0 # via pip-tools -pip-tools==6.2.0 +pip-tools==6.4.0 # via -r requirements/pip_tools.in -tomli==1.2.1 +tomli==2.0.0 # via pep517 -wheel==0.36.2 +wheel==0.37.1 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/test.in b/requirements/test.in index fa6a02b..a5353a8 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -4,5 +4,7 @@ -r base.txt # Core dependencies for the cookiecutter coverage +edx-lint pycodestyle pylint +tox diff --git a/requirements/test.txt b/requirements/test.txt index 0e3cdeb..63fe43f 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,34 +1,117 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.8 # To update, run: # # make upgrade # -astroid==2.6.6 - # via pylint -coverage==5.5 +astroid==2.9.3 + # via + # pylint + # pylint-celery +click==8.0.3 + # via + # click-log + # code-annotations + # edx-lint +click-log==0.3.2 + # via edx-lint +code-annotations==1.2.0 + # via edx-lint +coverage==6.2 # via -r requirements/test.in -isort==5.9.3 +distlib==0.3.4 + # via virtualenv +edx-lint==5.2.1 + # via -r requirements/test.in +filelock==3.4.2 + # via + # tox + # virtualenv +isort==5.10.1 # via pylint -lazy-object-proxy==1.6.0 +jinja2==3.0.3 + # via code-annotations +lazy-object-proxy==1.7.1 # via astroid +lxml==4.7.1 + # via -r requirements/base.txt +markupsafe==2.0.1 + # via + # -r requirements/base.txt + # jinja2 mccabe==0.6.1 # via pylint -numpy==1.21.1 +mpmath==1.2.1 + # via + # -r requirements/base.txt + # sympy +numpy==1.22.1 # via # -r requirements/base.txt # scipy -pycodestyle==2.7.0 - # via -r requirements/test.in -pylint==2.9.6 +packaging==21.3 + # via tox +pbr==5.8.0 + # via stevedore +platformdirs==2.4.1 + # via + # pylint + # virtualenv +pluggy==1.0.0 + # via tox +py==1.11.0 + # via tox +pycodestyle==2.8.0 # via -r requirements/test.in -pyparsing==2.4.7 +pylint==2.12.2 + # via + # -r requirements/test.in + # edx-lint + # pylint-celery + # pylint-django + # pylint-plugin-utils +pylint-celery==0.3 + # via edx-lint +pylint-django==2.5.0 + # via edx-lint +pylint-plugin-utils==0.7 + # via + # pylint-celery + # pylint-django +pyparsing==3.0.6 + # via + # -r requirements/base.txt + # packaging +python-slugify==5.0.2 + # via code-annotations +pyyaml==6.0 + # via code-annotations +scipy==1.7.3 # via -r requirements/base.txt -scipy==1.7.1 +six==1.16.0 + # via + # edx-lint + # tox + # virtualenv +stevedore==3.5.0 + # via code-annotations +sympy==1.9 # via -r requirements/base.txt +text-unidecode==1.3 + # via python-slugify toml==0.10.2 - # via pylint -wrapt==1.12.1 + # via + # pylint + # tox +tox==3.24.5 + # via -r requirements/test.in +typing-extensions==4.0.1 + # via + # astroid + # pylint +virtualenv==20.13.0 + # via tox +wrapt==1.13.3 # via astroid # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/tox.txt b/requirements/tox.txt index d3229e0..3e47a84 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -1,26 +1,24 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.8 # To update, run: # # make upgrade # -backports.entry-points-selectable==1.1.0 +distlib==0.3.4 # via virtualenv -distlib==0.3.2 - # via virtualenv -filelock==3.0.12 +filelock==3.4.2 # via # tox # virtualenv -packaging==21.0 +packaging==21.3 # via tox -platformdirs==2.2.0 +platformdirs==2.4.1 # via virtualenv -pluggy==0.13.1 +pluggy==1.0.0 # via tox -py==1.10.0 +py==1.11.0 # via tox -pyparsing==2.4.7 +pyparsing==3.0.6 # via packaging six==1.16.0 # via @@ -28,7 +26,7 @@ six==1.16.0 # virtualenv toml==0.10.2 # via tox -tox==3.24.1 +tox==3.24.5 # via -r requirements/tox.in -virtualenv==20.7.0 +virtualenv==20.13.0 # via tox diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..7e3b4b3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,19 @@ +[pycodestyle] +# error codes: https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes +# E501: line too long +# E265: block comment should start with '# ' +# We ignore this because pep8 used to erroneously lump E266 into it also. +# We should probably fix these now. +# E266: too many leading '#' for block comment +# We have lots of comments that look like "##### HEADING #####" which violate +# this rule, because they don't have a space after the first #. However, +# they're still perfectly reasonable comments, so we disable this rule. +# W602: deprecated form of raising exception +# We do this in a few places to modify the exception message while preserving +# the traceback. See this blog post for more info: +# http://nedbatchelder.com/blog/200711/rethrowing_exceptions_in_python.html +# It's a little unusual, but we have good reasons for doing so, so we disable +# this rule. +# E305,E402,E722,E731,E741,E743,W503,W504: errors and warnings added since pep8/pycodestyle +ignore=E265,E266,E305,E402,E501,E722,E731,E741,E743,W503,W504,W602 +exclude=.git,.pycharm_helpers,.tox \ No newline at end of file diff --git a/setup.py b/setup.py index 07d76ff..ea40f86 100644 --- a/setup.py +++ b/setup.py @@ -50,20 +50,22 @@ def get_version(*file_paths): # Note: cannot easily move version to calc/__init__.py because it imports all # of calc, which causes failure here when requirements have not yet been loaded. version=VERSION, - description='A helper library for mathematical calculations, used by Open edX.', + description=('A helper library for mathematical calculations and symbolic ' + 'mathematics, used by Open edX.'), long_description=README, long_description_content_type="text/x-rst", author='edX', author_email='oscm@edx.org', url='https://github.com/edx/openedx-calc', packages=[ - 'calc' + 'calc', + "symmath" ], include_package_data=True, install_requires=load_requirements('requirements/base.in'), python_requires=">=3.8", license="AGPL 3.0", - test_suite='calc.tests', + test_suite='tests', tests_require=[ 'coverage', ], diff --git a/symmath/README.md b/symmath/README.md new file mode 100644 index 0000000..8da9aa8 --- /dev/null +++ b/symmath/README.md @@ -0,0 +1,30 @@ +(Originally written by Ike.) + +At a high level, the main challenges of checking symbolic math expressions are +(1) making sure the expression is mathematically legal, and (2) simplifying the +expression for comparison with what is expected. + +(1) Generation (and testing) of legal input is done by using MathJax to provide +input math in an XML format known as Presentation MathML (PMathML). Such +expressions typeset correctly, but may not be mathematically legal, like "5 / +(1 = 2)". The PMathML is converted into "Content MathML" (CMathML), which is +by definition mathematically legal, using an XSLT 2.0 stylesheet, via a module +in SnuggleTeX. CMathML is then converted into a sympy expression. This work is +all done in `symmath/formula.py`. + +(2) Simplifying the expression and checking against what is expected is done by +using sympy, and a set of heuristics based on options flags provided by the +problem author. For example, the problem author may specify that the expected +expression is a matrix, in which case the dimensionality of the input +expression is checked. Other options include specifying that the comparison be +checked numerically in addition to symbolically. The checking is done in +stages, first with no simplification, then with increasing levels of testing; +if a match is found at any stage, then an "ok" is returned. Helpful messages +are also returned, eg if the input expression is of a different type than the +expected. This work is all done in `symmath/symmath_check.py`. + +Links: + +SnuggleTex: http://www2.ph.ed.ac.uk/snuggletex/documentation/overview-and-features.html +MathML: http://www.w3.org/TR/MathML2/overview.html +SymPy: http://sympy.org/en/index.html diff --git a/calc/tests/__init__.py b/symmath/__init__.py similarity index 100% rename from calc/tests/__init__.py rename to symmath/__init__.py diff --git a/symmath/formula.py b/symmath/formula.py new file mode 100644 index 0000000..c377f4e --- /dev/null +++ b/symmath/formula.py @@ -0,0 +1,596 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +Flexible python representation of a symbolic mathematical formula. +Acceptes Presentation MathML, Content MathML (and could also do OpenMath). +Provides sympy representation. +""" +# +# File: formula.py +# Date: 04-May-12 (creation) +# Author: I. Chuang +# + + +import logging +import operator +import os +import re +import string +import unicodedata +#import subprocess +from copy import deepcopy +from functools import reduce + +import six +import sympy +from lxml import etree +from sympy import latex, sympify +from sympy.physics.quantum.qubit import Qubit +from sympy.physics.quantum.state import Ket +from sympy.printing.latex import LatexPrinter +from sympy.printing.str import StrPrinter + +from markupsafe import Markup + +log = logging.getLogger(__name__) + +log.warning("Dark code. Needs review before enabling in prod.") + +os.environ['PYTHONIOENCODING'] = 'utf-8' + +#----------------------------------------------------------------------------- + + +class dot(sympy.core.operations.LatticeOp): + """my dot product""" + zero = sympy.Symbol('dotzero') + identity = sympy.Symbol('dotidentity') + + +def _print_dot(_self, expr): + """Print statement used for LatexPrinter""" + return r'{((%s) \cdot (%s))}' % (expr.args[0], expr.args[1]) + +LatexPrinter._print_dot = _print_dot # pylint: disable=protected-access + +#----------------------------------------------------------------------------- +# unit vectors (for 8.02) + + +def _print_hat(_self, expr): + """Print statement used for LatexPrinter""" + return '\\hat{%s}' % str(expr.args[0]).lower() + +LatexPrinter._print_hat = _print_hat # pylint: disable=protected-access +StrPrinter._print_hat = _print_hat # pylint: disable=protected-access + +#----------------------------------------------------------------------------- +# helper routines + + +def to_latex(expr): + """ + Convert expression to latex mathjax format + """ + if expr is None: + return '' + expr_s = latex(expr) + expr_s = expr_s.replace(r'\XI', 'XI') # workaround for strange greek + + # substitute back into latex form for scripts + # literally something of the form + # 'scriptN' becomes '\\mathcal{N}' + # note: can't use something akin to the _print_hat method above because we + # sometimes get 'script(N)__B' or more complicated terms + expr_s = re.sub( + r'script([a-zA-Z0-9]+)', + r'\\mathcal{\\1}', + expr_s + ) + + #return '%s{}{}' % (xs[1:-1]) + if expr_s[0] == '$': + # return HTML('[mathjax]{expression}[/mathjax]
').format(expression=expr_s[1:-1]) # for sympy v6 + return Markup('[mathjax]{expression}[/mathjax]
').format(expression=expr_s[1:-1]) # for sympy v6 + # return HTML('[mathjax]{expression}[/mathjax]
').format(expression=expr_s) # for sympy v7 + return Markup('[mathjax]{expression}[/mathjax]
').format(expression=expr_s) # for sympy v7 + + +def my_evalf(expr, chop=False): + """ + Enhanced sympy evalf to handle lists of expressions + and catch eval failures without dropping out. + """ + if isinstance(expr, list): + try: + return [x.evalf(chop=chop) for x in expr] + except Exception: # pylint: disable=broad-except + return expr + try: + return expr.evalf(chop=chop) + except Exception: # pylint: disable=broad-except + return expr + + +def my_sympify(expr, normphase=False, matrix=False, abcsym=False, do_qubit=False, symtab=None): + """ + Version of sympify to import expression into sympy + """ + # make all lowercase real? + if symtab: + varset = symtab + else: + varset = { + 'p': sympy.Symbol('p'), + 'g': sympy.Symbol('g'), + 'e': sympy.E, # for exp + 'i': sympy.I, # lowercase i is also sqrt(-1) + 'Q': sympy.Symbol('Q'), # otherwise it is a sympy "ask key" + 'I': sympy.Symbol('I'), # otherwise it is sqrt(-1) + 'N': sympy.Symbol('N'), # or it is some kind of sympy function + 'ZZ': sympy.Symbol('ZZ'), # otherwise it is the PythonIntegerRing + 'XI': sympy.Symbol('XI'), # otherwise it is the capital \XI + 'hat': sympy.Function('hat'), # for unit vectors (8.02) + } + if do_qubit: # turn qubit(...) into Qubit instance + varset.update({ + 'qubit': Qubit, + 'Ket': Ket, + 'dot': dot, + 'bit': sympy.Function('bit'), + }) + if abcsym: # consider all lowercase letters as real symbols, in the parsing + for letter in string.ascii_lowercase: + if letter in varset: # exclude those already done + continue + varset.update({letter: sympy.Symbol(letter, real=True)}) + + sexpr = sympify(expr, locals=varset) + if normphase: # remove overall phase if sexpr is a list + if isinstance(sexpr, list): + if sexpr[0].is_number: + ophase = sympy.sympify('exp(-I*arg(%s))' % sexpr[0]) + sexpr = [sympy.Mul(x, ophase) for x in sexpr] + + def to_matrix(expr): + """ + Convert a list, or list of lists to a matrix. + """ + # if expr is a list of lists, and is rectangular, then return Matrix(expr) + if not isinstance(expr, list): + return expr + for row in expr: + if not isinstance(row, list): + return expr + rdim = len(expr[0]) + for row in expr: + if not len(row) == rdim: + return expr + return sympy.Matrix(expr) + + if matrix: + sexpr = to_matrix(sexpr) + return sexpr + +#----------------------------------------------------------------------------- +# class for symbolic mathematical formulas + + +class formula(): + """ + Representation of a mathematical formula object. Accepts mathml math expression + for constructing, and can produce sympy translation. The formula may or may not + include an assignment (=). + """ + def __init__(self, expr, asciimath='', options=None): + self.expr = expr.strip() + self.asciimath = asciimath + self.the_cmathml = None + self.the_sympy = None + self.options = options + + def is_presentation_mathml(self): + """ + Check if formula is in mathml presentation format. + """ + return 'f-2" this is + really terrible for turning into cmathml. undo this here. + """ + for k in xml: + tag = gettag(k) + if tag == 'mrow': + if len(k) == 2: + if gettag(k[0]) == 'mi' and k[0].text in ['f', 'g'] and gettag(k[1]) == 'mo': + idx = xml.index(k) + xml.insert(idx, deepcopy(k[0])) # drop the container + xml.insert(idx + 1, deepcopy(k[1])) + xml.remove(k) + fix_pmathml(k) + + fix_pmathml(xml) + + def fix_hat(xml): + """ + hat i is turned into i^ ; mangle + this into hat(f) hat i also somtimes turned into + j ^ + """ + for k in xml: + tag = gettag(k) + if tag == 'mover': + if len(k) == 2: + if gettag(k[0]) == 'mi' and gettag(k[1]) == 'mo' and str(k[1].text) == '^': + newk = etree.Element('mi') + newk.text = 'hat(%s)' % k[0].text + xml.replace(k, newk) + if gettag(k[0]) == 'mrow' and gettag(k[0][0]) == 'mi' and \ + gettag(k[1]) == 'mo' and str(k[1].text) == '^': + newk = etree.Element('mi') + newk.text = 'hat(%s)' % k[0][0].text + xml.replace(k, newk) + fix_hat(k) + fix_hat(xml) + + def flatten_pmathml(xml): + """ + Give the text version of certain PMathML elements + + Sometimes MathML will be given with each letter separated (it + doesn't know if its implicit multiplication or what). From an xml + node, find the (text only) variable name it represents. So it takes + + m + a + x + + and returns 'max', for easier use later on. + """ + tag = gettag(xml) + if tag == 'mn': + return xml.text + elif tag == 'mi': + return xml.text + elif tag == 'mrow': + return ''.join([flatten_pmathml(y) for y in xml]) + raise Exception('[flatten_pmathml] unknown tag %s' % tag) + + def fix_mathvariant(parent): + """ + Fix certain kinds of math variants + + Literally replace N + with 'scriptN'. There have been problems using script_N or script(N) + """ + for child in parent: + if gettag(child) == 'mstyle' and child.get('mathvariant') == 'script': + newchild = etree.Element('mi') + newchild.text = 'script%s' % flatten_pmathml(child[0]) + parent.replace(child, newchild) + fix_mathvariant(child) + fix_mathvariant(xml) + + # find "tagged" superscripts + # they have the character \u200b in the superscript + # replace them with a__b so snuggle doesn't get confused + def fix_superscripts(xml): + """ Look for and replace sup elements with 'X__Y' or 'X_Y__Z' + + In the javascript, variables with '__X' in them had an invisible + character inserted into the sup (to distinguish from powers) + E.g. normal: + + a + b + c + + to be interpreted '(a_b)^c' (nothing done by this method) + + And modified: + + b + x + + + d + + + to be interpreted 'a_b__c' + + also: + + x + + + B + + + to be 'x__B' + """ + for k in xml: + tag = gettag(k) + + # match things like the last example-- + # the second item in msub is an mrow with the first + # character equal to \u200b + if ( + tag == 'msup' and + len(k) == 2 and gettag(k[1]) == 'mrow' and + gettag(k[1][0]) == 'mo' and k[1][0].text == '\u200b' # whew + ): + + # replace the msup with 'X__Y' + k[1].remove(k[1][0]) + newk = etree.Element('mi') + newk.text = '%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1])) + xml.replace(k, newk) + + # match things like the middle example- + # the third item in msubsup is an mrow with the first + # character equal to \u200b + if ( + tag == 'msubsup' and + len(k) == 3 and gettag(k[2]) == 'mrow' and + gettag(k[2][0]) == 'mo' and k[2][0].text == '\u200b' # whew + ): + + # replace the msubsup with 'X_Y__Z' + k[2].remove(k[2][0]) + newk = etree.Element('mi') + newk.text = '%s_%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1]), flatten_pmathml(k[2])) + xml.replace(k, newk) + + fix_superscripts(k) + fix_superscripts(xml) + + def fix_msubsup(parent): + """ + Snuggle returns an error when it sees an replace such + elements with an , except the first element is of + the form a_b. I.e. map a_b^c => (a_b)^c + """ + for child in parent: + # fix msubsup + if gettag(child) == 'msubsup' and len(child) == 3: + newchild = etree.Element('msup') + newbase = etree.Element('mi') + newbase.text = '%s_%s' % (flatten_pmathml(child[0]), flatten_pmathml(child[1])) + newexp = child[2] + newchild.append(newbase) + newchild.append(newexp) + parent.replace(child, newchild) + + fix_msubsup(child) + fix_msubsup(xml) + + self.xml = xml # pylint: disable=attribute-defined-outside-init + return self.xml + + def get_content_mathml(self): # lint-amnesty, pylint: disable=missing-function-docstring + if self.the_cmathml: + return self.the_cmathml + + # pre-process the presentation mathml before sending it to snuggletex to convert to content mathml + try: + xml = self.preprocess_pmathml(self.expr) + except Exception as err: # pylint: disable=broad-except + log.warning('Err %s while preprocessing; expr=%s', err, self.expr) + return "Error! Cannot process pmathml" + pmathml = etree.tostring(xml, pretty_print=True).decode('utf-8') + self.the_pmathml = pmathml # pylint: disable=attribute-defined-outside-init + return self.the_pmathml + + cmathml = property(get_content_mathml, None, None, 'content MathML representation') + + def make_sympy(self, xml=None): # lint-amnesty, pylint: disable=too-many-statements + """ + Return sympy expression for the math formula. + The math formula is converted to Content MathML then that is parsed. + + This is a recursive function, called on every CMML node. Support for + more functions can be added by modifying opdict, abould halfway down + """ + + if self.the_sympy: + return self.the_sympy + + if xml is None: # root + if not self.is_mathml(): + return my_sympify(self.expr) + if self.is_presentation_mathml(): + cmml = None + try: + cmml = self.cmathml + xml = etree.fromstring(str(cmml)) + except Exception as err: + if 'conversion from Presentation MathML to Content MathML was not successful' in cmml: # lint-amnesty, pylint: disable=unsupported-membership-test + msg = "Illegal math expression" + else: + msg = 'Err %s while converting cmathml to xml; cmml=%s' % (err, cmml) + raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from + xml = self.fix_greek_in_mathml(xml) + self.the_sympy = self.make_sympy(xml[0]) + else: + xml = etree.fromstring(self.expr) + xml = self.fix_greek_in_mathml(xml) + self.the_sympy = self.make_sympy(xml[0]) + return self.the_sympy + + def gettag(expr): + return re.sub('{http://[^}]+}', '', expr.tag) + + def op_plus(*args): + return args[0] if len(args) == 1 else op_plus(*args[:-1]) + args[-1] + + def op_times(*args): + return reduce(operator.mul, args) + + def op_minus(*args): + if len(args) == 1: + return -args[0] + if not len(args) == 2: # lint-amnesty, pylint: disable=unneeded-not + raise Exception('minus given wrong number of arguments!') + #return sympy.Add(args[0],-args[1]) + return args[0] - args[1] + + opdict = { + 'plus': op_plus, + 'divide': operator.truediv, + 'times': op_times, + 'minus': op_minus, + 'root': sympy.sqrt, + 'power': sympy.Pow, + 'sin': sympy.sin, + 'cos': sympy.cos, + 'tan': sympy.tan, + 'cot': sympy.cot, + 'sinh': sympy.sinh, + 'cosh': sympy.cosh, + 'coth': sympy.coth, + 'tanh': sympy.tanh, + 'asin': sympy.asin, + 'acos': sympy.acos, + 'atan': sympy.atan, + 'atan2': sympy.atan2, + 'acot': sympy.acot, + 'asinh': sympy.asinh, + 'acosh': sympy.acosh, + 'atanh': sympy.atanh, + 'acoth': sympy.acoth, + 'exp': sympy.exp, + 'log': sympy.log, + 'ln': sympy.ln, + } + + def parse_presentation_symbol(xml): + """ + Parse , , , and + """ + tag = gettag(xml) + if tag == 'mn': + return xml.text + elif tag == 'mi': + return xml.text + elif tag == 'msub': + return '_'.join([parse_presentation_symbol(y) for y in xml]) + elif tag == 'msup': + return '^'.join([parse_presentation_symbol(y) for y in xml]) + raise Exception('[parse_presentation_symbol] unknown tag %s' % tag) + + # parser tree for Content MathML + tag = gettag(xml) + + # first do compound objects + + if tag == 'apply': # apply operator + opstr = gettag(xml[0]) + if opstr in opdict: + op = opdict[opstr] # pylint: disable=invalid-name + args = [self.make_sympy(expr) for expr in xml[1:]] + try: + res = op(*args) + except Exception as err: + self.args = args # pylint: disable=attribute-defined-outside-init + self.op = op # pylint: disable=attribute-defined-outside-init, invalid-name + raise Exception('[formula] error=%s failed to apply %s to args=%s' % (err, opstr, args)) # lint-amnesty, pylint: disable=raise-missing-from + return res + else: + raise Exception('[formula]: unknown operator tag %s' % (opstr)) + + elif tag == 'list': # square bracket list + if gettag(xml[0]) == 'matrix': + return self.make_sympy(xml[0]) + else: + return [self.make_sympy(expr) for expr in xml] + + elif tag == 'matrix': + return sympy.Matrix([self.make_sympy(expr) for expr in xml]) + + elif tag == 'vector': + return [self.make_sympy(expr) for expr in xml] + + # atoms are below + + elif tag == 'cn': # number + return sympy.sympify(xml.text) + + elif tag == 'ci': # variable (symbol) + if len(xml) > 0 and (gettag(xml[0]) == 'msub' or gettag(xml[0]) == 'msup'): # subscript or superscript + usym = parse_presentation_symbol(xml[0]) + sym = sympy.Symbol(str(usym)) + else: + usym = six.text_type(xml.text) + if 'hat' in usym: + sym = my_sympify(usym) + else: + if usym == 'i' and self.options is not None and 'imaginary' in self.options: # i = sqrt(-1) + sym = sympy.I + else: + sym = sympy.Symbol(str(usym)) + return sym + + elif tag == 'mstyle': + return self.make_sympy(xml[0]) + + elif tag == 'mrow': + return my_sympify("".join([x.text for x in xml])) + + else: # unknown tag + raise Exception('[formula] unknown tag %s' % tag) + + sympy = property(make_sympy, None, None, 'sympy representation') diff --git a/symmath/symmath_check.py b/symmath/symmath_check.py new file mode 100644 index 0000000..1d13207 --- /dev/null +++ b/symmath/symmath_check.py @@ -0,0 +1,340 @@ +# lint-amnesty, pylint: disable=missing-module-docstring +# !/usr/bin/python +# -*- coding: utf-8 -*- +# +# File: symmath_check.py +# Date: 02-May-12 (creation) +# +# Symbolic mathematical expression checker for edX. Uses sympy to check for expression equality. +# +# Takes in math expressions given as Presentation MathML (from ASCIIMathML), converts to Content MathML using SnuggleTeX + + +import logging +import traceback + +from markupsafe import escape, Markup + +from .formula import * # lint-amnesty, pylint: disable=wildcard-import + +log = logging.getLogger(__name__) + +#----------------------------------------------------------------------------- +# check function interface +# +# This is one of the main entry points to call. + + +def symmath_check_simple(expect, ans, adict={}, symtab=None, extra_options=None): # lint-amnesty, pylint: disable=dangerous-default-value, unused-argument + """ + Check a symbolic mathematical expression using sympy. + The input is an ascii string (not MathML) converted to math using sympy.sympify. + """ + + options = {'__MATRIX__': False, '__ABC__': False, '__LOWER__': False} + if extra_options: + options.update(extra_options) + for op in options: # find options in expect string + if op in expect: + expect = expect.replace(op, '') + options[op] = True + expect = expect.replace('__OR__', '__or__') # backwards compatibility + + if options['__LOWER__']: + expect = expect.lower() + ans = ans.lower() + + try: + ret = check(expect, ans, + matrix=options['__MATRIX__'], + abcsym=options['__ABC__'], + symtab=symtab, + ) + except Exception as err: # lint-amnesty, pylint: disable=broad-except + return {'ok': False, + 'msg': Markup('Error {err}
Failed in evaluating check({expect},{ans})').format( + err=err, expect=expect, ans=ans + )} + return ret + +#----------------------------------------------------------------------------- +# pretty generic checking function + + +def check(expect, given, numerical=False, matrix=False, normphase=False, abcsym=False, do_qubit=True, symtab=None, dosimplify=False): # lint-amnesty, pylint: disable=line-too-long + """ + Returns dict with + + 'ok': True if check is good, False otherwise + 'msg': response message (in HTML) + + "expect" may have multiple possible acceptable answers, separated by "__OR__" + + """ + + if "__or__" in expect: # if multiple acceptable answers + eset = expect.split('__or__') # then see if any match + for eone in eset: + ret = check(eone, given, numerical, matrix, normphase, abcsym, do_qubit, symtab, dosimplify) + if ret['ok']: + return ret + return ret + + flags = {} + if "__autonorm__" in expect: + flags['autonorm'] = True + expect = expect.replace('__autonorm__', '') + matrix = True + + threshold = 1.0e-3 + if "__threshold__" in expect: + (expect, st) = expect.split('__threshold__') + threshold = float(st) + numerical = True + + if str(given) == '' and not str(expect) == '': # lint-amnesty, pylint: disable=unneeded-not + return {'ok': False, 'msg': ''} + + try: + xgiven = my_sympify(given, normphase, matrix, do_qubit=do_qubit, abcsym=abcsym, symtab=symtab) + except Exception as err: # lint-amnesty, pylint: disable=broad-except + return {'ok': False, 'msg': Markup('Error {err}
in evaluating your expression "{given}"').format( + err=err, given=given + )} + + try: + xexpect = my_sympify(expect, normphase, matrix, do_qubit=do_qubit, abcsym=abcsym, symtab=symtab) + except Exception as err: # lint-amnesty, pylint: disable=broad-except + return {'ok': False, 'msg': Markup('Error {err}
in evaluating OUR expression "{expect}"').format( + err=err, expect=expect + )} + + if 'autonorm' in flags: # normalize trace of matrices + try: + xgiven /= xgiven.trace() + except Exception as err: # lint-amnesty, pylint: disable=broad-except + return {'ok': False, 'msg': Markup('Error {err}
in normalizing trace of your expression {xgiven}'). + format(err=err, xgiven=to_latex(xgiven))} + try: + xexpect /= xexpect.trace() + except Exception as err: # lint-amnesty, pylint: disable=broad-except + return {'ok': False, 'msg': Markup('Error {err}
in normalizing trace of OUR expression {xexpect}'). + format(err=err, xexpect=to_latex(xexpect))} + + msg = 'Your expression was evaluated as ' + to_latex(xgiven) + # msg += '
Expected ' + to_latex(xexpect) + + # msg += "
flags=%s" % flags + + if matrix and numerical: + xgiven = my_evalf(xgiven, chop=True) + dm = my_evalf(sympy.Matrix(xexpect) - sympy.Matrix(xgiven), chop=True) + msg += " = " + to_latex(xgiven) + if abs(dm.vec().norm().evalf()) < threshold: + return {'ok': True, 'msg': msg} + else: + pass + #msg += "dm = " + to_latex(dm) + " diff = " + str(abs(dm.vec().norm().evalf())) + #msg += "expect = " + to_latex(xexpect) + elif dosimplify: + if sympy.simplify(xexpect) == sympy.simplify(xgiven): + return {'ok': True, 'msg': msg} + elif numerical: + if abs((xexpect - xgiven).evalf(chop=True)) < threshold: + return {'ok': True, 'msg': msg} + elif xexpect == xgiven: + return {'ok': True, 'msg': msg} + + #msg += "

expect='%s', given='%s'" % (expect,given) # debugging + # msg += "

dot test " + to_latex(dot(sympy.Symbol('x'),sympy.Symbol('y'))) + return {'ok': False, 'msg': msg} + +#----------------------------------------------------------------------------- +# helper function to convert all

to + + +def make_error_message(msg): + # msg = msg.replace('

','

').replace('

','

') + msg = Markup('
{msg}
').format(msg=msg) + return msg + + +def is_within_tolerance(expected, actual, tolerance): + if expected == 0: + return abs(actual) < tolerance + else: + return abs(abs(actual - expected) / expected) < tolerance + +#----------------------------------------------------------------------------- +# Check function interface, which takes pmathml input +# +# This is one of the main entry points to call. + + +def symmath_check(expect, ans, dynamath=None, options=None, debug=None, xml=None): # lint-amnesty, pylint: disable=too-many-statements + """ + Check a symbolic mathematical expression using sympy. + The input may be presentation MathML. Uses formula. + + This is the default Symbolic Response checking function + + Desc of args: + expect is a sympy string representing the correct answer. It is interpreted + using my_sympify (from formula.py), which reads strings as sympy input + (e.g. 'integrate(x^2, (x,1,2))' would be valid, and evaluate to give 1.5) + + ans is student-typed answer. It is expected to be ascii math, but the code + below would support a sympy string. + + dynamath is the PMathML string converted by MathJax. It is used if + evaluation with ans is not sufficient. + + options is a string with these possible substrings, set as an xml property + of the problem: + -matrix - make a sympy matrix, rather than a list of lists, if possible + -qubit - passed to my_sympify + -imaginary - used in formla, presumably to signal to use i as sqrt(-1)? + -numerical - force numerical comparison. + """ + + msg = '' + # msg += '

abname=%s' % abname + # msg += '

adict=%s' % (repr(adict).replace('<','<')) + + threshold = 1.0e-3 # for numerical comparison (also with matrices) + DEBUG = debug + + if xml is not None: + DEBUG = xml.get('debug', False) # override debug flag using attribute in symbolicmath xml + if DEBUG in ['0', 'False']: + DEBUG = False + + # options + if options is None: + options = '' + do_matrix = 'matrix' in options + do_qubit = 'qubit' in options + do_numerical = 'numerical' in options + + # parse expected answer + try: + fexpect = my_sympify(str(expect), matrix=do_matrix, do_qubit=do_qubit) + except Exception as err: # lint-amnesty, pylint: disable=broad-except + msg += Markup('

Error {err} in parsing OUR expected answer "{expect}"

').format(err=err, expect=expect) + return {'ok': False, 'msg': make_error_message(msg)} + + ###### Sympy input ####### + # if expected answer is a number, try parsing provided answer as a number also + try: + fans = my_sympify(str(ans), matrix=do_matrix, do_qubit=do_qubit) + except Exception as err: # lint-amnesty, pylint: disable=broad-except, unused-variable + fans = None + + # do a numerical comparison if both expected and answer are numbers + if hasattr(fexpect, 'is_number') and fexpect.is_number \ + and hasattr(fans, 'is_number') and fans.is_number: + if is_within_tolerance(fexpect, fans, threshold): + return {'ok': True, 'msg': msg} + else: + msg += Markup('

You entered: {fans}

').format(fans=to_latex(fans)) + return {'ok': False, 'msg': msg} + + if do_numerical: # numerical answer expected - force numerical comparison + if is_within_tolerance(fexpect, fans, threshold): + return {'ok': True, 'msg': msg} + else: + msg += Markup('

You entered: {fans} (note that a numerical answer is expected)

').\ + format(fans=to_latex(fans)) + return {'ok': False, 'msg': msg} + + if fexpect == fans: + msg += Markup('

You entered: {fans}

').format(fans=to_latex(fans)) + return {'ok': True, 'msg': msg} + + ###### PMathML input ###### + # convert mathml answer to formula + + # Everything below this line is poorly test-covered, probably broken, and + # potentially unused. For more information see issue #39: + # https://github.com/openedx/openedx-calc/issues/39 + + try: + mmlans = dynamath[0] if dynamath else None + except Exception as err: # lint-amnesty, pylint: disable=broad-except + mmlans = None + if not mmlans: + return {'ok': False, 'msg': '[symmath_check] failed to get MathML for input; dynamath=%s' % dynamath} + + f = formula(mmlans, options=options) + + # get sympy representation of the formula + # if DEBUG: msg += '

mmlans=%s' % repr(mmlans).replace('<','<') + try: + fsym = f.sympy + msg += Markup('

You entered: {sympy}

').format(sympy=to_latex(f.sympy)) + except Exception as err: # lint-amnesty, pylint: disable=broad-except + log.exception("Error evaluating expression '%s' as a valid equation", ans) + msg += Markup("

Error in evaluating your expression '{ans}' as a valid equation

").format(ans=ans) + if "Illegal math" in str(err): + msg += Markup("

Illegal math expression

") + if DEBUG: + msg += Markup('Error: {err}

DEBUG messages:

{format_exc}

' + '

cmathml=

{cmathml}

pmathml=

{pmathml}


').format( + err=escape(str(err)), format_exc=traceback.format_exc(), cmathml=escape(f.cmathml), + pmathml=escape(mmlans) + ) + return {'ok': False, 'msg': make_error_message(msg)} + + # do numerical comparison with expected + if hasattr(fexpect, 'is_number') and fexpect.is_number: + if hasattr(fsym, 'is_number') and fsym.is_number: + if abs(abs(fsym - fexpect) / fexpect) < threshold: + return {'ok': True, 'msg': msg} + return {'ok': False, 'msg': msg} + msg += Markup("

Expecting a numerical answer!

given = {ans}

fsym = {fsym}

").format( + ans=repr(ans), fsym=repr(fsym) + ) + # msg += "

cmathml =

%s

" % str(f.cmathml).replace('<','<') + return {'ok': False, 'msg': make_error_message(msg)} + + # Here is a good spot for adding calls to X.simplify() or X.expand(), + # allowing equivalence over binomial expansion or trig identities + + # exactly the same? + if fexpect == fsym: + return {'ok': True, 'msg': msg} + + if isinstance(fexpect, list): + try: + xgiven = my_evalf(fsym, chop=True) + dm = my_evalf(sympy.Matrix(fexpect) - sympy.Matrix(xgiven), chop=True) + if abs(dm.vec().norm().evalf()) < threshold: + return {'ok': True, 'msg': msg} + except sympy.ShapeError: + msg += Markup("

Error - your input vector or matrix has the wrong dimensions") + return {'ok': False, 'msg': make_error_message(msg)} + except Exception as err: # lint-amnesty, pylint: disable=broad-except + msg += Markup("

Error %s in comparing expected (a list) and your answer

").format(escape(str(err))) + if DEBUG: + msg += Markup("

{format_exc}
").format(format_exc=traceback.format_exc()) + return {'ok': False, 'msg': make_error_message(msg)} + + #diff = (fexpect-fsym).simplify() + #fsym = fsym.simplify() + #fexpect = fexpect.simplify() + try: + diff = (fexpect - fsym) + except Exception as err: # lint-amnesty, pylint: disable=broad-except + diff = None + + if DEBUG: + msg += Markup('

DEBUG messages:

Got: {fsym}

Expecting: {fexpect}

')\ + .format(fsym=repr(fsym), fexpect=repr(fexpect).replace('**', '^').replace('hat(I)', 'hat(i)')) + # msg += "

Got: %s" % str([type(x) for x in fsym.atoms()]).replace('<','<') + # msg += "

Expecting: %s" % str([type(x) for x in fexpect.atoms()]).replace('<','<') + if diff: + msg += Markup("

Difference: {diff}

").format(diff=to_latex(diff)) + msg += Markup('
') + + # Used to return more keys: 'ex': fexpect, 'got': fsym + return {'ok': False, 'msg': msg} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/calc/tests/test_calc.py b/tests/test_calc.py similarity index 100% rename from calc/tests/test_calc.py rename to tests/test_calc.py index 976d216..72f95c7 100644 --- a/calc/tests/test_calc.py +++ b/tests/test_calc.py @@ -5,8 +5,8 @@ import unittest import numpy -import calc from pyparsing import ParseException +import calc # numpy's default behavior when it evaluates a function outside its domain # is to raise a warning (not an exception) which is then printed to STDOUT. diff --git a/tests/test_formula.py b/tests/test_formula.py new file mode 100644 index 0000000..2f6366e --- /dev/null +++ b/tests/test_formula.py @@ -0,0 +1,117 @@ +""" +Tests of symbolic math +""" + +import re +import unittest + +from lxml import etree + +from symmath.formula import formula + + +def stripXML(xml): + xml = xml.replace('\n', '') + xml = re.sub(r'\> +\<', '><', xml) + return xml + + +class FormulaTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring + # for readability later + mathml_start = '' + mathml_end = '' + + def setUp(self): + super(FormulaTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments + self.formulaInstance = formula('') + + def test_replace_mathvariants(self): + expr = ''' + + N +''' + + expected = 'scriptN' + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + assert test.decode('utf-8') == expected + + def test_fix_simple_superscripts(self): + expr = ''' + + a + + + b + +''' + + expected = 'a__b' + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + assert test.decode('utf-8') == expected + + def test_fix_complex_superscripts(self): + expr = ''' + + a + b + + + c + +''' + + expected = 'a_b__c' + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + assert test.decode('utf-8') == expected + + def test_fix_msubsup(self): + expr = ''' + + a + b + c +''' + + expected = 'a_bc' # which is (a_b)^c + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + assert test.decode('utf-8') == expected diff --git a/calc/tests/test_preview.py b/tests/test_preview.py similarity index 99% rename from calc/tests/test_preview.py rename to tests/test_preview.py index 04a65a7..73d5e48 100644 --- a/calc/tests/test_preview.py +++ b/tests/test_preview.py @@ -4,8 +4,8 @@ import unittest -from calc import preview import pyparsing +from calc import preview class LatexRenderedTest(unittest.TestCase): @@ -231,7 +231,7 @@ def test_syntax_errors(self): preview.latex_preview(math) except pyparsing.ParseException: pass # This is what we were expecting. (not excepting :P) - except Exception as error: # pragma: no cover + except Exception as error: # pylint: disable=broad-except bad_exceptions[math] = error else: # pragma: no cover # If there is no exception thrown, this is a problem diff --git a/tests/test_symmath_check.py b/tests/test_symmath_check.py new file mode 100644 index 0000000..5beb25f --- /dev/null +++ b/tests/test_symmath_check.py @@ -0,0 +1,89 @@ +# lint-amnesty, pylint: disable=missing-module-docstring +from unittest import TestCase + +from six.moves import range + +from symmath.symmath_check import symmath_check + + +class SymmathCheckTest(TestCase): # lint-amnesty, pylint: disable=missing-class-docstring + def test_symmath_check_integers(self): + number_list = range(-100, 100) + self._symmath_check_numbers(number_list) + + def test_symmath_check_floats(self): + number_list = [i + 0.01 for i in range(-100, 100)] + self._symmath_check_numbers(number_list) + + def test_symmath_check_same_symbols(self): + expected_str = "x+2*y" + dynamath = ''' + + + + x + + + 2 + * + y + + +'''.strip() + + # Expect that the exact same symbolic string is marked correct + result = symmath_check(expected_str, expected_str, dynamath=[dynamath]) + assert (('ok' in result) and result['ok']) + + def test_symmath_check_equivalent_symbols(self): + expected_str = "x+2*y" + input_str = "x+y+y" + dynamath = ''' + + + + x + + + y + + + y + + +'''.strip() + + # Expect that equivalent symbolic strings are marked correct + result = symmath_check(expected_str, input_str, dynamath=[dynamath]) + assert (('ok' in result) and result['ok']) + + def test_symmath_check_different_symbols(self): + expected_str = "0" + input_str = "x+y" + dynamath = ''' + + + + x + + + y + + +'''.strip() + + # Expect that an incorrect response is marked incorrect + result = symmath_check(expected_str, input_str, dynamath=[dynamath]) + assert (('ok' in result) and (not result['ok'])) + assert 'fail' not in result['msg'] + + def _symmath_check_numbers(self, number_list): # lint-amnesty, pylint: disable=missing-function-docstring + + for n in number_list: + + # expect = ans, so should say the answer is correct + expect = n + ans = n + result = symmath_check(str(expect), str(ans)) + assert (('ok' in result) and result['ok']), ('%f should == %f' % (expect, ans)) + + # Change expect so that it != ans + expect += 0.1 + result = symmath_check(str(expect), str(ans)) + assert (('ok' in result) and (not result['ok'])), ('%f should != %f' % (expect, ans)) diff --git a/tox.ini b/tox.ini index 51b5eff..f429f52 100644 --- a/tox.ini +++ b/tox.ini @@ -14,9 +14,8 @@ commands = [testenv:quality] passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH deps = - pycodestyle - pylint + -r requirements/test.txt commands = - pycodestyle calc/__init__.py calc/tests/__init__.py - pylint calc/tests/__init__.py + pycodestyle calc symmath tests + pylint calc symmath tests