Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions NLPPlus/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ class EngineException(BaseException):
pass


class AnalysisStopped(EngineException, Exception):
"""An analysis ran past its ``timeout`` and was stopped.

The engine stops the run at its next while turn or pass, skips the
passes after it, and frees what it built, so the engine is fit for the
next call. Whatever the analyzer wrote before it stopped is partial.

Also an :class:`Exception`, unlike :class:`EngineException`, so code
that handles ordinary errors handles this one too.
"""

def __init__(self, analyzer_name, timeout: float):
self.analyzer_name = str(analyzer_name)
self.timeout = timeout
super().__init__(f"{self.analyzer_name} ran past its timeout of "
f"{timeout:g}s and was stopped")


class Results:
"""Various results produced by the NLP++ analyzer."""

Expand Down Expand Up @@ -270,7 +288,8 @@ def clear_logs(self, analyzer_name: PathLike) -> int:
return removed

def analyze(self, text: str, analyzer_name: PathLike, develop: bool = False,
compiled: bool = False, datum: Optional[str] = None) -> Results:
compiled: bool = False, datum: Optional[str] = None,
timeout: Optional[float] = None) -> Results:
"""Analyze text with the named analyzer.

Args:
Expand All @@ -291,14 +310,22 @@ def analyze(self, text: str, analyzer_name: PathLike, develop: bool = False,
call only -- eg the output format the caller wants
(``"json"``, ``"xml"``). None (the default) is none, and
``G("$datum")`` is then 0.
timeout: seconds this analysis may take; None (the default) is no
limit. A run past it is stopped inside the engine and
:class:`AnalysisStopped` is raised. Without one, an
analyzer whose loop never ends cannot be stopped short of
killing the process.
"""
outdir = self.analyzer_dir(analyzer_name) / "output"
# Delete the files of the previous run in the outdir.
for file_path in glob.glob(str(outdir / "*")):
if os.path.isfile(file_path):
os.remove(file_path)
outtext = self.engine.analyze(self._engine_analyzer_arg(analyzer_name),
text, develop, compiled, datum or "")
text, develop, compiled, datum or "",
timeout if timeout and timeout > 0 else 0.0)
if timeout and timeout > 0 and self.engine.last_run_stopped():
raise AnalysisStopped(analyzer_name, timeout)
return Results(outtext, outdir)

def compile(self, analyzer_name: PathLike, develop: bool = False,
Expand Down Expand Up @@ -528,16 +555,20 @@ def put_json_object(analyzer_name: str, obj: Any, name: str) -> Path:


def analyze(text: str, parser: str = "parse-en-us", develop: bool = False,
compiled: bool = False, datum: Optional[str] = None) -> str:
compiled: bool = False, datum: Optional[str] = None,
timeout: Optional[float] = None) -> str:
"""Run the analyzer named on the input string.

If ``compiled=True``, the engine loads the analyzer's compiled
shared libraries (``bin/run.<ext>`` and ``bin/kb.<ext>``) instead of
running interpreted. See :func:`compile` for producing those.
``datum`` reaches the analyzer as ``G("$datum")``; see
:meth:`Engine.analyze`.
``timeout`` (seconds) stops a run that takes longer and raises
:class:`AnalysisStopped`; see :meth:`Engine.analyze`.
"""
return engine.analyze(text, parser, develop, compiled, datum).output_text
return engine.analyze(text, parser, develop, compiled, datum,
timeout).output_text


