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 to
+
+
+def make_error_message(msg):
+ # msg = msg.replace(' ',' ').replace(' Error {err} in parsing OUR expected answer "{expect}" You entered: {fans} You entered: {fans} (note that a numerical answer is expected) You entered: {fans} You entered: {sympy} Error in evaluating your expression '{ans}' as a valid equation Illegal math expression DEBUG messages:
').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 '
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 {format_exc}
cmathml=
{cmathml}
pmathml=
{pmathml}
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('