qmlmathplot 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. qmlmathplot/__init__.py +48 -27
  2. qmlmathplot/calc/__init__.py +11 -0
  3. qmlmathplot/calc/__main__.py +61 -0
  4. qmlmathplot/calc/build.py +103 -0
  5. qmlmathplot/client.py +243 -0
  6. qmlmathplot/curve.py +267 -172
  7. qmlmathplot/curveplotter.py +638 -0
  8. qmlmathplot/ggb_compute.py +1 -9
  9. qmlmathplot/ggb_eligibility.py +69 -0
  10. qmlmathplot/ggb_path.py +61 -29
  11. qmlmathplot/interval.py +10 -65
  12. qmlmathplot/interval_reference.py +526 -0
  13. qmlmathplot/model.py +1 -158
  14. qmlmathplot/pathcurve.py +97 -50
  15. qmlmathplot/plot.py +36 -3
  16. qmlmathplot/program.py +32 -0
  17. qmlmathplot/protocol.py +216 -0
  18. qmlmathplot/qml/CpuCurves.qml +2 -1
  19. qmlmathplot/qml/PlotCurves.qml +4 -4
  20. qmlmathplot/qml/PlotView.qml +4 -3
  21. qmlmathplot/rhi.py +145 -31
  22. qmlmathplot/shaders/interval_path.comp +33 -260
  23. qmlmathplot/shaders/path.frag +7 -1
  24. qmlmathplot/shaders/path.vert +3 -0
  25. qmlmathplot/shaders/path_state.glsl +222 -0
  26. qmlmathplot/view.py +2 -2
  27. {qmlmathplot-0.5.0.dist-info → qmlmathplot-0.6.0.dist-info}/METADATA +58 -115
  28. {qmlmathplot-0.5.0.dist-info → qmlmathplot-0.6.0.dist-info}/RECORD +32 -26
  29. {qmlmathplot-0.5.0.dist-info → qmlmathplot-0.6.0.dist-info}/WHEEL +1 -1
  30. qmlmathplot/canvas.py +0 -107
  31. qmlmathplot/figure.py +0 -516
  32. qmlmathplot/pyplot.py +0 -370
  33. qmlmathplot/shaders/oned_sampled.frag +0 -490
  34. {qmlmathplot-0.5.0.dist-info → qmlmathplot-0.6.0.dist-info}/entry_points.txt +0 -0
  35. {qmlmathplot-0.5.0.dist-info → qmlmathplot-0.6.0.dist-info}/licenses/LICENSE +0 -0
  36. {qmlmathplot-0.5.0.dist-info → qmlmathplot-0.6.0.dist-info}/licenses/LICENSES/EUPL-1.2.txt +0 -0
qmlmathplot/__init__.py CHANGED
@@ -20,7 +20,7 @@ QtWidgets layout in one line:
20
20
 
21
21
  Qt Quick (QML) — the component plus the model:
22
22
 
23
- import QmlMathPlot 1.0
23
+ import QmlMathPlot 2.0
24
24
  PlotView { plot: myPlot; anchors.fill: parent }
25
25
 
26
26
  from PySide6.QtGui import QGuiApplication
@@ -50,30 +50,24 @@ Demos: ``examples/minimal.py``, ``examples/explorer_qtwidgets.py``,
50
50
 
51
51
  from typing import TYPE_CHECKING
52
52
 
53
- from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
54
- from .curve import Curve, CurveListModel, ImplicitCurve
55
- from .figure import Axes, Figure
56
- from .input import ViewInput
57
- from .overlay import Annotation, Guide
58
- from .plot import Plot
59
- from .qsb import cache_dir, cache_info, clear_cache, prune_cache
60
- from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
61
-
62
53
  if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getattr__ is used
63
- from .canvas import FigureCanvasQt
54
+ from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
55
+ from .curve import Curve, CurveListModel, ImplicitCurve
56
+ from .input import ViewInput
57
+ from .overlay import Annotation, Guide
58
+ from .plot import Plot
59
+ from .qsb import cache_dir, cache_info, clear_cache, prune_cache
60
+ from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
64
61
  from .widget import MathPlotWidget