def compile(analyzer: str = "parse-en-us", develop: bool = False,
Expand Down
17 changes: 14 additions & 3 deletions bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,17 @@ wrap_init(NLP_ENGINE *self, const std::string &workingFolder,
const std::string
wrap_analyze(NLP_ENGINE &engine, const std::string &parser,
const std::string &input, const bool develop,
const bool compiled, const std::string &datum) {
const bool compiled, const std::string &datum,
const double timeout) {
CLocaleGuard cLocale;
// For this call only: the analyzer sees it as G("$datum"), eg the
// output format the caller wants. "" (the default) is none.
engine.setDatum(datum.c_str());
// A limit for this call only: 0 (the default) is none. A run past it is
// stopped inside the engine and last_run_stopped() then says so -- the
// only way to stop an NLP++ loop that never ends without killing the
// process (engine 4.2.41+).
engine.setRunLimit(timeout > 0 ? (long long)(timeout * 1000.0) : 0);
_TCHAR *_parser = _tcsdup(parser.c_str());
std::istringstream instream(input);
std::ostringstream outstream;
Expand Down Expand Up @@ -187,7 +193,7 @@ NB_MODULE(bindings, m) {
"silent"_a = true)
.def("analyze", &wrap_analyze,
"parser"_a, "input"_a, "develop"_a = false,
"compiled"_a = false, "datum"_a = "",
"compiled"_a = false, "datum"_a = "", "timeout"_a = 0.0,
"Analyze `input` with `parser`.\n"
"The `parser` argument refers to an analyzer contained in the\n"
"`analyzers` folder inside the workingFolder used to create\n"
Expand All @@ -198,7 +204,12 @@ NB_MODULE(bindings, m) {
"libraries first via `compile()` plus an external cmake/\n"
"cloud build step.\n"
"`datum` reaches the analyzer as G(\"$datum\"), eg the output\n"
"format wanted; \"\" (the default) is none.")
"format wanted; \"\" (the default) is none.\n"
"`timeout` (seconds, 0 = none) stops a run that takes longer\n"
"at its next while turn or pass; last_run_stopped() is then\n"
"True.")
.def("last_run_stopped", &NLP_ENGINE::lastRunStopped,
"True if the last analyze() stopped a run at its timeout.")
.def("compile", &wrap_compile,
"analyzer"_a, "develop"_a = false, "kbOnly"_a = false,
"analyzerOnly"_a = false,
Expand Down
76 changes: 76 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,82 @@ def test_datum(self):
NLPPlus.engine = NLPPlus.Engine()


SPIN_SEQ = "tokenize\tnil\t# t\nnlp\tspin\t# s\nnlp\tafter\t# a\n"
SPIN_NLP = """@CODE
L("n") = 0;
while (L("n") >= 0) {
L("n")++;
L("s") = "x" + str(L("n"));
}
@@CODE
"""
DONE_NLP = """@CODE
"after.txt" << "after";
@@CODE
"""
QUICK_SEQ = "tokenize\tnil\t# t\nnlp\tafter\t# a\n"


class TimeoutTest(TestCase):
"""analyze(timeout=) stops an analyzer whose loop never ends.

Without it the only way out of such a loop was killing the process --
and a loop that makes a string a turn gets there by itself, out of
memory. Engine 4.2.41+.
"""

def setUp(self):
self.tmpdir = TemporaryDirectory(prefix="test-nlpplus-timeout",
ignore_cleanup_errors=True)
root = Path(self.tmpdir.name)
spec = root / "spin" / "spec"
spec.mkdir(parents=True)
(root / "spin" / "kb" / "user").mkdir(parents=True)
(spec / "analyzer.seq").write_text(SPIN_SEQ)
(spec / "spin.nlp").write_text(SPIN_NLP)
(spec / "after.nlp").write_text(DONE_NLP)
# One that finishes, for runs that must be unaffected.
quick = root / "quick" / "spec"
quick.mkdir(parents=True)
(root / "quick" / "kb" / "user").mkdir(parents=True)
(quick / "analyzer.seq").write_text(QUICK_SEQ)
(quick / "after.nlp").write_text(DONE_NLP)
self.eng = NLPPlus.Engine()
self.eng.set_analyzers_folder(root)

def _quick_ran(self, **kwargs):
out = self.eng.analyzer_dir("quick") / "output" / "after.txt"
self.eng.analyze("text", "quick", **kwargs)
return out.exists() and out.read_text() == "after"

def tearDown(self):
self.eng.close()
self.tmpdir.cleanup()

def test_a_loop_is_stopped(self):
import time
start = time.monotonic()
with self.assertRaises(NLPPlus.AnalysisStopped) as caught:
self.eng.analyze("text", "spin", timeout=1)
self.assertLess(time.monotonic() - start, 30)
self.assertEqual(caught.exception.timeout, 1)
# An ordinary `except Exception` catches it.
self.assertIsInstance(caught.exception, Exception)
# The pass after the loop did not run.
out = self.eng.analyzer_dir("spin") / "output" / "after.txt"
self.assertFalse(out.exists())

def test_the_engine_works_after_a_stop(self):
with self.assertRaises(NLPPlus.AnalysisStopped):
self.eng.analyze("text", "spin", timeout=1)
self.assertTrue(self._quick_ran(timeout=30))
self.assertTrue(self._quick_ran())

def test_no_timeout_and_a_run_inside_it_are_unchanged(self):
self.assertTrue(self._quick_ran())
self.assertTrue(self._quick_ran(timeout=30))


class ReentrancyRegressionTest(TestCase):
"""nlp-engine #658 / #632 regression.

Expand Down
Loading