diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..816bbef --- /dev/null +++ b/.coveragerc @@ -0,0 +1,18 @@ +[run] +#plugins = rivescript_coverage_plugin +source = . +omit = ./venv/* + +[report] +exclude_lines = + pragma: no cover + raise NotImplementedError + if __name__ == .__main__. + def _dump\(self\): + +[rivescript_coverage_plugin] +show_startup = False +show_parsing = False +show_tracing = False +clean_rs_objects = True +capture_streams = True diff --git a/.gitignore b/.gitignore index 8f7b4dd..750615f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,13 @@ __pycache__ +.coverage *.pyc +*~ +*.swp +htmlcov +_rs_streams_ build/ dist/ +venv *.egg-info/ +rivescript.zip +.gitold diff --git a/Changes.md b/Changes.md index c3851bb..9831917 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,25 @@ Revision history for the Python package RiveScript. +## 1.15.0 - Mar 29 2020 + +This release provides a major (~5x) speedup for RiveScripts that have +a large number of substitutions, and also fixes the following issues: + +- Add a `prepare_brain_transplant` method to clear the RiveScript brain + in preparation to load in a new one, while optionally preserving + much of the current state (enh #81) +- Implement the `trigger_info` method (bug #120) +- Fix the issue of a "=" appearing in a variable value (bug #130) +- Allow nested brackets (bug #132) +- Fix trigger sorting to only count the existance of stars, optionals, + etc. instead of how many there are in a trigger (bug #133) +- Fix the debug message for incomment (bug #138) +- Fix substitutions if they occur more than 3 times on a line (bug #140) +- Fix crash in `set_substitution` method (bug #142) +- Fix issue in `set_person` method (bug #143) +- Significantly improve code coverage of tests (add `test_coverage.py`) + ## 1.14.9 - Sept 21 2017 This release fixes some regular expressions and adds better Unicode diff --git a/dist.sh b/dist.sh old mode 100755 new mode 100644 diff --git a/docs/make.bat b/docs/make.bat index dfdb987..b1f4f81 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,281 +1,281 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\rivescript.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\rivescript.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\rivescript.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\rivescript.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/example.py b/example.py old mode 100755 new mode 100644 index c06a68d..881b8c0 --- a/example.py +++ b/example.py @@ -6,7 +6,7 @@ rs.load_directory("./eg/brain") rs.sort_replies() -print """This is a bare minimal example for how to write your own RiveScript bot! +print("""This is a bare minimal example for how to write your own RiveScript bot! For a more full-fledged example, try running: `python rivescript brain` This will run the built-in Interactive Mode of the RiveScript library. It has @@ -17,13 +17,13 @@ the 'brain/' folder, and lets you chat with the bot. Type /quit when you're done to exit this example. -""" +""") while True: msg = raw_input("You> ") if msg == '/quit': quit() reply = rs.reply("localuser", msg) - print "Bot>", reply + print("Bot>", reply) # vim:expandtab diff --git a/example3.py b/example3.py old mode 100755 new mode 100644 diff --git a/python-rivescript.spec b/python-rivescript.spec index 2a70844..caacc91 100644 --- a/python-rivescript.spec +++ b/python-rivescript.spec @@ -3,7 +3,7 @@ %global desc A scripting language to make it easy to write responses for a chatterbot. Name: python-%{srcname} -Version: 1.14.9 +Version: 1.15.0 Release: 1%{?dist} Summary: %{sum} diff --git a/rivescript/__init__.py b/rivescript/__init__.py index bbef2d7..9a8489f 100644 --- a/rivescript/__init__.py +++ b/rivescript/__init__.py @@ -9,10 +9,11 @@ from __future__ import print_function, unicode_literals __author__ = 'Noah Petherbridge' -__copyright__ = 'Copyright 2015, Noah Petherbridge' +__copyright__ = 'Copyright 2020, Noah Petherbridge' __credits__ = [ 'Noah Petherbridge', - 'dinever' + 'dinever', + 'snoopyjc' ] __license__ = 'MIT' __maintainer__ = 'Noah Petherbridge' @@ -20,7 +21,7 @@ __docformat__ = 'plaintext' __all__ = ['rivescript'] -__version__ = '1.14.9' +__version__ = '1.15.0' from .rivescript import RiveScript from .exceptions import ( diff --git a/rivescript/brain.py b/rivescript/brain.py index 83b04c1..0430ba5 100644 --- a/rivescript/brain.py +++ b/rivescript/brain.py @@ -214,7 +214,7 @@ def _getreply(self, user, msg, context='normal', step=0, ignore_object_errors=Tr # Scan them all! for top in allTopics: self.say("Checking topic " + top + " for any %Previous's.") - if top in self.master._sorted["thats"]: + if top in self.master._sorted["thats"] and self.master._sorted["thats"][top]: self.say("There is a %Previous in this topic!") # Do we have history yet? @@ -789,45 +789,62 @@ def substitute(self, msg, kind): one of ``subs`` or ``person``. """ - # Safety checking. - if 'lists' not in self.master._sorted: - raise RepliesNotSortedError("You must call sort_replies() once you are done loading RiveScript documents") - if kind not in self.master._sorted["lists"]: - raise RepliesNotSortedError("You must call sort_replies() once you are done loading RiveScript documents") + # Per the profiler, with a large base of rules and especially substitutions, 80% of the time + # in the rivescript interpreter is spent here, so we optimize the heck out of this + # routine, to get us a 5x+ improvement over the prior version - # Get the substitution map. - subs = None - if kind == 'sub': - subs = self.master._sub - else: + subs = self.master._sub + if kind[0] == 'p': subs = self.master._person # Make placeholders each time we substitute something. ph = [] i = 0 - - for pattern in self.master._sorted["lists"][kind]: - result = subs[pattern] - - # Make a placeholder. - ph.append(result) - placeholder = "\x00%d\x00" % i - i += 1 - - cache = self.master._regexc[kind][pattern] - msg = re.sub(cache["sub1"], placeholder, msg) - msg = re.sub(cache["sub2"], placeholder + r'\1', msg) - msg = re.sub(cache["sub3"], r'\1' + placeholder + r'\2', msg) - msg = re.sub(cache["sub4"], r'\1' + placeholder, msg) - - placeholders = re.findall(RE.placeholder, msg) - for match in placeholders: - i = int(match) - result = ph[i] - msg = msg.replace('\x00' + match + '\x00', result) - - # Strip & return. - return msg.strip() + possibly_found_one = False + try: + smrk = self.master._regexc[kind] + + for pattern in self.master._sorted["lists"][kind]: + result = subs[pattern] + + # Make a placeholder. + ph.append(result) + placeholder = "\x00%d\x00" % i + i += 1 + + cache = smrk[pattern] + if msg == pattern: + msg = placeholder + possibly_found_one = True + if msg.startswith(pattern): + msg = re.sub(cache["sub2"], placeholder + r'\1', msg) + possibly_found_one = True + if pattern in msg: + msg0 = msg + while True: + msg = re.sub(cache["sub3"], r'\1' + placeholder + r'\2', msg0) + if msg == msg0: + break + else: + possibly_found_one = True + msg0 = msg + if msg.endswith(pattern): + msg = re.sub(cache["sub4"], r'\1' + placeholder, msg) + possibly_found_one = True + + if not possibly_found_one: + return msg.strip() + + placeholders = re.findall(RE.placeholder, msg) + for match in placeholders: + i = int(match) + result = ph[i] + msg = msg.replace('\x00' + match + '\x00', result) + + # Strip & return. + return msg.strip() + except KeyError: + raise RepliesNotSortedError("You must call sort_replies() once you are done loading RiveScript documents") def default_history(self): return { diff --git a/rivescript/parser.py b/rivescript/parser.py index 48f5437..9381e0e 100644 --- a/rivescript/parser.py +++ b/rivescript/parser.py @@ -9,6 +9,7 @@ from .regexp import RE import re +from collections import Counter, deque # Version of RiveScript we support. rs_version = 2.0 @@ -86,7 +87,15 @@ def parse(self, filename, code): "previous": None, # 'previous' reply }, # ... - ] + ], + "syntax": { # to quickly find filenames/line numbers of triggers for trigger_info + "hello bot": { # Trigger text + "previous": None, # %Previous trigger text (or None) + "filename": "", # filename the trigger was found in (or "stream()") + "lineno": int # line number the trigger was found in + }, + # ... + } } } "objects": [ # parsed object macros @@ -140,7 +149,8 @@ def parse(self, filename, code): for lp, line in enumerate(code): lineno += 1 - self.say("Line: " + line + " (topic: " + topic + ") incomment: " + str(inobj)) + self.say("Line: " + line + " (topic: " + topic + ") incomment: " + str(comment) + \ + ", inobj: " + str(inobj)) if len(line.strip()) == 0: # Skip blank lines continue @@ -256,7 +266,7 @@ def parse(self, filename, code): # Handle the types of RiveScript commands. if cmd == '!': # ! DEFINE - halves = re.split(RE.equals, line, 2) + halves = re.split(RE.equals, line, 1) left = re.split(RE.ws, halves[0].strip(), 2) value, type, var = '', '', '' if len(halves) == 2: @@ -357,7 +367,7 @@ def parse(self, filename, code): # Convert any remaining '\s' escape codes into spaces. for f in fields: - f = f.replace('\s', ' ') + f = f.replace(r'\s', ' ') ast["begin"]["array"][var] = fields elif type == 'sub': @@ -468,6 +478,8 @@ def parse(self, filename, code): "previous": isThat, } ast["topics"][topic]["triggers"].append(curtrig) + ast["topics"][topic]["syntax"][line] = \ + dict(previous=isThat, filename=filename, lineno=lineno) elif cmd == '-': # - REPLY if curtrig is None: @@ -551,50 +563,34 @@ def check_syntax(self, cmd, line): # - No symbols except: ( | ) [ ] * _ # @ { } < > = # - All brackets should be matched # - No empty option with pipe such as ||, [|, |], (|, |) and whitespace between - parens = 0 # Open parenthesis - square = 0 # Open square brackets - curly = 0 # Open curly brackets - angle = 0 # Open angled brackets - # Count brackets. - for char in line: - if char == '(': - parens += 1 - elif char == ')': - parens -= 1 - elif char == '[': - square += 1 - elif char == ']': - square -= 1 - elif char == '{': - curly += 1 - elif char == '}': - curly -= 1 - elif char == '<': - angle += 1 - elif char == '>': - angle -= 1 - elif char == '|': - if parens == 0 and square == 0: # Pipe outside the alternative and option - return "Pipe | must be within parenthesis brackets or square brackets" + pairs = {'[': ']', '{': '}', '(': ')', '<': '>'} + rpairs = {v: k for k, v in pairs.items()} + bnames = {'[': 'angle', '{': 'curly', '(': 'parenthesis', '<': 'angle'} + not_angle = set(pairs.keys()) + not_angle.remove('<') - if (angle != 0) and (char in {"(", ")", "[", "]", "{", "}"}): - return "Angle bracket must be closed before closing or opening other type of brackets" + q = deque() + c = Counter() - total = parens + square + curly # At each character, not more than 1 bracket opens, except <> - for special_char_count in [parens, square, curly, angle, total]: - if special_char_count not in (0, 1): + for char in line: + if char in pairs: + q.append(char) + c[char] += 1 + if char in not_angle and c['<']: + return "Angle bracket must be closed before closing or opening other type of brackets" + elif char in rpairs: + p = rpairs[char] + if len(q) == 0: + return "Unmatched " + bnames[p] + " brackets" + if q.pop() != p: return "Unbalanced brackets" - - # Any mismatches? - if parens != 0: - return "Unmatched parenthesis brackets" - elif square != 0: - return "Unmatched square brackets" - elif curly != 0: - return "Unmatched curly brackets" - elif angle != 0: - return "Unmatched angle brackets" + c[rpairs[char]] -= 1 + elif char == '|': + if c['('] == 0 and c['['] == 0: # Pipe outside the alternative and option + return "Pipe | must be within parenthesis brackets or square brackets" + if len(q) != 0: + return "Unmatched " + bnames(q.pop()) + " brackets" # Check for empty pipe search = re.search(RE.empty_pipe, line) @@ -642,4 +638,5 @@ def _init_topic(self, topics, name): "includes": {}, "inherits": {}, "triggers": [], + "syntax": {}, } diff --git a/rivescript/rivescript.py b/rivescript/rivescript.py index d6d07cf..6592e31 100644 --- a/rivescript/rivescript.py +++ b/rivescript/rivescript.py @@ -267,12 +267,17 @@ def _parse(self, fname, code): # Does this trigger have a %Previous? If so, make a pointer to # this exact trigger in _thats. if trigger["previous"] is not None: + # Precompile the regexp for the previous too. + self._precompile_regexp(trigger["previous"]) + if not topic in self._thats: self._thats[topic] = {} if not trigger["trigger"] in self._thats[topic]: self._thats[topic][trigger["trigger"]] = {} self._thats[topic][trigger["trigger"]][trigger["previous"]] = trigger + self._syntax[topic] = data["syntax"] + # Load all the parsed objects. for obj in ast["objects"]: # Have a handler for it? @@ -308,7 +313,7 @@ def deparse(self): if self._debug: result["begin"]["global"]["debug"] = self._debug if self._depth != 50: - result["begin"]["global"]["depth"] = 50 + result["begin"]["global"]["depth"] = self._depth # Definitions result["begin"]["var"] = self._var.copy() @@ -508,7 +513,7 @@ def _write_wrapped(self, line, sep=" ", indent="", width=78): if len(lines): eol = "" if sep == " ": - eol = "\s" + eol = "\\s" for item in lines: result += eol + "\n" + indent + "^ " + item @@ -627,7 +632,8 @@ def set_global(self, name, value): # Unset the variable. if name in self._global: del self._global[name] - self._global[name] = value + else: + self._global[name] = value def get_global(self, name): """Retrieve the current value of a global variable. @@ -650,7 +656,8 @@ def set_variable(self, name, value): # Unset the variable. if name in self._var: del self._var[name] - self._var[name] = value + else: + self._var[name] = value def get_variable(self, name): """Retrieve the current value of a bot variable. @@ -664,6 +671,7 @@ def set_substitution(self, what, rep): """Set a substitution. Equivalent to ``! sub`` in RiveScript code. + Note: sort_replies() must be called after using set_substitution. :param str what: The original text to replace. :param str rep: The text to replace it with. @@ -671,14 +679,17 @@ def set_substitution(self, what, rep): """ if rep is None: # Unset the variable. - if what in self._subs: - del self._subs[what] - self._subs[what] = rep + if what in self._sub: + del self._sub[what] + else: + self._sub[what] = rep + self._precompile_substitution('sub', what) def set_person(self, what, rep): """Set a person substitution. Equivalent to ``! person`` in RiveScript code. + Note: sort_replies() must be called after using set_person. :param str what: The original text to replace. :param str rep: The text to replace it with. @@ -688,7 +699,9 @@ def set_person(self, what, rep): # Unset the variable. if what in self._person: del self._person[what] - self._person[what] = rep + else: + self._person[what] = rep + self._precompile_substitution('person', what) def set_uservar(self, user, name, value): """Set a variable for a user. @@ -855,7 +868,7 @@ def last_match(self, user): """ return self._session.get(user, "__lastmatch__", None) # Get directly to `get` function - def trigger_info(self, trigger=None, dump=False): + def trigger_info(self, topic=None, trigger=None, user=None, last_reply=None): """Get information about a trigger. Pass in a raw trigger to find out what file name and line number it @@ -866,41 +879,64 @@ def trigger_info(self, trigger=None, dump=False): The keys in the trigger info is as follows: - * ``category``: Either 'topic' (for normal) or 'thats' - (for %Previous triggers) * ``topic``: The topic name * ``trigger``: The raw trigger text + * ``previous``: The %Previous value specified, or None * ``filename``: The filename the trigger was found in. * ``lineno``: The line number the trigger was found on. - Pass in a true value for ``dump``, and the entire syntax tracking - tree is returned. - + :param str topic: The topic to look up. If none, then all topics are considered. :param str trigger: The raw trigger text to look up. - :param bool dump: Whether to dump the entire syntax tracking tree. + :param str user: The user ID to find the trigger for (or None). + :param str last_match: The prior reply to match with %Previous. If not specified, all matching triggers are returned. + + Note: If you pass no arguments, then a dump of all triggers is returned. :return: A list of matching triggers or ``None`` if no matches. """ - if dump: - return self._syntax response = None - - # Search the syntax tree for the trigger. - for category in self._syntax: - for topic in self._syntax[category]: - if trigger in self._syntax[category][topic]: - # We got a match! - if response is None: - response = list() - fname, lineno = self._syntax[category][topic][trigger]['trigger'] - response.append(dict( - category=category, - topic=topic, - trigger=trigger, - filename=fname, - line=lineno, - )) + syntax = None + + def reply_matches(prev, lr): + nonlocal user + botside = self._brain.reply_regexp(user, prev) + if re.match(botside, lr): + return True + return False + + def append_if_match(): + nonlocal response, last_reply, syntax + previous = syntax["previous"] + if last_reply is None or previous is None or reply_matches(previous, last_reply): + if response is None: + response = [] + response.append(dict(topic=topic, trigger=trigger, previous=previous, + filename=syntax["filename"], lineno=syntax["lineno"])) + + if topic is None and trigger is None: + response = [] + for topic, triggers in self._syntax.items(): + for trigger, syntax in triggers.items(): + append_if_match() + elif topic is not None: + if topic not in self._syntax: + return response + triggers = self._syntax[topic] + if trigger is None: + for trigger, syntax in triggers.items(): + append_if_match() + else: + if trigger not in triggers: + return response + syntax = triggers[trigger] + append_if_match() + else: # trigger is not None + for topic, triggers in self._syntax.items(): + if trigger not in triggers: + continue + syntax = triggers[trigger] + append_if_match() return response @@ -946,6 +982,59 @@ def reply(self, user, msg, errors_as_replies=True): """ return self._brain.reply(user, msg, errors_as_replies) + def prepare_brain_transplant(self, preserve_globals=True, preserve_vars=True, preserve_uservars=True, + preserve_substitutions=True, preserve_persons=True, preserve_handlers=True, preserve_subroutines=True, + preserve_arrays=False): + """Clear the brain in preparation for a full reload, preserving some important and specified things. + + Usage: + rs.prepare_brain_transplant() + rs.load_directory('new_brain') + rs.sort_replies() + + Arguments: + preserve_globals (bool): If True, then we preserve the set_global variables (! global in RiveScript) + preserve_vars (bool): If True, then we preserve the set_variable variables (! var in RiveScript) + preserve_uservars (bool): If True, then we preserve the set_uservar variables ( in RiveScript) + preserve_substitutions (bool): If True, then we preserve the set_substitution subs (! sub in RiveScript) + preserve_persons (bool): If True, then we preserve the set_person subs (! person in RiveScript) + preserve_handlers (bool): If True, then we preserve the set_handler object handlers + preserve_subroutines (bool): If True, then we preserve the set_subroutine object handlers + (> object in RiveScript) + preserve_arrays (bool): If True, then we preserve any defined arrays (! array in RiveScript) + + """ + + global_vars = self._global + handlers = self._handlers + objlangs = self._objlangs + subs = self._sub + subs_precompiled = self._regexc["sub"] + persons = self._person + persons_precompiled = self._regexc["person"] + array = self._array + var = self._var + self.__init__(debug=self._debug, strict=self._strict, depth=self._depth, + log=self._log, utf8=self._utf8, session_manager=self._session) + if preserve_globals: + self._global = global_vars + if preserve_handlers: + self._handlers = handlers + if preserve_subroutines: + self._objlangs = objlangs + if preserve_vars: + self._var = var + if preserve_substitutions: + self._sub = subs + self._regexc["sub"] = subs_precompiled + if preserve_persons: + self._person = persons + self._regexc["person"] = persons_precompiled + if not preserve_uservars: + self.clear_uservars() + if preserve_arrays: + self._array = array + def _precompile_substitution(self, kind, pattern): """Pre-compile the regexp for a substitution pattern. diff --git a/rivescript/sessions.py b/rivescript/sessions.py index 685680c..c631255 100644 --- a/rivescript/sessions.py +++ b/rivescript/sessions.py @@ -250,20 +250,20 @@ def set(self, *args, **kwargs): def get(self, *args, **kwargs): return "undefined" - def get_any(self, *args, **kwargs): + def get_any(self, *args, **kwargs): # pragma: no cover return {} - def get_all(self, *args, **kwargs): + def get_all(self, *args, **kwargs): # pragma: no cover return {} - def reset(self, *args, **kwargs): + def reset(self, *args, **kwargs): # pragma: no cover pass - def reset_all(self, *args, **kwargs): + def reset_all(self, *args, **kwargs): # pragma: no cover pass - def freeze(self, *args, **kwargs): + def freeze(self, *args, **kwargs): # pragma: no cover pass - def thaw(self, *args, **kwargs): + def thaw(self, *args, **kwargs): # pragma: no cover pass diff --git a/rivescript/sorting.py b/rivescript/sorting.py index e91132d..0c301fe 100644 --- a/rivescript/sorting.py +++ b/rivescript/sorting.py @@ -26,12 +26,14 @@ class TriggerObj(object): index: Unique positional index of the object in the original list weight: Pattern weight ``{weight}`` inherit: Pattern inherit level, extracted from i.e. "{inherit=1}hi" - wordcount: Length of pattern by wordcount - len: Length of pattern by character count - star: Number of wildcards (``*``), excluding alphabetical wildcards, and numeric wildcards - pound: Number of numeric wildcards (``#``) - under: Number of alphabetical wildcards (``_``) - option: Number of optional tags ("[man]" in "hey [man]"), assume that the template is properly formatted + + Computed: + wordcount: Negative length of pattern by wordcount + len: Negative length of pattern by character count + star: Boolean - has wildcards (``*``), excluding alphabetical wildcards, and numeric wildcards + pound: Boolean - has numeric wildcards (``#``) + under: Boolean - has alphabetical wildcards (``_``) + option: Boolean - has optional tags ("[man]" in "hey [man]"), assume that the template is properly formatted is_empty: Boolean variable indicating whether the trigger has non-zero wordcount """ @@ -42,10 +44,19 @@ def __init__(self, pattern, index, weight, inherit = sys.maxsize): self.inherit = inherit # Low inherit takes precedence i.e. 0 < 1 self.wordcount = - utils.word_count(pattern) # Length -2 < -1. Use `utils` for counting choice of wildcards self.len = -len(self.alphabet) # Length -10 < -5 - self.star = self.alphabet.count('*') # Number of wildcards 0 < 1 - self.pound = self.alphabet.count('#') # Number of numeric wildcards 0 < 1 - self.under = self.alphabet.count('_') # Number of alphabetical wildcards 0 < 1 - self.option = self.alphabet.count('[') + self.alphabet.count('(') # Number of option 0 < 1 + pattern_set = set(pattern) + self.star = '*' in pattern_set # Has wildcards 0 < 1 + self.pound = '#' in pattern_set # Has numeric wildcards 0 < 1 + self.under = '_' in pattern_set # Has alpha wildcards 0 < 1 + self.option = '[' in pattern_set # Has optionals 0 < 1 + #self.star = self.alphabet.count('*') # Number of wildcards 0 < 1 + #self.star = self.alphabet.startswith('* ') + self.alphabet.count(' * ') + self.alphabet.endswith(' *') + \ + #self.alphabet.startswith('[*] ') + self.alphabet.endswith(' [*]') + \ + #(self.alphabet == '*') + (self.alphabet == '[*]') # Number of wildcards 0 < 1 + #self.pound = self.alphabet.count('#') # Number of numeric wildcards 0 < 1 + #self.under = self.alphabet.count('_') # Number of alphabetical wildcards 0 < 1 + #self.option = self.alphabet.count('[') + self.alphabet.count('(') # Number of option 0 < 1 + #self.option = self.alphabet.count('[') - self.alphabet.count('[*') + self.alphabet.count('(') # Number of option 0 < 1 self.is_empty = self.wordcount == 0 # Triggers with words precede triggers with no words, False < True diff --git a/tests/config.py b/tests/config.py index caee2c5..aca6d5b 100644 --- a/tests/config.py +++ b/tests/config.py @@ -39,3 +39,22 @@ def uservar(self, var, expected): """Test the value of a user variable.""" value = self.rs.get_uservar(self.username, var) self.assertEqual(value, expected) + + def assertContains(self, big, small): + """Ensure everything from "small" is in "big" """ + self.assertIsInstance(small, big.__class__) + if isinstance(small, dict): + for k, v in small.items(): + self.assertIn(k, big) + self.assertContains(big[k], v) + elif isinstance(small, str): # Strings are iterable, but let's not! + self.assertEqual(big, small) + else: # pragma: no cover + try: + iterator = iter(small) + for it in iterator: + self.assertIn(it, big) + except TypeError: + self.assertEqual(big, small) + + diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..156738e --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals, absolute_import + +from .config import RiveScriptTestCase + +import os +import shutil + +class ImproveTestCoverage_Tests(RiveScriptTestCase): + + def test_improve_code_coverage_brain(self): + self.new(""" + + * + - star{topic=empty} + + > topic empty + < topic + """) + self.reply("hi", "star") + self.reply("hi", "[ERR: No Reply Matched]") # Should give an "empty topic" warning + # Doesn't work! self.reply("hi", "star") # and put us back in "random" + + self.new(""" + + * + @ recurse most deeply + """) + self.reply("recurse", "[ERR: Deep recursion detected]") + + self.new(""" // Trying to hit a bunch of code here! :-) + + * + - {random}star star{/random}{weight=2} + + + + * 1 <= 2 =>
+ - Nope! + + + upper lower + - {uppercase}lower{/uppercase} {lowercase}UPPER{/lowercase} + + + blank random + - {random} {/random}blank + + """) + self.reply("hi", "star") + self.reply("hi star var", "var star hi 2") + self.reply("upper lower", "LOWER upper") + self.reply("blank random", "blank") + + from rivescript import RiveScript + from rivescript.exceptions import RepliesNotSortedError + self.rs = RiveScript() + self.rs.stream(""" + + * + - star + """) + try: + self.reply("hi", "Nope!") + except RepliesNotSortedError: + pass + + def test_improve_code_coverage_parser(self): + self.new(""" + + * // Inline comment + - star // Another comment + """) + self.reply("hi", "star") + + self.new(""" + ! global g = gee + ! global debug = false + ! global depth = 100 + ! global strict = true + ! var v = vee + ! array a = a b c + ! sub g = a + ! person v = b + + // Now get rid of most of these + + ! global g = + ! var v = + ! array a = + ! sub g = + ! person v = + + + g + - g + + + v * + - + + + * + - star + + + @a arr + - a arr + + """) + self.reply("g gee", "star") + self.reply("g", "g undefined") + self.reply("v v", "v undefined") + self.reply("a arr", "star") + # self.reply("arr", "a arr") + +class RiveScript_Py_Tests(RiveScriptTestCase): + def setUp(self): + super().setUp() + self.testdir = "__testdir__" + os.mkdir(self.testdir) + os.mkdir(os.path.join(self.testdir, "subdir")) + def writeit(filename, contents): + with open(os.path.join(self.testdir, filename), 'w') as f: + f.write(contents + '\n') + writeit("star.rive", """ + + * + - star + """) + writeit("sub.rive", """ + ! sub aya = a + ! sub bee = b + """) + writeit(os.path.join("subdir", "cont.rive"), """ + + a + - aa + + + b + - bb + """) + + def tearDown(self): + shutil.rmtree(self.testdir) + + def test_improve_code_coverage_rivescript(self): + from rivescript import __version__ + from rivescript import RiveScript + self.assertEqual(RiveScript.VERSION(), __version__) + + self.rs = RiveScript() + self.rs.load_directory(self.testdir) + self.rs.sort_replies() + self.reply("a", "aa") + self.reply("aya", "aa") + self.reply("bee", "bb") + self.reply("cee", "star") + + self.rs = RiveScript() + self.rs.load_file(os.path.join(self.testdir, "star.rive")) + self.rs.load_file(os.path.join(self.testdir, "subdir", "cont.rive")) + self.rs.sort_replies() + self.reply("a", "aa") + self.reply("aya", "star") + + self.new(""" + ! global g = gee + ! var v = vee + + + g + - + + + v + - + """) + self.reply("g", "gee") + self.reply("v", "vee") + self.rs.set_global("g", None) + self.rs.set_variable("v", None) + self.reply("g", "undefined") + self.reply("v", "undefined") + + self.new(""" + + * + - star + """) + self.reply("hi", "star") + self.assertContains(self.rs.get_uservars(), {self.username: {'m': "me", 'u': "you"}}) + self.rs.set_uservar("u2", "a", "aye") + self.rs.clear_uservars(self.username) + uv = self.rs.get_uservars() + self.assertNotIn(self.username, uv) + self.assertContains(uv, {"u2": {'a': "aye"}}) + + self.new(""" + + u + - user + + > object user python + return rs.current_user() + < object + """) + self.reply('u', self.username) + self.assertEqual(self.rs.current_user(), None) + diff --git a/tests/test_format.py b/tests/test_format.py index 9416949..021f1d3 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -33,7 +33,7 @@ def test_format_triggers(self): def test_check_syntax(self): mismatch_brackets = ["a (b", "a [b", "a {b", "a "] empty_pipes = ["[a|b| ]", "[a|b|]", "[a| |c]", "[a||c]", "[ |b|c]", "[|b|c]"] - advanced_brackets = [") a (", "] b [", "> c <", "} d {", "a (b [c) d]", "a (b [c|d] e)"] + advanced_brackets = [") a (", "] b [", "> c <", "} d {", "a (b [c) d]", "a (b [c|d) e]"] angle_brackets = ["(a ", " c)", "[a ", "< a [b > c]", "{ a < b } c >", "< a {b > c }"] pipe_outside = ["a|b", "a|", "|b", "(a|b) | (c|d)", "(a|b)|(c|d)"] @@ -90,4 +90,4 @@ def test_space_tolerance_with_pipe(self): - hi """) for message in ['hey a', 'hey b', 'hey c']: - self.reply(message, "hi") \ No newline at end of file + self.reply(message, "hi") diff --git a/tests/test_issues.py b/tests/test_issues.py new file mode 100644 index 0000000..6a61da2 --- /dev/null +++ b/tests/test_issues.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals, absolute_import + +from .config import RiveScriptTestCase + +import io + +class IssueTests(RiveScriptTestCase): + """Test reported issues.""" + + def test_brain_transplant_issue_81(self): + for i in range(0x100): # One bit for each 'preserve_' option + preserve_globals = (i & 1) + preserve_vars = (i & 2) >> 1 + preserve_uservars = (i & 4) >> 2 + preserve_handlers = (i & 8) >> 3 + preserve_subroutines = (i & 0x10) >> 4 + preserve_substitutions = (i & 0x20) >> 5 + preserve_persons = (i & 0x40) >> 6 + preserve_arrays = (i & 0x80) >> 7 + with self.subTest(preserve_globals=preserve_globals, + preserve_vars=preserve_vars, + preserve_uservars=preserve_uservars, + preserve_handlers=preserve_handlers, + preserve_subroutines=preserve_subroutines, + preserve_substitutions=preserve_substitutions, + preserve_persons=preserve_persons, + preserve_arrays = preserve_arrays): + self.new(""" + ! array ar = a b + ! global gv = global_value + ! var vv = var_value + ! sub s = subbed + ! person i = you + + + hello * + - subpy + + + > object py python + return "py" + < object + """) + def sub(rs, args): + return "hi" + self.rs.set_subroutine("sub", sub) + self.reply("hello i", "hipyyou") + self.uservar("uv", "uv_value") + self.assertEqual(self.rs.get_global("gv"), "global_value") + self.assertEqual(self.rs.get_variable("vv"), "var_value") + self.rs.set_handler('python', None) + if i == 0x7F: # This is the default + self.rs.prepare_brain_transplant() + else: + self.rs.prepare_brain_transplant(preserve_globals=preserve_globals, + preserve_vars=preserve_vars, preserve_uservars=preserve_uservars, + preserve_handlers=preserve_handlers, preserve_subroutines=preserve_subroutines, + preserve_substitutions=preserve_substitutions, preserve_persons=preserve_persons, + preserve_arrays = preserve_arrays) + self.assertEqual(self.rs.get_global("gv"), ("undefined", "global_value")[preserve_globals]) + self.assertEqual(self.rs.get_variable("vv"), ("undefined", "var_value")[preserve_vars]) + self.uservar("uv", (None, "uv_value")[preserve_uservars]) + self.extend(""" + + arr (@ar) + - array + + + hi + - sub + + + call py + - py + + + subbed + - ess + + + * + - + + + new brain + - Passed! + """) + self.rs.sort_replies() + self.reply("arr b", ("arr b", "array")[preserve_arrays]) + hi_reply = ("[ERR: Object Not Found]", "[ERR: No Object Handler]", "[ERR: Object Not Found]", "hi") \ + [preserve_subroutines+((not preserve_handlers)<<1)] + self.reply("hi", hi_reply) + self.reply("s", ("s", "ess")[preserve_substitutions]) + self.reply("i", ("i", "you")[preserve_persons]) + self.reply("hello", "hello") + py_reply = hi_reply + if py_reply == "hi": + py_reply = "py" + self.reply("call py", py_reply) + self.reply("new brain", "Passed!") + + def test_trigger_info_issue_120(self): + self.new(""" + + * + - star + + + last * + % star + - match{topic=subtop} + + > topic subtop inherits random + + sub top + - subtop reply{topic=random} + < topic + """) + self.reply("s", "star") + self.assertEqual(self.rs.last_match(self.username), '*') + response1 = dict(topic="random", trigger="*", previous=None, filename="stream()", lineno=2) + self.assertEqual(self.rs.trigger_info(topic="random", trigger='*', user=self.username), [response1]) + self.reply("last match", "match") + self.assertEqual(self.rs.last_match(self.username), 'last *') + response2 = dict(topic="random", trigger="last *", previous="star", filename="stream()", lineno=5) + self.assertEqual(self.rs.trigger_info(topic="random", trigger='last *', user=self.username, last_reply="star"), [response2]) + self.assertEqual(self.rs.trigger_info(trigger='last *', user=self.username, last_reply="star"), [response2]) + self.assertEqual(self.rs.trigger_info(trigger='last *', user=self.username), [response2]) + self.assertEqual(self.rs.trigger_info(trigger='last *'), [response2]) + self.assertEqual(self.rs.trigger_info(trigger='last *', user=self.username, last_reply="Nope!"), None) + self.reply("sub top", "subtop reply") + response3 = dict(topic="subtop", trigger="sub top", previous=None, filename="stream()", lineno=10) + self.assertEqual(self.rs.trigger_info(topic="subtop", trigger='sub top'), [response3]) + self.assertEqual(self.rs.trigger_info(topic="subtop"), [response3]) + self.assertEqual(self.rs.trigger_info(trigger='sub top'), [response3]) + self.assertEqual(self.rs.trigger_info(), [response1, response2, response3]) + self.assertEqual(self.rs.trigger_info(topic="not found"), None) + self.assertEqual(self.rs.trigger_info(topic="random", trigger="not found"), None) + self.assertEqual(self.rs.trigger_info(trigger="not found"), None) + + def test_equal_in_var_issue_130(self): + self.new(""" + ! global website = https://www.rivescript.com/try/#/doc?x=y&more=global + ! var website = https://www.rivescript.com/try/#/doc?x=y&more=var + + + global + - + + + var + - + + """) + self.reply("global", "https://www.rivescript.com/try/#/doc?x=y&more=global") + self.reply("var", "https://www.rivescript.com/try/#/doc?x=y&more=var") + + def test_nested_brackets_issue_132(self): + self.new(""" + + ((cat|dog) [*] animal|animal [*] (cat|dog)) + - Passed + + + * + - Default + """) + self.reply("cat is an animal", "Passed") + self.reply("dog is an animal", "Passed") + self.reply("animal is a cat", "Passed") + self.reply("animal is a dog", "Passed") + self.reply("cat animal dog", "Default") + self.reply("the animal is a dog", "Default") + + def test_sorting_multi_optionals_issue_133(self): + self.new(""" + + [*] what [*] fram is [*] + - Failed + + + [*] what [*] flow [*] getting [*] reviewed [*] fram is answer type [*] + - Passed + + + hey + @ what flow getting reviewed fram is answer type + """) + self.reply("hey", "Passed") + + def test_incomment_debug_msg_issue_138(self): + logit = io.StringIO() + + self.new(""" + /* Start of comment + * middle of comment + */ + + + * + - Default + """, debug=True, log=logit) + self.reply("hi", "Default") + self.assertTrue("incomment: True, " in logit.getvalue()) + + logit = io.StringIO() + + self.new(""" + > object myobj python + return "Default" + < object + + + * + - myobj + """, debug=True, log=logit) + self.reply("hi", "Default") + self.assertFalse("incomment: True" in logit.getvalue()) + + def test_not_subbing_4x_issue_140(self): + self.new(""" + + a a a a + - Got a a a a + + + a a ab a + - Got a a ab a + + ! sub ab = a + + """) + self.reply("ab ab ab ab", "Got a a a a") + + def test_set_substitution_issue_142(self): + self.new(""" + + hello + - hi + + + * + - default + """) + self.rs.set_substitution("h", "hello") + self.rs.sort_replies() + self.reply("h", "hi") + self.rs.set_substitution("h", None) + self.rs.sort_replies() + self.reply("h", "default") + + def test_set_person_issue_143(self): + self.new(""" + + i * + - + """) + self.rs.set_person("i", "you") + self.rs.sort_replies() + self.reply("i i", "you") + self.rs.set_person("i", None) + self.rs.sort_replies() + self.reply("i i", "i") + diff --git a/tests/test_options.py b/tests/test_options.py index 990dbf2..728d7fb 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -44,9 +44,9 @@ def test_concat(self): ! local concat = none + [test] concat - ^ \sin trigger with space and optional + ^ \\sin trigger with space and optional - Hello - ^ \sworld! + ^ \\sworld! ! local concat = space + test concat space diff --git a/tests/test_replies.py b/tests/test_replies.py index 9a76235..e188038 100644 --- a/tests/test_replies.py +++ b/tests/test_replies.py @@ -101,7 +101,7 @@ def test_conditionals(self): def test_embedded_tags(self): self.new(""" + my name is * - * != undefined => >I thought\s + * != undefined => >I thought\\s ^ your name was ? ^ > - >OK. diff --git a/tests/test_sorting.py b/tests/test_sorting.py index dc2afa0..5803297 100644 --- a/tests/test_sorting.py +++ b/tests/test_sorting.py @@ -84,8 +84,8 @@ def test_sorting_triggers(self): # 4) Sorted by alphabetical order self.assertLess(sorted_triggers['* are *'], sorted_triggers['* you *']) - # 5) Sorted by number of wildcard triggers - self.assertLess(sorted_triggers['hi *'], sorted_triggers['* you *']) + # NOT!! 5) Sorted by number of wildcard triggers + # NOT!! self.assertLess(sorted_triggers['hi *'], sorted_triggers['* you *']) # 6) The `super catch all` (only single star `*` or `[*]`) should be the last two third_last_position = max(sorted_triggers.values())-2 @@ -116,4 +116,4 @@ def test_sorting_triggers(self): # 11) Making sure that the weight tag is taken into account self.assertLess(sorted_triggers['ho _{weight=100}'], sorted_triggers['hi _']) - self.assertLess(sorted_triggers['hi _'], sorted_triggers['ho _']) \ No newline at end of file + self.assertLess(sorted_triggers['hi _'], sorted_triggers['ho _'])