65
62
 
66
63
  __all__ = [
67
64
  "Annotation",
68
- "Axes",
69
65
  "cache_dir",
70
66
  "cache_info",
71
67
  "Camera",
72
68
  "clear_cache",
73
69
  "Curve",
74
70
  "CurveListModel",
75
- "Figure",
76
- "FigureCanvasQt",
77
71
  "Guide",
78
72
  "HOME_SIZE",
79
73
  "HOME_VIEW",
@@ -91,18 +85,45 @@ __all__ = [
91
85
  ]
92
86
 
93
87
 
94
- def __getattr__(name: str) -> object:
95
- """QtWidgets-backed names are lazy, so pure-QML use never loads QtWidgets."""
96
- if name == "MathPlotWidget":
97
- from .widget import MathPlotWidget
98
-
99
- return MathPlotWidget
100
- if name == "FigureCanvasQt":
101
- from .canvas import FigureCanvasQt
88
+ #: name -> (module, attribute). ``None`` for the attribute means "the module itself".
89
+ _LAZY: dict[str, tuple[str, str | None]] = {
90
+ "Annotation": (".overlay", "Annotation"),
91
+ "cache_dir": (".qsb", "cache_dir"),
92
+ "cache_info": (".qsb", "cache_info"),
93
+ "Camera": (".camera", "Camera"),
94
+ "clear_cache": (".qsb", "clear_cache"),
95
+ "Curve": (".curve", "Curve"),
96
+ "CurveListModel": (".curve", "CurveListModel"),
97
+ "Guide": (".overlay", "Guide"),
98
+ "HOME_SIZE": (".camera", "HOME_SIZE"),
99
+ "HOME_VIEW": (".camera", "HOME_VIEW"),
100
+ "ImplicitCurve": (".curve", "ImplicitCurve"),
101
+ "MathPlotWidget": (".widget", "MathPlotWidget"),
102
+ "nice_ticks": (".camera", "nice_ticks"),
103
+ "Plot": (".plot", "Plot"),
104
+ "prune_cache": (".qsb", "prune_cache"),
105
+ "qml_component_path": (".view", "qml_component_path"),
106
+ "QML_MAJOR": (".view", "QML_MAJOR"),
107
+ "QML_MINOR": (".view", "QML_MINOR"),
108
+ "QML_URI": (".view", "QML_URI"),
109
+ "register_qml_types": (".view", "register_qml_types"),
110
+ "ViewInput": (".input", "ViewInput"),
111
+ }
102
112
 
103
- return FigureCanvasQt
104
- if name == "pyplot":
105
- from importlib import import_module
106
113
 
107
- return import_module(".pyplot", __name__)
108
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
114
+ def __getattr__(name: str) -> object:
115
+ """Resolve public names lazily.
116
+
117
+ Nothing is imported at package-import time: ``import qmlmathplot`` must not load Qt or
118
+ sympy, so a host that only needs the calculator protocol (or a QML-only app) pays for
119
+ nothing it does not use. The same laziness keeps the calculator process free of Qt.
120
+ """
121
+ target = _LAZY.get(name)
122
+ if target is None:
123
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
124
+ from importlib import import_module
125
+
126
+ module = import_module(target[0], __name__)
127
+ value = module if target[1] is None else getattr(module, target[1])
128
+ globals()[name] = value
129
+ return value
@@ -0,0 +1,11 @@
1
+ """The calculator side: sympy lives here, not in the model.
2
+
3
+ ``build_program`` is Qt-free, so it runs both in-process and inside the worker launched by
4
+ ``python -m qmlmathplot.calc``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .build import build_program, python_sources
10
+
11
+ __all__ = ["build_program", "python_sources"]
@@ -0,0 +1,61 @@
1
+ """Worker process entry point: ``python -m qmlmathplot.calc``.
2
+
3
+ Reads length-prefixed JSON requests from stdin and writes one response per request to
4
+ stdout. Nothing but frames is ever written to stdout (import-time noise from sympy goes to
5
+ stderr, which the client drains), so the channel is safe to parse.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from ..protocol import (
13
+ SCHEMA,
14
+ Program,
15
+ decode_frames,
16
+ encode_frame,
17
+ parse_request,
18
+ response_payload,
19
+ )
20
+ from .build import build_program
21
+
22
+ __all__ = ["main"]
23
+
24
+
25
+ def _error_program(payload: object) -> tuple[int, Program]:
26
+ text = ""
27
+ request_id = 0
28
+ if isinstance(payload, dict):
29
+ request_id = int(payload.get("id", 0) or 0)
30
+ text = str(payload.get("text", ""))
31
+ return request_id, Program(text=text, error="malformed request")
32
+
33
+
34
+ def main() -> int:
35
+ stdin = sys.stdin.buffer
36
+ stdout = sys.stdout.buffer
37
+ buffer = b""
38
+ while True:
39
+ chunk = stdin.read1(65536) if hasattr(stdin, "read1") else stdin.read(65536)
40
+ if not chunk:
41
+ break
42
+ buffer += chunk
43
+ try:
44
+ frames, buffer = decode_frames(buffer, limit=64)
45
+ except ValueError as exc:
46
+ stdout.write(encode_frame({"schema": SCHEMA, "id": 0, "error": str(exc)}))
47
+ stdout.flush()
48
+ return 1
49
+ for payload in frames:
50
+ try:
51
+ request_id, text, implicit = parse_request(payload)
52
+ program = build_program(text, implicit=implicit)
53
+ except Exception: # noqa: BLE001 - a bad frame must not kill the worker
54
+ request_id, program = _error_program(payload)
55
+ stdout.write(encode_frame(response_payload(request_id, program)))
56
+ stdout.flush()
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ """Text -> :class:`Program`. The only place in the package that needs sympy at runtime.
2
+
3
+ Kept Qt-free on purpose: the same code runs in-process (:class:`qmlmathplot.client.
4
+ InlineCalculator`) and inside the worker process (``python -m qmlmathplot.calc``). The
5
+ routing rules are the model's established ones: an equation or a free ``y`` is a level set,
6
+ an expression the interval gate accepts is drawn by the compute path, everything else goes
7
+ through the CPU plotter. Shader sources and Python sources are generated here so the model
8
+ never parses an expression again.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sympy as sp
14
+ from sympy.printing.numpy import NumPyPrinter
15
+
16
+ from ..ggb_compute import path_compute_source
17
+ from ..interval import compute_supported
18
+ from ..model import implicit_shader_sources
19
+ from ..protocol import Program
20
+
21
+ __all__ = ["build_program", "python_sources"]
22
+
23
+ _EXTRA_SYMBOLS = "substitute parameters first"
24
+
25
+
26
+ def _parse(text: str) -> tuple[str, sp.Expr, bool]:
27
+ """Canonical text, normalized expression, and whether the input was an equation.
28
+
29
+ Mirrors ``qmlmathplot.curve._parse``: an ``Eq(lhs, rhs)`` becomes the level set
30
+ ``lhs - rhs = 0`` and is always implicit.
31
+ """
32
+ expr = sp.sympify(text, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
33
+ if not isinstance(expr, sp.Basic):
34
+ raise ValueError(f"cannot parse {text!r}")
35
+ if isinstance(expr, sp.Eq):
36
+ return text, expr.lhs - expr.rhs, True
37
+ return text, expr, False
38
+
39
+
40
+ def python_sources(
41
+ expr: sp.Expr, args: tuple[str, ...], name: str
42
+ ) -> tuple[str | None, str | None]:
43
+ """``(math_source, numpy_source)``: complete function definitions, or ``None``.
44
+
45
+ The two differ in the module they call (``math`` vs ``numpy``) and in vectorisation; the
46
+ model uses ``math`` for its scalar sampler and ``numpy`` for vectorised work.
47
+ """
48
+ math_source = _define(expr, args, name, lambda node: sp.pycode(node))
49
+ numpy_source = _define(expr, args, name, NumPyPrinter().doprint)
50
+ return math_source, numpy_source
51
+
52
+
53
+ def _define(
54
+ expr: sp.Expr,
55
+ args: tuple[str, ...],
56
+ name: str,
57
+ printer: object,
58
+ ) -> str | None:
59
+ try:
60
+ body = printer(expr) # type: ignore[operator] - a callable passed by the caller
61
+ except Exception: # noqa: BLE001 - some expressions have no printer representation
62
+ return None
63
+ signature = ", ".join(args)
64
+ return f"def {name}({signature}):\n return {body}\n"
65
+
66
+
67
+ def build_program(text: str, *, implicit: bool | None = None) -> Program:
68
+ """Turn one expression into a :class:`Program`; parse errors become ``Program.error``."""
69
+ text = str(text)
70
+ try:
71
+ _, expr, is_equation = _parse(text)
72
+ except Exception as exc: # noqa: BLE001 - reported through the program
73
+ return Program(text=text, error=f"{type(exc).__name__}: {exc}")
74
+ names = {symbol.name for symbol in expr.free_symbols}
75
+ if names - {"x", "y"}:
76
+ return Program(
77
+ text=text,
78
+ error=(f"only x (or x and y) may be free, got {sorted(names - {'x', 'y'})}: "
79
+ f"{_EXTRA_SYMBOLS}"),
80
+ )
81
+ level_set = bool(implicit) or is_equation or "y" in names
82
+ args: tuple[str, ...] = ("x", "y") if level_set else ("x",)
83
+ kind = "implicit" if level_set else "adaptive"
84
+ glsl: dict[str, str] = {}
85
+ error = ""
86
+ try:
87
+ if level_set:
88
+ glsl["fragment"] = implicit_shader_sources(expr)[1]
89
+ elif compute_supported(expr):
90
+ kind = "intervals"
91
+ glsl["compute"] = path_compute_source(expr)
92
+ except Exception as exc: # noqa: BLE001 - the model keeps the last working shader
93
+ error = f"{type(exc).__name__}: {exc}"
94
+ math_source, numpy_source = python_sources(expr, args, "F" if level_set else "f")
95
+ return Program(
96
+ text=text,
97
+ kind=kind, # type: ignore[arg-type]
98
+ args=args,
99
+ glsl=glsl,
100
+ numpy=numpy_source,
101
+ math=math_source,
102
+ error=error,
103
+ )
qmlmathplot/client.py ADDED
@@ -0,0 +1,243 @@
1
+ """Calculators: the injection point between the model and the expression source.
2
+
3
+ The model never parses text. It asks a :class:`Calculator` for a
4
+ :class:`~qmlmathplot.protocol.Program` and consumes that. Two implementations ship here:
5
+
6
+ * :class:`InlineCalculator` — builds the program in the calling thread. Needed by scripts,
7
+ headless use and the test suite; it imports sympy lazily, on the first request.
8
+ * :class:`ProcessCalculator` — asks a worker process (``python -m qmlmathplot.calc``). The
9
+ host owns the process: when to start it, how to ship it, when to stop it.
10
+
11
+ Both deliver through the same ``ready`` / ``failed`` signals, so the model has exactly one
12
+ state machine. A host may implement its own calculator (any language, any transport); it
13
+ only has to produce programs.
14
+
15
+ Results carry an opaque **token** chosen by the caller (the model uses its generation
16
+ counter). The calculator never interprets it, so several curves may share one calculator
17
+ without coordinating ids, and a stale answer is discarded by the caller.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shlex
24
+ import subprocess
25
+ import sys
26
+ import threading
27
+ from collections import deque
28
+
29
+ from PySide6.QtCore import QObject, Signal
30
+
31
+ from .protocol import (
32
+ Program,
33
+ decode_frames,
34
+ encode_frame,
35
+ parse_response,
36
+ request_payload,
37
+ )
38
+
39
+ __all__ = ["Calculator", "InlineCalculator", "ProcessCalculator", "calculator_command"]
40
+
41
+ #: Environment override for the worker command line (any executable/shell words).
42
+ COMMAND_ENV = "QMLMATHPLOT_CALC"
43
+
44
+
45
+ def calculator_command() -> list[str]:
46
+ """The worker command: ``$QMLMATHPLOT_CALC`` if set, else this interpreter's module."""
47
+ override = os.environ.get(COMMAND_ENV)
48
+ if override:
49
+ return shlex.split(override, posix=False)
50
+ return [sys.executable, "-m", "qmlmathplot.calc"]
51
+
52
+
53
+ class Calculator(QObject):
54
+ """Asynchronous expression source: ``request`` now, ``ready``/``failed`` later."""
55
+
56
+ #: ``(token, Program)``. Always emitted for a well-formed request, including parse errors
57
+ #: (which arrive as ``Program.error``).
58
+ ready = Signal(object, object)
59
+ #: ``(token, message)`` — transport failures only (worker died, timeout, bad frame).
60
+ failed = Signal(object, str)
61
+
62
+ def request(self, text: str, implicit: bool | None = None, *, token: object = None) -> None:
63
+ """Ask for a program. The result signals carry ``token`` back."""
64
+ raise NotImplementedError
65
+
66
+ def close(self) -> None:
67
+ """Release resources (the inline calculator has none)."""
68
+
69
+
70
+ class InlineCalculator(Calculator):
71
+ """Build programs in-process — the default, and what the tests use."""
72
+
73
+ def request(self, text: str, implicit: bool | None = None, *, token: object = None) -> None:
74
+ try:
75
+ from .calc import build_program
76
+ except ImportError: # the core install has no sympy; the host can inject a process
77
+ self.failed.emit(
78
+ token,
79
+ "the in-process calculator needs sympy: pip install 'qmlmathplot[calc]' "
80
+ "(or inject a ProcessCalculator / your own Calculator)",
81
+ )
82
+ return
83
+ self.ready.emit(token, build_program(text, implicit=implicit))
84
+
85
+
86
+ class ProcessCalculator(Calculator):
87
+ """Ask a worker process; framed JSON over its stdin/stdout.
88
+
89
+ The worker is started on the first request and restarted after a crash. One request is in
90
+ flight per token; the reader and stderr-drain threads spend their time blocked in C
91
+ reads, so they do not hold the GIL.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ command: list[str] | None = None,
97
+ *,
98
+ timeout: float = 10.0,
99
+ parent: QObject | None = None,
100
+ ) -> None:
101
+ super().__init__(parent)
102
+ self._command = list(command) if command is not None else calculator_command()
103
+ self._timeout = float(timeout)
104
+ self._lock = threading.Lock()
105
+ self._process: subprocess.Popen[bytes] | None = None
106
+ self._pending: dict[int, tuple[object, threading.Timer]] = {}
107
+ self._stderr: deque[str] = deque(maxlen=20)
108
+ self._next_id = 0
109
+ self._closed = False
110
+
111
+ # ------------------------------------------------------------------ public
112
+ def request(self, text: str, implicit: bool | None = None, *, token: object = None) -> None:
113
+ if self._closed:
114
+ raise RuntimeError("this calculator has been closed")
115
+ self._ensure_started()
116
+ self._next_id += 1
117
+ wire_id = self._next_id
118
+ payload = request_payload(wire_id, text, implicit)
119
+ timer = threading.Timer(self._timeout, self._on_timeout, args=(wire_id,))
120
+ timer.daemon = True
121
+ with self._lock:
122
+ self._pending[wire_id] = (token, timer)
123
+ timer.start()
124
+ try:
125
+ assert self._process is not None and self._process.stdin is not None
126
+ self._process.stdin.write(encode_frame(payload))
127
+ self._process.stdin.flush()
128
+ except (OSError, ValueError) as exc:
129
+ self._finish(wire_id, error=f"calculator write failed: {exc}")
130
+
131
+ def close(self) -> None:
132
+ self._closed = True
133
+ process = self._process
134
+ self._process = None
135
+ if process is not None:
136
+ try:
137
+ process.terminate()
138
+ process.wait(timeout=2.0)
139
+ except (OSError, subprocess.TimeoutExpired):
140
+ process.kill()
141
+ with self._lock:
142
+ timers = [timer for _token, timer in self._pending.values()]
143
+ self._pending.clear()
144
+ for timer in timers:
145
+ timer.cancel()
146
+
147
+ # --------------------------------------------------------------- internals
148
+ def _ensure_started(self) -> None:
149
+ if self._process is not None and self._process.poll() is None:
150
+ return
151
+ flags = 0
152
+ if sys.platform == "win32": # no console window from a GUI host (AGENTS 57)
153
+ flags = subprocess.CREATE_NO_WINDOW
154
+ self._process = subprocess.Popen(
155
+ self._command,
156
+ stdin=subprocess.PIPE,
157
+ stdout=subprocess.PIPE,
158
+ stderr=subprocess.PIPE,
159
+ creationflags=flags,
160
+ )
161
+ threading.Thread(target=self._read_stdout, name="qmlmathplot-calc-out",
162
+ daemon=True).start()
163
+ threading.Thread(target=self._read_stderr, name="qmlmathplot-calc-err",
164
+ daemon=True).start()
165
+
166
+ def _read_stdout(self) -> None:
167
+ process = self._process
168
+ if process is None or process.stdout is None:
169
+ return
170
+ buffer = b""
171
+ while True:
172
+ try:
173
+ read = getattr(process.stdout, "read1", process.stdout.read)
174
+ chunk = read(65536)
175
+ except (OSError, ValueError):
176
+ break
177
+ if not chunk:
178
+ break
179
+ buffer += chunk
180
+ try:
181
+ frames, buffer = decode_frames(buffer, limit=64)
182
+ except ValueError as exc:
183
+ self._fail_all(f"calculator sent a bad frame: {exc}")
184
+ break
185
+ for payload in frames:
186
+ try:
187
+ wire_id, program = parse_response(payload)
188
+ except ValueError as exc:
189
+ self._fail_all(f"calculator sent a bad response: {exc}")
190
+ continue
191
+ self._deliver(wire_id, program)
192
+ self._fail_all(self._death_message())
193
+
194
+ def _read_stderr(self) -> None:
195
+ process = self._process
196
+ if process is None or process.stderr is None:
197
+ return
198
+ while True:
199
+ line = process.stderr.readline()
200
+ if not line:
201
+ break
202
+ text = line.decode("utf-8", "replace").rstrip()
203
+ if text:
204
+ with self._lock:
205
+ self._stderr.append(text)
206
+
207
+ def _death_message(self) -> str:
208
+ with self._lock:
209
+ tail = list(self._stderr)[-3:]
210
+ return "calculator process exited" + (": " + " | ".join(tail) if tail else "")
211
+
212
+ def _deliver(self, wire_id: int, program: Program) -> None:
213
+ entry = self._take(wire_id)
214
+ if entry is None:
215
+ return # already timed out / closed
216
+ token, timer = entry
217
+ timer.cancel()
218
+ self.ready.emit(token, program)
219
+
220
+ def _on_timeout(self, wire_id: int) -> None:
221
+ entry = self._take(wire_id)
222
+ if entry is not None:
223
+ token, _timer = entry
224
+ self.failed.emit(token, f"calculator timed out after {self._timeout:.1f} s")
225
+
226
+ def _finish(self, wire_id: int, *, error: str) -> None:
227
+ entry = self._take(wire_id)
228
+ if entry is not None:
229
+ token, timer = entry
230
+ timer.cancel()
231
+ self.failed.emit(token, error)
232
+
233
+ def _take(self, wire_id: int) -> tuple[object, threading.Timer] | None:
234
+ with self._lock:
235
+ return self._pending.pop(wire_id, None)
236
+
237
+ def _fail_all(self, message: str) -> None:
238
+ with self._lock:
239
+ pending = list(self._pending.values())
240
+ self._pending.clear()
241
+ for token, timer in pending:
242
+ timer.cancel()
243
+ self.failed.emit(token, message)